From 813024861aa518fc74ae11ab85af2ba17fb6c7ea Mon Sep 17 00:00:00 2001
From: "heygengenesis[bot]"
<262951085+heygengenesis[bot]@users.noreply.github.com>
Date: Tue, 15 Sep 2026 18:08:18 +0000
Subject: [PATCH] fix(studio): fail closed on backup failures
Fail closed Studio-server mutations when a backup snapshot reports an error. Return the existing backup-failed HTTP 500 response before any raw or structured mutation can change the original.
Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com>
---
.../studio-server/src/routes/files.test.ts | 67 +++++++++++++++++++
packages/studio-server/src/routes/files.ts | 31 ++++++---
2 files changed, 87 insertions(+), 11 deletions(-)
diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts
index 4d4b6e686c..b6610146c5 100644
--- a/packages/studio-server/src/routes/files.test.ts
+++ b/packages/studio-server/src/routes/files.test.ts
@@ -485,6 +485,27 @@ describe("registerFileRoutes", () => {
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe("after");
});
+ it("fails PUT closed when the backup cannot be created", async () => {
+ const projectDir = createProjectDir();
+ const original = "before";
+ writeFileSync(join(projectDir, "index.html"), original);
+ writeFileSync(join(projectDir, ".hyperframes"), "not a directory");
+ const app = new Hono();
+ registerFileRoutes(app, createAdapter(projectDir));
+
+ const response = await app.request("http://localhost/projects/demo/files/index.html", {
+ method: "PUT",
+ headers: { "If-Match": fileContentVersion(original) },
+ body: "after",
+ });
+
+ expect(response.status).toBe(500);
+ expect(await response.json()).toEqual({
+ error: expect.stringMatching(/^backup failed: ENOTDIR:/),
+ });
+ expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(original);
+ });
+
it("backs up the previous file content before delete", async () => {
const projectDir = createProjectDir();
writeFileSync(join(projectDir, "index.html"), "before delete");
@@ -501,6 +522,25 @@ describe("registerFileRoutes", () => {
expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe("before delete");
});
+ it("fails DELETE closed when the backup cannot be created", async () => {
+ const projectDir = createProjectDir();
+ const original = "before delete";
+ writeFileSync(join(projectDir, "index.html"), original);
+ writeFileSync(join(projectDir, ".hyperframes"), "not a directory");
+ const app = new Hono();
+ registerFileRoutes(app, createAdapter(projectDir));
+
+ const response = await app.request("http://localhost/projects/demo/files/index.html", {
+ method: "DELETE",
+ });
+
+ expect(response.status).toBe(500);
+ expect(await response.json()).toEqual({
+ error: expect.stringMatching(/^backup failed: ENOTDIR:/),
+ });
+ expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(original);
+ });
+
it("backs up the previous file content before structured DOM mutations", async () => {
const projectDir = createProjectDir();
writeFileSync(projectDir + "/index.html", '
Before
');
@@ -538,6 +578,33 @@ describe("registerFileRoutes", () => {
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toContain("After");
});
+ it("fails structured DOM mutations closed when the backup cannot be created", async () => {
+ const projectDir = createProjectDir();
+ const original = 'Before
';
+ writeFileSync(join(projectDir, "index.html"), original);
+ writeFileSync(join(projectDir, ".hyperframes"), "not a directory");
+ const app = new Hono();
+ registerFileRoutes(app, createAdapter(projectDir));
+
+ const response = await app.request(
+ "http://localhost/projects/demo/file-mutations/patch-element/index.html",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ target: { id: "title" },
+ operations: [{ type: "text-content", property: "textContent", value: "After" }],
+ }),
+ },
+ );
+
+ expect(response.status).toBe(500);
+ expect(await response.json()).toEqual({
+ error: expect.stringMatching(/^backup failed: ENOTDIR:/),
+ });
+ expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(original);
+ });
+
it("returns the current durable version for a matched no-op element patch", async () => {
const projectDir = createProjectDir();
const original = 'Before
';
diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts
index 3c1afc351f..0756132ec2 100644
--- a/packages/studio-server/src/routes/files.ts
+++ b/packages/studio-server/src/routes/files.ts
@@ -456,9 +456,9 @@ function writeMutationResult(
filePath: string,
absPath: string,
html: string,
-): { backupPath: string | null; version: string } {
+): { backupPath: string | null; version: string } | Response {
const backup = snapshotBeforeWrite(projectDir, absPath);
- if (backup.error) console.warn(`Failed to create backup for ${filePath}: ${backup.error}`);
+ if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
const { version } = writeFileWithReceipt(c, filePath, absPath, html);
return { backupPath: backupPathForResponse(projectDir, backup.backupPath), version };
}
@@ -475,7 +475,9 @@ function writeIfChanged(
if (next === original) {
return c.json({ ok: true, changed: false, content: original, path: filePath });
}
- const { backupPath } = writeMutationResult(c, projectDir, filePath, absPath, next);
+ const mutationResult = writeMutationResult(c, projectDir, filePath, absPath, next);
+ if (mutationResult instanceof Response) return mutationResult;
+ const { backupPath } = mutationResult;
return c.json({
ok: true,
changed: true,
@@ -1311,13 +1313,15 @@ async function applyGsapMutations(
return c.json({ error: "file changed during GSAP mutation", conflict: true }, 409);
}
if (changed) {
- backupPath = writeMutationResult(
+ const mutationResult = writeMutationResult(
c,
res.project.dir,
res.filePath,
res.absPath,
newHtml,
- ).backupPath;
+ );
+ if (mutationResult instanceof Response) return mutationResult;
+ backupPath = mutationResult.backupPath;
}
const responsePayload: Record = {
@@ -2379,8 +2383,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
);
}
backup = snapshotBeforeWrite(res.project.dir, res.absPath);
- if (backup.error)
- console.warn(`Failed to create backup for ${res.filePath}: ${backup.error}`);
+ if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
ftruncateSync(fd, 0);
writeSync(fd, body, 0, body.length, 0);
} finally {
@@ -2429,7 +2432,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
const stat = statSync(res.absPath);
const backup = snapshotBeforeWrite(res.project.dir, res.absPath);
- if (backup.error) console.warn(`Failed to create backup for ${res.filePath}: ${backup.error}`);
+ if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
if (stat.isDirectory()) {
rmSync(res.absPath, { recursive: true });
} else {
@@ -2766,13 +2769,15 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
version,
});
}
- const { version, backupPath } = writeMutationResult(
+ const mutationResult = writeMutationResult(
c,
ctx.project.dir,
ctx.filePath,
ctx.absPath,
result.html,
);
+ if (mutationResult instanceof Response) return mutationResult;
+ const { version, backupPath } = mutationResult;
c.header("ETag", version);
return c.json({
ok: true,
@@ -2825,13 +2830,15 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
version,
});
}
- const { backupPath, version } = writeMutationResult(
+ const mutationResult = writeMutationResult(
c,
ctx.project.dir,
ctx.filePath,
ctx.absPath,
patched,
);
+ if (mutationResult instanceof Response) return mutationResult;
+ const { backupPath, version } = mutationResult;
c.header("ETag", version);
return c.json({
ok: true,
@@ -2962,13 +2969,15 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
result.error === "grouped elements must share a single parent" ? 422 : 400,
);
}
- const { backupPath } = writeMutationResult(
+ const mutationResult = writeMutationResult(
c,
ctx.project.dir,
ctx.filePath,
ctx.absPath,
result.html,
);
+ if (mutationResult instanceof Response) return mutationResult;
+ const { backupPath } = mutationResult;
return c.json({
ok: true,
changed: true,