refactor: remove workspace publishing and bundles
This commit is contained in:
Generated
-106
@@ -7,19 +7,14 @@
|
||||
"name": "thothii-backend",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@types/pg": "^8.20.3",
|
||||
"fastify": "^5.0.0",
|
||||
"pg": "^8.22.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",
|
||||
"@types/yazl": "^3.3.1",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
@@ -488,12 +483,6 @@
|
||||
"fast-uri": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/busboy": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz",
|
||||
"integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/cors": {
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz",
|
||||
@@ -514,22 +503,6 @@
|
||||
"toad-cache": "^3.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/deepmerge": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.1.tgz",
|
||||
"integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/error": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
||||
@@ -600,29 +573,6 @@
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/multipart": {
|
||||
"version": "9.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-9.4.0.tgz",
|
||||
"integrity": "sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/busboy": "^3.0.0",
|
||||
"@fastify/deepmerge": "^3.0.0",
|
||||
"@fastify/error": "^4.0.0",
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"secure-json-parse": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/proxy-addr": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
|
||||
@@ -1033,26 +983,6 @@
|
||||
"pg-types": "^2.2.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/@types/yazl": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/yazl/-/yazl-3.3.1.tgz",
|
||||
"integrity": "sha512-DIWfCKpsTp6hE5BDBHV3+fIL/bLUF9Bv13iDrWnMlmhQpH67buNvI291ZauQ1xcccxK3FqQ9honnXpq4R8NMuQ==",
|
||||
"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",
|
||||
@@ -1244,15 +1174,6 @@
|
||||
"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",
|
||||
@@ -1697,12 +1618,6 @@
|
||||
"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/pg": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
||||
@@ -2858,27 +2773,6 @@
|
||||
"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",
|
||||
|
||||
@@ -12,19 +12,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@types/pg": "^8.20.3",
|
||||
"fastify": "^5.0.0",
|
||||
"pg": "^8.22.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",
|
||||
"@types/yazl": "^3.3.1",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
|
||||
+1
-11
@@ -84,7 +84,7 @@ function safeInstallationId(value: string): string {
|
||||
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");
|
||||
throw new Error("workspace limit configuration is invalid");
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
@@ -239,18 +239,8 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
|
||||
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),
|
||||
dataRoot: env.THT_DATA_ROOT,
|
||||
maxEvidenceEntries: positiveImportLimit(env.THT_WORKSPACE_MAX_EVIDENCE_ENTRIES, 4096),
|
||||
maxEvidenceBytes: positiveImportLimit(env.THT_WORKSPACE_MAX_EVIDENCE_BYTES, 64 * 1024 * 1024),
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { Buffer } from "node:buffer";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import multipart from "@fastify/multipart";
|
||||
import yauzl from "yauzl";
|
||||
import yazl from "yazl";
|
||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import { z } from "zod";
|
||||
import type { WorkspaceRegistryConfig } from "../workspaces/types.js";
|
||||
import { WorkspaceRegistryError } from "../workspaces/git-repository.js";
|
||||
import {
|
||||
WorkspaceConflictError,
|
||||
type PublishWorkspaceRequest,
|
||||
type WorkspaceRegistry,
|
||||
} from "../workspaces/registry.js";
|
||||
import type { WorkspaceRegistry } from "../workspaces/registry.js";
|
||||
import { resolveRuntimeBindings } from "../workspaces/bindings.js";
|
||||
import { buildInstallationContract, renderWorkspaceDocs } from "../workspaces/contracts.js";
|
||||
import { buildInstallationContract } from "../workspaces/contracts.js";
|
||||
import {
|
||||
parseWorkspaceYaml,
|
||||
serializeWorkspaceYaml,
|
||||
validateOperationalWorkspace,
|
||||
validateWorkspaceDescriptor,
|
||||
type CanonicalWorkspace,
|
||||
@@ -38,248 +27,36 @@ interface WorkspaceRoutesDeps {
|
||||
}
|
||||
|
||||
const workspaceId = z.string().regex(/^[a-z][a-z0-9-]{2,62}$/);
|
||||
const commit = z.string().regex(/^[0-9a-f]{40}$/);
|
||||
const workspacePayload = z.object({ workspace: z.unknown() }).strict();
|
||||
const publishPayload = z.discriminatedUnion("action", [
|
||||
z.object({ action: z.literal("create"), workspace: z.unknown(), baseCommit: commit }).strict(),
|
||||
z.object({ action: z.literal("update"), workspace: z.unknown(), baseCommit: commit, baseBlob: commit }).strict(),
|
||||
z.object({ action: z.literal("delete"), id: workspaceId, baseCommit: commit, baseBlob: commit }).strict(),
|
||||
]);
|
||||
const bundleManifest = z.object({
|
||||
schema_version: z.literal(1),
|
||||
workspace_id: workspaceId,
|
||||
files: z.object({
|
||||
"workspace.yaml": z.string().regex(/^[0-9a-f]{64}$/),
|
||||
"contract.env.example": z.string().regex(/^[0-9a-f]{64}$/),
|
||||
"README.md": z.string().regex(/^[0-9a-f]{64}$/),
|
||||
}).strict(),
|
||||
}).strict();
|
||||
|
||||
// Public P1 bundles contain only the descriptor and derived docs. Evidence file bytes remain
|
||||
// revision-owned Git content for the later P6 materialization boundary.
|
||||
const BUNDLE_FILES = ["manifest.json", "workspace.yaml", "contract.env.example", "README.md"] as const;
|
||||
type BundleFile = (typeof BUNDLE_FILES)[number];
|
||||
|
||||
const SAFE_MESSAGES = {
|
||||
workspace_invalid: "Workspace request or bundle is invalid.",
|
||||
workspace_invalid: "Workspace request is invalid.",
|
||||
binding_missing: "Installation binding is missing or invalid.",
|
||||
workspace_not_activatable: "Workspace cannot be activated on this installation.",
|
||||
workspace_stale: "Workspace revision is stale.",
|
||||
workspace_conflict: "Workspace changed in the registry.",
|
||||
workspace_curator_owned: "Workspace descriptor is owned by the curator and must be changed through Git.",
|
||||
workspace_stale: "Workspace repository state is stale.",
|
||||
git_unavailable: "Workspace Git service is unavailable.",
|
||||
git_auth_failed: "Workspace Git authentication failed.",
|
||||
git_non_fast_forward: "Workspace Git branch has changed.",
|
||||
git_push_rejected: "Workspace Git publication was rejected.",
|
||||
connector_unavailable: "Workspace connector is unavailable.",
|
||||
semantic_index_incompatible: "Semantic index is incompatible with this workspace.",
|
||||
} as const;
|
||||
|
||||
function sha256(value: string | Buffer): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function invalidBundle(): WorkspaceRegistryError {
|
||||
return new WorkspaceRegistryError("workspace_invalid", "Workspace bundle is invalid");
|
||||
}
|
||||
|
||||
function isBundleFile(value: string): value is BundleFile {
|
||||
return (BUNDLE_FILES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function unsafeArchiveEntry(entry: yauzl.Entry): boolean {
|
||||
const name = entry.fileName;
|
||||
const unixType = (entry.externalFileAttributes >>> 16) & 0o170000;
|
||||
return name.length === 0
|
||||
|| name.startsWith("/")
|
||||
|| name.startsWith("\\")
|
||||
|| name.includes("\\")
|
||||
|| name.split("/").includes("..")
|
||||
|| name.endsWith("/")
|
||||
|| unixType === 0o120000
|
||||
|| !isBundleFile(name);
|
||||
}
|
||||
|
||||
async function readZipBundle(source: Buffer, config: WorkspaceRegistryConfig): Promise<Record<BundleFile, Buffer>> {
|
||||
if (source.length === 0 || source.length > config.maxImportBytes) throw invalidBundle();
|
||||
return await new Promise<Record<BundleFile, Buffer>>((resolve, reject) => {
|
||||
yauzl.fromBuffer(source, {
|
||||
lazyEntries: true,
|
||||
strictFileNames: true,
|
||||
validateEntrySizes: true,
|
||||
decodeStrings: true,
|
||||
}, (error, archive) => {
|
||||
if (error || !archive) return reject(invalidBundle());
|
||||
const files = new Map<BundleFile, Buffer>();
|
||||
let entries = 0;
|
||||
let settled = false;
|
||||
const fail = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
archive.close();
|
||||
reject(invalidBundle());
|
||||
};
|
||||
archive.on("error", fail);
|
||||
archive.on("entry", (entry) => {
|
||||
entries += 1;
|
||||
if (entries > config.maxImportEntries || unsafeArchiveEntry(entry) || files.has(entry.fileName as BundleFile)) {
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
if (entry.uncompressedSize > config.maxImportBytes) {
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
archive.openReadStream(entry, (streamError, stream) => {
|
||||
if (streamError || !stream) return fail();
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > config.maxImportBytes) return fail();
|
||||
chunks.push(chunk);
|
||||
});
|
||||
stream.on("error", fail);
|
||||
stream.on("end", () => {
|
||||
if (settled || size !== entry.uncompressedSize) return fail();
|
||||
files.set(entry.fileName as BundleFile, Buffer.concat(chunks));
|
||||
archive.readEntry();
|
||||
});
|
||||
});
|
||||
});
|
||||
archive.on("end", () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (entries !== BUNDLE_FILES.length || BUNDLE_FILES.some((name) => !files.has(name))) return reject(invalidBundle());
|
||||
resolve(Object.fromEntries(files) as Record<BundleFile, Buffer>);
|
||||
});
|
||||
archive.readEntry();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function utf8(buffer: Buffer): string {
|
||||
const text = buffer.toString("utf8");
|
||||
if (!Buffer.from(text, "utf8").equals(buffer) || text.includes("\0")) throw invalidBundle();
|
||||
return text;
|
||||
}
|
||||
|
||||
async function importDraft(source: Buffer, config: WorkspaceRegistryConfig): Promise<CanonicalWorkspace> {
|
||||
const files = await readZipBundle(source, config);
|
||||
let manifest: z.infer<typeof bundleManifest>;
|
||||
try {
|
||||
manifest = bundleManifest.parse(JSON.parse(utf8(files["manifest.json"])));
|
||||
} catch {
|
||||
throw invalidBundle();
|
||||
}
|
||||
for (const name of ["workspace.yaml", "contract.env.example", "README.md"] as const) {
|
||||
if (sha256(files[name]) !== manifest.files[name]) throw invalidBundle();
|
||||
}
|
||||
try {
|
||||
const descriptor = parseWorkspaceYaml(utf8(files["workspace.yaml"]));
|
||||
const workspace = validateWorkspaceDescriptor(descriptor);
|
||||
const docs = renderWorkspaceDocs(workspace);
|
||||
if (
|
||||
workspace.workspace.id !== manifest.workspace_id
|
||||
|| serializeWorkspaceYaml(workspace) !== utf8(files["workspace.yaml"])
|
||||
|| docs.envExample !== utf8(files["contract.env.example"])
|
||||
|| docs.markdown !== utf8(files["README.md"])
|
||||
) throw invalidBundle();
|
||||
return workspace;
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceRegistryError) throw error;
|
||||
throw invalidBundle();
|
||||
}
|
||||
}
|
||||
|
||||
async function exportBundle(workspace: CanonicalWorkspace): Promise<Buffer> {
|
||||
const yaml = serializeWorkspaceYaml(workspace);
|
||||
const docs = renderWorkspaceDocs(workspace);
|
||||
const files: Record<BundleFile, string> = {
|
||||
"manifest.json": JSON.stringify({
|
||||
schema_version: 1,
|
||||
workspace_id: workspace.workspace.id,
|
||||
files: {
|
||||
"workspace.yaml": sha256(yaml),
|
||||
"contract.env.example": sha256(docs.envExample),
|
||||
"README.md": sha256(docs.markdown),
|
||||
},
|
||||
}),
|
||||
"workspace.yaml": yaml,
|
||||
"contract.env.example": docs.envExample,
|
||||
"README.md": docs.markdown,
|
||||
};
|
||||
const archive = new yazl.ZipFile();
|
||||
const chunks: Buffer[] = [];
|
||||
archive.outputStream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
for (const name of BUNDLE_FILES) archive.addBuffer(Buffer.from(files[name]), name);
|
||||
archive.end();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
archive.outputStream.once("end", resolve);
|
||||
archive.outputStream.once("error", reject);
|
||||
});
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
function workspaceErrorCode(error: unknown): keyof typeof SAFE_MESSAGES {
|
||||
return error instanceof WorkspaceRegistryError ? error.code : "workspace_invalid";
|
||||
}
|
||||
|
||||
function workspaceErrorStatus(code: keyof typeof SAFE_MESSAGES): number {
|
||||
if (code === "workspace_conflict" || code === "workspace_curator_owned" || code === "workspace_stale" || code === "git_non_fast_forward") return 409;
|
||||
if (code === "git_unavailable" || code === "git_auth_failed" || code === "git_push_rejected") return 503;
|
||||
if (code === "workspace_stale" || code === "git_non_fast_forward") return 409;
|
||||
if (code === "git_unavailable" || code === "git_auth_failed") return 503;
|
||||
return 400;
|
||||
}
|
||||
|
||||
function validatedWorkspace(value: unknown): WorkspaceDescriptor | undefined {
|
||||
try {
|
||||
return validateWorkspaceDescriptor(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function errorReply(reply: FastifyReply, error: unknown) {
|
||||
const code = workspaceErrorCode(error);
|
||||
const body: Record<string, unknown> = { code, message: SAFE_MESSAGES[code] };
|
||||
if (error instanceof WorkspaceConflictError) {
|
||||
body.fields = error.fields;
|
||||
body.expected = error.expected;
|
||||
body.actual = error.actual;
|
||||
if (error.base) body.base = error.base;
|
||||
if (error.local) body.local = error.local;
|
||||
if (error.remote) body.remote = error.remote;
|
||||
} else if (code === "workspace_conflict" && error && typeof error === "object") {
|
||||
const conflict = error as Partial<WorkspaceConflictError>;
|
||||
if (Array.isArray(conflict.fields) && conflict.fields.every((field) => typeof field === "string")) body.fields = conflict.fields;
|
||||
for (const key of ["expected", "actual"] as const) {
|
||||
const revision = conflict[key];
|
||||
if (
|
||||
revision
|
||||
&& typeof revision.commit === "string" && /^[0-9a-f]{40}$/.test(revision.commit)
|
||||
&& (revision.blob === undefined || (typeof revision.blob === "string" && /^[0-9a-f]{40}$/.test(revision.blob)))
|
||||
) body[key] = revision;
|
||||
}
|
||||
for (const key of ["base", "local", "remote"] as const) {
|
||||
const workspace = validatedWorkspace(conflict[key]);
|
||||
if (workspace) body[key] = workspace;
|
||||
}
|
||||
}
|
||||
return reply.code(workspaceErrorStatus(code)).send(body);
|
||||
}
|
||||
|
||||
function publishRequest(value: unknown): PublishWorkspaceRequest {
|
||||
const parsed = publishPayload.parse(value);
|
||||
if (parsed.action === "delete") return parsed;
|
||||
return { ...parsed, workspace: validateWorkspaceDescriptor(parsed.workspace) };
|
||||
return reply.code(workspaceErrorStatus(code)).send({ code, message: SAFE_MESSAGES[code] });
|
||||
}
|
||||
|
||||
export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps): void {
|
||||
app.register(multipart, {
|
||||
limits: { fileSize: deps.config.maxImportBytes, files: 1, fields: 0, parts: 1 },
|
||||
throwFileSizeLimit: true,
|
||||
});
|
||||
|
||||
app.get("/workspace-registry/status", async (_request, reply) => {
|
||||
try {
|
||||
return await deps.registry.bootstrap();
|
||||
@@ -352,38 +129,4 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/workspaces/publish", async (request, reply) => {
|
||||
try {
|
||||
const result = await deps.registry.publish(publishRequest(request.body));
|
||||
return result ? { revision: result } : reply.code(204).send();
|
||||
} catch (error) {
|
||||
return errorReply(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/workspaces/:id/export", async (request, reply) => {
|
||||
try {
|
||||
const { id } = z.object({ id: workspaceId }).parse(request.params);
|
||||
const { workspace } = await deps.registry.read(id);
|
||||
const canonical = validateWorkspaceDescriptor(workspace);
|
||||
const bundle = await exportBundle(canonical);
|
||||
return reply
|
||||
.type("application/zip")
|
||||
.header("content-disposition", `attachment; filename=\"${id}.zip\"`)
|
||||
.send(bundle);
|
||||
} catch (error) {
|
||||
return errorReply(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/workspaces/import", async (request: FastifyRequest, reply) => {
|
||||
try {
|
||||
const file = await request.file();
|
||||
if (!file || file.fieldname !== "bundle" || file.mimetype !== "application/zip") throw invalidBundle();
|
||||
const draft = await importDraft(await file.toBuffer(), deps.config);
|
||||
return { draft: { workspace: draft, contract: buildInstallationContract(draft) } };
|
||||
} catch (error) {
|
||||
return errorReply(reply, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -67,9 +67,6 @@ function gitErrorCode(error: unknown): WorkspaceErrorCode {
|
||||
if (/non-fast-forward|not possible to fast-forward|fast-forward/.test(detail)) {
|
||||
return "git_non_fast_forward";
|
||||
}
|
||||
if (/remote rejected|pre-receive hook declined|push.*rejected/.test(detail)) {
|
||||
return "git_push_rejected";
|
||||
}
|
||||
return "git_unavailable";
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,8 @@ export interface WorkspaceRegistryConfig {
|
||||
root: string;
|
||||
remoteUrl?: string;
|
||||
branch: string;
|
||||
gitAuthorName: string;
|
||||
gitAuthorEmail: string;
|
||||
installationId: string;
|
||||
secretRoots: readonly string[];
|
||||
maxImportBytes: number;
|
||||
maxImportEntries: number;
|
||||
/** Absolute runtime data root; when set, activation also syncs curated annotations per revision. */
|
||||
dataRoot?: string;
|
||||
/** P6 Evidence materialization bounds; defaults are applied by the materializer. */
|
||||
@@ -20,8 +16,7 @@ export interface WorkspaceRegistryConfig {
|
||||
|
||||
export type WorkspaceErrorCode =
|
||||
| "workspace_invalid" | "binding_missing" | "workspace_not_activatable"
|
||||
| "workspace_stale" | "workspace_conflict" | "workspace_curator_owned" | "git_unavailable"
|
||||
| "git_auth_failed" | "git_non_fast_forward" | "git_push_rejected"
|
||||
| "workspace_stale" | "git_unavailable" | "git_auth_failed" | "git_non_fast_forward"
|
||||
| "connector_unavailable" | "semantic_index_incompatible";
|
||||
|
||||
export type { WorkspaceV3 } from "./schema.js";
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import yauzl from "yauzl";
|
||||
import yazl from "yazl";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { createProductionWorkspaceDiagnoser } from "../src/workspaces/diagnostics.js";
|
||||
import { WorkspaceRegistryError } from "../src/workspaces/git-repository.js";
|
||||
import { WorkspaceRegistry, type WorkspaceRevision } from "../src/workspaces/registry.js";
|
||||
import {
|
||||
parseWorkspaceYaml, renderWorkspaceDocs, serializeWorkspaceYaml, validateWorkspaceDescriptor,
|
||||
type CanonicalWorkspace,
|
||||
} from "../src/workspaces/schema.js";
|
||||
import { serializeWorkspaceYaml, type CanonicalWorkspace } from "../src/workspaces/schema.js";
|
||||
|
||||
const workspace: CanonicalWorkspace = {
|
||||
workspace: {
|
||||
@@ -49,66 +40,6 @@ const workspace: CanonicalWorkspace = {
|
||||
llm_policy: { allowed: ["zai/glm-5.2"] },
|
||||
};
|
||||
|
||||
const workspaceV2 = {
|
||||
workspace: {
|
||||
schema_version: 2,
|
||||
id: "psd-clinical",
|
||||
name: "Policlinico San Donato",
|
||||
description: "Clinical analytics workspace",
|
||||
language: "it",
|
||||
},
|
||||
dwh: {
|
||||
engine: "postgres",
|
||||
database: "warehouse",
|
||||
schema: "datawarehouse",
|
||||
supported_transports: ["rest_api"],
|
||||
},
|
||||
semantic_index: {
|
||||
vector_store: {
|
||||
engine: "pgvector",
|
||||
database: "warehouse",
|
||||
schema: "vectors",
|
||||
collection: "clinical_documents",
|
||||
dimensions: 768,
|
||||
distance: "cosine",
|
||||
supported_transports: ["rest_api"],
|
||||
},
|
||||
embedding: {
|
||||
provider: "ollama_compatible",
|
||||
model: "nomic-embed-text-v2-moe",
|
||||
dimensions: 768,
|
||||
},
|
||||
},
|
||||
diagnostics: {
|
||||
dwh_rest: {
|
||||
method: "GET",
|
||||
path: "/health",
|
||||
auth: "none",
|
||||
response: { database: "database", schema: "schema" },
|
||||
},
|
||||
vector_rest: {
|
||||
metadata: {
|
||||
method: "GET",
|
||||
path: "/metadata",
|
||||
auth: "none",
|
||||
response: { collection: "collection", dimensions: "dimensions", distance: "distance" },
|
||||
},
|
||||
},
|
||||
embedding: {
|
||||
method: "GET",
|
||||
path: "/models",
|
||||
auth: "none",
|
||||
response: { model: "model", dimensions: "dimensions" },
|
||||
},
|
||||
},
|
||||
llm_policy: { allowed: ["zai/glm-5.2"] },
|
||||
};
|
||||
|
||||
const workspaceV1 = {
|
||||
...workspaceV2,
|
||||
workspace: { ...workspaceV2.workspace, schema_version: 1 as const },
|
||||
};
|
||||
|
||||
const revision: WorkspaceRevision = {
|
||||
id: workspace.workspace.id,
|
||||
commit: "a".repeat(40),
|
||||
@@ -116,7 +47,7 @@ const revision: WorkspaceRevision = {
|
||||
snapshotPath: "/registry/snapshots/psd-clinical.yaml",
|
||||
};
|
||||
|
||||
type RegistryFake = Pick<WorkspaceRegistry, "bootstrap" | "pull" | "list" | "read" | "publish">;
|
||||
type RegistryFake = Pick<WorkspaceRegistry, "bootstrap" | "pull" | "list" | "listCatalog" | "read">;
|
||||
|
||||
function registryFake(overrides: Partial<RegistryFake> = {}): RegistryFake {
|
||||
return {
|
||||
@@ -128,15 +59,20 @@ function registryFake(overrides: Partial<RegistryFake> = {}): RegistryFake {
|
||||
})),
|
||||
list: vi.fn(async () => [revision]),
|
||||
listCatalog: vi.fn(async () => [{
|
||||
id: "psd-clinical", name: "Policlinico San Donato", configurationState: "ready", revision,
|
||||
id: "psd-clinical",
|
||||
name: "Policlinico San Donato",
|
||||
configurationState: "ready" as const,
|
||||
revision,
|
||||
}]),
|
||||
read: vi.fn(async () => ({ workspace, revision })),
|
||||
publish: vi.fn(async () => revision),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function appFor(registry: RegistryFake, diagnose = vi.fn(async () => ({ activatable: true, diagnostics: [] }))) {
|
||||
function appFor(
|
||||
registry: RegistryFake,
|
||||
diagnose = vi.fn(async () => ({ activatable: true, diagnostics: [] })),
|
||||
) {
|
||||
return buildApp(loadConfig({
|
||||
THT_HARNESS_DIR: "/missing-harness",
|
||||
THT_WORKSPACE_REGISTRY_ROOT: "/tmp/thoth-route-test-registry",
|
||||
@@ -147,71 +83,15 @@ function appFor(registry: RegistryFake, diagnose = vi.fn(async () => ({ activata
|
||||
} as any);
|
||||
}
|
||||
|
||||
function sha256(value: string | Buffer): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
async function zip(files: Record<string, string>): Promise<Buffer> {
|
||||
const archive = new yazl.ZipFile();
|
||||
const chunks: Buffer[] = [];
|
||||
archive.outputStream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
for (const [name, contents] of Object.entries(files)) archive.addBuffer(Buffer.from(contents), name);
|
||||
archive.end();
|
||||
await once(archive.outputStream, "end");
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
async function validBundle(): Promise<Buffer> {
|
||||
const workspaceYaml = serializeWorkspaceYaml(workspace);
|
||||
const docs = renderWorkspaceDocs(workspace);
|
||||
const contractEnv = docs.envExample;
|
||||
const readme = docs.markdown;
|
||||
return await zip({
|
||||
"manifest.json": JSON.stringify({
|
||||
schema_version: 1,
|
||||
workspace_id: workspace.workspace.id,
|
||||
files: {
|
||||
"workspace.yaml": sha256(workspaceYaml),
|
||||
"contract.env.example": sha256(contractEnv),
|
||||
"README.md": sha256(readme),
|
||||
},
|
||||
}),
|
||||
"workspace.yaml": workspaceYaml,
|
||||
"contract.env.example": contractEnv,
|
||||
"README.md": readme,
|
||||
});
|
||||
}
|
||||
|
||||
function zipWithZipSlipEntry(): Promise<Buffer> {
|
||||
return zip({ "aa/escape.yaml": "bad" }).then((archive) => {
|
||||
const safeName = Buffer.from("aa/escape.yaml");
|
||||
const unsafeName = Buffer.from("../escape.yaml");
|
||||
for (let offset = archive.indexOf(safeName); offset !== -1; offset = archive.indexOf(safeName, offset + safeName.length)) {
|
||||
unsafeName.copy(archive, offset);
|
||||
}
|
||||
return archive;
|
||||
});
|
||||
}
|
||||
|
||||
async function importBundle(app: ReturnType<typeof appFor>, archive: Buffer) {
|
||||
const boundary = "----thoth-workspace-test-boundary";
|
||||
const payload = Buffer.concat([
|
||||
Buffer.from(`--${boundary}\r\ncontent-disposition: form-data; name="bundle"; filename="workspace.zip"\r\ncontent-type: application/zip\r\n\r\n`),
|
||||
archive,
|
||||
Buffer.from(`\r\n--${boundary}--\r\n`),
|
||||
]);
|
||||
return await app.inject({
|
||||
method: "POST",
|
||||
url: "/workspaces/import",
|
||||
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
test("returns a redacted registry status and pulls without Git credential details", async () => {
|
||||
const registry = registryFake({
|
||||
bootstrap: vi.fn(async () => ({
|
||||
branch: "main", head: revision.commit, ahead: 0, behind: 0, degraded: true, lastError: "git_auth_failed" as const,
|
||||
branch: "main",
|
||||
head: revision.commit,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
degraded: true,
|
||||
lastError: "git_auth_failed" as const,
|
||||
})),
|
||||
});
|
||||
const app = appFor(registry);
|
||||
@@ -220,83 +100,81 @@ test("returns a redacted registry status and pulls without Git credential detail
|
||||
const pull = await app.inject({ method: "POST", url: "/workspace-registry/pull" });
|
||||
|
||||
expect(status.statusCode).toBe(200);
|
||||
expect(status.json()).toEqual({
|
||||
branch: "main", head: revision.commit, ahead: 0, behind: 0, degraded: true, lastError: "git_auth_failed",
|
||||
});
|
||||
expect(status.json()).toMatchObject({ branch: "main", degraded: true, lastError: "git_auth_failed" });
|
||||
expect(status.body).not.toMatch(/token|credential|private.?key/i);
|
||||
expect(pull.statusCode).toBe(200);
|
||||
expect(JSON.stringify([status.json(), pull.json()])).not.toMatch(/token|password|ssh:\/\//i);
|
||||
});
|
||||
|
||||
test("lists compatible workspace summaries and reads a validated workspace", async () => {
|
||||
const app = appFor(registryFake());
|
||||
|
||||
const list = await app.inject({ method: "GET", url: "/workspaces" });
|
||||
const detail = await app.inject({ method: "GET", url: "/workspaces/psd-clinical" });
|
||||
|
||||
expect(list.statusCode).toBe(200);
|
||||
expect(list.json()).toEqual([expect.objectContaining({
|
||||
id: "psd-clinical", name: "psd-clinical", file: "psd-clinical/workspace.yaml", displayName: "Policlinico San Donato", configurationState: "ready",
|
||||
})]);
|
||||
expect(detail.statusCode).toBe(200);
|
||||
expect(detail.json()).toMatchObject({ workspace, revision });
|
||||
expect(list.json()[0].revision).not.toHaveProperty("state");
|
||||
expect(detail.json().revision).not.toHaveProperty("state");
|
||||
});
|
||||
|
||||
test("validates a canonical workspace and runs the injected installation diagnostic", async () => {
|
||||
const diagnose = vi.fn(async () => ({
|
||||
activatable: false,
|
||||
diagnostics: [{ level: "error" as const, code: "binding_missing" as const, field: "THT_WS_PSD_CLINICAL_DWH_PASSWORD_FILE", message: "Installation binding is missing or invalid." }],
|
||||
}));
|
||||
const app = appFor(registryFake(), diagnose);
|
||||
|
||||
const validate = await app.inject({ method: "POST", url: "/workspaces/validate", payload: { workspace } });
|
||||
|
||||
expect(validate.statusCode).toBe(200);
|
||||
expect(validate.json()).toMatchObject({ workspace });
|
||||
expect(diagnose).not.toHaveBeenCalled();
|
||||
expect(registry.pull).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["v1", workspaceV1],
|
||||
["v2", workspaceV2],
|
||||
])("rejects schema %s at validate and publish boundaries with a sanitized error", async (_version, legacy) => {
|
||||
const registry = registryFake();
|
||||
const app = appFor(registry);
|
||||
["POST", "/workspaces/publish"],
|
||||
["GET", "/workspaces/psd-clinical/export"],
|
||||
["POST", "/workspaces/import"],
|
||||
] as const)("does not register the removed %s %s mutation or bundle route", async (method, url) => {
|
||||
const response = await appFor(registryFake()).inject({ method, url });
|
||||
|
||||
for (const request of [
|
||||
{ url: "/workspaces/validate", payload: { workspace: legacy } },
|
||||
{
|
||||
url: "/workspaces/publish",
|
||||
payload: { action: "create", workspace: legacy, baseCommit: revision.commit },
|
||||
},
|
||||
]) {
|
||||
const response = await app.inject({ method: "POST", ...request });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({
|
||||
code: "workspace_invalid",
|
||||
message: "Workspace request or bundle is invalid.",
|
||||
});
|
||||
expect(response.body).not.toMatch(/migration_required|schema version/i);
|
||||
}
|
||||
expect(registry.publish).not.toHaveBeenCalled();
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test("runs diagnostics for a schema v3 workspace without external semantic bindings", async () => {
|
||||
test("lists workspace summaries and reads a validated immutable workspace", async () => {
|
||||
const app = appFor(registryFake());
|
||||
|
||||
const list = await app.inject({ method: "GET", url: "/workspaces" });
|
||||
const read = await app.inject({ method: "GET", url: "/workspaces/psd-clinical" });
|
||||
|
||||
expect(list.statusCode).toBe(200);
|
||||
expect(list.json()).toEqual([expect.objectContaining({
|
||||
id: "psd-clinical",
|
||||
displayName: "Policlinico San Donato",
|
||||
configurationState: "ready",
|
||||
revision,
|
||||
})]);
|
||||
expect(read.statusCode).toBe(200);
|
||||
expect(read.json()).toEqual({ workspace, revision });
|
||||
});
|
||||
|
||||
test("validates a schema v3 workspace without mutating the repository", async () => {
|
||||
const app = appFor(registryFake());
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST", url: "/workspaces/validate", payload: { workspace },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({ workspace });
|
||||
});
|
||||
|
||||
test.each([1, 2])("rejects schema v%s at the validation boundary with a sanitized error", async (version) => {
|
||||
const legacy = {
|
||||
...workspace,
|
||||
workspace: { ...workspace.workspace, schema_version: version },
|
||||
};
|
||||
const response = await appFor(registryFake()).inject({
|
||||
method: "POST", url: "/workspaces/validate", payload: { workspace: legacy },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ code: "workspace_invalid", message: "Workspace request is invalid." });
|
||||
expect(response.body).not.toMatch(/migration_required|schema version/i);
|
||||
});
|
||||
|
||||
test("runs diagnostics for a schema v3 workspace", async () => {
|
||||
const diagnose = vi.fn(async () => ({ activatable: true, diagnostics: [] }));
|
||||
const app = appFor(registryFake(), diagnose);
|
||||
|
||||
const testResult = await app.inject({ method: "POST", url: "/workspaces/psd-clinical/test", payload: {} });
|
||||
const response = await app.inject({
|
||||
method: "POST", url: "/workspaces/psd-clinical/test", payload: {},
|
||||
});
|
||||
|
||||
expect(testResult.statusCode).toBe(200);
|
||||
expect(testResult.json()).toMatchObject({ activatable: true, diagnostics: [] });
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ activatable: true, diagnostics: [] });
|
||||
expect(diagnose).toHaveBeenCalledWith(workspace, {
|
||||
dwh: expect.objectContaining({ transport: "postgres_direct" }),
|
||||
evidence: { missing: [], values: {} },
|
||||
}, { writeProbe: false });
|
||||
});
|
||||
|
||||
test("reports missing Evidence binding through the real test route without changing registry revision", async () => {
|
||||
test("reports a missing Evidence credential without changing the registry revision", async () => {
|
||||
const evidenceWorkspace: CanonicalWorkspace = {
|
||||
...workspace,
|
||||
evidence: {
|
||||
@@ -315,203 +193,83 @@ test("reports missing Evidence binding through the real test route without chang
|
||||
},
|
||||
};
|
||||
const read = vi.fn(async () => ({ workspace: evidenceWorkspace, revision }));
|
||||
const registry = registryFake({ read });
|
||||
const app = appFor(registry, createProductionWorkspaceDiagnoser(100));
|
||||
const app = appFor(registryFake({ read }), createProductionWorkspaceDiagnoser(100));
|
||||
const variable = "THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE";
|
||||
const previous = process.env[variable];
|
||||
delete process.env[variable];
|
||||
|
||||
try {
|
||||
const res = await app.inject({ method: "POST", url: "/workspaces/psd-clinical/test", payload: {} });
|
||||
const response = await app.inject({
|
||||
method: "POST", url: "/workspaces/psd-clinical/test", payload: {},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.activatable).toBe(false);
|
||||
expect(body.diagnostics).toEqual(expect.arrayContaining([expect.objectContaining({
|
||||
code: "binding_missing",
|
||||
field: "evidence.source.authentication",
|
||||
variable,
|
||||
})]));
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
activatable: false,
|
||||
diagnostics: expect.arrayContaining([expect.objectContaining({
|
||||
code: "binding_missing",
|
||||
field: "evidence.source.authentication",
|
||||
variable,
|
||||
})]),
|
||||
});
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
expect(registry.publish).not.toHaveBeenCalled();
|
||||
expect(revision).toMatchObject({ commit: "a".repeat(40), blob: "b".repeat(40) });
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env[variable];
|
||||
else process.env[variable] = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test("returns a 409 field conflict instead of overwriting a changed workspace", async () => {
|
||||
const conflict = Object.assign(
|
||||
new WorkspaceRegistryError("workspace_conflict", "Workspace has changed"),
|
||||
{
|
||||
fields: ["workspace.description"],
|
||||
expected: { commit: "c".repeat(40), blob: "d".repeat(40) },
|
||||
actual: { commit: revision.commit, blob: revision.blob },
|
||||
base: workspace,
|
||||
local: { ...workspace, workspace: { ...workspace.workspace, description: "Local description" } },
|
||||
remote: { ...workspace, workspace: { ...workspace.workspace, description: "Remote description" } },
|
||||
},
|
||||
);
|
||||
const registry = registryFake({ publish: vi.fn(async () => { throw conflict; }) });
|
||||
const app = appFor(registry);
|
||||
const staleUpdate = {
|
||||
action: "update",
|
||||
workspace,
|
||||
baseCommit: "c".repeat(40),
|
||||
baseBlob: "d".repeat(40),
|
||||
};
|
||||
|
||||
const res = await app.inject({ method: "POST", url: "/workspaces/publish", payload: staleUpdate });
|
||||
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(res.json()).toMatchObject({
|
||||
code: "workspace_conflict",
|
||||
fields: ["workspace.description"],
|
||||
expected: { commit: "c".repeat(40), blob: "d".repeat(40) },
|
||||
actual: { commit: revision.commit, blob: revision.blob },
|
||||
base: workspace,
|
||||
remote: expect.objectContaining({
|
||||
workspace: expect.objectContaining({ description: "Remote description" }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("maps a stale registry commit to HTTP 409 without conflict payloads", async () => {
|
||||
const registry = registryFake({
|
||||
publish: vi.fn(async () => {
|
||||
throw new WorkspaceRegistryError("workspace_stale", "Workspace revision is stale");
|
||||
}),
|
||||
});
|
||||
const app = appFor(registry);
|
||||
|
||||
const res = await app.inject({ method: "POST", url: "/workspaces/publish", payload: {
|
||||
action: "update",
|
||||
workspace,
|
||||
baseCommit: "c".repeat(40),
|
||||
baseBlob: "d".repeat(40),
|
||||
} });
|
||||
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(res.json()).toEqual({ code: "workspace_stale", message: "Workspace revision is stale." });
|
||||
});
|
||||
|
||||
test("exports generated public artifacts without secret values", async () => {
|
||||
const app = appFor(registryFake());
|
||||
|
||||
const res = await app.inject({ method: "GET", url: "/workspaces/psd-clinical/export" });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers["content-disposition"]).toMatch(/attachment; filename="psd-clinical\.zip"/);
|
||||
expect(res.headers["content-type"]).toMatch(/application\/zip/);
|
||||
expect(res.rawPayload.toString("utf8")).toContain("contract.env.example");
|
||||
expect(res.rawPayload.toString("utf8")).not.toContain("secret-value");
|
||||
});
|
||||
|
||||
test("rejects a zip-slip import without publishing or writing a checkout file", async () => {
|
||||
const registry = registryFake();
|
||||
const app = appFor(registry);
|
||||
|
||||
const res = await importBundle(app, await zipWithZipSlipEntry());
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json()).toMatchObject({ code: "workspace_invalid" });
|
||||
expect(registry.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("imports an exact generated bundle only as a browser draft", async () => {
|
||||
const registry = registryFake();
|
||||
const app = appFor(registry);
|
||||
|
||||
const res = await importBundle(app, await validBundle());
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toMatchObject({ draft: { workspace } });
|
||||
expect(registry.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
const runFile = promisify(execFile);
|
||||
const realRouteRoots: string[] = [];
|
||||
|
||||
interface RealRouteFixture {
|
||||
root: string;
|
||||
remote: string;
|
||||
author: string;
|
||||
registryRoot: string;
|
||||
initialCommit: string;
|
||||
app: ReturnType<typeof buildApp>;
|
||||
registry: WorkspaceRegistry;
|
||||
async function git(cwd: string, args: string[]): Promise<string> {
|
||||
const { stdout } = await runFile("git", args, { cwd });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
const EVIDENCE_FILE_BYTES = "PUBLIC-EVIDENCE-FILE-BYTES-NOT-FOR-ZIP\n";
|
||||
const SECRET_CANARY = "CANARY-EVIDENCE-ROUTE-SECRET-DO-NOT-LEAK";
|
||||
|
||||
function withEvidence(
|
||||
source: Partial<CanonicalWorkspace["evidence"]["source"]> & { type: "filesystem" | "http" | "s3" },
|
||||
changes: Partial<CanonicalWorkspace["evidence"]["policy"]> = {},
|
||||
): CanonicalWorkspace {
|
||||
return validateWorkspaceDescriptor({
|
||||
...workspace,
|
||||
evidence: { source, policy: changes },
|
||||
});
|
||||
}
|
||||
|
||||
const filesystemEvidenceWorkspace = withEvidence({
|
||||
type: "filesystem", uri: "psd-clinical/evidence",
|
||||
});
|
||||
const httpEvidenceWorkspace = withEvidence({
|
||||
type: "http",
|
||||
uris: ["https://evidence.example.test/guide.md"],
|
||||
authentication: "signed_urls_file",
|
||||
});
|
||||
|
||||
async function realGit(cwd: string, args: string[]): Promise<string> {
|
||||
return (await runFile("git", args, { cwd })).stdout.trim();
|
||||
}
|
||||
|
||||
async function createRealRouteFixture(
|
||||
initialWorkspace: CanonicalWorkspace = filesystemEvidenceWorkspace,
|
||||
): Promise<RealRouteFixture> {
|
||||
const root = mkdtempSync(join(tmpdir(), "thoth-real-workspace-route-"));
|
||||
async function createRealRouteFixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), "thoth-workspace-route-"));
|
||||
realRouteRoots.push(root);
|
||||
const remote = join(root, "remote.git");
|
||||
const author = join(root, "author");
|
||||
const registryRoot = join(root, "registry");
|
||||
await realGit(root, ["init", "--bare", "--initial-branch=main", remote]);
|
||||
await git(root, ["init", "--bare", "--initial-branch=main", remote]);
|
||||
mkdirSync(author);
|
||||
await realGit(author, ["init", "--initial-branch=main"]);
|
||||
await realGit(author, ["config", "user.name", "Workspace Route Test"]);
|
||||
await realGit(author, ["config", "user.email", "workspace-route@example.invalid"]);
|
||||
const catalogName = initialWorkspace.workspace.name;
|
||||
const catalogDescription = initialWorkspace.workspace.description;
|
||||
await git(author, ["init", "--initial-branch=main"]);
|
||||
await git(author, ["config", "user.name", "Workspace Route Test"]);
|
||||
await git(author, ["config", "user.email", "workspace-route@example.invalid"]);
|
||||
const descriptor: CanonicalWorkspace = {
|
||||
...workspace,
|
||||
evidence: {
|
||||
source: {
|
||||
type: "filesystem",
|
||||
uri: "psd-clinical/evidence",
|
||||
patterns: ["**/*.md"],
|
||||
max_bytes: 1024 * 1024,
|
||||
},
|
||||
policy: { max_chunk_chars: 4_000, retain_published_generations: 3 },
|
||||
},
|
||||
};
|
||||
writeFileSync(join(author, "thoth-workspaces.yaml"), [
|
||||
"schema_version: 1",
|
||||
"workspaces:",
|
||||
` - id: psd-clinical\n name: ${catalogName}${catalogDescription ? `\n description: ${catalogDescription}` : ""}`,
|
||||
` - id: research-clinical\n name: Research Clinical${catalogDescription ? `\n description: ${catalogDescription}` : ""}`,
|
||||
` - id: missing-evidence\n name: Missing Evidence${catalogDescription ? `\n description: ${catalogDescription}` : ""}`,
|
||||
].join("\n") + "\n");
|
||||
mkdirSync(join(author, "psd-clinical"), { recursive: true });
|
||||
writeFileSync(join(author, "psd-clinical", "workspace.yaml"), serializeWorkspaceYaml(initialWorkspace));
|
||||
if (initialWorkspace.evidence?.source.type === "filesystem") {
|
||||
mkdirSync(join(author, "psd-clinical", "evidence"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(author, "psd-clinical", "evidence", "guide.md"),
|
||||
EVIDENCE_FILE_BYTES,
|
||||
);
|
||||
}
|
||||
await realGit(author, ["add", "."]);
|
||||
await realGit(author, ["commit", "-m", "Initial Evidence workspace"]);
|
||||
await realGit(author, ["remote", "add", "origin", remote]);
|
||||
await realGit(author, ["push", "origin", "main"]);
|
||||
const initialCommit = await realGit(author, ["rev-parse", "HEAD"]);
|
||||
" - id: psd-clinical",
|
||||
" name: Policlinico San Donato",
|
||||
" description: Clinical analytics workspace",
|
||||
"",
|
||||
].join("\n"));
|
||||
mkdirSync(join(author, "psd-clinical", "evidence"), { recursive: true });
|
||||
writeFileSync(join(author, "psd-clinical", "workspace.yaml"), serializeWorkspaceYaml(descriptor));
|
||||
writeFileSync(join(author, "psd-clinical", "evidence", "guide.md"), "Evidence bytes\n");
|
||||
await git(author, ["add", "."]);
|
||||
await git(author, ["commit", "-m", "Initial workspace"]);
|
||||
await git(author, ["remote", "add", "origin", remote]);
|
||||
await git(author, ["push", "origin", "main"]);
|
||||
const initialCommit = await git(author, ["rev-parse", "HEAD"]);
|
||||
const config = loadConfig({
|
||||
THT_HARNESS_DIR: "/missing-harness",
|
||||
THT_WORKSPACE_REGISTRY_ROOT: registryRoot,
|
||||
THT_WORKSPACE_GIT_REMOTE: remote,
|
||||
THT_WORKSPACE_GIT_AUTHOR_NAME: "Workspace Route Publisher",
|
||||
THT_WORKSPACE_GIT_AUTHOR_EMAIL: "workspace-route-publisher@example.invalid",
|
||||
});
|
||||
const registry = new WorkspaceRegistry(config.workspaceRegistry);
|
||||
const app = buildApp(config, {
|
||||
@@ -519,306 +277,26 @@ async function createRealRouteFixture(
|
||||
workspaceRegistry: registry,
|
||||
workspaceDiagnoser: vi.fn(async () => ({ activatable: true, diagnostics: [] })),
|
||||
});
|
||||
return { root, remote, author, registryRoot, initialCommit, app, registry };
|
||||
}
|
||||
|
||||
async function extractZip(source: Buffer): Promise<Record<string, Buffer>> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
yauzl.fromBuffer(source, { lazyEntries: true, strictFileNames: true }, (error, archive) => {
|
||||
if (error || !archive) return reject(error ?? new Error("archive unavailable"));
|
||||
const files: Record<string, Buffer> = {};
|
||||
archive.on("error", reject);
|
||||
archive.on("entry", (entry) => {
|
||||
if (entry.fileName.startsWith("/") || entry.fileName.includes("..") || entry.fileName.includes("\\")) {
|
||||
archive.close();
|
||||
reject(new Error("unsafe exported path"));
|
||||
return;
|
||||
}
|
||||
archive.openReadStream(entry, (streamError, stream) => {
|
||||
if (streamError || !stream) return reject(streamError ?? new Error("entry unavailable"));
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => {
|
||||
files[entry.fileName] = Buffer.concat(chunks);
|
||||
archive.readEntry();
|
||||
});
|
||||
});
|
||||
});
|
||||
archive.on("end", () => resolve(files));
|
||||
archive.readEntry();
|
||||
});
|
||||
});
|
||||
return { author, initialCommit, app, registry };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
realRouteRoots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }));
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
source: { type: "filesystem", uri: "psd-clinical/evidence" },
|
||||
expectedVariables: [],
|
||||
},
|
||||
{
|
||||
source: {
|
||||
type: "http", uris: ["https://evidence.example.test/guide.md"],
|
||||
authentication: "signed_urls_file",
|
||||
},
|
||||
expectedVariables: ["THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE"],
|
||||
},
|
||||
{
|
||||
source: { type: "s3", uri: "s3://clinical-evidence/published/", credentials: "static_files" },
|
||||
expectedVariables: [
|
||||
"THT_WS_PSD_CLINICAL_EVIDENCE_ACCESS_KEY_FILE",
|
||||
"THT_WS_PSD_CLINICAL_EVIDENCE_SECRET_KEY_FILE",
|
||||
"THT_WS_PSD_CLINICAL_EVIDENCE_SESSION_TOKEN_FILE",
|
||||
],
|
||||
},
|
||||
])("real validate route canonicalizes $source.type Evidence and returns only its file contract", async ({
|
||||
source, expectedVariables,
|
||||
}) => {
|
||||
const fixture = await createRealRouteFixture();
|
||||
const response = await fixture.app.inject({
|
||||
method: "POST", url: "/workspaces/validate",
|
||||
payload: { workspace: { ...workspace, evidence: { source } } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json();
|
||||
expect(body.workspace.evidence.policy).toEqual({
|
||||
max_chunk_chars: 4_000, retain_published_generations: 3,
|
||||
});
|
||||
expect(body.workspace.evidence.source.max_bytes).toBe(10 * 1024 * 1024);
|
||||
expect(body.contract.variables.filter(({ role }: { role: string }) => role === "EVIDENCE")
|
||||
.map(({ name }: { name: string }) => name)).toEqual(expectedVariables);
|
||||
});
|
||||
|
||||
test("real bootstrap create, curator push/pull, list, and read preserve a complete Evidence descriptor", async () => {
|
||||
const fixture = await createRealRouteFixture(httpEvidenceWorkspace);
|
||||
const status = await fixture.app.inject({ method: "GET", url: "/workspace-registry/status" });
|
||||
const created = validateWorkspaceDescriptor({
|
||||
...httpEvidenceWorkspace,
|
||||
workspace: { ...httpEvidenceWorkspace.workspace, id: "research-clinical", name: "Research Clinical" },
|
||||
semantic_index: {
|
||||
...httpEvidenceWorkspace.semantic_index,
|
||||
vector_store: { ...httpEvidenceWorkspace.semantic_index.vector_store, collection: "research-clinical" },
|
||||
},
|
||||
});
|
||||
const create = await fixture.app.inject({
|
||||
method: "POST", url: "/workspaces/publish",
|
||||
payload: { action: "create", workspace: created, baseCommit: status.json().head },
|
||||
});
|
||||
expect(create.statusCode).toBe(200);
|
||||
const createdRevision = create.json().revision as WorkspaceRevision;
|
||||
|
||||
await realGit(fixture.author, ["pull", "--ff-only", "origin", "main"]);
|
||||
const remotelyEdited = validateWorkspaceDescriptor({
|
||||
...created,
|
||||
evidence: {
|
||||
...created.evidence,
|
||||
policy: { max_chunk_chars: 9_001, retain_published_generations: 9 },
|
||||
},
|
||||
});
|
||||
writeFileSync(
|
||||
join(fixture.author, "research-clinical", "workspace.yaml"),
|
||||
serializeWorkspaceYaml(remotelyEdited),
|
||||
);
|
||||
await realGit(fixture.author, ["add", "research-clinical/workspace.yaml"]);
|
||||
await realGit(fixture.author, ["commit", "-m", "Remote Evidence-only descriptor edit"]);
|
||||
await realGit(fixture.author, ["push", "origin", "main"]);
|
||||
const remoteCommit = await realGit(fixture.author, ["rev-parse", "HEAD"]);
|
||||
|
||||
const update = await fixture.app.inject({
|
||||
method: "POST", url: "/workspaces/publish",
|
||||
payload: {
|
||||
action: "update", workspace: remotelyEdited,
|
||||
baseCommit: createdRevision.commit, baseBlob: createdRevision.blob,
|
||||
},
|
||||
});
|
||||
expect(update.statusCode).toBe(409);
|
||||
expect(update.json()).toMatchObject({ code: "workspace_curator_owned" });
|
||||
|
||||
const pull = await fixture.app.inject({ method: "POST", url: "/workspace-registry/pull" });
|
||||
const list = await fixture.app.inject({ method: "GET", url: "/workspaces" });
|
||||
const read = await fixture.app.inject({ method: "GET", url: "/workspaces/research-clinical" });
|
||||
|
||||
expect(status.statusCode).toBe(200);
|
||||
expect(create.statusCode).toBe(200);
|
||||
expect(pull.statusCode).toBe(200);
|
||||
// Explicit pull may produce a deterministic docs-only follow-up commit on top of the curator
|
||||
// commit; the active descriptor must always be the curator's bytes.
|
||||
const pulledHead = pull.json().head;
|
||||
const pulledDiff = pulledHead === remoteCommit ? [] : (await realGit(join(fixture.registryRoot, "repo"), ["diff", "--name-only", `${remoteCommit}..${pulledHead}`])).split(/\s+/).filter(Boolean);
|
||||
expect(pulledHead).toMatch(/^[0-9a-f]{40}$/);
|
||||
expect(pulledDiff.every((path) => path.startsWith("workspace-docs/"))).toBe(true);
|
||||
expect(list.statusCode).toBe(200);
|
||||
const summary = list.json().find(({ id }: { id: string }) => id === "research-clinical");
|
||||
expect(summary.configurationState).toBe("ready");
|
||||
expect(summary.revision.commit).toBe(pulledHead);
|
||||
expect(read.statusCode).toBe(200);
|
||||
expect(read.json().workspace).toEqual(remotelyEdited);
|
||||
});
|
||||
test("real route refuses curator-owned updates with a safe 409 after an Evidence-only concurrent edit", async () => {
|
||||
const fixture = await createRealRouteFixture(httpEvidenceWorkspace);
|
||||
await fixture.registry.bootstrap();
|
||||
const base = await fixture.registry.read("psd-clinical");
|
||||
const remote = withEvidence(
|
||||
{ ...httpEvidenceWorkspace.evidence!.source },
|
||||
{ max_chunk_chars: 9_000, retain_published_generations: 3 },
|
||||
);
|
||||
writeFileSync(join(fixture.author, "psd-clinical", "workspace.yaml"), serializeWorkspaceYaml(remote));
|
||||
await realGit(fixture.author, ["add", "psd-clinical/workspace.yaml"]);
|
||||
await realGit(fixture.author, ["commit", "-m", "Change Evidence policy only"]);
|
||||
await realGit(fixture.author, ["push", "origin", "main"]);
|
||||
const local = withEvidence(
|
||||
{ ...httpEvidenceWorkspace.evidence!.source },
|
||||
{ max_chunk_chars: 4_000, retain_published_generations: 8 },
|
||||
);
|
||||
|
||||
const response = await fixture.app.inject({
|
||||
method: "POST", url: "/workspaces/publish",
|
||||
payload: {
|
||||
action: "update", workspace: local,
|
||||
baseCommit: base.revision.commit, baseBlob: base.revision.blob,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json()).toMatchObject({ code: "workspace_curator_owned" });
|
||||
expect(response.body).not.toContain(SECRET_CANARY);
|
||||
});
|
||||
test.each([
|
||||
["absolute", "/tmp/CANARY-EVIDENCE-ROUTE-SECRET-DO-NOT-LEAK"],
|
||||
["traversal", "psd-clinical/../CANARY-EVIDENCE-ROUTE-SECRET-DO-NOT-LEAK"],
|
||||
["cross-workspace", "research/evidence"],
|
||||
])("real publish rejects %s filesystem Evidence paths without changing HEAD", async (_label, uri) => {
|
||||
test("a failed candidate pull keeps the last valid active workspace", async () => {
|
||||
const fixture = await createRealRouteFixture();
|
||||
await fixture.registry.bootstrap();
|
||||
const base = await fixture.registry.read("psd-clinical");
|
||||
const invalid = structuredClone(filesystemEvidenceWorkspace) as any;
|
||||
invalid.evidence.source.uri = uri;
|
||||
|
||||
const response = await fixture.app.inject({
|
||||
method: "POST", url: "/workspaces/publish",
|
||||
payload: { action: "update", workspace: invalid, baseCommit: base.revision.commit, baseBlob: base.revision.blob },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ code: "workspace_invalid", message: "Workspace request or bundle is invalid." });
|
||||
expect(response.body).not.toContain(SECRET_CANARY);
|
||||
expect(await realGit(fixture.author, ["--git-dir", fixture.remote, "rev-parse", "HEAD"]))
|
||||
.toBe(fixture.initialCommit);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
label: "credential-bearing HTTP URI",
|
||||
source: { type: "http", uris: [`https://user:${SECRET_CANARY}@evidence.example.test/guide.md`] },
|
||||
},
|
||||
{
|
||||
label: "unsupported HTTP protocol",
|
||||
source: { type: "http", uris: [`ftp://evidence.example.test/${SECRET_CANARY}`] },
|
||||
},
|
||||
{
|
||||
label: "inline S3 credential field",
|
||||
source: { type: "s3", uri: "s3://clinical-evidence/published/", access_key: SECRET_CANARY },
|
||||
},
|
||||
])("real publish rejects $label without echoing it or changing HEAD", async ({ source }) => {
|
||||
const fixture = await createRealRouteFixture();
|
||||
await fixture.registry.bootstrap();
|
||||
const base = await fixture.registry.read("psd-clinical");
|
||||
const invalid = structuredClone(base.workspace) as any;
|
||||
invalid.evidence = { source };
|
||||
const response = await fixture.app.inject({
|
||||
method: "POST", url: "/workspaces/publish",
|
||||
payload: {
|
||||
action: "update", workspace: invalid,
|
||||
baseCommit: base.revision.commit, baseBlob: base.revision.blob,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ code: "workspace_invalid", message: "Workspace request or bundle is invalid." });
|
||||
expect(response.body).not.toContain(SECRET_CANARY);
|
||||
expect(await realGit(fixture.author, ["--git-dir", fixture.remote, "rev-parse", "HEAD"]))
|
||||
.toBe(fixture.initialCommit);
|
||||
});
|
||||
|
||||
test("real publish and pull fail safely when the contextual Evidence Git tree is missing", async () => {
|
||||
const fixture = await createRealRouteFixture();
|
||||
await fixture.registry.bootstrap();
|
||||
const current = await fixture.registry.read("psd-clinical");
|
||||
const missing = validateWorkspaceDescriptor({
|
||||
...workspace,
|
||||
workspace: { ...workspace.workspace, id: "missing-evidence", name: "Missing Evidence" },
|
||||
semantic_index: {
|
||||
...workspace.semantic_index,
|
||||
vector_store: { ...workspace.semantic_index.vector_store, collection: "missing-evidence" },
|
||||
},
|
||||
evidence: { source: { type: "filesystem", uri: "missing-evidence/evidence" } },
|
||||
});
|
||||
const publish = await fixture.app.inject({
|
||||
method: "POST", url: "/workspaces/publish",
|
||||
payload: { action: "create", workspace: missing, baseCommit: current.revision.commit },
|
||||
});
|
||||
expect(publish.statusCode).toBe(400);
|
||||
expect(publish.json()).toEqual({ code: "workspace_invalid", message: "Workspace request or bundle is invalid." });
|
||||
expect(publish.json()).toEqual({ code: "workspace_invalid", message: "Workspace request or bundle is invalid." });
|
||||
expect(await realGit(fixture.author, ["--git-dir", fixture.remote, "rev-parse", "HEAD"]))
|
||||
.toBe(fixture.initialCommit);
|
||||
|
||||
rmSync(join(fixture.author, "psd-clinical", "evidence"), { recursive: true });
|
||||
await realGit(fixture.author, ["add", "-A"]);
|
||||
await realGit(fixture.author, ["commit", "-m", "Remove Evidence tree"]);
|
||||
await realGit(fixture.author, ["push", "origin", "main"]);
|
||||
await git(fixture.author, ["add", "-A"]);
|
||||
await git(fixture.author, ["commit", "-m", "Remove required Evidence tree"]);
|
||||
await git(fixture.author, ["push", "origin", "main"]);
|
||||
|
||||
const pull = await fixture.app.inject({ method: "POST", url: "/workspace-registry/pull" });
|
||||
|
||||
expect(pull.statusCode).toBe(400);
|
||||
expect(pull.json()).toEqual({ code: "workspace_invalid", message: "Workspace request or bundle is invalid." });
|
||||
expect(pull.body).not.toContain(SECRET_CANARY);
|
||||
expect(pull.json()).toEqual({ code: "workspace_invalid", message: "Workspace request is invalid." });
|
||||
await expect(fixture.registry.read("psd-clinical")).resolves.toMatchObject({
|
||||
revision: { commit: fixture.initialCommit },
|
||||
});
|
||||
});
|
||||
|
||||
test("real export and import preserve stable public Evidence artifacts without Evidence or secret bytes", async () => {
|
||||
const fixture = await createRealRouteFixture();
|
||||
const secretDirectory = join(fixture.root, "fixture-secrets");
|
||||
mkdirSync(secretDirectory);
|
||||
writeFileSync(join(secretDirectory, "credential"), SECRET_CANARY);
|
||||
await fixture.registry.bootstrap();
|
||||
|
||||
const firstResponse = await fixture.app.inject({ method: "GET", url: "/workspaces/psd-clinical/export" });
|
||||
const secondResponse = await fixture.app.inject({ method: "GET", url: "/workspaces/psd-clinical/export" });
|
||||
expect(firstResponse.statusCode).toBe(200);
|
||||
expect(secondResponse.statusCode).toBe(200);
|
||||
const first = await extractZip(firstResponse.rawPayload);
|
||||
const second = await extractZip(secondResponse.rawPayload);
|
||||
const names = ["manifest.json", "workspace.yaml", "contract.env.example", "README.md"];
|
||||
expect(Object.keys(first).sort()).toEqual([...names].sort());
|
||||
expect(Object.keys(second).sort()).toEqual([...names].sort());
|
||||
for (const name of names) expect(second[name]).toEqual(first[name]);
|
||||
|
||||
const descriptor = parseWorkspaceYaml(first["workspace.yaml"].toString("utf8"));
|
||||
const docs = renderWorkspaceDocs(descriptor);
|
||||
const manifest = JSON.parse(first["manifest.json"].toString("utf8"));
|
||||
expect(descriptor).toEqual(filesystemEvidenceWorkspace);
|
||||
expect(first["contract.env.example"].toString("utf8")).toBe(docs.envExample);
|
||||
expect(first["README.md"].toString("utf8")).toBe(docs.markdown);
|
||||
expect(manifest.files).toEqual({
|
||||
"workspace.yaml": sha256(first["workspace.yaml"]),
|
||||
"contract.env.example": sha256(first["contract.env.example"]),
|
||||
"README.md": sha256(first["README.md"]),
|
||||
});
|
||||
const publicBytes = Buffer.concat(Object.values(first)).toString("utf8");
|
||||
expect(publicBytes).not.toContain(EVIDENCE_FILE_BYTES.trim());
|
||||
expect(publicBytes).not.toContain(SECRET_CANARY);
|
||||
|
||||
const imported = await importBundle(fixture.app, firstResponse.rawPayload);
|
||||
expect(imported.statusCode).toBe(200);
|
||||
expect(imported.json().draft.workspace).toEqual(filesystemEvidenceWorkspace);
|
||||
expect(imported.json().draft.contract.variables.some(({ role }: { role: string }) => role === "EVIDENCE"))
|
||||
.toBe(false);
|
||||
expect(imported.body).not.toContain(EVIDENCE_FILE_BYTES.trim());
|
||||
expect(imported.body).not.toContain(SECRET_CANARY);
|
||||
});
|
||||
|
||||
@@ -20,24 +20,19 @@ test("loads a safe Git workspace registry configuration", () => {
|
||||
});
|
||||
|
||||
test("uses safe workspace registry defaults", () => {
|
||||
expect(loadConfig({}).workspaceRegistry).toMatchObject({
|
||||
const registry = loadConfig({}).workspaceRegistry;
|
||||
expect(registry).toMatchObject({
|
||||
root: "/data/workspace-registry",
|
||||
branch: "main",
|
||||
maxImportBytes: 10 * 1024 * 1024,
|
||||
maxImportEntries: 32,
|
||||
});
|
||||
expect(registry).not.toHaveProperty("gitAuthorName");
|
||||
expect(registry).not.toHaveProperty("gitAuthorEmail");
|
||||
expect(registry).not.toHaveProperty("maxImportBytes");
|
||||
expect(registry).not.toHaveProperty("maxImportEntries");
|
||||
});
|
||||
|
||||
test("rejects a relative registry root and invalid import limits", () => {
|
||||
test("rejects a relative registry root", () => {
|
||||
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", () => {
|
||||
|
||||
Reference in New Issue
Block a user