diff --git a/src/lib/server/stack-paths.ts b/src/lib/server/stack-paths.ts
new file mode 100644
index 0000000..b6d15d0
--- /dev/null
+++ b/src/lib/server/stack-paths.ts
@@ -0,0 +1,15 @@
+/**
+ * The ONE formula for a stack's folder under a base dir on a host: `/`.
+ * Deploy staging (write) and backup (read) share it so they can't diverge (#1383 / #1240).
+ * Pure + dependency-free, so both sides can import it.
+ */
+
+/** Trim + drop trailing slashes: `/data/stacks/` -> `/data/stacks`. */
+export function normalizeBaseDir(base: string): string {
+ return base.trim().replace(/\/+$/, '');
+}
+
+/** `/`. Name is used verbatim - do NOT slugify (both sides use it raw). */
+export function stackDirIn(base: string, stackName: string): string {
+ return `${normalizeBaseDir(base)}/${stackName}`;
+}
diff --git a/src/routes/api/stacks/[name]/compose/+server.ts b/src/routes/api/stacks/[name]/compose/+server.ts
index c051e4c..71d8f25 100644
--- a/src/routes/api/stacks/[name]/compose/+server.ts
+++ b/src/routes/api/stacks/[name]/compose/+server.ts
@@ -70,22 +70,36 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
return json({ error: 'secretProviderId must be a number or null' }, { status: 400 });
}
+ // Binding a secret provider resolves its secrets into the container at deploy;
+ // require the secrets permission so a stacks-only user can't exfiltrate a
+ // provider's secrets by binding it and reading the container env.
+ if (
+ typeof secretProviderId === 'number' &&
+ auth.authEnabled &&
+ !(await auth.can('secrets', 'view', envIdNum))
+ ) {
+ return json({ error: 'Permission denied: binding a secret provider requires the secrets permission' }, { status: 403 });
+ }
+
// Build options object for custom paths, move operation, file renames, and secret provider binding
const pathOptions = (composePath || envPath !== undefined || moveFromDir || oldComposePath || oldEnvPath || secretProviderId !== undefined)
? { composePath, envPath, moveFromDir, oldComposePath, oldEnvPath, secretProviderId }
: undefined;
+ // Persist the submitted content on EVERY accepted PUT, whether or not path fields came
+ // along. Gating this on pathOptions left Dockhand's stored copy stale while restart:true
+ // deployed the new content - a later GET then served the old copy, silently reverting the
+ // change on the next read/edit/deploy round-trip (#1383). saveStackComposeFile handles a
+ // possibly-undefined pathOptions fine (the non-restart branch already relied on that).
+ const saveResult = await saveStackComposeFile(name, content, false, envIdNum, pathOptions);
+ if (!saveResult.success) {
+ return json({ error: saveResult.error }, { status: 500 });
+ }
+
if (restart) {
- // Deploy with docker compose up -d --force-recreate
- // Force recreate ensures env var changes are applied
- // Save paths first if provided
- if (pathOptions) {
- const saveResult = await saveStackComposeFile(name, content, false, envIdNum, pathOptions);
- if (!saveResult.success) {
- return json({ error: saveResult.error }, { status: 500 });
- }
- }
- // Get authoritative paths from DB/filesystem for deploy
+ // Deploy with docker compose up -d --force-recreate.
+ // Force recreate ensures env var changes are applied.
+ // Get authoritative paths from DB/filesystem for deploy (now reflects the saved content).
const composeInfo = await getStackComposeFile(name, envIdNum);
// Deploy via SSE to keep connection alive during long operations
@@ -112,13 +126,7 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
}, request);
}
- // Just save the file without restarting (update operation, not create)
- const result = await saveStackComposeFile(name, content, false, envIdNum, pathOptions);
-
- if (!result.success) {
- return json({ error: result.error }, { status: 500 });
- }
-
+ // No restart: the content is already persisted above.
return json({ success: true });
} catch (error: any) {
console.error(`Error updating compose file for stack ${name}:`, error);