refactor: make workspace repository strictly read only

This commit is contained in:
2026-08-14 16:20:08 +02:00
parent 3a50c447c3
commit 42e02f8b1c
4 changed files with 38 additions and 570 deletions
+10 -265
View File
@@ -202,14 +202,11 @@ async function fixture(workspaceSource = validYaml): Promise<{
mkdirSync(join(source, "psd-clinical"), { recursive: true });
writeFileSync(join(source, "thoth-workspaces.yaml"), catalogYaml([
{ id: "psd-clinical", name: workspace?.workspace.name ?? "Policlinico San Donato", ...(workspace?.workspace.description ? { description: workspace.workspace.description } : {}) },
{ id: "research", name: "research" },
]));
writeFileSync(join(source, "psd-clinical", "workspace.yaml"), workspaceSource);
if (workspaceSource.includes("type: filesystem")) {
mkdirSync(join(source, "psd-clinical", "evidence"), { recursive: true });
mkdirSync(join(source, "research", "evidence"), { recursive: true });
writeFileSync(join(source, "psd-clinical", "evidence", "guide.md"), "guide v1\n");
writeFileSync(join(source, "research", "evidence", "guide.md"), "research guide\n");
await git(source, ["add", "-A"]);
} else {
await git(source, ["add", "thoth-workspaces.yaml", "psd-clinical/workspace.yaml"]);
@@ -293,38 +290,6 @@ function config(
};
}
function workspaceWith(
id: string,
changes: Partial<Pick<CanonicalWorkspace["workspace"], "name" | "description">> = {},
): CanonicalWorkspace {
const workspace = parseWorkspaceYaml(validYaml) as CanonicalWorkspace;
return {
...workspace,
workspace: { ...workspace.workspace, id, name: id, ...changes },
semantic_index: {
...workspace.semantic_index,
vector_store: { ...workspace.semantic_index.vector_store, collection: id },
},
};
}
function filesystemWorkspace(id: string): CanonicalWorkspace {
return parseWorkspaceYaml(withFilesystemEvidence(
validYaml
.replace("id: psd-clinical", `id: ${id}`)
.replace("name: Policlinico San Donato", `name: ${id}`)
.replace("collection: psd-clinical", `collection: ${id}`),
id,
)) as CanonicalWorkspace;
}
async function checkoutStatus(checkout: string): Promise<{ porcelain: string; divergence: string }> {
return {
porcelain: await gitOutput(checkout, ["status", "--porcelain"]),
divergence: await gitOutput(checkout, ["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]),
};
}
async function pushInvalidWorkspace(source: string): Promise<void> {
writeFileSync(join(source, "psd-clinical", "workspace.yaml"), "workspace: invalid\n");
await git(source, ["add", "psd-clinical/workspace.yaml"]);
@@ -373,38 +338,12 @@ function persistedState(root: string, commit: string): { active: any; manifest:
};
}
test("allows bootstrap creation from a catalog-only base and refuses later curator-owned writes", async () => {
test("rejects a catalog entry without a descriptor instead of creating a bootstrap slot", async () => {
const remote = await contentOnlyFixture();
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
await expect(registry.bootstrap()).resolves.toMatchObject({ head: remote.initialCommit });
await expect(registry.list()).resolves.toEqual([]);
await expect(registry.listCatalog()).resolves.toEqual([
expect.objectContaining({ id: "p1-filesystem", configurationState: "configuration_required" }),
]);
const created = await registry.publish({
action: "create",
workspace: filesystemWorkspace("p1-filesystem"),
baseCommit: remote.initialCommit,
});
expect(created).toMatchObject({ id: "p1-filesystem" });
await expect(registry.list()).resolves.toEqual([
expect.objectContaining({ id: "p1-filesystem", commit: created!.commit }),
]);
await expect(registry.listCatalog()).resolves.toEqual([
expect.objectContaining({ id: "p1-filesystem", configurationState: "ready", revision: created }),
]);
await expect(registry.publish({
action: "delete",
id: "p1-filesystem",
baseCommit: created!.commit,
baseBlob: created!.blob,
})).rejects.toMatchObject({ code: "workspace_curator_owned" });
await expect(registry.list()).resolves.toEqual([
expect.objectContaining({ id: "p1-filesystem" }),
]);
await expect(registry.bootstrap()).rejects.toMatchObject({ code: "workspace_invalid" });
expect(existsSync(join(remote.root, "registry", "state", "active.json"))).toBe(false);
});
test("bootstraps a checkout and activates a validated immutable snapshot", async () => {
const remote = await fixture();
@@ -436,45 +375,6 @@ test("concurrent first lists lazily bootstrap a clean registry once safely", asy
expect(existsSync(join(root, "state", "active.json"))).toBe(true);
});
test("publishes a filesystem descriptor only when its Evidence tree exists in the pulled base", async () => {
const remote = await fixture(withFilesystemEvidence(validYaml));
const root = join(remote.root, "registry");
const registry = new WorkspaceRegistry(config(root, remote.remote));
await registry.bootstrap();
const evidencePath = "research/evidence";
const initialTree = await gitOutput(remote.root, [
"--git-dir", remote.remote, "rev-parse", `${remote.initialCommit}:${evidencePath}`,
]);
const created = await registry.publish({
action: "create",
workspace: filesystemWorkspace("research"),
baseCommit: remote.initialCommit,
});
expect(created?.commit).not.toBe(remote.initialCommit);
await expect(runFile("git", [
"--git-dir", remote.remote, "cat-file", "-e", `${created!.commit}:research/workspace.yaml`,
], { cwd: remote.root })).resolves.toBeDefined();
await expect(runFile("git", [
"--git-dir", remote.remote, "cat-file", "-e", `${created!.commit}:${evidencePath}/guide.md`,
], { cwd: remote.root })).resolves.toBeDefined();
expect(await gitOutput(remote.root, [
"--git-dir", remote.remote, "rev-parse", `${created!.commit}:${evidencePath}`,
])).toBe(initialTree);
const remoteHeadBeforeMissing = await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"]);
await expect(registry.publish({
action: "create",
workspace: filesystemWorkspace("missing-tree"),
baseCommit: created!.commit,
})).rejects.toMatchObject({ code: "workspace_invalid" });
expect(await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"])).toBe(
remoteHeadBeforeMissing,
);
expect(await checkoutStatus(join(root, "repo"))).toEqual({ porcelain: "", divergence: "0\t0" });
});
test.each(["missing", "blob"])(
"rejects a remote filesystem descriptor with a %s Evidence root and keeps the active snapshot",
async (invalidKind) => {
@@ -549,27 +449,6 @@ test("creates an immutable descriptor revision for a content-only Evidence commi
], { cwd: remote.root })).resolves.toBeDefined();
});
test("rejects a stale API update after a content-only Evidence commit", async () => {
const remote = await fixture(withFilesystemEvidence(validYaml));
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
await registry.bootstrap();
const initial = await registry.read("psd-clinical");
const guide = join(remote.source, "psd-clinical", "evidence", "guide.md");
writeFileSync(guide, "curator content\n");
await git(remote.source, ["add", "psd-clinical/evidence/guide.md"]);
await git(remote.source, ["commit", "-m", "Curator Evidence update"]);
await git(remote.source, ["push", "origin", "main"]);
const curatorCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]);
await expect(registry.publish({
action: "update",
workspace: filesystemWorkspace("psd-clinical"),
baseCommit: initial.revision.commit,
baseBlob: initial.revision.blob,
})).rejects.toMatchObject({ code: "workspace_curator_owned" });
expect(await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"])).toBe(curatorCommit);
});
test("keeps content-only historical descriptor revisions distinguishable by commit", async () => {
const remote = await fixture(withFilesystemEvidence(validYaml));
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
@@ -593,72 +472,6 @@ test("keeps content-only historical descriptor revisions distinguishable by comm
expect(oldPinned.workspace).toEqual(newPinned.workspace);
});
test("publishes one bootstrap descriptor with the configured Git author identity and refuses curator-owned mutation", async () => {
const remote = await fixture();
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote, {
gitAuthorName: "Configured Workspace Publisher",
gitAuthorEmail: "publisher@example.invalid",
}));
await registry.bootstrap();
const createdWorkspace = workspaceWith("research");
const created = await registry.publish({
action: "create",
workspace: createdWorkspace,
baseCommit: remote.initialCommit,
});
expect(created).toMatchObject({ id: "research", commit: expect.stringMatching(/^[0-9a-f]{40}$/) });
expect(await gitOutput(remote.root, ["--git-dir", remote.remote, "log", "-1", "--format=%an <%ae>"])).toBe(
"Configured Workspace Publisher <publisher@example.invalid>",
);
await expect(runFile("git", ["--git-dir", remote.remote, "cat-file", "-e", "HEAD:workspace-docs/research/README.md"], {
cwd: remote.root,
})).resolves.toBeDefined();
const before = await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"]);
await expect(registry.publish({
action: "update",
workspace: workspaceWith("research", { name: "Research", description: "Updated workspace description" }),
baseCommit: created!.commit,
baseBlob: created!.blob,
})).rejects.toMatchObject({ code: "workspace_curator_owned" });
await expect(registry.publish({
action: "delete",
id: "research",
baseCommit: created!.commit,
baseBlob: created!.blob,
})).rejects.toMatchObject({ code: "workspace_curator_owned" });
expect(await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"])).toBe(before);
await expect(runFile("git", ["--git-dir", remote.remote, "cat-file", "-e", "HEAD:research/workspace.yaml"], {
cwd: remote.root,
})).resolves.toBeDefined();
});
test("rejects curator-owned update after a curator push and leaves the active snapshot intact", async () => {
const remote = await fixture();
const root = join(remote.root, "registry");
const registry = new WorkspaceRegistry(config(root, remote.remote));
await registry.bootstrap();
const initial = await registry.read("psd-clinical");
writeFileSync(join(remote.source, "psd-clinical", "workspace.yaml"), validYaml.replace(
"schema: datawarehouse", "schema: analytics",
));
await git(remote.source, ["add", "psd-clinical/workspace.yaml"]);
await git(remote.source, ["commit", "-m", "Change dwh schema"]);
await git(remote.source, ["push", "origin", "main"]);
const actualCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]);
await registry.pull();
await expect(registry.publish({
action: "update",
workspace: workspaceWith("psd-clinical", { description: "Local stale change" }),
baseCommit: initial.revision.commit,
baseBlob: initial.revision.blob,
})).rejects.toMatchObject({ code: "workspace_curator_owned" });
await expect(registry.read("psd-clinical")).resolves.toMatchObject({
revision: { commit: actualCommit },
});
});
test.each([
["adds", validYaml, withDwhRestDiagnostic(validYaml)],
["removes", withDwhRestDiagnostic(validYaml), validYaml],
@@ -675,56 +488,7 @@ test.each([
await registry.pull();
const updated = await registry.read("psd-clinical");
expect(updated.revision.commit).not.toBe(initial.revision.commit);
await expect(registry.publish({
action: "update",
workspace: workspaceWith("psd-clinical", { description: "Local stale change" }),
baseCommit: initial.revision.commit,
baseBlob: initial.revision.blob,
})).rejects.toMatchObject({ code: "workspace_curator_owned" });
});
test("restores a clean checkout after a failed commit and retries publication", async () => {
const remote = await fixture();
const root = join(remote.root, "registry");
const registry = new WorkspaceRegistry(config(root, remote.remote));
await registry.bootstrap();
const objects = join(root, "repo", ".git", "objects");
chmodSync(objects, 0o500);
const request = {
action: "create" as const,
workspace: workspaceWith("research"),
baseCommit: remote.initialCommit,
};
try {
await expect(registry.publish(request)).rejects.toMatchObject({ code: "git_unavailable" });
} finally {
chmodSync(objects, 0o700);
}
expect(await checkoutStatus(join(root, "repo"))).toEqual({ porcelain: "", divergence: "0\t0" });
await expect(registry.pull()).resolves.toMatchObject({ head: remote.initialCommit });
await expect(registry.publish(request)).resolves.toMatchObject({ id: "research" });
});
test("resets an ahead checkout after a rejected push and retries publication", async () => {
const remote = await fixture();
const root = join(remote.root, "registry");
const registry = new WorkspaceRegistry(config(root, remote.remote));
await registry.bootstrap();
const hook = join(remote.remote, "hooks", "pre-receive");
writeFileSync(hook, "#!/bin/sh\nexit 1\n", { mode: 0o755 });
const request = {
action: "create" as const,
workspace: workspaceWith("research"),
baseCommit: remote.initialCommit,
};
await expect(registry.publish(request)).rejects.toMatchObject({ code: "git_push_rejected" });
expect(await checkoutStatus(join(root, "repo"))).toEqual({ porcelain: "", divergence: "0\t0" });
rmSync(hook);
await expect(registry.pull()).resolves.toMatchObject({ head: remote.initialCommit });
await expect(registry.publish(request)).resolves.toMatchObject({ id: "research" });
});
test.each([
["v1", legacyV1Yaml()],
["v2", legacyV2Yaml()],
@@ -752,16 +516,6 @@ test("writes only state-free revisions and never exposes revision state", async
const read = await registry.read("psd-clinical");
expect(listed[0]).not.toHaveProperty("state");
expect(read.revision).not.toHaveProperty("state");
const published = await registry.publish({
action: "create",
workspace: workspaceWith("research"),
baseCommit: remote.initialCommit,
});
const updated = persistedState(root, published!.commit);
expect(updated.active.revisions[0]).not.toHaveProperty("state");
expect(updated.manifest.revisions[0]).not.toHaveProperty("state");
expect(published).not.toHaveProperty("state");
});
test("accepts historical operational state without leaking it or rewriting the immutable snapshot", async () => {
@@ -851,7 +605,7 @@ test("normalizes operational state in retained historical snapshots without rewr
"name: Policlinico San Donato", "name: Current workspace",
));
writeFileSync(join(remote.source, "thoth-workspaces.yaml"), catalogYaml([
{ id: "psd-clinical", name: "Current workspace" }, { id: "research", name: "Research" },
{ id: "psd-clinical", name: "Current workspace" },
]));
await git(remote.source, ["add", "-A"]);
await git(remote.source, ["commit", "-m", "Update active workspace"]);
@@ -966,7 +720,7 @@ test("retains a historical snapshot while a resumable manifest still references
"name: Policlinico San Donato", "name: Updated Policlinico San Donato",
));
writeFileSync(join(remote.source, "thoth-workspaces.yaml"), catalogYaml([
{ id: "psd-clinical", name: "Updated Policlinico San Donato" }, { id: "research", name: "Research" },
{ id: "psd-clinical", name: "Updated Policlinico San Donato" },
]));
await git(remote.source, ["add", "-A"]);
await git(remote.source, ["commit", "-m", "Update workspace"]);
@@ -994,7 +748,7 @@ test("a session revision lease survives stale retention scans until its manifest
"name: Policlinico San Donato", "name: Concurrent revision",
));
writeFileSync(join(remote.source, "thoth-workspaces.yaml"), catalogYaml([
{ id: "psd-clinical", name: "Concurrent revision" }, { id: "research", name: "Research" },
{ id: "psd-clinical", name: "Concurrent revision" },
]));
await git(remote.source, ["add", "-A"]);
await git(remote.source, ["commit", "-m", "Publish while session is starting"]);
@@ -1024,7 +778,7 @@ test("lists operational descriptors retained after their workspace was removed f
"id: psd-clinical", "id: archive-only",
).replace("collection: psd-clinical", "collection: archive-only"));
writeFileSync(join(remote.source, "thoth-workspaces.yaml"), catalogYaml([
{ id: "psd-clinical", name: "Policlinico San Donato" }, { id: "research", name: "Research" },
{ id: "psd-clinical", name: "Policlinico San Donato" },
{ id: "archive-only", name: "Policlinico San Donato" },
]));
await git(remote.source, ["add", "-A"]);
@@ -1033,6 +787,9 @@ test("lists operational descriptors retained after their workspace was removed f
await registry.pull();
rmSync(join(remote.source, "psd-clinical", "workspace.yaml"));
writeFileSync(join(remote.source, "thoth-workspaces.yaml"), catalogYaml([
{ id: "archive-only", name: "Policlinico San Donato" },
]));
await git(remote.source, ["add", "-u"]);
await git(remote.source, ["commit", "-m", "Remove original workspace"]);
await git(remote.source, ["push", "origin", "main"]);
@@ -1249,18 +1006,6 @@ test("never copies an installation secret canary into Git, generated artifacts,
for (const name of readdirSync(snapshotDirectory)) {
expect(readFileSync(join(snapshotDirectory, name), "utf8")).not.toContain(canary);
}
let thrown: unknown;
try {
await registry.publish({
action: "create",
workspace: filesystemWorkspace("missing-secret-canary-tree"),
baseCommit: status.head!,
});
} catch (error) {
thrown = error;
}
expect(thrown).toMatchObject({ code: "workspace_invalid" });
expect(String(thrown)).not.toContain(canary);
} finally {
if (previous === undefined) delete process.env.THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE;
else process.env.THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE = previous;
+11 -17
View File
@@ -1,5 +1,5 @@
import { execFile } from "node:child_process";
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
@@ -92,6 +92,16 @@ function config(root: string, remoteUrl: string): WorkspaceRegistryConfig {
};
}
test("does not expose repository mutation or publication operations", async () => {
const fixture = await temporaryRemote();
const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote));
expect(repository).not.toHaveProperty("createRegistryFile");
expect(repository).not.toHaveProperty("writeRegistryFile");
expect(repository).not.toHaveProperty("removeRegistryFile");
expect(repository).not.toHaveProperty("commitAndPush");
});
test("bootstraps a persistent checkout from a local bare repository", async () => {
const fixture = await temporaryRemote();
const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote));
@@ -300,19 +310,3 @@ test("parallel contenders recover a stale lock file without overlapping critical
expect(results.filter((result) => result.status === "rejected")).toHaveLength(1);
expect(maximum).toBe(1);
});
test("creates a descriptor only when the exact curator path is absent", async () => {
const fixture = await temporaryRemote();
const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote));
await repository.bootstrap();
const descriptorPath = "new-workspace/workspace.yaml";
const descriptor = "curator descriptor\n";
await expect(repository.createRegistryFile(descriptorPath, descriptor)).resolves.toBeUndefined();
expect(readFileSync(join(fixture.root, "registry", "repo", descriptorPath), "utf8")).toBe(descriptor);
await expect(repository.createRegistryFile(descriptorPath, "overwrite\n"))
.rejects.toMatchObject({ code: "workspace_curator_owned" });
expect(readFileSync(join(fixture.root, "registry", "repo", descriptorPath), "utf8")).toBe(descriptor);
await expect(repository.createRegistryFile("workspace-docs/workspace.yaml", descriptor))
.rejects.toMatchObject({ code: "workspace_invalid" });
});