@or3/cloud 0.1.37 → 0.1.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,19 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { createHash, randomBytes } from "node:crypto";
4
+ import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
5
5
  import { execFile as execFileCallback, spawn } from "node:child_process";
6
6
  import { createServer } from "node:net";
7
7
  import { lookup } from "node:dns/promises";
8
8
  import readline from "node:readline/promises";
9
9
  import { stdin as input, stdout as output } from "node:process";
10
- import { createReadStream, createWriteStream, existsSync } from "node:fs";
10
+ import { createReadStream, createWriteStream, existsSync, readFileSync } from "node:fs";
11
11
  import {
12
12
  chmod,
13
13
  copyFile,
14
14
  mkdir,
15
+ open,
15
16
  readFile,
16
17
  readdir,
18
+ realpath,
17
19
  rename,
18
20
  rm,
19
21
  stat,
@@ -27,16 +29,26 @@ import { dirname, basename, isAbsolute, join, resolve, sep } from "node:path";
27
29
  import { fileURLToPath } from "node:url";
28
30
  import { createGunzip } from "node:zlib";
29
31
  var execFile = promisify(execFileCallback);
30
- var PACKAGE_VERSION = "0.1.37";
32
+ var PACKAGE_ROOT = resolve(fileURLToPath(new URL("../", import.meta.url)));
33
+ var PACKAGE_VERSION = "0.1.49";
31
34
  var IMAGE_REPOSITORY = "ghcr.io/saluana/or3-chat";
32
35
  var ASSET_ROOT = resolve(fileURLToPath(new URL("../assets/", import.meta.url)));
33
36
  var STATE_SCHEMA_VERSION = 1;
34
37
  var DEFAULT_PORT = 3000;
35
38
  var DEEP_HEALTH_TIMEOUT_MS = 180000;
39
+ var COMMAND_TIMEOUT_MS = 180000;
40
+ var STREAM_COMMAND_TIMEOUT_MS = 15 * 60000;
36
41
  var BACKUP_RETENTION_KEEP = 5;
37
42
  var PURGE_REQUIRES_BACKUP_WITHIN_MS = 24 * 60 * 60 * 1000;
38
43
  var FREE_SPACE_HEADROOM_BYTES = 64 * 1024 * 1024;
39
44
  var BACKUP_ID_PATTERN = /^backup-[0-9A-Za-z-]+$/;
45
+ var MANAGED_ASSET_INVENTORY_VERSION = 3;
46
+ var DASHBOARD_LEASE_STALE_MS = 30000;
47
+ var PROVISIONING_CREDENTIAL_KEYS = [
48
+ "OR3_BASIC_AUTH_BOOTSTRAP_EMAIL",
49
+ "OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD",
50
+ "OR3_ADMIN_PASSWORD"
51
+ ];
40
52
  var SECRET_KEYS = [
41
53
  "OR3_BASIC_AUTH_JWT_SECRET",
42
54
  "OR3_BASIC_AUTH_REFRESH_SECRET",
@@ -54,6 +66,32 @@ var DEPLOYMENT_ENV_KEYS = [
54
66
  "OR3_PORT",
55
67
  "OR3_PUBLIC_DOMAIN"
56
68
  ];
69
+ var DEPLOYMENT_ID_ENV_KEY = "OR3_DEPLOYMENT_ID";
70
+ var PROCESS_ENV_PASSTHROUGH = [
71
+ "PATH",
72
+ "HOME",
73
+ "USER",
74
+ "LOGNAME",
75
+ "SHELL",
76
+ "TMPDIR",
77
+ "TMP",
78
+ "TEMP",
79
+ "XDG_CONFIG_HOME",
80
+ "XDG_RUNTIME_DIR",
81
+ "SSL_CERT_FILE",
82
+ "SSL_CERT_DIR",
83
+ "HTTP_PROXY",
84
+ "HTTPS_PROXY",
85
+ "NO_PROXY",
86
+ "http_proxy",
87
+ "https_proxy",
88
+ "no_proxy",
89
+ "DOCKER_HOST",
90
+ "DOCKER_CONTEXT",
91
+ "DOCKER_TLS_VERIFY",
92
+ "DOCKER_CERT_PATH",
93
+ "DOCKER_CONFIG"
94
+ ];
57
95
  var ALLOWED_ENV_KEYS = new Set([
58
96
  "SSR_AUTH_ENABLED",
59
97
  "AUTH_PROVIDER",
@@ -68,6 +106,7 @@ var ALLOWED_ENV_KEYS = new Set([
68
106
  "OR3_BASIC_AUTH_DB_PATH",
69
107
  "OR3_BASIC_AUTH_BOOTSTRAP_EMAIL",
70
108
  "OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD",
109
+ "OR3_MANAGED_OWNER_EMAIL",
71
110
  "OR3_SYNC_ENABLED",
72
111
  "OR3_CLOUD_SYNC_ENABLED",
73
112
  "OR3_SYNC_PROVIDER",
@@ -116,6 +155,9 @@ function serializeInitialCredentials(input2) {
116
155
  `);
117
156
  }
118
157
  function validatePassword(password) {
158
+ if (password.includes("\x00") || /\r|\n/.test(password)) {
159
+ throw new Error("The administrator password may not contain NUL or newline characters.");
160
+ }
119
161
  if (password.length < 12 || !/[A-Z]/.test(password) || !/[a-z]/.test(password) || !/[0-9]/.test(password)) {
120
162
  throw new Error("The administrator password must be at least 12 characters and contain uppercase, lowercase, and numeric characters.");
121
163
  }
@@ -127,7 +169,7 @@ function redact(text, secrets = []) {
127
169
  let output2 = text;
128
170
  for (const secret of secrets.filter(Boolean))
129
171
  output2 = output2.replaceAll(secret, "[REDACTED]");
130
- output2 = output2.replace(/((?:PASSWORD|SECRET|TOKEN|JWT)\s*[=:]\s*)([^\s\n]+)/gi, "$1[REDACTED]");
172
+ output2 = output2.replace(/((?:PASSWORD|SECRET|TOKEN|JWT)\s*[=:]\s*)(?!\[REDACTED\])([^\r\n]+)/gi, "$1[REDACTED]");
131
173
  return output2;
132
174
  }
133
175
  function parseFlags(argv) {
@@ -161,9 +203,10 @@ var COMMAND_FLAGS = {
161
203
  restore: ["yes"],
162
204
  rollback: ["yes"],
163
205
  doctor: [],
206
+ verify: ["public", "verification-email", "verification-password-file"],
164
207
  recover: [],
165
208
  adopt: ["from"],
166
- credentials: ["yes", "owner-password", "admin-password"],
209
+ credentials: ["yes", "owner-password", "owner-password-file", "admin-password", "admin-password-file"],
167
210
  status: [],
168
211
  logs: ["tail"],
169
212
  start: [],
@@ -178,6 +221,28 @@ function assertCommandFlags(command, flags) {
178
221
  throw new Error(`Unknown option${unknown.length === 1 ? "" : "s"} for ${command}: ${unknown.map((flag) => `--${flag}`).join(", ")}. Run npx @or3/cloud ${command} --help.`);
179
222
  }
180
223
  }
224
+ function assertCommandPositionals(command, positionals) {
225
+ if (command === "backup" || command === "credentials")
226
+ return;
227
+ if (command === "init" || command === "adopt") {
228
+ if (positionals.length > 1)
229
+ throw new Error(`${command} accepts at most one target directory.`);
230
+ return;
231
+ }
232
+ if (command === "restore") {
233
+ if (positionals.length !== 1)
234
+ throw new Error("restore requires exactly one backup ID or absolute backup path.");
235
+ return;
236
+ }
237
+ if (command === "logs") {
238
+ if (positionals.length > 1)
239
+ throw new Error("logs accepts at most one service name.");
240
+ return;
241
+ }
242
+ if (positionals.length > 0) {
243
+ throw new Error(`${command} accepts no positional arguments. Run npx @or3/cloud ${command} --help.`);
244
+ }
245
+ }
181
246
  function stringFlag(flags, key) {
182
247
  const value = flags[key];
183
248
  return typeof value === "string" ? value : undefined;
@@ -202,6 +267,9 @@ function imageFor(version) {
202
267
  return testImage;
203
268
  return `${IMAGE_REPOSITORY}:${version}`;
204
269
  }
270
+ function operatorImageFor(version) {
271
+ return process.env.OR3_CLOUD_TEST_OPERATOR_IMAGE?.trim() || `${IMAGE_REPOSITORY}:${version}-operator`;
272
+ }
205
273
  function sanitizeName(value, fallback = "or3-cloud") {
206
274
  const name = value.toLowerCase().replaceAll(/[^a-z0-9_-]+/g, "-").replaceAll(/^-+|-+$/g, "");
207
275
  return name || fallback;
@@ -234,14 +302,38 @@ function validateDomain(domain) {
234
302
  throw new Error(`"${domain}" is not a hostname. Use a name such as cloud.example.com.`);
235
303
  }
236
304
  }
237
- async function run(command, args, cwd) {
305
+ function composeProcessEnv(directory) {
306
+ const safe = {};
307
+ for (const key of PROCESS_ENV_PASSTHROUGH) {
308
+ if (process.env[key] !== undefined)
309
+ safe[key] = process.env[key];
310
+ }
311
+ const envPath = join(directory, ".env");
312
+ try {
313
+ for (const [key, value] of Object.entries(parseEnv(readFileSync(envPath, "utf8")))) {
314
+ if (key.startsWith("OR3_"))
315
+ safe[key] = value;
316
+ }
317
+ return safe;
318
+ } catch (error) {
319
+ throw new Error(`Could not read managed Compose environment ${envPath}: ${error instanceof Error ? error.message : String(error)}`);
320
+ }
321
+ }
322
+ function commandEnvironment(command, args, cwd, supplied) {
323
+ if (supplied)
324
+ return supplied;
325
+ return command === "docker" && args[0] === "compose" && cwd ? composeProcessEnv(cwd) : process.env;
326
+ }
327
+ async function run(command, args, cwd, environment) {
238
328
  const printable = `${command} ${args.map(quote).join(" ")}`;
239
329
  try {
240
330
  const result = await execFile(command, args, {
241
331
  cwd,
242
332
  maxBuffer: 4 * 1024 * 1024,
243
333
  encoding: "utf8",
244
- env: process.env
334
+ timeout: COMMAND_TIMEOUT_MS,
335
+ killSignal: "SIGKILL",
336
+ env: commandEnvironment(command, args, cwd, environment)
245
337
  });
246
338
  return {
247
339
  ok: true,
@@ -274,13 +366,35 @@ async function writeSecure(path, content) {
274
366
  const temporary = `${path}.tmp-${randomBytes(4).toString("hex")}`;
275
367
  await writeFile(temporary, content, { mode: 384 });
276
368
  await chmod(temporary, 384);
277
- await rename(temporary, path);
278
- await chmod(path, 384);
369
+ await durableRename(temporary, path);
370
+ }
371
+ async function syncFile(path) {
372
+ const handle = await open(path, "r");
373
+ try {
374
+ await handle.sync();
375
+ } finally {
376
+ await handle.close();
377
+ }
378
+ }
379
+ async function syncDirectory(path) {
380
+ const handle = await open(path, "r");
381
+ try {
382
+ await handle.sync();
383
+ } finally {
384
+ await handle.close();
385
+ }
386
+ }
387
+ async function durableRename(source, destination) {
388
+ await syncFile(source);
389
+ await rename(source, destination);
390
+ await syncDirectory(dirname(destination));
279
391
  }
280
392
  async function copySecure(source, destination) {
281
393
  await mkdir(dirname(destination), { recursive: true, mode: 448 });
282
394
  await copyFile(source, destination);
283
395
  await chmod(destination, 384);
396
+ await syncFile(destination);
397
+ await syncDirectory(dirname(destination));
284
398
  }
285
399
  async function fileExists(path) {
286
400
  try {
@@ -293,6 +407,13 @@ async function fileExists(path) {
293
407
  async function readText(path) {
294
408
  return readFile(path, "utf8");
295
409
  }
410
+ async function readOwnerOnlyText(path, label) {
411
+ const info = await stat(path);
412
+ if (!info.isFile() || (info.mode & 63) !== 0) {
413
+ throw new Error(`${label} must be a regular owner-only file with no group/world permissions.`);
414
+ }
415
+ return readText(path);
416
+ }
296
417
  function requiredArchiveSpace(requiredBytes) {
297
418
  return requiredBytes + Math.max(Math.ceil(requiredBytes / 2), FREE_SPACE_HEADROOM_BYTES);
298
419
  }
@@ -353,9 +474,56 @@ function deploymentPaths(directory) {
353
474
  state: join(cloud, "state.json"),
354
475
  operations: join(cloud, "operations"),
355
476
  backups: join(cloud, "backups"),
356
- exports: join(cloud, "exports")
477
+ exports: join(cloud, "exports"),
478
+ lease: join(cloud, "operation-lease"),
479
+ backupAuthKey: join(cloud, "backup-auth.key"),
480
+ operatorIpc: join(cloud, "operator-ipc")
357
481
  };
358
482
  }
483
+ async function backupAuthenticationKey(directory, create = false) {
484
+ const keyPath = deploymentPaths(directory).backupAuthKey;
485
+ try {
486
+ const key = (await readText(keyPath)).trim();
487
+ if (!/^[0-9a-f]{64}$/i.test(key))
488
+ throw new Error("invalid format");
489
+ return key;
490
+ } catch (error) {
491
+ if (error.code !== "ENOENT") {
492
+ throw new Error(`Backup authentication key at ${keyPath} is invalid or unreadable. Restore is refused until it is repaired from the deployment owner’s secure key material.`);
493
+ }
494
+ if (!create) {
495
+ throw new Error(`This deployment has no backup authentication key at ${keyPath}. Backups created by older CLI versions are intentionally not trusted for restore. Create a new verified backup before attempting a destructive restore.`);
496
+ }
497
+ const key = randomBytes(32).toString("hex");
498
+ await writeSecure(keyPath, `${key}
499
+ `);
500
+ return key;
501
+ }
502
+ }
503
+ function backupAuthenticationTag(key, manifestContents) {
504
+ return createHmac("sha256", Buffer.from(key, "hex")).update(manifestContents).digest("hex");
505
+ }
506
+ async function writeBackupAuthentication(directory, backupPath, manifestContents) {
507
+ const key = await backupAuthenticationKey(directory, true);
508
+ await writeSecure(join(backupPath, "manifest.auth"), `${backupAuthenticationTag(key, manifestContents)}
509
+ `);
510
+ }
511
+ async function assertBackupAuthentication(directory, backupPath, manifestContents) {
512
+ const key = await backupAuthenticationKey(directory);
513
+ let provided;
514
+ try {
515
+ const value = (await readText(join(backupPath, "manifest.auth"))).trim();
516
+ if (!/^[0-9a-f]{64}$/i.test(value))
517
+ throw new Error("invalid authentication tag");
518
+ provided = Buffer.from(value, "hex");
519
+ } catch {
520
+ throw new Error(`Backup at ${backupPath} has no valid deployment authentication tag. Refusing to restore or export an unauthenticated backup.`);
521
+ }
522
+ const expected = Buffer.from(backupAuthenticationTag(key, manifestContents), "hex");
523
+ if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
524
+ throw new Error(`Backup authentication failed for ${backupPath}. Refusing to trust data, configuration, or managed assets from another deployment.`);
525
+ }
526
+ }
359
527
  async function readState(directory) {
360
528
  const paths = deploymentPaths(directory);
361
529
  const parsed = JSON.parse(await readText(paths.state));
@@ -371,8 +539,15 @@ async function writeState(directory, state) {
371
539
  async function readDirectoryEmpty(directory) {
372
540
  try {
373
541
  const entries = await readdir(directory);
374
- if (entries.length)
542
+ const nonLeaseEntries = entries.filter((entry) => entry !== ".or3-cloud");
543
+ if (nonLeaseEntries.length)
375
544
  throw new Error(`Refusing to use non-empty directory ${directory}. Choose a new directory or run adopt explicitly.`);
545
+ if (entries.includes(".or3-cloud")) {
546
+ const cloudEntries = await readdir(join(directory, ".or3-cloud"));
547
+ if (cloudEntries.some((entry) => entry !== "operation-lease")) {
548
+ throw new Error(`Refusing to use non-empty directory ${directory}. Choose a new directory or run adopt explicitly.`);
549
+ }
550
+ }
376
551
  return false;
377
552
  } catch (error) {
378
553
  if (error.code === "ENOENT")
@@ -380,12 +555,124 @@ async function readDirectoryEmpty(directory) {
380
555
  throw error;
381
556
  }
382
557
  }
558
+ async function readLeaseOwner(leasePath) {
559
+ try {
560
+ const owner = JSON.parse(await readText(join(leasePath, "owner.json")));
561
+ if (owner.schemaVersion !== 1 || typeof owner.nonce !== "string" || typeof owner.command !== "string" || owner.origin !== "cli" && owner.origin !== "dashboard" || !Number.isSafeInteger(owner.pid) || typeof owner.acquiredAt !== "string" || typeof owner.heartbeatAt !== "string")
562
+ return;
563
+ return owner;
564
+ } catch {
565
+ return;
566
+ }
567
+ }
568
+ function cliLeaseOwnerIsGone(owner) {
569
+ if (owner.origin !== "cli")
570
+ return false;
571
+ try {
572
+ process.kill(owner.pid, 0);
573
+ return false;
574
+ } catch (error) {
575
+ return error.code === "ESRCH";
576
+ }
577
+ }
578
+ function dashboardLeaseOwnerIsStale(owner, jobId) {
579
+ if (owner.origin !== "dashboard" || jobId && owner.jobId !== jobId)
580
+ return false;
581
+ const heartbeat = Date.parse(owner.heartbeatAt);
582
+ return Number.isFinite(heartbeat) && Date.now() - heartbeat > DASHBOARD_LEASE_STALE_MS;
583
+ }
584
+ async function acquireDeploymentLease(directory, command) {
585
+ const leasePath = deploymentPaths(directory).lease;
586
+ await mkdir(dirname(leasePath), { recursive: true, mode: 448 });
587
+ const nonce = randomBytes(16).toString("hex");
588
+ const owner = {
589
+ schemaVersion: 1,
590
+ nonce,
591
+ command,
592
+ origin: process.env.OR3_DASHBOARD_UPDATE_JOB_ID ? "dashboard" : "cli",
593
+ jobId: process.env.OR3_DASHBOARD_UPDATE_JOB_ID?.trim() || undefined,
594
+ pid: process.pid,
595
+ acquiredAt: now(),
596
+ heartbeatAt: now()
597
+ };
598
+ try {
599
+ await mkdir(leasePath, { recursive: false, mode: 448 });
600
+ } catch (error) {
601
+ if (error.code !== "EEXIST")
602
+ throw error;
603
+ const active = await readLeaseOwner(leasePath);
604
+ if (active && (cliLeaseOwnerIsGone(active) || dashboardLeaseOwnerIsStale(active, owner.jobId))) {
605
+ const stalePath = `${leasePath}.stale-${randomBytes(8).toString("hex")}`;
606
+ try {
607
+ await rename(leasePath, stalePath);
608
+ await rm(stalePath, { recursive: true, force: true });
609
+ await mkdir(leasePath, { recursive: false, mode: 448 });
610
+ } catch (reclaimError) {
611
+ throw new Error(`Another OR3 Cloud operation owns ${directory}. Refusing to race its lifecycle lock. ${reclaimError instanceof Error ? reclaimError.message : String(reclaimError)}`);
612
+ }
613
+ } else {
614
+ const detail = active ? `${active.command} (${active.origin}) acquired at ${active.acquiredAt}` : "an unreadable owner record";
615
+ throw new Error(`Another OR3 Cloud operation owns ${directory}: ${detail}. Refusing to run concurrently.`);
616
+ }
617
+ }
618
+ try {
619
+ await writeSecure(join(leasePath, "owner.json"), `${JSON.stringify(owner, null, 2)}
620
+ `);
621
+ } catch (error) {
622
+ await rm(leasePath, { recursive: true, force: true }).catch(() => {
623
+ return;
624
+ });
625
+ throw error;
626
+ }
627
+ const heartbeat = setInterval(() => {
628
+ owner.heartbeatAt = now();
629
+ writeSecure(join(leasePath, "owner.json"), `${JSON.stringify(owner, null, 2)}
630
+ `).catch(() => {
631
+ return;
632
+ });
633
+ }, 5000);
634
+ heartbeat.unref();
635
+ return async () => {
636
+ clearInterval(heartbeat);
637
+ const current = await readLeaseOwner(leasePath);
638
+ if (current?.nonce === nonce)
639
+ await rm(leasePath, { recursive: true, force: true });
640
+ };
641
+ }
642
+ async function withDeploymentLease(directory, command, action) {
643
+ const release = await acquireDeploymentLease(directory, command);
644
+ try {
645
+ return await action();
646
+ } finally {
647
+ await release();
648
+ }
649
+ }
650
+ function dashboardUpdatesEnabled(directory) {
651
+ try {
652
+ return parseEnv(readFileSync(join(directory, ".env"), "utf8")).OR3_DASHBOARD_UPDATES_ENABLED === "true";
653
+ } catch {
654
+ return false;
655
+ }
656
+ }
383
657
  function composeArgs(directory, mode, command = []) {
658
+ const env = parseEnv(readFileSync(join(directory, ".env"), "utf8"));
659
+ const project = env.OR3_COMPOSE_PROJECT;
660
+ if (!project)
661
+ throw new Error(`Managed Compose environment at ${join(directory, ".env")} has no OR3_COMPOSE_PROJECT.`);
384
662
  const files = ["-f", join(directory, "compose.yaml")];
385
663
  if (mode === "public")
386
664
  files.push("-f", join(directory, "compose.public.yaml"));
665
+ if (dashboardUpdatesEnabled(directory)) {
666
+ const operatorOverlay = join(directory, "compose.operator.yaml");
667
+ if (!existsSync(operatorOverlay)) {
668
+ throw new Error("Dashboard updates are enabled but compose.operator.yaml is missing. Run `npx @or3/cloud recover` before operating on this deployment.");
669
+ }
670
+ files.push("-f", operatorOverlay);
671
+ }
387
672
  return [
388
673
  "compose",
674
+ "--project-name",
675
+ project,
389
676
  "--project-directory",
390
677
  directory,
391
678
  "--env-file",
@@ -395,7 +682,7 @@ function composeArgs(directory, mode, command = []) {
395
682
  ];
396
683
  }
397
684
  function diagnostics(directory, mode) {
398
- const files = `-f ${quote(join(directory, "compose.yaml"))}${mode === "public" ? ` -f ${quote(join(directory, "compose.public.yaml"))}` : ""}`;
685
+ const files = `-f ${quote(join(directory, "compose.yaml"))}${mode === "public" ? ` -f ${quote(join(directory, "compose.public.yaml"))}` : ""}${dashboardUpdatesEnabled(directory) ? ` -f ${quote(join(directory, "compose.operator.yaml"))}` : ""}`;
399
686
  return `cd ${quote(directory)} && docker compose --env-file ${quote(join(directory, ".env"))} ${files} ps && docker compose --env-file ${quote(join(directory, ".env"))} ${files} logs --tail=200`;
400
687
  }
401
688
  async function captureComposeDiagnostics(directory, mode, secrets) {
@@ -453,6 +740,15 @@ function composeEnvironmentValue(service, key) {
453
740
  const value = environment?.[key];
454
741
  return typeof value === "string" ? value : undefined;
455
742
  }
743
+ function composeLabelValue(service, key) {
744
+ const labels = service.labels;
745
+ if (Array.isArray(labels)) {
746
+ const line = labels.find((value2) => value2.startsWith(`${key}=`));
747
+ return line?.slice(key.length + 1);
748
+ }
749
+ const value = labels?.[key];
750
+ return typeof value === "string" ? value : undefined;
751
+ }
456
752
  function composePublishedPort(port) {
457
753
  const target = Number(port.target);
458
754
  const published = Number(port.published);
@@ -465,6 +761,8 @@ function checkResolvedLoopbackBinding(config, expectedPort) {
465
761
  const service = parsed.services?.or3;
466
762
  if (!service)
467
763
  return false;
764
+ if (service.network_mode)
765
+ return false;
468
766
  const ports = (service.ports ?? []).map(composePublishedPort);
469
767
  const appPorts = ports.filter(({ target }) => target === 3000);
470
768
  if (!appPorts.length)
@@ -485,12 +783,94 @@ ${result.stderr}`);
485
783
  }
486
784
  async function assertSafeComposeBinding(directory, mode, env) {
487
785
  const config = await resolvedComposeConfig(directory, mode);
786
+ const parsed = parseComposeConfig(config);
787
+ const service = parsed.services?.or3;
788
+ if (parsed.name !== undefined && parsed.name !== env.OR3_COMPOSE_PROJECT) {
789
+ throw new Error("Resolved Compose configuration does not use the managed project name. Refusing to operate on another deployment.");
790
+ }
488
791
  if (!checkResolvedLoopbackBinding(config, Number(env.OR3_PORT))) {
489
792
  throw new Error("Resolved Compose configuration must publish OR3 port 3000 only on 127.0.0.1. Refusing to expose the application directly.");
490
793
  }
794
+ if (!service || service.image !== env.OR3_IMAGE) {
795
+ throw new Error("Resolved Compose configuration does not use the managed immutable OR3 image reference. Refusing to start an unexpected image.");
796
+ }
797
+ if (env.OR3_DEPLOYMENT_ID && composeLabelValue(service, "io.or3.cloud.deployment-id") !== env.OR3_DEPLOYMENT_ID) {
798
+ throw new Error("Resolved Compose configuration does not carry the managed deployment identity label. Refusing to start an unbound project.");
799
+ }
800
+ const dataMount = (service.volumes ?? []).find((mount) => mount.target === "/data");
801
+ const source = typeof dataMount?.source === "string" ? dataMount.source : "";
802
+ const volumeName = source ? parsed.volumes?.[source]?.name : undefined;
803
+ if (dataMount?.type !== "volume" || volumeName !== env.OR3_VOLUME_NAME) {
804
+ throw new Error("Resolved Compose configuration does not bind the managed OR3 data volume. Refusing to operate on an unexpected volume.");
805
+ }
806
+ const volumeLabels = source ? parsed.volumes?.[source]?.labels : undefined;
807
+ const deploymentLabel = Array.isArray(volumeLabels) ? volumeLabels.find((value) => value.startsWith("io.or3.cloud.deployment-id="))?.slice("io.or3.cloud.deployment-id=".length) : volumeLabels?.["io.or3.cloud.deployment-id"];
808
+ if (env.OR3_DEPLOYMENT_ID && deploymentLabel !== env.OR3_DEPLOYMENT_ID) {
809
+ throw new Error("Resolved managed data volume lacks the expected deployment identity label.");
810
+ }
811
+ if (env.OR3_DASHBOARD_UPDATES_ENABLED === "true") {
812
+ const operator = parsed.services?.["or3-operator"];
813
+ if (!operator || operator.network_mode || operator.image !== env.OR3_OPERATOR_IMAGE || env.OR3_DEPLOYMENT_ID && composeLabelValue(operator, "io.or3.cloud.deployment-id") !== env.OR3_DEPLOYMENT_ID) {
814
+ throw new Error("Resolved dashboard operator does not match the managed digest-qualified deployment bridge. Refusing to start it.");
815
+ }
816
+ if (process.env.OR3_CLOUD_SKIP_PULL !== "true" && !/@sha256:[0-9a-f]{64}$/i.test(env.OR3_OPERATOR_IMAGE ?? "")) {
817
+ throw new Error("The dashboard operator image is not digest-qualified. Refusing to start a mutable privileged runtime.");
818
+ }
819
+ }
820
+ }
821
+ async function assertRunningAppImage(directory, mode, expectedImage) {
822
+ const container = await run("docker", composeArgs(directory, mode, ["ps", "-q", "or3"]), directory);
823
+ const containerId = container.stdout.trim();
824
+ if (!container.ok || !containerId)
825
+ throw new Error("OR3 started without a running application container.");
826
+ const image = await run("docker", ["inspect", "--format", "{{.Image}}", containerId], directory);
827
+ if (!image.ok || !image.stdout.trim())
828
+ throw new Error("Could not inspect the image of the running OR3 container.");
829
+ const expectedDigest = expectedImage.match(/@((?:sha256:)[0-9a-f]{64})$/i)?.[1];
830
+ if (!expectedDigest && process.env.OR3_CLOUD_SKIP_PULL === "true") {
831
+ const expectedId = await run("docker", ["image", "inspect", "--format", "{{.Id}}", expectedImage], directory);
832
+ if (!expectedId.ok || expectedId.stdout.trim() !== image.stdout.trim()) {
833
+ throw new Error("The local qualification fixture started a different OR3 image than the one selected by the managed environment.");
834
+ }
835
+ return;
836
+ }
837
+ if (!expectedDigest)
838
+ throw new Error(`Managed OR3 image ${expectedImage} is not digest-qualified.`);
839
+ const repoDigests = await run("docker", ["image", "inspect", "--format", "{{json .RepoDigests}}", image.stdout.trim()], directory);
840
+ if (!repoDigests.ok)
841
+ throw new Error("Could not inspect repository digests for the running OR3 container.");
842
+ let values;
843
+ try {
844
+ values = JSON.parse(repoDigests.stdout.trim());
845
+ } catch {
846
+ throw new Error("The running OR3 container has no readable repository digests.");
847
+ }
848
+ const expected = `${imageRepository(expectedImage)}@${expectedDigest}`;
849
+ if (!values.includes(expected)) {
850
+ throw new Error(`The running OR3 container image does not match the managed digest ${expectedDigest}. Refusing to commit startup.`);
851
+ }
491
852
  }
492
853
  var HEALTH_SCRIPT = "const fs=require('node:fs');try{fs.accessSync('/data',fs.constants.R_OK|fs.constants.W_OK);for(const path of [process.env.OR3_BASIC_AUTH_DB_PATH,process.env.OR3_SQLITE_DB_PATH].filter(Boolean)){const fd=fs.openSync(path,'r+');fs.closeSync(fd)}}catch{process.exit(1)}fetch('http://127.0.0.1:3000/api/health?deep=true').then(async response=>{const body=await response.json().catch(()=>({}));if(!response.ok||body.status!=='ok')process.exit(1)}).catch(()=>process.exit(1))";
493
854
  var MAINTENANCE_SCRIPT = "fetch('http://127.0.0.1:3000/api/health?deep=true').then(async response=>{const body=await response.json().catch(()=>({}));const m=body?.providers?.sync?.details?.maintenance;if(m)console.log(JSON.stringify(m))}).catch(()=>{})";
855
+ var VERIFY_DATABASES_SCRIPT = `
856
+ const fs = require('node:fs');
857
+ const Database = require('/app/.output/server/node_modules/better-sqlite3');
858
+ const results = [];
859
+ for (const path of [process.env.OR3_BASIC_AUTH_DB_PATH, process.env.OR3_SQLITE_DB_PATH].filter(Boolean)) {
860
+ const info = fs.statSync(path);
861
+ if (info.uid !== 65532 || info.gid !== 65532) throw new Error(path + ' must be owned by 65532:65532');
862
+ const db = new Database(path, { readonly: true, fileMustExist: true });
863
+ const quickCheck = db.pragma('quick_check', { simple: true });
864
+ const tables = db.prepare("select count(*) as count from sqlite_master where type = 'table'").get().count;
865
+ db.close();
866
+ if (quickCheck !== 'ok') throw new Error(path + ' quick_check failed: ' + quickCheck);
867
+ results.push({ path, quickCheck, tables });
868
+ }
869
+ if (process.env.OR3_FORCE_HTTPS === 'true' && process.env.NUXT_SECURITY_PROXY_TRUST_PROXY !== 'true') {
870
+ throw new Error('NUXT_SECURITY_PROXY_TRUST_PROXY must be true for a managed public deployment');
871
+ }
872
+ console.log(JSON.stringify(results));
873
+ `;
494
874
  var CONTAINER_NODE = "/nodejs/bin/node";
495
875
  var LEGACY_CONTAINER_NODE = "/usr/local/bin/node";
496
876
  var CONTAINER_NODE_SHELL = `if [ -x ${CONTAINER_NODE} ]; then exec ${CONTAINER_NODE} -e "$1"; elif [ -x ${LEGACY_CONTAINER_NODE} ]; then exec ${LEGACY_CONTAINER_NODE} -e "$1"; else exit 127; fi`;
@@ -580,6 +960,81 @@ async function setManagedVolumeRootOwnership(image, volume, ownership) {
580
960
  throw new Error(`Managed volume ${volume} root ownership verification failed.`);
581
961
  }
582
962
  }
963
+ function updateRequiresVolumeRecreation(state, env) {
964
+ return !state.deploymentId && !env.OR3_DEPLOYMENT_ID;
965
+ }
966
+ function restoreRequiresVolumeRecreation(currentEnv, targetEnv) {
967
+ return currentEnv.OR3_DEPLOYMENT_ID !== targetEnv.OR3_DEPLOYMENT_ID;
968
+ }
969
+ async function removeManagedDataVolumeForRecreation(directory, state) {
970
+ const containers = await run("docker", [
971
+ "ps",
972
+ "-aq",
973
+ "--filter",
974
+ `label=com.docker.compose.project=${state.composeProject}`,
975
+ "--filter",
976
+ "label=com.docker.compose.service=or3"
977
+ ], directory);
978
+ if (!containers.ok)
979
+ throw new Error(`Could not resolve the managed OR3 container. ${containers.stderr.trim()}`);
980
+ const containerIds = containers.stdout.trim().split(/\s+/).filter(Boolean);
981
+ if (containerIds.some((value) => !/^[0-9a-f]{12,64}$/i.test(value))) {
982
+ throw new Error("Docker returned an invalid managed OR3 container ID. Refusing to recreate the data volume.");
983
+ }
984
+ if (containerIds.length) {
985
+ const removedContainers = await run("docker", ["rm", "--force", ...containerIds], directory);
986
+ if (!removedContainers.ok)
987
+ throw new Error(`Could not remove the stopped managed OR3 container. ${removedContainers.stderr.trim()}`);
988
+ }
989
+ const inspected = await run("docker", ["volume", "inspect", state.volumeName, "--format", "{{json .}}"], directory);
990
+ if (!inspected.ok)
991
+ return;
992
+ let volume;
993
+ try {
994
+ volume = JSON.parse(inspected.stdout.trim());
995
+ } catch {
996
+ throw new Error(`Docker returned unreadable metadata for managed volume ${state.volumeName}.`);
997
+ }
998
+ if (volume.Name !== state.volumeName || volume.Labels?.["com.docker.compose.project"] !== state.composeProject || volume.Labels?.["com.docker.compose.volume"] !== "or3-data") {
999
+ throw new Error(`Volume ${state.volumeName} is not bound to this managed Compose deployment. Refusing to recreate it.`);
1000
+ }
1001
+ const removedVolume = await run("docker", ["volume", "rm", state.volumeName], directory);
1002
+ if (!removedVolume.ok)
1003
+ throw new Error(`Could not remove the verified legacy data volume for recreation. ${removedVolume.stderr.trim()}`);
1004
+ }
1005
+ async function ensureManagedDataVolume(directory, mode, state, env) {
1006
+ const existing = await run("docker", ["volume", "inspect", state.volumeName, "--format", "{{.Name}}"], directory);
1007
+ if (existing.ok && existing.stdout.trim() === state.volumeName)
1008
+ return;
1009
+ const created = await run("docker", composeArgs(directory, mode, [
1010
+ "run",
1011
+ "--rm",
1012
+ "-T",
1013
+ "--no-deps",
1014
+ "--entrypoint",
1015
+ "sh",
1016
+ "or3",
1017
+ "-c",
1018
+ "true"
1019
+ ]), directory);
1020
+ if (!created.ok)
1021
+ throw new Error(`Could not create the managed data volume. ${redact(created.stderr, secretValues(env))}`);
1022
+ const inspected = await run("docker", ["volume", "inspect", state.volumeName, "--format", "{{json .Labels}}"], directory);
1023
+ if (!inspected.ok)
1024
+ throw new Error(`Could not verify the recreated managed data volume ${state.volumeName}.`);
1025
+ let labels;
1026
+ try {
1027
+ labels = JSON.parse(inspected.stdout.trim());
1028
+ } catch {
1029
+ throw new Error(`Docker returned unreadable labels for recreated managed volume ${state.volumeName}.`);
1030
+ }
1031
+ if (!labels || typeof labels !== "object") {
1032
+ throw new Error(`Recreated volume ${state.volumeName} has no managed deployment labels.`);
1033
+ }
1034
+ if (labels["com.docker.compose.project"] !== state.composeProject || labels["com.docker.compose.volume"] !== "or3-data" || env.OR3_DEPLOYMENT_ID && labels["io.or3.cloud.deployment-id"] !== env.OR3_DEPLOYMENT_ID) {
1035
+ throw new Error(`Recreated volume ${state.volumeName} does not carry the expected managed deployment labels.`);
1036
+ }
1037
+ }
583
1038
  async function portAvailable(port) {
584
1039
  return new Promise((resolvePromise) => {
585
1040
  const server = createServer();
@@ -588,6 +1043,16 @@ async function portAvailable(port) {
588
1043
  server.listen(port, "127.0.0.1");
589
1044
  });
590
1045
  }
1046
+ async function dockerDaemonIsLocal() {
1047
+ const configured = process.env.DOCKER_HOST?.trim();
1048
+ if (configured)
1049
+ return configured.startsWith("unix://") || configured.startsWith("npipe://");
1050
+ const context = await run("docker", ["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"]);
1051
+ if (!context.ok)
1052
+ throw new Error(`Could not resolve the active Docker context endpoint. ${context.stderr.trim()}`);
1053
+ const endpoint = context.stdout.trim();
1054
+ return endpoint.startsWith("unix://") || endpoint.startsWith("npipe://");
1055
+ }
591
1056
  async function waitForDeepHealth(directory, mode, secrets = []) {
592
1057
  try {
593
1058
  await waitForDeepHealthWithArgs(composeArgs(directory, mode), directory, secrets);
@@ -599,9 +1064,84 @@ ${captured}
599
1064
  Diagnostics: ${diagnostics(directory, mode)}`);
600
1065
  }
601
1066
  }
602
- async function pullImage(image) {
1067
+ function packagedImageDigest(version) {
1068
+ let manifest;
1069
+ try {
1070
+ manifest = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"));
1071
+ } catch {
1072
+ return;
1073
+ }
1074
+ if (manifest.version !== version || manifest.or3Cloud?.imageDigest === undefined)
1075
+ return;
1076
+ const digest = manifest.or3Cloud.imageDigest;
1077
+ if (typeof digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(digest)) {
1078
+ throw new Error("This @or3/cloud package contains an invalid release image digest. Refusing to pull an unverified image.");
1079
+ }
1080
+ return digest;
1081
+ }
1082
+ function packagedOperatorImageDigest(version) {
1083
+ let manifest;
1084
+ try {
1085
+ manifest = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"));
1086
+ } catch {
1087
+ return;
1088
+ }
1089
+ if (manifest.version !== version || manifest.or3Cloud?.operatorImageDigest === undefined)
1090
+ return;
1091
+ const digest = manifest.or3Cloud.operatorImageDigest;
1092
+ if (typeof digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(digest)) {
1093
+ throw new Error("This @or3/cloud package contains an invalid dashboard operator image digest. Refusing to enable a mutable privileged runtime.");
1094
+ }
1095
+ return digest;
1096
+ }
1097
+ function packagedSourceRevision(version) {
1098
+ let manifest;
1099
+ try {
1100
+ manifest = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"));
1101
+ } catch {
1102
+ return;
1103
+ }
1104
+ if (manifest.version !== version || manifest.or3Cloud?.sourceRevision === undefined)
1105
+ return;
1106
+ const revision = manifest.or3Cloud.sourceRevision;
1107
+ if (typeof revision !== "string" || !/^[0-9a-f]{40}$/.test(revision)) {
1108
+ throw new Error("This @or3/cloud package contains an invalid source revision. Refusing to run an unbound release image.");
1109
+ }
1110
+ return revision;
1111
+ }
1112
+ function expectedImageDigest(version) {
1113
+ const packaged = packagedImageDigest(version);
1114
+ const supplied = process.env.OR3_EXPECTED_IMAGE_DIGEST?.trim();
1115
+ if (supplied && !/^sha256:[0-9a-f]{64}$/.test(supplied)) {
1116
+ throw new Error("OR3_EXPECTED_IMAGE_DIGEST must be a complete sha256 digest.");
1117
+ }
1118
+ if (packaged && supplied && packaged !== supplied) {
1119
+ throw new Error("The requested image digest does not match the authenticated @or3/cloud package.");
1120
+ }
1121
+ return packaged ?? supplied;
1122
+ }
1123
+ function expectedOperatorImageDigest(version) {
1124
+ const packaged = packagedOperatorImageDigest(version);
1125
+ const supplied = process.env.OR3_EXPECTED_OPERATOR_IMAGE_DIGEST?.trim();
1126
+ if (supplied && !/^sha256:[0-9a-f]{64}$/.test(supplied)) {
1127
+ throw new Error("OR3_EXPECTED_OPERATOR_IMAGE_DIGEST must be a complete sha256 digest.");
1128
+ }
1129
+ if (packaged && supplied && packaged !== supplied) {
1130
+ throw new Error("The requested dashboard operator digest does not match the authenticated @or3/cloud package.");
1131
+ }
1132
+ return packaged ?? supplied;
1133
+ }
1134
+ async function pullImage(image, expectedDigest) {
603
1135
  const local = await run("docker", ["image", "inspect", image]);
604
- if (!local.ok && process.env.OR3_CLOUD_SKIP_PULL !== "true") {
1136
+ if (local.ok) {
1137
+ const localDigest = await imageDigest(image);
1138
+ if (!expectedDigest || localDigest === expectedDigest)
1139
+ return localDigest;
1140
+ if (process.env.OR3_CLOUD_SKIP_PULL === "true") {
1141
+ throw new Error(`Local image digest mismatch for ${image}. Expected ${expectedDigest}, found ${localDigest}, and OR3_CLOUD_SKIP_PULL=true prevents downloading the authenticated image.`);
1142
+ }
1143
+ }
1144
+ if (process.env.OR3_CLOUD_SKIP_PULL !== "true") {
605
1145
  const result = await run("docker", ["pull", image]);
606
1146
  if (!result.ok) {
607
1147
  const detail = result.stderr.trim();
@@ -610,10 +1150,14 @@ async function pullImage(image) {
610
1150
  }
611
1151
  throw new Error(`Could not download ${image}. Check your internet connection and Docker registry access, then retry. ${detail}`);
612
1152
  }
613
- } else if (!local.ok) {
1153
+ } else {
614
1154
  throw new Error(`${image} is not available locally and OR3_CLOUD_SKIP_PULL=true prevents downloading it.`);
615
1155
  }
616
- return imageDigest(image);
1156
+ const actual = await imageDigest(image);
1157
+ if (expectedDigest && actual !== expectedDigest) {
1158
+ throw new Error(`Published image digest mismatch for ${image}. Expected the package-authenticated ${expectedDigest}, found ${actual}. The image tag may have been replaced; refusing to continue.`);
1159
+ }
1160
+ return actual;
617
1161
  }
618
1162
  function imageRepository(image) {
619
1163
  const reference = image.split("@", 1)[0];
@@ -621,6 +1165,19 @@ function imageRepository(image) {
621
1165
  const colon = reference.lastIndexOf(":");
622
1166
  return colon > slash ? reference.slice(0, colon) : reference;
623
1167
  }
1168
+ function imageAtDigest(image, digest) {
1169
+ if (!/^sha256:[0-9a-f]{64}$/i.test(digest)) {
1170
+ throw new Error(`Could not bind ${image} to a complete immutable image digest.`);
1171
+ }
1172
+ if (image.includes("@")) {
1173
+ if (!image.endsWith(`@${digest}`))
1174
+ throw new Error(`Image reference ${image} does not match expected digest ${digest}.`);
1175
+ return image;
1176
+ }
1177
+ if (process.env.OR3_CLOUD_SKIP_PULL === "true")
1178
+ return image;
1179
+ return `${imageRepository(image)}@${digest}`;
1180
+ }
624
1181
  async function requireImageDigest(image, expected, label) {
625
1182
  const actual = await imageDigest(image);
626
1183
  if (actual !== expected) {
@@ -629,12 +1186,30 @@ async function requireImageDigest(image, expected, label) {
629
1186
  return actual;
630
1187
  }
631
1188
  async function pullAndRequireImage(image, expected, label) {
632
- const actual = await pullImage(image);
1189
+ const actual = await pullImage(image, expected);
633
1190
  if (actual !== expected) {
634
1191
  throw new Error(`${label} image digest mismatch for ${image}. Expected ${expected}, found ${actual}. The registry tag may have moved; refusing to mutate the deployment.`);
635
1192
  }
636
1193
  return actual;
637
1194
  }
1195
+ function assertImageReleaseLabels(image, labels, version, expectedRevision) {
1196
+ const revision = labels["org.opencontainers.image.revision"];
1197
+ if (labels["org.opencontainers.image.source"] !== "https://github.com/Saluana/or3-chat" || labels["org.opencontainers.image.version"] !== version || typeof revision !== "string" || !/^[0-9a-f]{40}$/i.test(revision) || expectedRevision !== undefined && revision.toLowerCase() !== expectedRevision) {
1198
+ throw new Error(`OR3 image ${image} does not carry the expected source/version release labels for ${version}.`);
1199
+ }
1200
+ }
1201
+ async function assertImageReleaseIdentity(image, version) {
1202
+ const result = await run("docker", ["image", "inspect", "--format", "{{json .Config.Labels}}", image]);
1203
+ if (!result.ok)
1204
+ throw new Error(`Could not inspect release labels for ${image}. ${result.stderr.trim()}`);
1205
+ let labels;
1206
+ try {
1207
+ labels = JSON.parse(result.stdout.trim());
1208
+ } catch {
1209
+ throw new Error(`OR3 image ${image} has no readable release labels.`);
1210
+ }
1211
+ assertImageReleaseLabels(image, labels, version, packagedSourceRevision(version));
1212
+ }
638
1213
  async function imageDigest(image) {
639
1214
  const result = await run("docker", ["image", "inspect", "--format", "{{json .RepoDigests}}", image]);
640
1215
  if (!result.ok)
@@ -668,15 +1243,19 @@ function assertSupportedArchitecture(manifest, hostArch) {
668
1243
  throw new Error(`OR3 does not publish a ${hostArch} image for this version yet. Supported architectures: ${supported.join(", ") || "none detected"}. Install on a supported machine or wait for the next release.`);
669
1244
  }
670
1245
  }
671
- function hostArchitecture() {
672
- if (process.arch === "arm64")
1246
+ async function dockerDaemonArchitecture() {
1247
+ const result = await run("docker", ["info", "--format", "{{.Architecture}}"]);
1248
+ if (!result.ok)
1249
+ throw new Error(`Could not determine the selected Docker daemon architecture. ${result.stderr.trim()}`);
1250
+ const architecture = result.stdout.trim().toLowerCase();
1251
+ if (architecture === "arm64" || architecture === "aarch64")
673
1252
  return "arm64";
674
- if (process.arch === "x64")
1253
+ if (architecture === "amd64" || architecture === "x86_64")
675
1254
  return "amd64";
676
- throw new Error(`OR3 supports only linux/amd64 and linux/arm64 hosts; this machine reports ${process.arch}. Install on a supported machine.`);
1255
+ throw new Error(`OR3 supports only linux/amd64 and linux/arm64 Docker daemons; the selected daemon reports ${architecture || "no architecture"}.`);
677
1256
  }
678
1257
  async function assertSupportedHostArchitecture(image) {
679
- const hostArch = hostArchitecture();
1258
+ const hostArch = await dockerDaemonArchitecture();
680
1259
  if (process.env.OR3_CLOUD_SKIP_PULL === "true") {
681
1260
  const local = await run("docker", ["image", "inspect", "--format", "{{.Architecture}}", image]);
682
1261
  if (local.ok && local.stdout.trim()) {
@@ -705,13 +1284,149 @@ function composeProjectNames(directory) {
705
1284
  caddyConfig: `${base}-caddy-config`
706
1285
  };
707
1286
  }
1287
+ async function assertDockerProjectAbsent(project) {
1288
+ for (const [resource, args] of [
1289
+ ["container", ["ps", "-aq", "--filter", `label=com.docker.compose.project=${project}`]],
1290
+ ["network", ["network", "ls", "-q", "--filter", `label=com.docker.compose.project=${project}`]]
1291
+ ]) {
1292
+ const result = await run("docker", args);
1293
+ if (!result.ok) {
1294
+ throw new Error(`Could not inspect Docker ${resource}s for Compose project ${project}. Refusing to continue while the daemon state is unknown. ${result.stderr.trim()}`);
1295
+ }
1296
+ if (result.stdout.trim()) {
1297
+ throw new Error(`Docker ${resource}s already exist for Compose project ${project}. Choose a different target directory or inspect the existing project before continuing.`);
1298
+ }
1299
+ }
1300
+ }
1301
+ async function dashboardOperatorEnv(directory, version) {
1302
+ if (process.platform !== "linux" || !process.getuid || !process.getgid)
1303
+ return;
1304
+ const expectedDigest = expectedOperatorImageDigest(version);
1305
+ if (!expectedDigest)
1306
+ return;
1307
+ const configured = process.env.DOCKER_HOST?.trim();
1308
+ if (configured && !configured.startsWith("unix://"))
1309
+ return;
1310
+ const socket = configured ? configured.slice("unix://".length) : "/var/run/docker.sock";
1311
+ if (!isAbsolute(socket))
1312
+ return;
1313
+ let socketStat;
1314
+ let uid;
1315
+ let gid;
1316
+ try {
1317
+ socketStat = await stat(socket);
1318
+ if (!socketStat.isSocket())
1319
+ return;
1320
+ uid = process.getuid();
1321
+ gid = process.getgid();
1322
+ if (!Number.isSafeInteger(uid) || uid < 0 || !Number.isSafeInteger(gid) || gid < 0)
1323
+ return;
1324
+ } catch {
1325
+ return;
1326
+ }
1327
+ const operatorTag = operatorImageFor(version);
1328
+ let digest;
1329
+ try {
1330
+ digest = await pullImage(operatorTag, expectedDigest);
1331
+ await assertSupportedHostArchitecture(operatorTag);
1332
+ await assertImageReleaseIdentity(operatorTag, version);
1333
+ } catch {
1334
+ return;
1335
+ }
1336
+ return {
1337
+ OR3_DASHBOARD_UPDATES_ENABLED: "true",
1338
+ OR3_OPERATOR_IMAGE: imageAtDigest(operatorTag, digest),
1339
+ OR3_DEPLOYMENT_DIR: resolve(directory),
1340
+ OR3_OPERATOR_UID: String(uid),
1341
+ OR3_OPERATOR_GID: String(gid),
1342
+ OR3_DOCKER_SOCKET: socket,
1343
+ OR3_DOCKER_GID: String(socketStat.gid)
1344
+ };
1345
+ }
1346
+ async function prepareDashboardOperatorIpc(directory, enabled) {
1347
+ if (!enabled)
1348
+ return;
1349
+ const ipc = deploymentPaths(directory).operatorIpc;
1350
+ await mkdir(ipc, { recursive: true, mode: 456 });
1351
+ await chmod(ipc, 456);
1352
+ }
1353
+ async function verifyDashboardOperatorBridge(directory, operator) {
1354
+ if (!operator)
1355
+ return false;
1356
+ const probe = `.operator-probe-${randomBytes(8).toString("hex")}`;
1357
+ const result = await run("docker", [
1358
+ "run",
1359
+ "--rm",
1360
+ "--network",
1361
+ "none",
1362
+ "--read-only",
1363
+ "--user",
1364
+ `${operator.OR3_OPERATOR_UID}:${operator.OR3_OPERATOR_GID}`,
1365
+ "--group-add",
1366
+ operator.OR3_DOCKER_GID,
1367
+ "--mount",
1368
+ `type=bind,src=${operator.OR3_DOCKER_SOCKET},dst=/var/run/docker.sock`,
1369
+ "--mount",
1370
+ `type=bind,src=${operator.OR3_DEPLOYMENT_DIR},dst=/deployment`,
1371
+ "--workdir",
1372
+ "/deployment",
1373
+ "--tmpfs",
1374
+ "/tmp:rw,noexec,nosuid,size=16m",
1375
+ "--entrypoint",
1376
+ "sh",
1377
+ operator.OR3_OPERATOR_IMAGE,
1378
+ "-c",
1379
+ `docker version --format '{{.Server.Version}}' >/dev/null && umask 077 && : > .or3-cloud/operator-ipc/${probe} && test -O .or3-cloud/operator-ipc/${probe} && rm .or3-cloud/operator-ipc/${probe}`
1380
+ ], directory);
1381
+ if (!result.ok) {
1382
+ return false;
1383
+ }
1384
+ return true;
1385
+ }
1386
+ async function prepareVerifiedDashboardOperator(directory, version) {
1387
+ const candidate = await dashboardOperatorEnv(directory, version);
1388
+ if (!candidate)
1389
+ return;
1390
+ try {
1391
+ await prepareDashboardOperatorIpc(directory, true);
1392
+ if (await verifyDashboardOperatorBridge(directory, candidate))
1393
+ return candidate;
1394
+ } catch {}
1395
+ await rm(deploymentPaths(directory).operatorIpc, { recursive: true, force: true }).catch(() => {
1396
+ return;
1397
+ });
1398
+ console.warn("Dashboard updates are unavailable on this Docker setup; the deployment will remain host-CLI managed.");
1399
+ return;
1400
+ }
1401
+ var DASHBOARD_OPERATOR_ENV_KEYS = [
1402
+ "OR3_DASHBOARD_UPDATES_ENABLED",
1403
+ "OR3_OPERATOR_IMAGE",
1404
+ "OR3_DEPLOYMENT_DIR",
1405
+ "OR3_OPERATOR_UID",
1406
+ "OR3_OPERATOR_GID",
1407
+ "OR3_DOCKER_SOCKET",
1408
+ "OR3_DOCKER_GID"
1409
+ ];
1410
+ function withoutDashboardOperator(env) {
1411
+ const result = { ...env };
1412
+ for (const key of DASHBOARD_OPERATOR_ENV_KEYS)
1413
+ delete result[key];
1414
+ return result;
1415
+ }
1416
+ function withoutProvisioningCredentials(env) {
1417
+ const result = { ...env };
1418
+ for (const key of PROVISIONING_CREDENTIAL_KEYS)
1419
+ delete result[key];
1420
+ return result;
1421
+ }
708
1422
  function buildEnv(input2) {
709
1423
  const names = composeProjectNames(input2.directory);
710
1424
  const secrets = input2.secrets ?? {};
711
1425
  const publicOrigin = input2.mode === "public" ? `https://${input2.domain}` : `http://127.0.0.1:${input2.port}`;
712
1426
  const values = {
713
1427
  OR3_VERSION: input2.version,
714
- OR3_IMAGE: imageFor(input2.version),
1428
+ OR3_IMAGE: input2.image ?? imageFor(input2.version),
1429
+ OR3_DEPLOYMENT_ID: input2.deploymentId ?? id("deployment"),
715
1430
  OR3_COMPOSE_PROJECT: names.project,
716
1431
  OR3_VOLUME_NAME: names.volume,
717
1432
  OR3_CADDY_DATA_VOLUME: names.caddyData,
@@ -732,6 +1447,7 @@ function buildEnv(input2) {
732
1447
  OR3_BASIC_AUTH_DB_PATH: "/data/auth.sqlite",
733
1448
  OR3_BASIC_AUTH_BOOTSTRAP_EMAIL: input2.email,
734
1449
  OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD: input2.password,
1450
+ OR3_MANAGED_OWNER_EMAIL: input2.email,
735
1451
  OR3_AUTH_INVITE_TOKEN_SECRET: secrets.OR3_AUTH_INVITE_TOKEN_SECRET ?? randomSecret(),
736
1452
  OR3_SYNC_ENABLED: "true",
737
1453
  OR3_CLOUD_SYNC_ENABLED: "true",
@@ -757,6 +1473,8 @@ function buildEnv(input2) {
757
1473
  OR3_TRUST_PROXY: input2.mode === "public" ? "true" : "false",
758
1474
  OR3_FORWARDED_FOR_HEADER: "x-forwarded-for"
759
1475
  };
1476
+ if (input2.dashboardOperator)
1477
+ Object.assign(values, input2.dashboardOperator);
760
1478
  return values;
761
1479
  }
762
1480
  function secretValues(env) {
@@ -794,14 +1512,142 @@ async function readPassword(flags) {
794
1512
  validatePassword(password);
795
1513
  return password;
796
1514
  }
1515
+ function managedAssetNames(mode) {
1516
+ return ["compose.yaml", "compose.operator.yaml", "dashboard-operator.mjs", ...mode === "public" ? ["compose.public.yaml", "Caddyfile"] : []];
1517
+ }
1518
+ function managedAssetNamesForInventory(mode, inventoryVersion) {
1519
+ if (inventoryVersion === MANAGED_ASSET_INVENTORY_VERSION)
1520
+ return managedAssetNames(mode);
1521
+ if (inventoryVersion === 2) {
1522
+ return ["compose.yaml", "dashboard-operator.mjs", ...mode === "public" ? ["compose.public.yaml", "Caddyfile"] : []];
1523
+ }
1524
+ return ["compose.yaml", ...mode === "public" ? ["compose.public.yaml", "Caddyfile"] : []];
1525
+ }
1526
+ async function installManagedAssets(directory, assets) {
1527
+ await mkdir(directory, { recursive: true, mode: 448 });
1528
+ const token = randomBytes(4).toString("hex");
1529
+ const staged = [];
1530
+ let retainRollbackCopies = false;
1531
+ try {
1532
+ for (const [name, contents] of assets) {
1533
+ const destination = join(directory, name);
1534
+ const replacement = `${destination}.next-${token}`;
1535
+ const entry = {
1536
+ destination,
1537
+ replacement
1538
+ };
1539
+ staged.push(entry);
1540
+ await writeFile(replacement, contents, { mode: 420 });
1541
+ await chmod(replacement, 420);
1542
+ if (await fileExists(destination)) {
1543
+ entry.rollback = `${destination}.previous-${token}`;
1544
+ await copyFile(destination, entry.rollback);
1545
+ await chmod(entry.rollback, 420);
1546
+ }
1547
+ }
1548
+ const applied = [];
1549
+ try {
1550
+ for (const entry of staged) {
1551
+ applied.push(entry);
1552
+ await durableRename(entry.replacement, entry.destination);
1553
+ }
1554
+ for (const [name, contents] of assets) {
1555
+ if (!(await readFile(join(directory, name))).equals(contents)) {
1556
+ throw new Error(`Managed asset ${name} did not match its staged replacement after commit.`);
1557
+ }
1558
+ }
1559
+ } catch (error) {
1560
+ const rollbackErrors = [];
1561
+ for (const entry of applied.reverse()) {
1562
+ try {
1563
+ if (entry.rollback)
1564
+ await durableRename(entry.rollback, entry.destination);
1565
+ else
1566
+ await rm(entry.destination, { force: true });
1567
+ } catch (rollbackError) {
1568
+ rollbackErrors.push(rollbackError instanceof Error ? rollbackError : new Error(String(rollbackError)));
1569
+ }
1570
+ }
1571
+ if (rollbackErrors.length > 0) {
1572
+ retainRollbackCopies = true;
1573
+ throw new AggregateError([error, ...rollbackErrors], "Managed asset installation failed and one or more rollback copies could not be restored. Recovery copies were retained.");
1574
+ }
1575
+ throw error;
1576
+ }
1577
+ } finally {
1578
+ for (const entry of staged) {
1579
+ await rm(entry.replacement, { force: true }).catch(() => {
1580
+ return;
1581
+ });
1582
+ if (entry.rollback && !retainRollbackCopies)
1583
+ await rm(entry.rollback, { force: true }).catch(() => {
1584
+ return;
1585
+ });
1586
+ }
1587
+ }
1588
+ }
797
1589
  async function copyAssets(directory, mode) {
798
- await copyFile(join(ASSET_ROOT, "compose.yaml"), join(directory, "compose.yaml"));
799
- await chmod(join(directory, "compose.yaml"), 420);
800
- if (mode === "public") {
801
- await copyFile(join(ASSET_ROOT, "compose.public.yaml"), join(directory, "compose.public.yaml"));
802
- await copyFile(join(ASSET_ROOT, "Caddyfile"), join(directory, "Caddyfile"));
803
- await chmod(join(directory, "compose.public.yaml"), 420);
804
- await chmod(join(directory, "Caddyfile"), 420);
1590
+ const assets = new Map;
1591
+ for (const name of managedAssetNames(mode)) {
1592
+ assets.set(name, await readFile(join(ASSET_ROOT, name)));
1593
+ }
1594
+ await installManagedAssets(directory, assets);
1595
+ }
1596
+ async function snapshotManagedAssets(directory, mode, backupDir) {
1597
+ const assetDir = join(backupDir, "managed-assets");
1598
+ await mkdir(assetDir, { recursive: true, mode: 448 });
1599
+ await chmod(assetDir, 448);
1600
+ const checksums = {};
1601
+ for (const name of managedAssetNames(mode)) {
1602
+ if ((name === "dashboard-operator.mjs" || name === "compose.operator.yaml") && !await fileExists(join(directory, name)))
1603
+ continue;
1604
+ const destination = join(assetDir, name);
1605
+ await copySecure(join(directory, name), destination);
1606
+ checksums[name] = await sha256File(destination);
1607
+ }
1608
+ return checksums;
1609
+ }
1610
+ async function verifiedManagedAssetContents(backupPath, manifest) {
1611
+ const checksums = manifest.managedAssetSha256;
1612
+ if (!checksums)
1613
+ return;
1614
+ const expected = managedAssetNamesForInventory(manifest.mode, manifest.managedAssetInventoryVersion).sort();
1615
+ const actual = Object.keys(checksums).sort();
1616
+ if (manifest.managedAssetInventoryVersion !== undefined && manifest.managedAssetInventoryVersion !== 2 && manifest.managedAssetInventoryVersion !== MANAGED_ASSET_INVENTORY_VERSION || actual.length !== expected.length || actual.some((name, index) => name !== expected[index])) {
1617
+ throw new Error(`Backup ${manifest.backupId} has an invalid managed asset inventory.`);
1618
+ }
1619
+ const assets = new Map;
1620
+ for (const name of expected) {
1621
+ const expectedSha = checksums[name];
1622
+ if (!expectedSha || !/^[0-9a-f]{64}$/i.test(expectedSha)) {
1623
+ throw new Error(`Backup ${manifest.backupId} has an invalid checksum for managed asset ${name}.`);
1624
+ }
1625
+ const source = join(backupPath, "managed-assets", name);
1626
+ const actualSha = await sha256File(source);
1627
+ if (actualSha !== expectedSha) {
1628
+ throw new Error(`Backup managed asset checksum mismatch for ${name}. Expected ${expectedSha}, got ${actualSha}.`);
1629
+ }
1630
+ assets.set(name, await readFile(source));
1631
+ }
1632
+ return assets;
1633
+ }
1634
+ async function restoreManagedAssets(directory, backupPath, manifest) {
1635
+ const assets = await verifiedManagedAssetContents(backupPath, manifest);
1636
+ if (!assets) {
1637
+ throw new Error(`Backup ${manifest.backupId} predates authenticated managed-asset snapshots. Refusing to run its image under today's Compose/Caddy assets; restore with the exact historical @or3/cloud release after separately authenticating its assets.`);
1638
+ }
1639
+ await installManagedAssets(directory, assets);
1640
+ if (manifest.managedAssetInventoryVersion !== 2 && manifest.managedAssetInventoryVersion !== MANAGED_ASSET_INVENTORY_VERSION) {
1641
+ await rm(join(directory, "dashboard-operator.mjs"), { force: true });
1642
+ }
1643
+ if (manifest.managedAssetInventoryVersion !== MANAGED_ASSET_INVENTORY_VERSION) {
1644
+ await rm(join(directory, "compose.operator.yaml"), { force: true });
1645
+ }
1646
+ return true;
1647
+ }
1648
+ function assertRestorableManagedAssets(manifest) {
1649
+ if (!manifest.managedAssetSha256) {
1650
+ throw new Error(`Backup ${manifest.backupId} predates authenticated managed-asset snapshots. Refusing to run its image under today's Compose/Caddy assets; restore with the exact historical @or3/cloud release after separately authenticating its assets.`);
805
1651
  }
806
1652
  }
807
1653
  async function checkPublicPrerequisites(domain) {
@@ -815,6 +1661,10 @@ async function checkPublicPrerequisites(domain) {
815
1661
  return address;
816
1662
  }
817
1663
  async function markPending(directory, state, operation) {
1664
+ const dashboardJobId = process.env.OR3_DASHBOARD_UPDATE_JOB_ID?.trim();
1665
+ operation.origin = dashboardJobId ? "dashboard" : "cli";
1666
+ if (dashboardJobId)
1667
+ operation.dashboardJobId = dashboardJobId;
818
1668
  state.incompleteOperation = operation;
819
1669
  await writeState(directory, state);
820
1670
  await writeSecure(join(deploymentPaths(directory).operations, `${operation.id}.json`), `${JSON.stringify(operation, null, 2)}
@@ -844,10 +1694,29 @@ async function clearPending(directory, state) {
844
1694
  async function stopProject(directory, mode) {
845
1695
  await compose(directory, mode, ["stop", "or3"]);
846
1696
  }
1697
+ async function projectServiceRunning(directory, mode) {
1698
+ const result = await run("docker", composeArgs(directory, mode, ["ps", "--status", "running", "-q", "or3"]), directory);
1699
+ if (!result.ok)
1700
+ throw new Error(`Could not determine whether OR3 is running. ${result.stderr.trim()}`);
1701
+ return Boolean(result.stdout.trim());
1702
+ }
1703
+ async function sourceServiceRunning(directory, composeFiles) {
1704
+ const result = await run("docker", [...sourceComposeArgs(directory, composeFiles), "ps", "--status", "running", "-q", "or3"], directory);
1705
+ if (!result.ok)
1706
+ throw new Error(`Could not determine whether the V1 OR3 service is running. ${result.stderr.trim()}`);
1707
+ return Boolean(result.stdout.trim());
1708
+ }
1709
+ async function removeDashboardOperator(directory, mode) {
1710
+ if (!dashboardUpdatesEnabled(directory))
1711
+ return;
1712
+ await compose(directory, mode, ["rm", "--stop", "--force", "or3-operator"]);
1713
+ }
847
1714
  async function startProject(directory, mode, env) {
848
1715
  await assertSafeComposeBinding(directory, mode, env);
849
- await compose(directory, mode, ["up", "-d", "--wait", "--wait-timeout", "180"], secretValues(env));
1716
+ const services = process.env.OR3_DASHBOARD_UPDATE_JOB_ID ? ["or3"] : [];
1717
+ await compose(directory, mode, ["up", "-d", "--wait", "--wait-timeout", "180", ...services], secretValues(env));
850
1718
  await waitForDeepHealth(directory, mode, secretValues(env));
1719
+ await assertRunningAppImage(directory, mode, env.OR3_IMAGE);
851
1720
  }
852
1721
  function backupDirectory(directory, backupId) {
853
1722
  return join(deploymentPaths(directory).backups, backupId);
@@ -862,7 +1731,22 @@ async function dataVolumeSize(directory, mode, env) {
862
1731
  const exec = await run("docker", [...composeArgs(directory, mode, ["exec", "-T", "or3", "sh", "-c", "du -sb /data 2>/dev/null"])], directory);
863
1732
  if (exec.ok)
864
1733
  return parseDuOutput(exec.stdout);
865
- const fallback = await run("docker", [...composeArgs(directory, mode, ["run", "--rm", "--no-deps", "--entrypoint", "sh", "or3", "-c", "du -sb /data 2>/dev/null"])], directory);
1734
+ const fallback = await run("docker", [
1735
+ "run",
1736
+ "--rm",
1737
+ "--network",
1738
+ "none",
1739
+ "--read-only",
1740
+ "--cap-drop",
1741
+ "ALL",
1742
+ "-v",
1743
+ `${env.OR3_VOLUME_NAME}:/data:ro`,
1744
+ "--entrypoint",
1745
+ "sh",
1746
+ env.OR3_IMAGE,
1747
+ "-c",
1748
+ "du -sb /data 2>/dev/null"
1749
+ ], directory);
866
1750
  if (fallback.ok)
867
1751
  return parseDuOutput(fallback.stdout);
868
1752
  throw new Error(`Could not measure the data volume size for the free-space preflight. Start the deployment and retry. ${redact(`${exec.stderr} ${fallback.stderr}`)}`);
@@ -882,37 +1766,41 @@ async function removeNamedBackupArtifact(directory, backupId) {
882
1766
  await rm(target, { recursive: true, force: true });
883
1767
  await rm(join(deploymentPaths(directory).exports, `${backupId}.json`), { force: true });
884
1768
  }
885
- async function readBackupManifestMetadata(backupPath) {
886
- try {
887
- const manifest = JSON.parse(await readText(join(backupPath, "manifest.json")));
888
- if (manifest.schemaVersion !== 1 || !manifest.backupId || !manifest.createdAt)
889
- return null;
890
- return manifest;
891
- } catch {
892
- return null;
1769
+ async function removeEnumeratedBackupArtifact(directory, backup) {
1770
+ const backupsRoot = resolve(deploymentPaths(directory).backups);
1771
+ const target = resolve(backup.path);
1772
+ if (dirname(target) !== backupsRoot || basename(target) !== backup.backupId || !BACKUP_ID_PATTERN.test(backup.backupId)) {
1773
+ throw new Error(`Refusing to prune an untrusted backup path ${backup.path}.`);
893
1774
  }
1775
+ await rm(target, { recursive: true, force: true });
1776
+ await rm(join(deploymentPaths(directory).exports, `${backup.backupId}.json`), { force: true });
894
1777
  }
895
1778
  async function enumerateBackups(directory) {
896
1779
  const backupsRoot = deploymentPaths(directory).backups;
897
1780
  let entries = [];
898
1781
  try {
899
1782
  entries = await readdir(backupsRoot);
900
- } catch {
901
- return [];
1783
+ } catch (error) {
1784
+ if (error.code === "ENOENT")
1785
+ return [];
1786
+ throw new Error(`Could not enumerate managed backups at ${backupsRoot}: ${error instanceof Error ? error.message : String(error)}`);
902
1787
  }
903
1788
  const result = [];
904
1789
  for (const entry of entries) {
1790
+ if (!BACKUP_ID_PATTERN.test(entry)) {
1791
+ throw new Error(`Backup store contains an unexpected artifact ${join(backupsRoot, entry)}. Refusing to use it for retention; inspect or remove it explicitly.`);
1792
+ }
905
1793
  const path = join(backupsRoot, entry);
906
- const manifest = await readBackupManifestMetadata(path);
907
- if (!manifest)
908
- continue;
1794
+ const manifest = await readManifest(path, directory);
1795
+ if (manifest.backupId !== entry) {
1796
+ throw new Error(`Backup directory ${path} does not match manifest ID ${manifest.backupId}. Refusing to use it for retention.`);
1797
+ }
1798
+ if (!Number.isFinite(Date.parse(manifest.createdAt))) {
1799
+ throw new Error(`Backup ${manifest.backupId} has an invalid creation time.`);
1800
+ }
909
1801
  let bytes = 0;
910
- try {
911
- for (const file of ["data.tgz", "config.env", "manifest.json"]) {
912
- bytes += (await stat(join(path, file))).size;
913
- }
914
- } catch {
915
- bytes = 0;
1802
+ for (const file of ["data.tgz", "config.env", "manifest.json", "manifest.auth"]) {
1803
+ bytes += (await stat(join(path, file))).size;
916
1804
  }
917
1805
  result.push({
918
1806
  backupId: manifest.backupId,
@@ -952,8 +1840,11 @@ async function pruneBackups(directory, state, keep, force) {
952
1840
  console.log(`--force --yes will permanently delete: ${targets.join(", ")}`);
953
1841
  }
954
1842
  for (const backupId of targets) {
955
- await removeNamedBackupArtifact(directory, backupId);
956
- console.log(`Deleted backup ${backupId} at ${backupDirectory(directory, backupId)}`);
1843
+ const backup = backups.find((entry) => entry.backupId === backupId);
1844
+ if (!backup)
1845
+ throw new Error(`Retention selected backup ${backupId}, but its verified path disappeared before deletion.`);
1846
+ await removeEnumeratedBackupArtifact(directory, backup);
1847
+ console.log(`Deleted backup ${backupId} at ${backup.path}`);
957
1848
  }
958
1849
  return targets.length;
959
1850
  }
@@ -973,9 +1864,31 @@ function assertDeploymentIdentity(state, env) {
973
1864
  throw new Error(`Managed state does not match ${key} in .env. Refusing to operate on an unexpected deployment identity.`);
974
1865
  }
975
1866
  }
1867
+ const pending = state.incompleteOperation;
1868
+ let pendingTargetImage;
1869
+ if (pending?.targetImage) {
1870
+ try {
1871
+ pendingTargetImage = pending.targetImageDigest ? imageAtDigest(pending.targetImage, pending.targetImageDigest) : pending.targetImage;
1872
+ } catch {
1873
+ pendingTargetImage = undefined;
1874
+ }
1875
+ }
1876
+ const journaledTarget = Boolean(pending && (pending.operation === "update" || pending.operation === "restore" || pending.operation === "rollback") && pending.targetVersion && pendingTargetImage && env.OR3_VERSION === pending.targetVersion && env.OR3_IMAGE === pendingTargetImage);
1877
+ const journaledIdentityMigration = Boolean(journaledTarget && !state.deploymentId && pending?.targetDeploymentId && env[DEPLOYMENT_ID_ENV_KEY] === pending.targetDeploymentId);
1878
+ if (state.deploymentId || env[DEPLOYMENT_ID_ENV_KEY]) {
1879
+ if ((!state.deploymentId || env[DEPLOYMENT_ID_ENV_KEY] !== state.deploymentId) && !journaledIdentityMigration) {
1880
+ throw new Error("Managed state does not match OR3_DEPLOYMENT_ID in .env. Refusing to operate on an unexpected deployment identity.");
1881
+ }
1882
+ }
1883
+ if ((env.OR3_VERSION !== state.appVersion || env.OR3_IMAGE !== state.image) && !journaledTarget) {
1884
+ throw new Error("Managed state does not match OR3_VERSION or OR3_IMAGE in .env. Refusing to create a backup or mutate an unverified release configuration.");
1885
+ }
976
1886
  }
977
1887
  function assertDeploymentDirectoryIdentity(directory, state) {
978
1888
  const resolved = resolve(directory);
1889
+ if (state.deploymentRoot && resolve(state.deploymentRoot) !== resolved) {
1890
+ throw new Error(`Managed deployment root is ${state.deploymentRoot}, not ${resolved}. Refusing to operate on a copied or relocated deployment; perform an explicit relocation before mutation.`);
1891
+ }
979
1892
  const names = composeProjectNames(resolved);
980
1893
  const mismatches = [];
981
1894
  if (state.composeProject !== names.project) {
@@ -1029,6 +1942,12 @@ function assertBackupMatchesDeployment(manifest, backupEnv, state, env) {
1029
1942
  if (manifest.caddyConfigVolume !== undefined && manifest.caddyConfigVolume !== state.caddyConfigVolume) {
1030
1943
  throw new Error(`Backup ${manifest.backupId} belongs to a different Caddy config volume.`);
1031
1944
  }
1945
+ if (state.deploymentId && (manifest.deploymentId !== state.deploymentId || backupEnv.OR3_DEPLOYMENT_ID !== state.deploymentId)) {
1946
+ const legacySnapshot = manifest.deploymentId === undefined && backupEnv.OR3_DEPLOYMENT_ID === undefined;
1947
+ if (!legacySnapshot) {
1948
+ throw new Error(`Backup ${manifest.backupId} belongs to a different immutable deployment identity.`);
1949
+ }
1950
+ }
1032
1951
  if (manifest.port !== undefined && manifest.port !== state.port) {
1033
1952
  throw new Error(`Backup ${manifest.backupId} belongs to port ${manifest.port}, not ${state.port}.`);
1034
1953
  }
@@ -1043,14 +1962,35 @@ async function sha256File(path) {
1043
1962
  });
1044
1963
  return digest.digest("hex");
1045
1964
  }
1965
+ function terminateChildProcess(child) {
1966
+ if (child.killed)
1967
+ return;
1968
+ if (process.platform !== "win32" && child.pid) {
1969
+ try {
1970
+ process.kill(-child.pid, "SIGKILL");
1971
+ return;
1972
+ } catch {}
1973
+ }
1974
+ child.kill("SIGKILL");
1975
+ }
1046
1976
  async function streamCommandToFile(command, args, destination, cwd, secrets = []) {
1047
1977
  if (await fileExists(destination)) {
1048
1978
  throw new Error(`Refusing to overwrite existing archive ${destination}.`);
1049
1979
  }
1050
1980
  await mkdir(dirname(destination), { recursive: true, mode: 448 });
1051
1981
  const temporary = `${destination}.${randomBytes(8).toString("hex")}.partial`;
1052
- const child = spawn(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
1982
+ const child = spawn(command, args, {
1983
+ cwd,
1984
+ env: commandEnvironment(command, args, cwd),
1985
+ detached: process.platform !== "win32",
1986
+ stdio: ["ignore", "pipe", "pipe"]
1987
+ });
1053
1988
  let stderr = "";
1989
+ let timedOut = false;
1990
+ const timeout = setTimeout(() => {
1991
+ timedOut = true;
1992
+ terminateChildProcess(child);
1993
+ }, STREAM_COMMAND_TIMEOUT_MS);
1054
1994
  child.stderr.setEncoding("utf8");
1055
1995
  child.stderr.on("data", (chunk) => {
1056
1996
  stderr = `${stderr}${chunk}`.slice(-64 * 1024);
@@ -1064,25 +2004,38 @@ async function streamCommandToFile(command, args, destination, cwd, secrets = []
1064
2004
  exit,
1065
2005
  pipeline(child.stdout, createWriteStream(temporary, { flags: "wx", mode: 384 }))
1066
2006
  ]);
2007
+ if (timedOut)
2008
+ throw new Error(`${command} exceeded the ${STREAM_COMMAND_TIMEOUT_MS / 1000}-second archive deadline.`);
1067
2009
  if (exitCode !== 0) {
1068
2010
  throw new Error(`${command} ${args.join(" ")} exited with ${exitCode}. ${redact(stderr, secrets)}`.trim());
1069
2011
  }
1070
2012
  await chmod(temporary, 384);
1071
- await rename(temporary, destination);
2013
+ await durableRename(temporary, destination);
1072
2014
  } catch (error) {
1073
- if (!child.killed)
1074
- child.kill("SIGTERM");
2015
+ terminateChildProcess(child);
1075
2016
  await rm(temporary, { force: true }).catch(() => {
1076
2017
  return;
1077
2018
  });
1078
2019
  const detail = error instanceof Error ? error.message : String(error);
1079
2020
  throw new Error(`${detail}${stderr && !detail.includes(stderr) ? `
1080
2021
  ${redact(stderr, secrets)}` : ""}`);
2022
+ } finally {
2023
+ clearTimeout(timeout);
1081
2024
  }
1082
2025
  }
1083
2026
  async function streamFileToCommand(command, args, source, cwd, secrets = []) {
1084
- const child = spawn(command, args, { cwd, stdio: ["pipe", "ignore", "pipe"] });
2027
+ const child = spawn(command, args, {
2028
+ cwd,
2029
+ env: commandEnvironment(command, args, cwd),
2030
+ detached: process.platform !== "win32",
2031
+ stdio: ["pipe", "ignore", "pipe"]
2032
+ });
1085
2033
  let stderr = "";
2034
+ let timedOut = false;
2035
+ const timeout = setTimeout(() => {
2036
+ timedOut = true;
2037
+ terminateChildProcess(child);
2038
+ }, STREAM_COMMAND_TIMEOUT_MS);
1086
2039
  child.stderr.setEncoding("utf8");
1087
2040
  child.stderr.on("data", (chunk) => {
1088
2041
  stderr = `${stderr}${chunk}`.slice(-64 * 1024);
@@ -1096,15 +2049,18 @@ async function streamFileToCommand(command, args, source, cwd, secrets = []) {
1096
2049
  exit,
1097
2050
  pipeline(createReadStream(source), child.stdin)
1098
2051
  ]);
2052
+ if (timedOut)
2053
+ throw new Error(`${command} exceeded the ${STREAM_COMMAND_TIMEOUT_MS / 1000}-second archive deadline.`);
1099
2054
  if (exitCode !== 0) {
1100
2055
  throw new Error(`${command} ${args.join(" ")} exited with ${exitCode}. ${redact(stderr, secrets)}`.trim());
1101
2056
  }
1102
2057
  } catch (error) {
1103
- if (!child.killed)
1104
- child.kill("SIGTERM");
2058
+ terminateChildProcess(child);
1105
2059
  const detail = error instanceof Error ? error.message : String(error);
1106
2060
  throw new Error(`${detail}${stderr && !detail.includes(stderr) ? `
1107
2061
  ${redact(stderr, secrets)}` : ""}`);
2062
+ } finally {
2063
+ clearTimeout(timeout);
1108
2064
  }
1109
2065
  }
1110
2066
  async function gzipUncompressedBytes(path) {
@@ -1122,38 +2078,121 @@ async function volumeArchive(directory, mode, env, backupDir) {
1122
2078
  await streamCommandToFile("docker", composeArgs(directory, mode, [
1123
2079
  "run",
1124
2080
  "--rm",
1125
- "--no-deps",
2081
+ "--no-deps",
2082
+ "--entrypoint",
2083
+ "sh",
2084
+ "or3",
2085
+ "-c",
2086
+ "tar czf - -C /data ."
2087
+ ]), join(backupDir, "data.tgz"), directory, secretValues(env));
2088
+ }
2089
+ async function archiveExternalVolume(image, volume, backupDir) {
2090
+ await streamCommandToFile("docker", [
2091
+ "run",
2092
+ "--rm",
2093
+ "--network",
2094
+ "none",
2095
+ "--read-only",
2096
+ "--user",
2097
+ "0:0",
2098
+ "--security-opt",
2099
+ "no-new-privileges:true",
2100
+ "--cap-drop",
2101
+ "ALL",
2102
+ "--cap-add",
2103
+ "DAC_READ_SEARCH",
2104
+ "--tmpfs",
2105
+ "/tmp:rw,noexec,nosuid,size=16m",
2106
+ "-v",
2107
+ `${volume}:/source:ro`,
2108
+ image,
2109
+ "sh",
2110
+ "-c",
2111
+ "tar czf - -C /source ."
2112
+ ], join(backupDir, "data.tgz"));
2113
+ }
2114
+ async function restoreVolumeArchive(directory, mode, env, backupPath) {
2115
+ const state = await readState(directory);
2116
+ await ensureManagedDataVolume(directory, mode, state, env);
2117
+ const clear = await run("docker", [
2118
+ "run",
2119
+ "--rm",
2120
+ "--network",
2121
+ "none",
2122
+ "--user",
2123
+ "0:0",
2124
+ "--read-only",
2125
+ "--cap-drop",
2126
+ "ALL",
2127
+ "--cap-add",
2128
+ "DAC_OVERRIDE",
2129
+ "--cap-add",
2130
+ "FOWNER",
2131
+ "--security-opt",
2132
+ "no-new-privileges:true",
2133
+ "-v",
2134
+ `${state.volumeName}:/data`,
2135
+ "--entrypoint",
2136
+ "sh",
2137
+ env.OR3_IMAGE,
2138
+ "-c",
2139
+ "find /data -mindepth 1 -delete"
2140
+ ], directory);
2141
+ if (!clear.ok) {
2142
+ throw new Error(`Could not safely clear the managed data volume before restore. ${redact(clear.stderr, secretValues(env))}`);
2143
+ }
2144
+ await streamFileToCommand("docker", [
2145
+ "run",
2146
+ "--rm",
2147
+ "-i",
2148
+ "--network",
2149
+ "none",
2150
+ "--read-only",
2151
+ "--security-opt",
2152
+ "no-new-privileges:true",
2153
+ "--cap-drop",
2154
+ "ALL",
2155
+ "-v",
2156
+ `${state.volumeName}:/data`,
1126
2157
  "--entrypoint",
1127
2158
  "sh",
1128
- "or3",
2159
+ env.OR3_IMAGE,
1129
2160
  "-c",
1130
- "tar czf - -C /data ."
1131
- ]), join(backupDir, "data.tgz"), directory, secretValues(env));
1132
- }
1133
- async function archiveExternalVolume(image, volume, backupDir) {
1134
- await streamCommandToFile("docker", ["run", "--rm", "--user", "0:0", "-v", `${volume}:/source:ro`, image, "sh", "-c", "tar czf - -C /source ."], join(backupDir, "data.tgz"));
2161
+ "tar xzf - -C /data"
2162
+ ], join(backupPath, "data.tgz"), directory, secretValues(env));
1135
2163
  }
1136
- async function restoreVolumeArchive(directory, mode, env, backupPath) {
1137
- await streamFileToCommand("docker", composeArgs(directory, mode, [
2164
+ async function validateVolumeArchive(directory, _mode, env, backupPath) {
2165
+ await streamFileToCommand("docker", [
1138
2166
  "run",
1139
2167
  "--rm",
1140
- "-T",
1141
- "--no-deps",
2168
+ "-i",
2169
+ "--network",
2170
+ "none",
2171
+ "--read-only",
2172
+ "--security-opt",
2173
+ "no-new-privileges:true",
2174
+ "--cap-drop",
2175
+ "ALL",
1142
2176
  "--entrypoint",
1143
2177
  "sh",
1144
- "or3",
2178
+ env.OR3_IMAGE,
1145
2179
  "-c",
1146
- "find /data -mindepth 1 -delete && tar xzf - -C /data"
1147
- ]), join(backupPath, "data.tgz"), directory, secretValues(env));
2180
+ "tar tzf - >/dev/null"
2181
+ ], join(backupPath, "data.tgz"), directory, secretValues(env));
1148
2182
  }
1149
- async function createBackup(directory, state, env) {
2183
+ async function createBackup(directory, state, env, options = {}) {
2184
+ const restartAfter = options.restartAfter ?? true;
1150
2185
  await requireImageDigest(state.image, state.imageDigest, "Current deployment");
1151
- const backupId = id("backup");
2186
+ const backupId = options.backupId ?? id("backup");
2187
+ if (!BACKUP_ID_PATTERN.test(backupId))
2188
+ throw new Error(`Backup ID ${backupId} is invalid.`);
1152
2189
  const backupDir = backupDirectory(directory, backupId);
2190
+ const initiallyRunning = options.initiallyRunning ?? await projectServiceRunning(directory, state.mode);
1153
2191
  const volumeSize = await dataVolumeSize(directory, state.mode, env);
1154
2192
  await assertFreeSpaceForArchive(backupDir, volumeSize, "Backup");
1155
2193
  let manifestWritten = false;
1156
2194
  let stopAttempted = false;
2195
+ let backupFailure;
1157
2196
  try {
1158
2197
  await mkdir(backupDir, { recursive: true, mode: 448 });
1159
2198
  await chmod(backupDir, 448);
@@ -1161,6 +2200,7 @@ async function createBackup(directory, state, env) {
1161
2200
  await stopProject(directory, state.mode);
1162
2201
  await volumeArchive(directory, state.mode, env, backupDir);
1163
2202
  await copySecure(deploymentPaths(directory).env, join(backupDir, "config.env"));
2203
+ const managedAssetSha256 = await snapshotManagedAssets(directory, state.mode, backupDir);
1164
2204
  const manifest = {
1165
2205
  schemaVersion: 1,
1166
2206
  backupId,
@@ -1171,44 +2211,54 @@ async function createBackup(directory, state, env) {
1171
2211
  dataSha256: await sha256File(join(backupDir, "data.tgz")),
1172
2212
  dataBytes: volumeSize,
1173
2213
  configSha256: await sha256File(join(backupDir, "config.env")),
2214
+ managedAssetSha256,
2215
+ managedAssetInventoryVersion: managedAssetSha256["compose.operator.yaml"] ? MANAGED_ASSET_INVENTORY_VERSION : managedAssetSha256["dashboard-operator.mjs"] ? 2 : undefined,
1174
2216
  mode: state.mode,
1175
2217
  domain: state.domain,
1176
2218
  composeProject: state.composeProject,
1177
2219
  volumeName: state.volumeName,
1178
2220
  caddyDataVolume: state.caddyDataVolume,
1179
2221
  caddyConfigVolume: state.caddyConfigVolume,
2222
+ deploymentId: state.deploymentId,
1180
2223
  port: state.port
1181
2224
  };
1182
- await writeSecure(join(backupDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
1183
- `);
2225
+ const manifestContents = `${JSON.stringify(manifest, null, 2)}
2226
+ `;
2227
+ await writeSecure(join(backupDir, "manifest.json"), manifestContents);
2228
+ await writeBackupAuthentication(directory, backupDir, manifestContents);
2229
+ const verifiedManifest = await readManifest(backupDir, directory);
1184
2230
  manifestWritten = true;
1185
- try {
1186
- await pruneBackups(directory, state, BACKUP_RETENTION_KEEP, false);
1187
- } catch (error) {
1188
- console.log(`Warning: automatic backup retention could not run: ${error instanceof Error ? error.message : String(error)}`);
1189
- }
1190
- return { backupId, backupDir, manifest };
2231
+ return { backupId, backupDir, manifest: verifiedManifest };
1191
2232
  } catch (error) {
1192
2233
  if (!manifestWritten) {
1193
2234
  await removeNamedBackupArtifact(directory, backupId).catch(() => {
1194
2235
  return;
1195
2236
  });
1196
- throw new Error(`${error instanceof Error ? error.message : String(error)} The partial backup artifact at ${backupDir} was removed.`);
2237
+ backupFailure = new Error(`${error instanceof Error ? error.message : String(error)} The partial backup artifact at ${backupDir} was removed.`);
2238
+ throw backupFailure;
1197
2239
  }
2240
+ backupFailure = error instanceof Error ? error : new Error(String(error));
1198
2241
  throw error;
1199
2242
  } finally {
1200
- if (stopAttempted) {
2243
+ if (stopAttempted && restartAfter && initiallyRunning) {
1201
2244
  try {
1202
2245
  await startProject(directory, state.mode, env);
1203
2246
  } catch (error) {
2247
+ const restartFailure = error instanceof Error ? error : new Error(String(error));
2248
+ if (backupFailure) {
2249
+ throw new AggregateError([backupFailure, restartFailure], `Backup failed and OR3 could not restart. Primary failure: ${backupFailure.message}. Restart failure: ${restartFailure.message}`);
2250
+ }
1204
2251
  throw new Error(`Backup was created but OR3 could not restart: ${error instanceof Error ? error.message : String(error)}`);
1205
2252
  }
1206
2253
  }
1207
2254
  }
1208
2255
  }
1209
- async function readManifest(backupPath) {
1210
- const manifest = JSON.parse(await readText(join(backupPath, "manifest.json")));
1211
- if (manifest.schemaVersion !== 1 || !manifest.backupId || !isVersion(manifest.appVersion) || !manifest.image || !/^sha256:[0-9a-f]{64}$/i.test(manifest.imageDigest) || manifest.dataBytes !== undefined && (!Number.isSafeInteger(manifest.dataBytes) || manifest.dataBytes < 0)) {
2256
+ async function readManifest(backupPath, authenticatedForDirectory) {
2257
+ const manifestContents = await readText(join(backupPath, "manifest.json"));
2258
+ if (authenticatedForDirectory)
2259
+ await assertBackupAuthentication(authenticatedForDirectory, backupPath, manifestContents);
2260
+ const manifest = JSON.parse(manifestContents);
2261
+ if (manifest.schemaVersion !== 1 || !BACKUP_ID_PATTERN.test(manifest.backupId) || !Number.isFinite(Date.parse(manifest.createdAt)) || !isVersion(manifest.appVersion) || !manifest.image || !/^sha256:[0-9a-f]{64}$/i.test(manifest.imageDigest) || !/^[0-9a-f]{64}$/i.test(manifest.dataSha256) || !/^[0-9a-f]{64}$/i.test(manifest.configSha256 ?? "") || manifest.dataBytes !== undefined && (!Number.isSafeInteger(manifest.dataBytes) || manifest.dataBytes < 0)) {
1212
2262
  throw new Error(`Invalid backup manifest at ${backupPath}.`);
1213
2263
  }
1214
2264
  const actual = await sha256File(join(backupPath, "data.tgz"));
@@ -1219,8 +2269,21 @@ async function readManifest(backupPath) {
1219
2269
  if (configActual !== manifest.configSha256)
1220
2270
  throw new Error(`Backup configuration checksum mismatch for ${manifest.backupId}.`);
1221
2271
  }
2272
+ await verifiedManagedAssetContents(backupPath, manifest);
1222
2273
  return manifest;
1223
2274
  }
2275
+ async function cleanupJournaledPartialBackup(directory, backupId, backupPath) {
2276
+ if (!backupId || !backupPath || !BACKUP_ID_PATTERN.test(backupId))
2277
+ return;
2278
+ const expectedPath = resolve(backupDirectory(directory, backupId));
2279
+ if (resolve(backupPath) !== expectedPath || !await fileExists(expectedPath))
2280
+ return;
2281
+ try {
2282
+ await readManifest(expectedPath, directory);
2283
+ } catch {
2284
+ await removeNamedBackupArtifact(directory, backupId);
2285
+ }
2286
+ }
1224
2287
  async function dataVolumeFreeBytes(directory, mode, env) {
1225
2288
  const parse = (stdout) => {
1226
2289
  const blocks = Number(stdout.trim());
@@ -1232,7 +2295,22 @@ async function dataVolumeFreeBytes(directory, mode, env) {
1232
2295
  const exec = await run("docker", [...composeArgs(directory, mode, ["exec", "-T", "or3", "sh", "-c", script])], directory);
1233
2296
  if (exec.ok)
1234
2297
  return parse(exec.stdout);
1235
- const fallback = await run("docker", [...composeArgs(directory, mode, ["run", "--rm", "--no-deps", "--entrypoint", "sh", "or3", "-c", script])], directory);
2298
+ const fallback = await run("docker", [
2299
+ "run",
2300
+ "--rm",
2301
+ "--network",
2302
+ "none",
2303
+ "--read-only",
2304
+ "--cap-drop",
2305
+ "ALL",
2306
+ "-v",
2307
+ `${env.OR3_VOLUME_NAME}:/data:ro`,
2308
+ "--entrypoint",
2309
+ "sh",
2310
+ env.OR3_IMAGE,
2311
+ "-c",
2312
+ script
2313
+ ], directory);
1236
2314
  if (fallback.ok)
1237
2315
  return parse(fallback.stdout);
1238
2316
  throw new Error(`Could not measure free space in the Docker data volume. ${redact(`${exec.stderr} ${fallback.stderr}`, secretValues(env))}`);
@@ -1241,10 +2319,12 @@ async function assertRestoreFreeSpace(directory, state, env, manifest, backupPat
1241
2319
  const archiveBytes = await gzipUncompressedBytes(join(backupPath, "data.tgz"));
1242
2320
  const requiredBytes = Math.max(manifest.dataBytes ?? 0, archiveBytes);
1243
2321
  const freeBytes = await dataVolumeFreeBytes(directory, state.mode, env);
1244
- assertEnoughFreeSpace(freeBytes, requiredBytes, "Restore");
2322
+ const reclaimableBytes = await dataVolumeSize(directory, state.mode, env);
2323
+ assertEnoughFreeSpace(freeBytes + reclaimableBytes, requiredBytes, "Restore");
1245
2324
  }
1246
- async function restoreBackupData(directory, state, env, backupPath) {
1247
- const manifest = await readManifest(backupPath);
2325
+ async function restoreBackupData(directory, state, env, backupPath, options = {}) {
2326
+ const manifest = await readManifest(backupPath, directory);
2327
+ assertRestorableManagedAssets(manifest);
1248
2328
  const backupEnv = parseEnv(await readText(join(backupPath, "config.env")));
1249
2329
  if (backupEnv.OR3_VERSION !== manifest.appVersion || backupEnv.OR3_IMAGE !== manifest.image) {
1250
2330
  throw new Error(`Backup ${manifest.backupId} configuration does not match its manifest.`);
@@ -1252,29 +2332,28 @@ async function restoreBackupData(directory, state, env, backupPath) {
1252
2332
  assertBackupMatchesDeployment(manifest, backupEnv, state, env);
1253
2333
  if (manifest.imageDigest)
1254
2334
  await requireImageDigest(manifest.image, manifest.imageDigest, `Backup ${manifest.backupId}`);
2335
+ await validateVolumeArchive(directory, state.mode, env, backupPath);
2336
+ await ensureManagedDataVolume(directory, state.mode, state, env);
1255
2337
  await assertRestoreFreeSpace(directory, state, env, manifest, backupPath);
1256
- let started = false;
1257
- let stopAttempted = false;
1258
- try {
1259
- stopAttempted = true;
2338
+ const restoresOperatorOverlay = manifest.managedAssetInventoryVersion === MANAGED_ASSET_INVENTORY_VERSION && Boolean(manifest.managedAssetSha256?.["compose.operator.yaml"]);
2339
+ const restoredEnv = restoresOperatorOverlay ? {
2340
+ ...backupEnv,
2341
+ OR3_IMAGE: imageAtDigest(manifest.image, manifest.imageDigest)
2342
+ } : withoutDashboardOperator({
2343
+ ...backupEnv,
2344
+ OR3_IMAGE: imageAtDigest(manifest.image, manifest.imageDigest)
2345
+ });
2346
+ if (options.recreateDataVolume)
2347
+ await removeManagedDataVolumeForRecreation(directory, state);
2348
+ else
1260
2349
  await stopProject(directory, state.mode);
1261
- await copySecure(join(backupPath, "config.env"), deploymentPaths(directory).env);
1262
- await restoreVolumeArchive(directory, state.mode, backupEnv, backupPath);
1263
- const restoredEnv = parseEnv(await readText(deploymentPaths(directory).env));
1264
- await startProject(directory, state.mode, restoredEnv);
1265
- started = true;
1266
- } catch (error) {
1267
- let restartError = "";
1268
- if (stopAttempted && !started) {
1269
- try {
1270
- const recoveryEnv = parseEnv(await readText(deploymentPaths(directory).env));
1271
- await startProject(directory, state.mode, recoveryEnv);
1272
- } catch (recovery) {
1273
- restartError = ` OR3 could not be restarted: ${recovery instanceof Error ? recovery.message : String(recovery)}`;
1274
- }
1275
- }
1276
- throw new Error(`Restore failed for ${manifest.backupId}: ${error instanceof Error ? error.message : String(error)}${restartError}`);
2350
+ if (dashboardUpdatesEnabled(directory) && restoredEnv.OR3_DASHBOARD_UPDATES_ENABLED !== "true" && !process.env.OR3_DASHBOARD_UPDATE_JOB_ID) {
2351
+ await removeDashboardOperator(directory, state.mode);
1277
2352
  }
2353
+ await writeSecure(deploymentPaths(directory).env, serializeEnv(restoredEnv));
2354
+ await restoreManagedAssets(directory, backupPath, manifest);
2355
+ await restoreVolumeArchive(directory, state.mode, restoredEnv, backupPath);
2356
+ await startProject(directory, state.mode, restoredEnv);
1278
2357
  return manifest;
1279
2358
  }
1280
2359
  function stateFromEnv(directory, env, mode, operation, digest) {
@@ -1285,6 +2364,8 @@ function stateFromEnv(directory, env, mode, operation, digest) {
1285
2364
  volumeName: env.OR3_VOLUME_NAME,
1286
2365
  caddyDataVolume: mode === "public" ? env.OR3_CADDY_DATA_VOLUME : undefined,
1287
2366
  caddyConfigVolume: mode === "public" ? env.OR3_CADDY_CONFIG_VOLUME : undefined,
2367
+ deploymentId: env.OR3_DEPLOYMENT_ID,
2368
+ deploymentRoot: resolve(directory),
1288
2369
  appVersion: env.OR3_VERSION,
1289
2370
  image: env.OR3_IMAGE,
1290
2371
  imageDigest: digest,
@@ -1304,8 +2385,9 @@ Usage:
1304
2385
  npx @or3/cloud backup [list|prune [--keep <n>]|export <backup-id> <destination-dir>]
1305
2386
  npx @or3/cloud restore <backup-id-or-path> --yes
1306
2387
  npx @or3/cloud rollback --yes
1307
- npx @or3/cloud credentials reset --yes [--owner-password <p> --admin-password <p>]
2388
+ npx @or3/cloud credentials reset --yes [--owner-password-file <path> --admin-password-file <path>]
1308
2389
  npx @or3/cloud doctor
2390
+ npx @or3/cloud verify [--public] [--verification-email <email> --verification-password-file <path>]
1309
2391
  npx @or3/cloud recover
1310
2392
  npx @or3/cloud adopt --from <v1-directory> [directory]
1311
2393
  npx @or3/cloud status
@@ -1316,12 +2398,16 @@ Usage:
1316
2398
  Options:
1317
2399
  --admin-email <email> Administrator email for first login
1318
2400
  --admin-password <password> Explicit password (prefer --admin-password-file); for credentials reset, the new admin password
1319
- --admin-password-file <path> Read the bootstrap password without shell history
2401
+ --admin-password-file <path> Read the bootstrap/reset admin password without shell history
1320
2402
  --owner-password <password> New owner (basic auth) password for credentials reset
2403
+ --owner-password-file <path> Read the new owner password without shell history
2404
+ --verification-email <email> Current owner email used only for this verification
2405
+ --verification-password-file <path> Read the current owner password for this verification
1321
2406
  --port <port> Local OR3 port (default: 3000)
1322
2407
  --keep <n> Backups to retain when pruning (default: 5)
1323
2408
  --force Prune backups even when referenced by the rollback point
1324
2409
  --tail <n> Log lines to show (default: 200)
2410
+ --public Require verification through the public HTTPS origin
1325
2411
  --purge-data Remove data volumes and managed files (with remove)
1326
2412
  --yes Confirm a destructive restore, rollback, credentials reset, or purge
1327
2413
  --help Show this help
@@ -1351,9 +2437,10 @@ async function initCommand(positionals, flags) {
1351
2437
  throw new Error("--port must be a valid TCP port.");
1352
2438
  if (mode === "public" && [80, 443].includes(port))
1353
2439
  throw new Error("Public mode reserves ports 80 and 443 for Caddy; choose another --port for OR3.");
1354
- if (!await portAvailable(port))
2440
+ const localDockerDaemon = await dockerDaemonIsLocal();
2441
+ if (localDockerDaemon && !await portAvailable(port))
1355
2442
  throw new Error(`Port ${port} is already in use. Choose another port with --port.`);
1356
- if (mode === "public") {
2443
+ if (mode === "public" && localDockerDaemon) {
1357
2444
  for (const publicPort of [80, 443]) {
1358
2445
  if (!await portAvailable(publicPort))
1359
2446
  throw new Error(`Public port ${publicPort} is already in use. Stop the conflicting service before starting Caddy.`);
@@ -1361,23 +2448,31 @@ async function initCommand(positionals, flags) {
1361
2448
  }
1362
2449
  await ensureDocker();
1363
2450
  const names = composeProjectNames(directory);
2451
+ await assertDockerProjectAbsent(names.project);
1364
2452
  for (const volume of [names.volume, ...mode === "public" ? [names.caddyData, names.caddyConfig] : []]) {
1365
2453
  const existing = await run("docker", ["volume", "inspect", volume]);
1366
2454
  if (existing.ok)
1367
2455
  throw new Error(`Docker volume ${volume} already exists. Choose a new directory or inspect it before initializing.`);
2456
+ if (existing.exitCode !== 1 || !/no such volume/i.test(`${existing.stdout}
2457
+ ${existing.stderr}`)) {
2458
+ throw new Error(`Could not confirm whether Docker volume ${volume} exists. Refusing initialization until Docker returns an explicit not-found result. ${existing.stderr.trim()}`);
2459
+ }
1368
2460
  }
1369
2461
  const email = await resolveAdminEmail(flags);
1370
2462
  const password = await readPassword(flags);
1371
2463
  const version = PACKAGE_VERSION;
1372
- const image = imageFor(version);
1373
- await pullImage(image);
1374
- await assertSupportedHostArchitecture(image);
2464
+ const imageTag = imageFor(version);
2465
+ const digest = await pullImage(imageTag, expectedImageDigest(version));
2466
+ await assertSupportedHostArchitecture(imageTag);
2467
+ await assertImageReleaseIdentity(imageTag, version);
2468
+ const image = imageAtDigest(imageTag, digest);
1375
2469
  await mkdir(directory, { recursive: true, mode: 448 });
1376
2470
  await chmod(directory, 448);
1377
2471
  await mkdir(deploymentPaths(directory).operations, { recursive: true, mode: 448 });
1378
2472
  await mkdir(deploymentPaths(directory).backups, { recursive: true, mode: 448 });
2473
+ const operator = await prepareVerifiedDashboardOperator(directory, version);
1379
2474
  await copyAssets(directory, mode);
1380
- const env = buildEnv({ mode, version, directory, email, password, domain, port });
2475
+ const env = buildEnv({ mode, version, directory, image, email, password, domain, port, dashboardOperator: operator });
1381
2476
  await writeSecure(deploymentPaths(directory).env, serializeEnv(env));
1382
2477
  await writeSecure(join(directory, ".or3-initial-credentials"), serializeInitialCredentials({
1383
2478
  bootstrapEmail: email,
@@ -1385,7 +2480,6 @@ async function initCommand(positionals, flags) {
1385
2480
  adminUsername: email,
1386
2481
  adminPassword: password
1387
2482
  }));
1388
- const digest = await imageDigest(image);
1389
2483
  const state = stateFromEnv(directory, env, mode, "init", digest);
1390
2484
  await markPending(directory, state, {
1391
2485
  id: id("init"),
@@ -1395,12 +2489,19 @@ async function initCommand(positionals, flags) {
1395
2489
  });
1396
2490
  try {
1397
2491
  await startProject(directory, mode, env);
2492
+ await provisionManagedCredentials(mode === "public" ? new URL(`https://${domain}`) : new URL(`http://127.0.0.1:${port}`), email, password);
2493
+ const runtimeEnv = withoutProvisioningCredentials(env);
2494
+ await writeSecure(deploymentPaths(directory).env, serializeEnv(runtimeEnv));
2495
+ await stopProject(directory, mode);
2496
+ await startProject(directory, mode, runtimeEnv);
1398
2497
  await clearPending(directory, state);
1399
2498
  console.log(`
1400
2499
  OR3 Cloud ${version} is running at ${mode === "public" ? `https://${domain}` : `http://127.0.0.1:${port}`}`);
1401
2500
  console.log(`Credentials were written to ${join(directory, ".or3-initial-credentials")} (mode 0600). Save them, then remove that file.`);
1402
2501
  console.log(`
1403
2502
  Check: cd ${quote(directory)} && npx @or3/cloud doctor`);
2503
+ if (operator)
2504
+ console.log("Dashboard updates are enabled for super admins in Operations.");
1404
2505
  } catch (error) {
1405
2506
  state.lastError = redact(error instanceof Error ? error.message : String(error), secretValues(env));
1406
2507
  await writeState(directory, state);
@@ -1416,6 +2517,7 @@ async function loadManaged(directory = process.cwd()) {
1416
2517
  const state = await readState(resolved);
1417
2518
  const env = parseEnv(await readText(deploymentPaths(resolved).env));
1418
2519
  assertDeploymentIdentity(state, env);
2520
+ assertDeploymentDirectoryIdentity(resolved, state);
1419
2521
  return { directory: resolved, state, env };
1420
2522
  }
1421
2523
  function operationToStateOperation(operation, fallback) {
@@ -1427,6 +2529,14 @@ async function commitRecoveredState(directory, previous, next) {
1427
2529
  await removeOperationRecord(directory, previous.incompleteOperation?.id);
1428
2530
  await writeState(directory, next);
1429
2531
  }
2532
+ async function commitPreMutationRecovery(directory, state, message) {
2533
+ const restoredEnv = parseEnv(await readText(deploymentPaths(directory).env));
2534
+ const recovered = stateFromEnv(directory, restoredEnv, state.mode, state.lastSuccessfulOperation, await imageDigest(restoredEnv.OR3_IMAGE));
2535
+ recovered.rollback = state.rollback;
2536
+ recovered.lastError = message;
2537
+ await commitRecoveredState(directory, state, recovered);
2538
+ return recovered;
2539
+ }
1430
2540
  async function recoverCommand(directory) {
1431
2541
  await ensureDocker();
1432
2542
  const loaded = await loadManaged(directory);
@@ -1436,6 +2546,14 @@ async function recoverCommand(directory) {
1436
2546
  return;
1437
2547
  }
1438
2548
  try {
2549
+ if (pending.phase === "prepared") {
2550
+ if (pending.operation === "backup" || pending.operation === "update" || pending.operation === "adopt") {
2551
+ await cleanupJournaledPartialBackup(loaded.directory, pending.backupId, pending.backupPath);
2552
+ }
2553
+ if (pending.operation === "restore" || pending.operation === "rollback") {
2554
+ await cleanupJournaledPartialBackup(loaded.directory, pending.previousBackupId, pending.previousBackupPath);
2555
+ }
2556
+ }
1439
2557
  if (pending.operation === "credentials-reset") {
1440
2558
  const nextEnv = pending.credentialReset?.nextEnv;
1441
2559
  if (!nextEnv)
@@ -1447,91 +2565,69 @@ async function recoverCommand(directory) {
1447
2565
  return;
1448
2566
  }
1449
2567
  if (pending.operation === "restore" || pending.operation === "rollback") {
1450
- if (!pending.backupId)
1451
- throw new Error(`The incomplete ${pending.operation} has no recorded backup ID.`);
1452
- const backupPath = await resolveBackup(loaded.directory, pending.backupId);
1453
- const manifest = await readManifest(backupPath);
1454
- const backupEnv = parseEnv(await readText(join(backupPath, "config.env")));
1455
- if (backupEnv.OR3_VERSION !== manifest.appVersion || backupEnv.OR3_IMAGE !== manifest.image) {
1456
- throw new Error(`Backup ${manifest.backupId} configuration does not match its manifest.`);
2568
+ if (pending.phase === "prepared" || pending.phase === "snapshot-created") {
2569
+ await startProject(loaded.directory, loaded.state.mode, loaded.env);
2570
+ loaded.state.lastError = undefined;
2571
+ await clearPending(loaded.directory, loaded.state);
2572
+ console.log(`Recovered the incomplete ${pending.operation} before data replacement. OR3 ${loaded.state.appVersion} is deeply healthy.`);
2573
+ return;
1457
2574
  }
1458
- if (pending.targetVersion && pending.targetVersion !== manifest.appVersion || pending.targetImage && pending.targetImage !== manifest.image || pending.targetImageDigest && pending.targetImageDigest !== manifest.imageDigest) {
1459
- throw new Error(`Backup ${manifest.backupId} does not match the recorded ${pending.operation} target.`);
2575
+ if (!pending.previousBackupPath && !pending.previousBackupId) {
2576
+ throw new Error(`The incomplete ${pending.operation} has no verified pre-mutation snapshot. Refusing to replay a target that may have been partially restored; inspect the deployment and restore an authenticated backup explicitly.`);
1460
2577
  }
1461
- assertBackupMatchesDeployment(manifest, backupEnv, loaded.state, loaded.env);
1462
- await pullAndRequireImage(manifest.image, manifest.imageDigest, `Backup ${manifest.backupId}`);
1463
- await restoreBackupData(loaded.directory, loaded.state, loaded.env, backupPath);
1464
- const restoredEnv = parseEnv(await readText(deploymentPaths(loaded.directory).env));
1465
- const restored = stateFromEnv(loaded.directory, restoredEnv, loaded.state.mode, "restore", await imageDigest(restoredEnv.OR3_IMAGE));
1466
- restored.lastError = undefined;
1467
- await commitRecoveredState(loaded.directory, loaded.state, restored);
1468
- console.log(`Recovered the incomplete ${pending.operation} operation from backup ${manifest.backupId}. OR3 ${restored.appVersion} is deeply healthy.`);
2578
+ const previous = await restorePreMutationSnapshot(loaded.directory, loaded.state, loaded.env);
2579
+ const recovered2 = await commitPreMutationRecovery(loaded.directory, loaded.state, `Interrupted ${pending.operation} was rolled back to pre-mutation snapshot ${previous.manifest.backupId}.`);
2580
+ console.log(`Recovered the incomplete ${pending.operation} by restoring pre-mutation snapshot ${previous.manifest.backupId}. OR3 ${recovered2.appVersion} is deeply healthy.`);
1469
2581
  return;
1470
2582
  }
1471
2583
  if (pending.operation === "update") {
1472
- const oldDeployment = loaded.env.OR3_VERSION === loaded.state.appVersion && loaded.env.OR3_IMAGE === loaded.state.image;
1473
- if (oldDeployment) {
2584
+ if (pending.phase === "prepared" || pending.phase === "snapshot-created") {
1474
2585
  await pullAndRequireImage(loaded.state.image, loaded.state.imageDigest, "Current deployment");
1475
2586
  await startProject(loaded.directory, loaded.state.mode, loaded.env);
1476
- const recovered2 = stateFromEnv(loaded.directory, loaded.env, loaded.state.mode, loaded.state.lastSuccessfulOperation, await imageDigest(loaded.env.OR3_IMAGE));
1477
- recovered2.rollback = loaded.state.rollback;
1478
- recovered2.lastError = undefined;
1479
- await commitRecoveredState(loaded.directory, loaded.state, recovered2);
1480
- console.log(`Recovered the incomplete update before the replacement was applied. OR3 ${recovered2.appVersion} is deeply healthy.`);
2587
+ const recovered3 = stateFromEnv(loaded.directory, loaded.env, loaded.state.mode, loaded.state.lastSuccessfulOperation, await imageDigest(loaded.env.OR3_IMAGE));
2588
+ recovered3.rollback = loaded.state.rollback;
2589
+ recovered3.lastError = undefined;
2590
+ await commitRecoveredState(loaded.directory, loaded.state, recovered3);
2591
+ console.log(`Recovered the incomplete update before the replacement was applied. OR3 ${recovered3.appVersion} is deeply healthy.`);
1481
2592
  return;
1482
2593
  }
1483
- if (!pending.backupId || loaded.env.OR3_VERSION !== pending.targetVersion || loaded.env.OR3_IMAGE !== pending.targetImage) {
1484
- throw new Error("The incomplete update has an unknown .env version/image. Refusing to guess which image or data should be live.");
2594
+ if (!pending.backupPath && !pending.backupId) {
2595
+ throw new Error("The incomplete update has no verified pre-update snapshot. Refusing to guess which image or data should be live.");
1485
2596
  }
1486
- const backupPath = await resolveBackup(loaded.directory, pending.backupId);
1487
- const manifest = await readManifest(backupPath);
1488
- const backupEnv = parseEnv(await readText(join(backupPath, "config.env")));
1489
- if (backupEnv.OR3_VERSION !== manifest.appVersion || backupEnv.OR3_IMAGE !== manifest.image) {
1490
- throw new Error(`Backup ${manifest.backupId} configuration does not match its manifest.`);
1491
- }
1492
- assertBackupMatchesDeployment(manifest, backupEnv, loaded.state, loaded.env);
1493
- try {
1494
- if (pending.targetImageDigest)
1495
- await pullAndRequireImage(pending.targetImage, pending.targetImageDigest, `OR3 ${pending.targetVersion}`);
1496
- await startProject(loaded.directory, loaded.state.mode, loaded.env);
1497
- } catch (replacementError) {
1498
- await restoreBackupData(loaded.directory, loaded.state, loaded.env, backupPath);
1499
- const restoredEnv = parseEnv(await readText(deploymentPaths(loaded.directory).env));
1500
- const restored = stateFromEnv(loaded.directory, restoredEnv, loaded.state.mode, loaded.state.lastSuccessfulOperation, await imageDigest(restoredEnv.OR3_IMAGE));
1501
- restored.rollback = loaded.state.rollback;
1502
- restored.lastError = `Interrupted update was restored: ${redact(replacementError instanceof Error ? replacementError.message : String(replacementError), secretValues(restoredEnv))}`;
1503
- await commitRecoveredState(loaded.directory, loaded.state, restored);
1504
- console.log(`Recovered the incomplete update by restoring backup ${manifest.backupId}. OR3 ${restored.appVersion} is deeply healthy.`);
1505
- return;
1506
- }
1507
- const updated = stateFromEnv(loaded.directory, loaded.env, loaded.state.mode, "update", await imageDigest(loaded.env.OR3_IMAGE));
1508
- updated.rollback = {
1509
- appVersion: loaded.state.appVersion,
1510
- image: loaded.state.image,
1511
- imageDigest: loaded.state.imageDigest,
1512
- backupId: pending.backupId,
1513
- createdAt: now()
1514
- };
1515
- updated.lastError = undefined;
1516
- await commitRecoveredState(loaded.directory, loaded.state, updated);
1517
- console.log(`Recovered the incomplete update. OR3 ${updated.appVersion} is deeply healthy.`);
2597
+ const previous = await restorePreMutationSnapshot(loaded.directory, loaded.state, loaded.env);
2598
+ const recovered2 = await commitPreMutationRecovery(loaded.directory, loaded.state, `Interrupted update was rolled back to pre-update snapshot ${previous.manifest.backupId}.`);
2599
+ console.log(`Recovered the incomplete update by restoring pre-update snapshot ${previous.manifest.backupId}. OR3 ${recovered2.appVersion} is deeply healthy.`);
1518
2600
  return;
1519
2601
  }
1520
2602
  if (pending.operation === "adopt") {
1521
2603
  if (!pending.backupId)
1522
2604
  throw new Error("The incomplete adoption has no source backup ID. Refusing to claim that data was transferred.");
1523
2605
  const sourceBackupPath = await resolveBackup(loaded.directory, pending.backupId);
1524
- const sourceManifest = await readManifest(sourceBackupPath);
2606
+ const sourceManifest = await readManifest(sourceBackupPath, loaded.directory);
1525
2607
  if (sourceManifest.mode !== loaded.state.mode || sourceManifest.appVersion !== loaded.state.appVersion || sourceManifest.image !== loaded.state.image || sourceManifest.imageDigest !== loaded.state.imageDigest) {
1526
2608
  throw new Error(`Source backup ${sourceManifest.backupId} does not match the managed adoption target.`);
1527
2609
  }
1528
2610
  await stopProject(loaded.directory, loaded.state.mode);
1529
2611
  await restoreVolumeArchive(loaded.directory, loaded.state.mode, loaded.env, sourceBackupPath);
1530
2612
  }
2613
+ if (pending.operation === "backup" && pending.initialAppRunning === false) {
2614
+ loaded.state.lastError = undefined;
2615
+ await clearPending(loaded.directory, loaded.state);
2616
+ console.log("Recovered the incomplete backup operation and preserved the intentionally stopped OR3 service.");
2617
+ return;
2618
+ }
1531
2619
  await pullAndRequireImage(loaded.state.image, loaded.state.imageDigest, "Current deployment");
1532
2620
  await startProject(loaded.directory, loaded.state.mode, loaded.env);
1533
- const digest = await imageDigest(loaded.env.OR3_IMAGE);
1534
- const recovered = stateFromEnv(loaded.directory, loaded.env, loaded.state.mode, operationToStateOperation(pending.operation, loaded.state.lastSuccessfulOperation), digest);
2621
+ let recoveredEnv = loaded.env;
2622
+ if ((pending.operation === "init" || pending.operation === "adopt") && loaded.env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL && loaded.env.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD && loaded.env.OR3_ADMIN_PASSWORD) {
2623
+ await provisionManagedCredentials(loaded.state.mode === "public" ? new URL(`https://${loaded.env.OR3_PUBLIC_DOMAIN}`) : new URL(`http://127.0.0.1:${loaded.env.OR3_PORT}`), loaded.env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL, loaded.env.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD, loaded.env.OR3_ADMIN_USERNAME, loaded.env.OR3_ADMIN_PASSWORD);
2624
+ recoveredEnv = withoutProvisioningCredentials(loaded.env);
2625
+ await writeSecure(deploymentPaths(loaded.directory).env, serializeEnv(recoveredEnv));
2626
+ await stopProject(loaded.directory, loaded.state.mode);
2627
+ await startProject(loaded.directory, loaded.state.mode, recoveredEnv);
2628
+ }
2629
+ const digest = await imageDigest(recoveredEnv.OR3_IMAGE);
2630
+ const recovered = stateFromEnv(loaded.directory, recoveredEnv, loaded.state.mode, operationToStateOperation(pending.operation, loaded.state.lastSuccessfulOperation), digest);
1535
2631
  recovered.rollback = loaded.state.rollback;
1536
2632
  recovered.lastError = undefined;
1537
2633
  await commitRecoveredState(loaded.directory, loaded.state, recovered);
@@ -1549,7 +2645,9 @@ async function recoverCommand(directory) {
1549
2645
  const sourceModeValue = sourceMode(pending.sourceDirectory, sourceEnv);
1550
2646
  if (sourceModeValue === "public")
1551
2647
  sourceFiles.push("-f", join(pending.sourceDirectory, "compose.public.yaml"));
1552
- await restartSource(pending.sourceDirectory, sourceFiles, secretValues(sourceEnv));
2648
+ if (pending.sourceInitiallyRunning !== false) {
2649
+ await restartSource(pending.sourceDirectory, sourceFiles, secretValues(sourceEnv));
2650
+ }
1553
2651
  } catch (recovery) {
1554
2652
  loaded.state.lastError += ` Original deployment recovery failed: ${recovery instanceof Error ? recovery.message : String(recovery)}`;
1555
2653
  }
@@ -1567,16 +2665,23 @@ async function backupCreateCommand(directory) {
1567
2665
  await ensureDocker();
1568
2666
  const loaded = await loadManaged(directory);
1569
2667
  assertNoPending(loaded.state);
2668
+ const backupId = id("backup");
2669
+ const initialAppRunning = await projectServiceRunning(loaded.directory, loaded.state.mode);
1570
2670
  const pending = {
1571
- id: id("backup"),
2671
+ id: id("backup-operation"),
1572
2672
  operation: "backup",
1573
2673
  startedAt: now(),
1574
- message: "Creating a stopped-volume backup"
2674
+ message: "Creating a stopped-volume backup",
2675
+ backupId,
2676
+ backupPath: backupDirectory(loaded.directory, backupId),
2677
+ initialAppRunning,
2678
+ phase: "prepared"
1575
2679
  };
1576
2680
  await markPending(loaded.directory, loaded.state, pending);
1577
2681
  try {
1578
- const result = await createBackup(loaded.directory, loaded.state, loaded.env);
2682
+ const result = await createBackup(loaded.directory, loaded.state, loaded.env, { backupId, initiallyRunning: initialAppRunning });
1579
2683
  await clearPending(loaded.directory, loaded.state);
2684
+ await pruneBackups(loaded.directory, loaded.state, BACKUP_RETENTION_KEEP, false);
1580
2685
  console.log(`Backup ${result.backupId} created at ${result.backupDir}`);
1581
2686
  console.log(`SHA-256: ${result.manifest.dataSha256}`);
1582
2687
  } catch (error) {
@@ -1612,21 +2717,34 @@ async function backupPruneCommand(directory, flags) {
1612
2717
  }
1613
2718
  async function backupExportCommand(directory, backupId, destination) {
1614
2719
  const backupPath = await resolveBackup(directory, backupId);
1615
- const manifest = await readManifest(backupPath);
2720
+ const manifest = await readManifest(backupPath, directory);
1616
2721
  const dest = resolve(destination);
1617
2722
  const source = resolve(backupPath);
2723
+ const deploymentRoot = await realpath(directory);
1618
2724
  if (source === dest || dest.startsWith(`${source}${sep}`)) {
1619
2725
  throw new Error("Choose a destination directory different from the backup itself.");
1620
2726
  }
1621
2727
  if (await fileExists(dest))
1622
2728
  throw new Error(`Destination ${dest} already exists. Choose a new empty destination so an export can never merge with unrelated files.`);
1623
2729
  await mkdir(dirname(dest), { recursive: true, mode: 448 });
2730
+ const canonicalDestination = join(await realpath(dirname(dest)), basename(dest));
2731
+ if (canonicalDestination === deploymentRoot || canonicalDestination.startsWith(`${deploymentRoot}${sep}`)) {
2732
+ throw new Error("Backup exports must live outside the managed deployment directory so `remove --purge-data` can never delete the remaining copy.");
2733
+ }
1624
2734
  await mkdir(dest, { mode: 448 });
1625
2735
  try {
2736
+ if (await realpath(dest) !== canonicalDestination) {
2737
+ throw new Error("Backup export destination changed while it was being created. Refusing to write an export through an unexpected path.");
2738
+ }
1626
2739
  await chmod(dest, 448);
1627
- for (const file of ["data.tgz", "config.env", "manifest.json"]) {
2740
+ for (const file of ["data.tgz", "config.env", "manifest.json", "manifest.auth"]) {
1628
2741
  await copySecure(join(backupPath, file), join(dest, file));
1629
2742
  }
2743
+ if (manifest.managedAssetSha256) {
2744
+ for (const name of managedAssetNamesForInventory(manifest.mode, manifest.managedAssetInventoryVersion)) {
2745
+ await copySecure(join(backupPath, "managed-assets", name), join(dest, "managed-assets", name));
2746
+ }
2747
+ }
1630
2748
  const dataSha = await sha256File(join(dest, "data.tgz"));
1631
2749
  if (dataSha !== manifest.dataSha256) {
1632
2750
  throw new Error(`Exported data.tgz checksum mismatch for ${manifest.backupId}. Expected ${manifest.dataSha256}, got ${dataSha}.`);
@@ -1637,6 +2755,7 @@ async function backupExportCommand(directory, backupId, destination) {
1637
2755
  throw new Error(`Exported config.env checksum mismatch for ${manifest.backupId}.`);
1638
2756
  }
1639
2757
  }
2758
+ await readManifest(dest, directory);
1640
2759
  const destinationDevice = (await stat(dest)).dev;
1641
2760
  await mkdir(deploymentPaths(directory).exports, { recursive: true, mode: 448 });
1642
2761
  const receipt = {
@@ -1650,7 +2769,12 @@ async function backupExportCommand(directory, backupId, destination) {
1650
2769
  };
1651
2770
  await writeSecure(join(deploymentPaths(directory).exports, `${manifest.backupId}.json`), `${JSON.stringify(receipt, null, 2)}
1652
2771
  `);
1653
- const bytes = (await stat(join(dest, "data.tgz"))).size + (await stat(join(dest, "config.env"))).size + (await stat(join(dest, "manifest.json"))).size;
2772
+ let bytes = (await stat(join(dest, "data.tgz"))).size + (await stat(join(dest, "config.env"))).size + (await stat(join(dest, "manifest.json"))).size + (await stat(join(dest, "manifest.auth"))).size;
2773
+ if (manifest.managedAssetSha256) {
2774
+ for (const name of managedAssetNamesForInventory(manifest.mode, manifest.managedAssetInventoryVersion)) {
2775
+ bytes += (await stat(join(dest, "managed-assets", name))).size;
2776
+ }
2777
+ }
1654
2778
  console.log(`Exported backup ${manifest.backupId} (${bytes} bytes) to ${dest}`);
1655
2779
  console.log("The exported copy contains credentials and secrets. It is owner-only (0600); keep it off-host.");
1656
2780
  if ((await stat(backupPath)).dev === destinationDevice) {
@@ -1697,12 +2821,19 @@ async function updateCommand(directory, flags) {
1697
2821
  const targetVersion = stringFlag(flags, "to")?.trim() ?? PACKAGE_VERSION;
1698
2822
  if (!isVersion(targetVersion))
1699
2823
  throw new Error(`--to must be a complete semantic version such as ${PACKAGE_VERSION}.`);
2824
+ if (targetVersion !== PACKAGE_VERSION) {
2825
+ throw new Error(`This CLI contains deployment assets for OR3 ${PACKAGE_VERSION}. Run \`npx --yes @or3/cloud@${targetVersion} update --to ${targetVersion}\` so the image and generated assets match.`);
2826
+ }
1700
2827
  if (targetVersion === state.appVersion)
1701
2828
  throw new Error(`The deployment is already on OR3 ${targetVersion}.`);
1702
- const targetImage = imageFor(targetVersion);
1703
- const targetDigest = await pullImage(targetImage);
1704
- await assertSupportedHostArchitecture(targetImage);
2829
+ const targetImageTag = imageFor(targetVersion);
2830
+ const targetDigest = await pullImage(targetImageTag, expectedImageDigest(targetVersion));
2831
+ await assertSupportedHostArchitecture(targetImageTag);
2832
+ await assertImageReleaseIdentity(targetImageTag, targetVersion);
2833
+ const targetImage = imageAtDigest(targetImageTag, targetDigest);
1705
2834
  const oldEnv = { ...env };
2835
+ const targetDeploymentId = env.OR3_DEPLOYMENT_ID ?? state.deploymentId ?? id("deployment");
2836
+ const backupId = id("backup");
1706
2837
  const pending = {
1707
2838
  id: id("update"),
1708
2839
  operation: "update",
@@ -1710,49 +2841,75 @@ async function updateCommand(directory, flags) {
1710
2841
  message: `Updating from ${state.appVersion} to ${targetVersion}`,
1711
2842
  targetVersion,
1712
2843
  targetImage,
1713
- targetImageDigest: targetDigest
2844
+ targetImageDigest: targetDigest,
2845
+ targetDeploymentId,
2846
+ backupId,
2847
+ backupPath: backupDirectory(loaded.directory, backupId),
2848
+ phase: "prepared"
1714
2849
  };
1715
2850
  await markPending(loaded.directory, state, pending);
1716
2851
  try {
1717
- const backup = await createBackup(loaded.directory, state, env);
1718
- await updatePending(loaded.directory, state, { backupId: backup.backupId });
1719
- const nextEnv = { ...env, OR3_VERSION: targetVersion, OR3_IMAGE: targetImage };
2852
+ const backup = await createBackup(loaded.directory, state, env, { restartAfter: false, backupId });
2853
+ await updatePending(loaded.directory, state, {
2854
+ backupId: backup.backupId,
2855
+ backupPath: backup.backupDir,
2856
+ backupDataSha256: backup.manifest.dataSha256,
2857
+ backupConfigSha256: backup.manifest.configSha256,
2858
+ phase: "snapshot-created"
2859
+ });
2860
+ const operator = await prepareVerifiedDashboardOperator(loaded.directory, targetVersion);
2861
+ if (env.OR3_DASHBOARD_UPDATES_ENABLED === "true" && !operator && !process.env.OR3_DASHBOARD_UPDATE_JOB_ID) {
2862
+ await removeDashboardOperator(loaded.directory, state.mode);
2863
+ }
2864
+ const nextEnv = {
2865
+ ...withoutDashboardOperator(env),
2866
+ OR3_VERSION: targetVersion,
2867
+ OR3_IMAGE: targetImage,
2868
+ OR3_DEPLOYMENT_ID: targetDeploymentId,
2869
+ ...operator ?? {}
2870
+ };
1720
2871
  const previousRootOwnership = await managedVolumeRootOwnership(targetImage, state.volumeName);
1721
2872
  const migrateLegacyVolume = previousRootOwnership.uid !== MANAGED_RUNTIME_UID || previousRootOwnership.gid !== MANAGED_RUNTIME_GID;
2873
+ const recreateDataVolume = updateRequiresVolumeRecreation(state, env);
2874
+ await updatePending(loaded.directory, state, {
2875
+ phase: "target-mutating",
2876
+ previousRootOwnership,
2877
+ recreateDataVolume
2878
+ });
1722
2879
  try {
1723
- await stopProject(loaded.directory, state.mode);
1724
- if (migrateLegacyVolume) {
2880
+ if (recreateDataVolume) {
2881
+ await removeManagedDataVolumeForRecreation(loaded.directory, state);
2882
+ } else {
2883
+ await stopProject(loaded.directory, state.mode);
2884
+ }
2885
+ if (migrateLegacyVolume && !recreateDataVolume) {
1725
2886
  await setManagedVolumeRootOwnership(targetImage, state.volumeName, {
1726
2887
  uid: MANAGED_RUNTIME_UID,
1727
2888
  gid: MANAGED_RUNTIME_GID
1728
2889
  });
1729
2890
  }
1730
2891
  await writeSecure(deploymentPaths(loaded.directory).env, serializeEnv(nextEnv));
1731
- if (migrateLegacyVolume) {
2892
+ await copyAssets(loaded.directory, state.mode);
2893
+ if (migrateLegacyVolume || recreateDataVolume) {
1732
2894
  await restoreVolumeArchive(loaded.directory, state.mode, nextEnv, backup.backupDir);
1733
2895
  }
1734
2896
  await startProject(loaded.directory, state.mode, nextEnv);
1735
2897
  } catch (error) {
1736
- await writeSecure(deploymentPaths(loaded.directory).env, serializeEnv(oldEnv));
2898
+ await updatePending(loaded.directory, state, { phase: "restoring-previous" });
1737
2899
  try {
1738
- await stopProject(loaded.directory, state.mode).catch(() => {
1739
- return;
1740
- });
1741
- if (migrateLegacyVolume) {
2900
+ if (!recreateDataVolume)
2901
+ await stopProject(loaded.directory, state.mode).catch(() => {
2902
+ return;
2903
+ });
2904
+ if (migrateLegacyVolume && !recreateDataVolume) {
1742
2905
  await setManagedVolumeRootOwnership(targetImage, state.volumeName, previousRootOwnership);
1743
2906
  }
1744
- await restoreBackupData(loaded.directory, state, oldEnv, backup.backupDir);
2907
+ await restoreBackupData(loaded.directory, state, oldEnv, backup.backupDir, { recreateDataVolume });
1745
2908
  } catch (restoreError) {
1746
2909
  throw new Error(`Update to ${targetVersion} failed, and automatic backup restoration also failed: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}. Original update error: ${error instanceof Error ? error.message : String(error)}`);
1747
2910
  }
1748
- const oldDigest = await imageDigest(state.image);
1749
- state.imageDigest = oldDigest;
1750
- state.lastError = `Update to ${targetVersion} failed and was restored: ${redact(error instanceof Error ? error.message : String(error), secretValues(oldEnv))}`;
1751
- const operationId = pending.id;
1752
- delete state.incompleteOperation;
1753
- await removeOperationRecord(loaded.directory, operationId);
1754
- await writeState(loaded.directory, state);
1755
- throw new Error(state.lastError);
2911
+ const recovered = await commitPreMutationRecovery(loaded.directory, state, `Update to ${targetVersion} failed and was restored: ${redact(error instanceof Error ? error.message : String(error), secretValues(oldEnv))}`);
2912
+ throw new Error(recovered.lastError);
1756
2913
  }
1757
2914
  const digest = await requireImageDigest(targetImage, targetDigest, `OR3 ${targetVersion}`);
1758
2915
  state.rollback = {
@@ -1765,9 +2922,12 @@ async function updateCommand(directory, flags) {
1765
2922
  state.appVersion = targetVersion;
1766
2923
  state.image = targetImage;
1767
2924
  state.imageDigest = digest;
2925
+ state.deploymentId = targetDeploymentId;
2926
+ state.deploymentRoot = resolve(loaded.directory);
1768
2927
  state.lastSuccessfulOperation = "update";
1769
2928
  state.lastError = undefined;
1770
2929
  await clearPending(loaded.directory, state);
2930
+ await pruneBackups(loaded.directory, state, BACKUP_RETENTION_KEEP, false);
1771
2931
  console.log(`OR3 updated to ${targetVersion}. Image digest: ${digest}`);
1772
2932
  console.log(`Rollback point: ${backup.backupId}. Keep it until login, chat, and file checks pass.`);
1773
2933
  } catch (error) {
@@ -1779,11 +2939,52 @@ async function updateCommand(directory, flags) {
1779
2939
  }
1780
2940
  }
1781
2941
  async function resolveBackup(directory, value) {
2942
+ if (!isAbsolute(value) && !BACKUP_ID_PATTERN.test(value)) {
2943
+ throw new Error(`Backup ID "${value}" is invalid. Use an OR3-generated backup ID or an absolute external backup path.`);
2944
+ }
1782
2945
  const candidate = isAbsolute(value) ? value : backupDirectory(directory, value);
1783
2946
  if (!await fileExists(join(candidate, "manifest.json")))
1784
2947
  throw new Error(`Backup ${value} was not found in ${deploymentPaths(directory).backups}.`);
1785
2948
  return resolve(candidate);
1786
2949
  }
2950
+ async function recordedBackupPath(directory, pending, previous = false) {
2951
+ const path = previous ? pending.previousBackupPath : pending.backupPath;
2952
+ const backupId = previous ? pending.previousBackupId : pending.backupId;
2953
+ const resolved = path ? resolve(path) : backupId ? await resolveBackup(directory, backupId) : undefined;
2954
+ if (!resolved || !await fileExists(join(resolved, "manifest.json"))) {
2955
+ throw new Error(`The incomplete ${pending.operation} has no readable ${previous ? "pre-mutation" : "target"} backup source.`);
2956
+ }
2957
+ const manifest = await readManifest(resolved, directory);
2958
+ if (!previous) {
2959
+ if (pending.backupDataSha256 && pending.backupDataSha256 !== manifest.dataSha256) {
2960
+ throw new Error(`The recorded target backup at ${resolved} no longer has its expected data checksum.`);
2961
+ }
2962
+ if (pending.backupConfigSha256 && pending.backupConfigSha256 !== manifest.configSha256) {
2963
+ throw new Error(`The recorded target backup at ${resolved} no longer has its expected configuration checksum.`);
2964
+ }
2965
+ }
2966
+ return { path: resolved, manifest };
2967
+ }
2968
+ async function createPreMutationSnapshot(directory, state, env, backupId) {
2969
+ return await createBackup(directory, state, env, { restartAfter: false, backupId });
2970
+ }
2971
+ async function restorePreMutationSnapshot(directory, state, env) {
2972
+ const pending = state.incompleteOperation;
2973
+ if (!pending)
2974
+ throw new Error("No incomplete operation is available to restore.");
2975
+ const previous = await recordedBackupPath(directory, pending, true);
2976
+ await updatePending(directory, state, { phase: "restoring-previous" });
2977
+ if (pending.recreateDataVolume) {
2978
+ await restoreBackupData(directory, state, env, previous.path, { recreateDataVolume: true });
2979
+ return previous;
2980
+ }
2981
+ if (pending.previousRootOwnership) {
2982
+ await pullAndRequireImage(state.image, state.imageDigest, "Previous deployment");
2983
+ await setManagedVolumeRootOwnership(state.image, state.volumeName, pending.previousRootOwnership);
2984
+ }
2985
+ await restoreBackupData(directory, state, env, previous.path);
2986
+ return previous;
2987
+ }
1787
2988
  async function restoreCommand(directory, flags, positionals) {
1788
2989
  if (!boolFlag(flags, "yes"))
1789
2990
  throw new Error("Restore replaces live data. Re-run with --yes after confirming the backup and data-loss boundary.");
@@ -1794,12 +2995,14 @@ async function restoreCommand(directory, flags, positionals) {
1794
2995
  if (!backupValue)
1795
2996
  throw new Error("restore requires a backup ID or path.");
1796
2997
  const backupPath = await resolveBackup(loaded.directory, backupValue);
1797
- const manifest = await readManifest(backupPath);
2998
+ const manifest = await readManifest(backupPath, loaded.directory);
2999
+ assertRestorableManagedAssets(manifest);
1798
3000
  const backupEnv = parseEnv(await readText(join(backupPath, "config.env")));
1799
3001
  if (backupEnv.OR3_VERSION !== manifest.appVersion || backupEnv.OR3_IMAGE !== manifest.image) {
1800
3002
  throw new Error(`Backup ${manifest.backupId} configuration does not match its manifest.`);
1801
3003
  }
1802
3004
  assertBackupMatchesDeployment(manifest, backupEnv, loaded.state, loaded.env);
3005
+ const recreateDataVolume = restoreRequiresVolumeRecreation(loaded.env, backupEnv);
1803
3006
  if (manifest.imageDigest)
1804
3007
  await pullAndRequireImage(manifest.image, manifest.imageDigest, `Backup ${manifest.backupId}`);
1805
3008
  await assertSupportedHostArchitecture(manifest.image);
@@ -1810,13 +3013,30 @@ async function restoreCommand(directory, flags, positionals) {
1810
3013
  startedAt: now(),
1811
3014
  message: `Restoring backup ${manifest.backupId}`,
1812
3015
  backupId: manifest.backupId,
3016
+ backupPath,
3017
+ backupDataSha256: manifest.dataSha256,
3018
+ backupConfigSha256: manifest.configSha256,
1813
3019
  targetVersion: manifest.appVersion,
1814
3020
  targetImage: manifest.image,
1815
- targetImageDigest: manifest.imageDigest
3021
+ targetImageDigest: manifest.imageDigest,
3022
+ recreateDataVolume,
3023
+ phase: "prepared"
1816
3024
  };
1817
3025
  await markPending(loaded.directory, loaded.state, pending);
1818
3026
  try {
1819
- await restoreBackupData(loaded.directory, loaded.state, loaded.env, backupPath);
3027
+ const previousBackupId = id("backup-before-restore");
3028
+ await updatePending(loaded.directory, loaded.state, {
3029
+ previousBackupId,
3030
+ previousBackupPath: backupDirectory(loaded.directory, previousBackupId)
3031
+ });
3032
+ const previous = await createPreMutationSnapshot(loaded.directory, loaded.state, loaded.env, previousBackupId);
3033
+ await updatePending(loaded.directory, loaded.state, {
3034
+ previousBackupId: previous.backupId,
3035
+ previousBackupPath: previous.backupDir,
3036
+ phase: "snapshot-created"
3037
+ });
3038
+ await updatePending(loaded.directory, loaded.state, { phase: "target-mutating" });
3039
+ await restoreBackupData(loaded.directory, loaded.state, loaded.env, backupPath, { recreateDataVolume });
1820
3040
  const restoredEnv = parseEnv(await readText(deploymentPaths(loaded.directory).env));
1821
3041
  const digest = await imageDigest(restoredEnv.OR3_IMAGE);
1822
3042
  const nextState = stateFromEnv(loaded.directory, restoredEnv, loaded.state.mode, "restore", digest);
@@ -1825,8 +3045,33 @@ async function restoreCommand(directory, flags, positionals) {
1825
3045
  await writeState(loaded.directory, nextState);
1826
3046
  console.log(`Restored ${manifest.backupId}. Verify sign-in, a conversation, and a previously uploaded file.`);
1827
3047
  } catch (error) {
1828
- loaded.state.lastError = redact(error instanceof Error ? error.message : String(error), secretValues(loaded.env));
1829
- await writeState(loaded.directory, loaded.state);
3048
+ const original = redact(error instanceof Error ? error.message : String(error), secretValues(loaded.env));
3049
+ const phase = loaded.state.incompleteOperation?.phase;
3050
+ const targetMayHaveMutated = phase === "target-mutating" || phase === "restoring-previous" || phase === "starting-target";
3051
+ if (targetMayHaveMutated && (loaded.state.incompleteOperation?.previousBackupPath || loaded.state.incompleteOperation?.previousBackupId)) {
3052
+ let recoveredMessage;
3053
+ try {
3054
+ const previous = await restorePreMutationSnapshot(loaded.directory, loaded.state, loaded.env);
3055
+ recoveredMessage = `Restore of ${manifest.backupId} failed and the pre-restore snapshot ${previous.manifest.backupId} was restored: ${original}`;
3056
+ await commitPreMutationRecovery(loaded.directory, loaded.state, recoveredMessage);
3057
+ } catch (recoveryError) {
3058
+ const recovery = redact(recoveryError instanceof Error ? recoveryError.message : String(recoveryError), secretValues(loaded.env));
3059
+ loaded.state.lastError = `Restore of ${manifest.backupId} failed, and automatic restoration of the pre-restore snapshot also failed: ${recovery}. Original restore error: ${original}`;
3060
+ await writeState(loaded.directory, loaded.state);
3061
+ throw new Error(loaded.state.lastError);
3062
+ }
3063
+ throw new Error(recoveredMessage);
3064
+ }
3065
+ try {
3066
+ await startProject(loaded.directory, loaded.state.mode, loaded.env);
3067
+ loaded.state.lastError = `Restore of ${manifest.backupId} stopped before data replacement: ${original}`;
3068
+ await clearPending(loaded.directory, loaded.state);
3069
+ } catch (restartError) {
3070
+ const restart = redact(restartError instanceof Error ? restartError.message : String(restartError), secretValues(loaded.env));
3071
+ loaded.state.lastError = `Restore of ${manifest.backupId} stopped before data replacement, but the original deployment could not restart: ${restart}. Original restore error: ${original}`;
3072
+ await writeState(loaded.directory, loaded.state);
3073
+ throw new Error(loaded.state.lastError);
3074
+ }
1830
3075
  throw error;
1831
3076
  }
1832
3077
  }
@@ -1840,7 +3085,8 @@ async function rollbackCommand(directory, flags) {
1840
3085
  if (!point)
1841
3086
  throw new Error("No immediate rollback point is recorded for this deployment.");
1842
3087
  const backupPath = await resolveBackup(loaded.directory, point.backupId);
1843
- const manifest = await readManifest(backupPath);
3088
+ const manifest = await readManifest(backupPath, loaded.directory);
3089
+ assertRestorableManagedAssets(manifest);
1844
3090
  if (manifest.appVersion !== point.appVersion || manifest.image !== point.image || manifest.imageDigest !== point.imageDigest) {
1845
3091
  throw new Error(`Rollback point ${point.backupId} no longer matches its recorded image/version. Refusing to mutate the deployment.`);
1846
3092
  }
@@ -1849,6 +3095,7 @@ async function rollbackCommand(directory, flags) {
1849
3095
  throw new Error(`Backup ${manifest.backupId} configuration does not match its manifest.`);
1850
3096
  }
1851
3097
  assertBackupMatchesDeployment(manifest, backupEnv, loaded.state, loaded.env);
3098
+ const recreateDataVolume = restoreRequiresVolumeRecreation(loaded.env, backupEnv);
1852
3099
  await pullAndRequireImage(point.image, point.imageDigest, "Rollback");
1853
3100
  await assertSupportedHostArchitecture(point.image);
1854
3101
  await assertRestoreFreeSpace(loaded.directory, loaded.state, loaded.env, manifest, backupPath);
@@ -1858,13 +3105,30 @@ async function rollbackCommand(directory, flags) {
1858
3105
  startedAt: now(),
1859
3106
  message: `Rolling back to ${point.appVersion}`,
1860
3107
  backupId: point.backupId,
3108
+ backupPath,
3109
+ backupDataSha256: manifest.dataSha256,
3110
+ backupConfigSha256: manifest.configSha256,
1861
3111
  targetVersion: point.appVersion,
1862
3112
  targetImage: point.image,
1863
- targetImageDigest: point.imageDigest
3113
+ targetImageDigest: point.imageDigest,
3114
+ recreateDataVolume,
3115
+ phase: "prepared"
1864
3116
  };
1865
3117
  await markPending(loaded.directory, loaded.state, pending);
1866
3118
  try {
1867
- await restoreBackupData(loaded.directory, loaded.state, loaded.env, backupPath);
3119
+ const previousBackupId = id("backup-before-rollback");
3120
+ await updatePending(loaded.directory, loaded.state, {
3121
+ previousBackupId,
3122
+ previousBackupPath: backupDirectory(loaded.directory, previousBackupId)
3123
+ });
3124
+ const previous = await createPreMutationSnapshot(loaded.directory, loaded.state, loaded.env, previousBackupId);
3125
+ await updatePending(loaded.directory, loaded.state, {
3126
+ previousBackupId: previous.backupId,
3127
+ previousBackupPath: previous.backupDir,
3128
+ phase: "snapshot-created"
3129
+ });
3130
+ await updatePending(loaded.directory, loaded.state, { phase: "target-mutating" });
3131
+ await restoreBackupData(loaded.directory, loaded.state, loaded.env, backupPath, { recreateDataVolume });
1868
3132
  const restoredEnv = parseEnv(await readText(deploymentPaths(loaded.directory).env));
1869
3133
  const nextState = stateFromEnv(loaded.directory, restoredEnv, loaded.state.mode, "restore", await imageDigest(restoredEnv.OR3_IMAGE));
1870
3134
  nextState.rollback = undefined;
@@ -1872,8 +3136,33 @@ async function rollbackCommand(directory, flags) {
1872
3136
  await writeState(loaded.directory, nextState);
1873
3137
  console.log(`Rolled back to OR3 ${nextState.appVersion}. Verify sign-in, chat, and file access.`);
1874
3138
  } catch (error) {
1875
- loaded.state.lastError = redact(error instanceof Error ? error.message : String(error), secretValues(loaded.env));
1876
- await writeState(loaded.directory, loaded.state);
3139
+ const original = redact(error instanceof Error ? error.message : String(error), secretValues(loaded.env));
3140
+ const phase = loaded.state.incompleteOperation?.phase;
3141
+ const targetMayHaveMutated = phase === "target-mutating" || phase === "restoring-previous" || phase === "starting-target";
3142
+ if (targetMayHaveMutated && (loaded.state.incompleteOperation?.previousBackupPath || loaded.state.incompleteOperation?.previousBackupId)) {
3143
+ let recoveredMessage;
3144
+ try {
3145
+ const previous = await restorePreMutationSnapshot(loaded.directory, loaded.state, loaded.env);
3146
+ recoveredMessage = `Rollback to ${point.appVersion} failed and the pre-rollback snapshot ${previous.manifest.backupId} was restored: ${original}`;
3147
+ await commitPreMutationRecovery(loaded.directory, loaded.state, recoveredMessage);
3148
+ } catch (recoveryError) {
3149
+ const recovery = redact(recoveryError instanceof Error ? recoveryError.message : String(recoveryError), secretValues(loaded.env));
3150
+ loaded.state.lastError = `Rollback to ${point.appVersion} failed, and automatic restoration of the pre-rollback snapshot also failed: ${recovery}. Original rollback error: ${original}`;
3151
+ await writeState(loaded.directory, loaded.state);
3152
+ throw new Error(loaded.state.lastError);
3153
+ }
3154
+ throw new Error(recoveredMessage);
3155
+ }
3156
+ try {
3157
+ await startProject(loaded.directory, loaded.state.mode, loaded.env);
3158
+ loaded.state.lastError = `Rollback to ${point.appVersion} stopped before data replacement: ${original}`;
3159
+ await clearPending(loaded.directory, loaded.state);
3160
+ } catch (restartError) {
3161
+ const restart = redact(restartError instanceof Error ? restartError.message : String(restartError), secretValues(loaded.env));
3162
+ loaded.state.lastError = `Rollback to ${point.appVersion} stopped before data replacement, but the original deployment could not restart: ${restart}. Original rollback error: ${original}`;
3163
+ await writeState(loaded.directory, loaded.state);
3164
+ throw new Error(loaded.state.lastError);
3165
+ }
1877
3166
  throw error;
1878
3167
  }
1879
3168
  }
@@ -1901,7 +3190,7 @@ async function doctorCommand(directory) {
1901
3190
  failures.push(error instanceof Error ? error.message : String(error));
1902
3191
  console.log("✗ Managed state or .env is missing/invalid");
1903
3192
  }
1904
- for (const file of [paths.env, paths.state]) {
3193
+ for (const file of [paths.env, paths.state, join(resolved, ".or3-initial-credentials")]) {
1905
3194
  if (!existsSync(file))
1906
3195
  continue;
1907
3196
  const mode = (await stat(file)).mode & 511;
@@ -1942,6 +3231,53 @@ async function doctorCommand(directory) {
1942
3231
  failures.push(error instanceof Error ? error.message : String(error));
1943
3232
  console.log("✗ Deep health is not passing");
1944
3233
  }
3234
+ try {
3235
+ const operatorEnabled = env.OR3_DASHBOARD_UPDATES_ENABLED === "true";
3236
+ const operatorContainer = await run("docker", [
3237
+ "ps",
3238
+ "-aq",
3239
+ "--filter",
3240
+ `label=com.docker.compose.project=${state.composeProject}`,
3241
+ "--filter",
3242
+ "label=com.docker.compose.service=or3-operator"
3243
+ ], resolved);
3244
+ if (!operatorContainer.ok)
3245
+ throw new Error(`Could not inspect the dashboard operator service. ${operatorContainer.stderr.trim()}`);
3246
+ const containerId = operatorContainer.stdout.trim();
3247
+ if (!operatorEnabled) {
3248
+ if (containerId)
3249
+ throw new Error("A dashboard operator container exists even though the bridge is disabled.");
3250
+ console.log("✓ Dashboard operator is disabled with no orphaned service");
3251
+ } else {
3252
+ if (!containerId)
3253
+ throw new Error("Dashboard updates are enabled but the operator container is missing.");
3254
+ const inspected = await run("docker", ["inspect", containerId]);
3255
+ if (!inspected.ok)
3256
+ throw new Error(`Could not inspect dashboard operator container ${containerId}.`);
3257
+ const container = JSON.parse(inspected.stdout)?.[0];
3258
+ if (container.Config?.Image !== env.OR3_OPERATOR_IMAGE)
3259
+ throw new Error("Dashboard operator runtime image does not match the digest-qualified managed environment.");
3260
+ if (state.deploymentId && container.Config?.Labels?.["io.or3.cloud.deployment-id"] !== state.deploymentId) {
3261
+ throw new Error("Dashboard operator deployment identity does not match managed state.");
3262
+ }
3263
+ const destinations = new Set(container.Mounts?.map((mount) => mount.Destination));
3264
+ for (const destination of ["/var/run/docker.sock", "/deployment", "/run/or3-operator"]) {
3265
+ if (!destinations.has(destination))
3266
+ throw new Error(`Dashboard operator is missing its required ${destination} mount.`);
3267
+ }
3268
+ const ipcDirectory = await stat(paths.operatorIpc);
3269
+ const operatorSocket = await stat(join(paths.operatorIpc, "operator.sock"));
3270
+ const operatorUid = Number(env.OR3_OPERATOR_UID);
3271
+ const operatorGid = Number(env.OR3_OPERATOR_GID);
3272
+ if ((ipcDirectory.mode & 511) !== 456 || ipcDirectory.uid !== operatorUid || ipcDirectory.gid !== operatorGid || !operatorSocket.isSocket() || (operatorSocket.mode & 511) !== 432 || operatorSocket.uid !== operatorUid || operatorSocket.gid !== operatorGid) {
3273
+ throw new Error("Dashboard operator IPC ownership boundary has unexpected owners, types, or modes.");
3274
+ }
3275
+ console.log("✓ Dashboard operator image, identity, mounts, and IPC modes are valid");
3276
+ }
3277
+ } catch (error) {
3278
+ failures.push(error instanceof Error ? error.message : String(error));
3279
+ console.log("✗ Dashboard operator boundary is invalid");
3280
+ }
1945
3281
  }
1946
3282
  if (state.mode === "public" && state.domain) {
1947
3283
  try {
@@ -1961,7 +3297,22 @@ async function doctorCommand(directory) {
1961
3297
  console.log(`✗ DNS: ${state.domain} does not resolve`);
1962
3298
  }
1963
3299
  for (const port of [80, 443]) {
1964
- console.log(await portAvailable(port) ? `⚠ Port ${port} is not listening (Caddy may be stopped)` : `✓ Port ${port} is in use by a local service; verify it is Caddy`);
3300
+ const mapping = await run("docker", composeArgs(resolved, state.mode, ["port", "caddy", String(port)]), resolved);
3301
+ if (!mapping.ok || !mapping.stdout.trim().split(/\r?\n/).some((line) => line.trim().endsWith(`:${port}`))) {
3302
+ failures.push(`Caddy does not publish TCP ${port} through the selected Docker daemon.`);
3303
+ console.log(`✗ Caddy does not publish TCP ${port}`);
3304
+ } else {
3305
+ console.log(`✓ Caddy publishes TCP ${port}`);
3306
+ }
3307
+ }
3308
+ try {
3309
+ const response = await verificationFetch(new URL(`https://${state.domain}`));
3310
+ if (response.status !== 200)
3311
+ throw new Error(`Public HTTPS root returned HTTP ${response.status}; redirects are not accepted.`);
3312
+ console.log("✓ Public HTTPS root returns HTTP 200 without redirects");
3313
+ } catch (error) {
3314
+ failures.push(error instanceof Error ? error.message : String(error));
3315
+ console.log("✗ Public HTTPS root is unreachable or redirects");
1965
3316
  }
1966
3317
  console.log("ℹ Firewall: allow TCP 80/443 (and optional UDP 443) in your existing nftables or UFW rules; OR3 did not modify them.");
1967
3318
  }
@@ -1971,6 +3322,358 @@ async function doctorCommand(directory) {
1971
3322
  }
1972
3323
  console.log("OR3 Cloud doctor passed.");
1973
3324
  }
3325
+ function validateVerificationHealth(value) {
3326
+ const health = value;
3327
+ if (!health || health.status !== "ok" || health.providers?.auth?.provider !== "basic-auth" || health.providers?.sync?.provider !== "sqlite" || health.providers?.storage?.provider !== "fs") {
3328
+ throw new Error("Public deep health does not report the managed Basic Auth + SQLite + filesystem profile.");
3329
+ }
3330
+ return health;
3331
+ }
3332
+ async function verificationFetch(url, init = {}) {
3333
+ return fetch(url, {
3334
+ ...init,
3335
+ redirect: "manual",
3336
+ signal: AbortSignal.timeout(20000)
3337
+ });
3338
+ }
3339
+ async function verificationJson(baseUrl, path, options = {}) {
3340
+ const method = options.method ?? (options.body === undefined ? "GET" : "POST");
3341
+ const response = await verificationFetch(new URL(path, baseUrl), {
3342
+ method,
3343
+ headers: {
3344
+ accept: "application/json",
3345
+ ...options.body === undefined ? {} : { "content-type": "application/json" },
3346
+ ...options.cookie ? { cookie: options.cookie } : {},
3347
+ ...method === "GET" ? {} : { origin: baseUrl.origin }
3348
+ },
3349
+ ...options.body === undefined ? {} : { body: JSON.stringify(options.body) }
3350
+ });
3351
+ if (response.status !== 200) {
3352
+ const detail = (await response.text().catch(() => "")).slice(0, 300);
3353
+ throw new Error(`${response.url} returned HTTP ${response.status}${detail ? `: ${detail}` : ""}`);
3354
+ }
3355
+ return response.json();
3356
+ }
3357
+ function sameOriginVerificationUrl(baseUrl, value, label) {
3358
+ if (typeof value !== "string")
3359
+ throw new Error(`Filesystem storage did not return a ${label} URL.`);
3360
+ const url = new URL(value, baseUrl);
3361
+ if (url.origin !== baseUrl.origin) {
3362
+ throw new Error(`Filesystem storage returned a cross-origin ${label} URL. Refusing to send the verification session to ${url.origin}.`);
3363
+ }
3364
+ return url;
3365
+ }
3366
+ function assertVerificationGrant(value, expectedMethod, label) {
3367
+ if (value.method !== undefined && value.method !== expectedMethod) {
3368
+ throw new Error(`Filesystem storage returned unexpected ${label} method ${String(value.method)}.`);
3369
+ }
3370
+ if (value.headers !== undefined && (!value.headers || typeof value.headers !== "object" || Array.isArray(value.headers) || Object.keys(value.headers).length > 0)) {
3371
+ throw new Error(`Filesystem storage returned unexpected ${label} headers.`);
3372
+ }
3373
+ }
3374
+ async function verificationCredentials(directory, flags, env) {
3375
+ const passwordFile = stringFlag(flags, "verification-password-file");
3376
+ if (flags["verification-email"] !== undefined && !stringFlag(flags, "verification-email")) {
3377
+ throw new Error("--verification-email requires a value.");
3378
+ }
3379
+ if (flags["verification-password-file"] !== undefined && !passwordFile) {
3380
+ throw new Error("--verification-password-file requires a path.");
3381
+ }
3382
+ const configuredEmail = stringFlag(flags, "verification-email")?.trim() || env.OR3_MANAGED_OWNER_EMAIL || env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
3383
+ let initialCredentials = {};
3384
+ if (!configuredEmail || !passwordFile && !env.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD) {
3385
+ const initialCredentialsPath = join(directory, ".or3-initial-credentials");
3386
+ if (await fileExists(initialCredentialsPath)) {
3387
+ initialCredentials = parseEnv(await readOwnerOnlyText(initialCredentialsPath, "The initial credentials file"));
3388
+ }
3389
+ }
3390
+ const email = configuredEmail || initialCredentials.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
3391
+ if (!email)
3392
+ throw new Error("A current owner email is required for verification.");
3393
+ validateEmail(email);
3394
+ const password = passwordFile ? (await readOwnerOnlyText(resolve(passwordFile), "The verification password file")).trim() : env.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD || initialCredentials.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD;
3395
+ if (!password) {
3396
+ throw new Error("A current owner password is required. Pass --verification-password-file after changing the bootstrap password.");
3397
+ }
3398
+ if (password.includes("\x00") || /\r|\n/.test(password))
3399
+ throw new Error("The verification password file must contain exactly one password line.");
3400
+ return { email, password };
3401
+ }
3402
+ async function verifyPublicApplication(baseUrl, credentials) {
3403
+ const root = await verificationFetch(baseUrl);
3404
+ if (root.status !== 200)
3405
+ throw new Error(`${baseUrl} returned HTTP ${root.status}; redirects are not accepted during verification.`);
3406
+ const health = validateVerificationHealth(await verificationJson(baseUrl, "/api/health?deep=true"));
3407
+ const { email, password } = credentials;
3408
+ const signIn = await verificationFetch(new URL("/api/basic-auth/sign-in", baseUrl), {
3409
+ method: "POST",
3410
+ headers: { accept: "application/json", "content-type": "application/json", origin: baseUrl.origin },
3411
+ body: JSON.stringify({ email, password })
3412
+ });
3413
+ if (signIn.status !== 200)
3414
+ throw new Error(`Public Basic Auth sign-in returned HTTP ${signIn.status}.`);
3415
+ const responseHeaders = signIn.headers;
3416
+ const setCookies = responseHeaders.getSetCookie?.() ?? [signIn.headers.get("set-cookie")].filter((value) => Boolean(value));
3417
+ const cookie = setCookies.map((value) => value.split(";", 1)[0]).join("; ");
3418
+ if (!cookie)
3419
+ throw new Error("Public Basic Auth sign-in did not set a session cookie.");
3420
+ try {
3421
+ const session = await verificationJson(baseUrl, "/api/auth/session", { cookie });
3422
+ if (session.session?.user?.email !== email || !session.session?.workspace?.id) {
3423
+ throw new Error("Public session hydration did not return the verification user and workspace.");
3424
+ }
3425
+ const workspaceId = String(session.session.workspace.id);
3426
+ const pull = await verificationJson(baseUrl, "/api/sync/pull", {
3427
+ cookie,
3428
+ body: { scope: { workspaceId }, cursor: 0, limit: 1, tables: ["messages"] }
3429
+ });
3430
+ if (!Array.isArray(pull.changes) || typeof pull.nextCursor !== "number") {
3431
+ throw new Error("Public SQLite sync pull returned an invalid response.");
3432
+ }
3433
+ const probeBytes = Buffer.concat([
3434
+ Buffer.from("89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489", "hex"),
3435
+ randomBytes(8)
3436
+ ]);
3437
+ const hash = `sha256:${createHash("sha256").update(probeBytes).digest("hex")}`;
3438
+ let storageId;
3439
+ let metadataAttempted = false;
3440
+ try {
3441
+ const presign = await verificationJson(baseUrl, "/api/storage/presign-upload", {
3442
+ cookie,
3443
+ body: {
3444
+ workspace_id: workspaceId,
3445
+ hash,
3446
+ mime_type: "image/png",
3447
+ size_bytes: probeBytes.length,
3448
+ disposition: "inline"
3449
+ }
3450
+ });
3451
+ storageId = typeof presign.storageId === "string" && presign.storageId.trim() ? presign.storageId : undefined;
3452
+ if (!storageId)
3453
+ throw new Error("Filesystem storage did not return a non-empty storage ID.");
3454
+ assertVerificationGrant(presign, "PUT", "upload");
3455
+ const upload = await verificationFetch(sameOriginVerificationUrl(baseUrl, presign.url, "upload"), {
3456
+ method: "PUT",
3457
+ headers: { "content-type": "image/png", cookie },
3458
+ body: probeBytes
3459
+ });
3460
+ if (!upload.ok)
3461
+ throw new Error(`Filesystem verification upload returned HTTP ${upload.status}.`);
3462
+ await verificationJson(baseUrl, "/api/storage/commit", {
3463
+ cookie,
3464
+ body: {
3465
+ workspace_id: workspaceId,
3466
+ hash,
3467
+ storage_id: storageId,
3468
+ storage_provider_id: "fs",
3469
+ mime_type: "image/png",
3470
+ size_bytes: probeBytes.length,
3471
+ name: "or3-production-verification.png",
3472
+ kind: "image"
3473
+ }
3474
+ });
3475
+ const metadataCreatedAt = Date.now();
3476
+ const metadataOpId = randomUUID();
3477
+ metadataAttempted = true;
3478
+ const pushed = await verificationJson(baseUrl, "/api/sync/push", {
3479
+ cookie,
3480
+ body: {
3481
+ scope: { workspaceId },
3482
+ ops: [{
3483
+ id: `or3-verification-${metadataOpId}`,
3484
+ tableName: "file_meta",
3485
+ operation: "put",
3486
+ pk: hash,
3487
+ payload: {
3488
+ hash,
3489
+ kind: "image",
3490
+ mime_type: "image/png",
3491
+ size_bytes: probeBytes.length,
3492
+ storage_id: storageId,
3493
+ name: "or3-production-verification.png",
3494
+ deleted: false,
3495
+ created_at: metadataCreatedAt,
3496
+ updated_at: metadataCreatedAt,
3497
+ clock: metadataCreatedAt
3498
+ },
3499
+ stamp: {
3500
+ deviceId: "or3-cloud-verification",
3501
+ opId: metadataOpId,
3502
+ hlc: `${String(metadataCreatedAt).padStart(13, "0")}:0000:or3-cloud-verification`,
3503
+ clock: metadataCreatedAt
3504
+ },
3505
+ createdAt: metadataCreatedAt,
3506
+ attempts: 0,
3507
+ status: "pending"
3508
+ }]
3509
+ }
3510
+ });
3511
+ if (pushed.results?.[0]?.success !== true) {
3512
+ throw new Error(`Filesystem verification metadata sync failed: ${JSON.stringify(pushed)}`);
3513
+ }
3514
+ const downloadGrant = await verificationJson(baseUrl, "/api/storage/presign-download", {
3515
+ cookie,
3516
+ body: { workspace_id: workspaceId, hash, storage_id: storageId, disposition: "attachment" }
3517
+ });
3518
+ assertVerificationGrant(downloadGrant, "GET", "download");
3519
+ const download = await verificationFetch(sameOriginVerificationUrl(baseUrl, downloadGrant.url, "download"), { headers: { cookie } });
3520
+ if (!download.ok || !Buffer.from(await download.arrayBuffer()).equals(probeBytes)) {
3521
+ throw new Error("Filesystem verification download did not match the uploaded probe.");
3522
+ }
3523
+ } finally {
3524
+ if (storageId) {
3525
+ try {
3526
+ await verificationJson(baseUrl, "/api/storage/delete", {
3527
+ cookie,
3528
+ body: { workspace_id: workspaceId, hash, storage_id: storageId }
3529
+ });
3530
+ } finally {
3531
+ if (metadataAttempted) {
3532
+ const metadataDeleteAt = Date.now();
3533
+ const metadataDeleteOpId = randomUUID();
3534
+ const deleted = await verificationJson(baseUrl, "/api/sync/push", {
3535
+ cookie,
3536
+ body: {
3537
+ scope: { workspaceId },
3538
+ ops: [{
3539
+ id: `or3-verification-${metadataDeleteOpId}`,
3540
+ tableName: "file_meta",
3541
+ operation: "delete",
3542
+ pk: hash,
3543
+ payload: { hash },
3544
+ stamp: {
3545
+ deviceId: "or3-cloud-verification",
3546
+ opId: metadataDeleteOpId,
3547
+ hlc: `${String(metadataDeleteAt).padStart(13, "0")}:0000:or3-cloud-verification`,
3548
+ clock: metadataDeleteAt
3549
+ },
3550
+ createdAt: metadataDeleteAt,
3551
+ attempts: 0,
3552
+ status: "pending"
3553
+ }]
3554
+ }
3555
+ });
3556
+ if (deleted.results?.[0]?.success !== true) {
3557
+ throw new Error(`Filesystem verification metadata cleanup failed: ${JSON.stringify(deleted)}`);
3558
+ }
3559
+ }
3560
+ }
3561
+ }
3562
+ }
3563
+ return health;
3564
+ } finally {
3565
+ await verificationJson(baseUrl, "/api/basic-auth/sign-out", {
3566
+ cookie,
3567
+ method: "POST"
3568
+ });
3569
+ }
3570
+ }
3571
+ function responseCookie(response, label) {
3572
+ const headers = response.headers;
3573
+ const values = headers.getSetCookie?.() ?? [response.headers.get("set-cookie")].filter((value) => Boolean(value));
3574
+ const cookie = values.map((value) => value.split(";", 1)[0]).join("; ");
3575
+ if (!cookie)
3576
+ throw new Error(`${label} did not set a session cookie.`);
3577
+ return cookie;
3578
+ }
3579
+ async function provisionManagedCredentialsOnce(baseUrl, email, password, adminUsername = email, adminPassword = password) {
3580
+ const appSignIn = await verificationFetch(new URL("/api/basic-auth/sign-in", baseUrl), {
3581
+ method: "POST",
3582
+ headers: { accept: "application/json", "content-type": "application/json", origin: baseUrl.origin },
3583
+ body: JSON.stringify({ email, password })
3584
+ });
3585
+ if (appSignIn.status !== 200)
3586
+ throw new Error(`Managed owner provisioning returned HTTP ${appSignIn.status}.`);
3587
+ const appCookie = responseCookie(appSignIn, "Managed owner provisioning");
3588
+ try {
3589
+ const session = await verificationJson(baseUrl, "/api/auth/session", { cookie: appCookie });
3590
+ if (session.session?.user?.email !== email || !session.session?.workspace?.id) {
3591
+ throw new Error("Managed owner provisioning did not create the expected account and workspace.");
3592
+ }
3593
+ } finally {
3594
+ await verificationJson(baseUrl, "/api/basic-auth/sign-out", { cookie: appCookie, method: "POST" });
3595
+ }
3596
+ const adminSignIn = await verificationFetch(new URL("/api/admin/auth/login", baseUrl), {
3597
+ method: "POST",
3598
+ headers: { accept: "application/json", "content-type": "application/json", origin: baseUrl.origin },
3599
+ body: JSON.stringify({ username: adminUsername, password: adminPassword })
3600
+ });
3601
+ if (adminSignIn.status !== 200)
3602
+ throw new Error(`Managed admin provisioning returned HTTP ${adminSignIn.status}.`);
3603
+ const adminCookie = responseCookie(adminSignIn, "Managed admin provisioning");
3604
+ await verificationJson(baseUrl, "/api/admin/auth/logout", { cookie: adminCookie, method: "POST" });
3605
+ }
3606
+ async function provisionManagedCredentials(baseUrl, email, password, adminUsername = email, adminPassword = password) {
3607
+ const deadline = Date.now() + 60000;
3608
+ let lastError;
3609
+ do {
3610
+ try {
3611
+ await provisionManagedCredentialsOnce(baseUrl, email, password, adminUsername, adminPassword);
3612
+ return;
3613
+ } catch (error) {
3614
+ lastError = error;
3615
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, 2000));
3616
+ }
3617
+ } while (Date.now() < deadline);
3618
+ throw new Error(`Managed credential provisioning did not become ready within 60 seconds: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
3619
+ }
3620
+ async function verifyCommand(directory, flags, positionals) {
3621
+ if (positionals.length)
3622
+ throw new Error("verify accepts no positional arguments.");
3623
+ if (flags.public !== undefined && !boolFlag(flags, "public"))
3624
+ throw new Error("--public does not accept a value.");
3625
+ await ensureDocker();
3626
+ const loaded = await loadManaged(directory);
3627
+ assertDeploymentDirectoryIdentity(directory, loaded.state);
3628
+ assertNoPending(loaded.state);
3629
+ if (boolFlag(flags, "public") && loaded.state.mode !== "public") {
3630
+ throw new Error("--public requires a managed public deployment.");
3631
+ }
3632
+ if (loaded.env.OR3_VERSION !== loaded.state.appVersion || loaded.env.OR3_IMAGE !== loaded.state.image) {
3633
+ throw new Error("Managed state and .env version/image do not match.");
3634
+ }
3635
+ const digest = await imageDigest(loaded.state.image);
3636
+ if (digest !== loaded.state.imageDigest) {
3637
+ throw new Error(`Managed image digest differs from state. Expected ${loaded.state.imageDigest}, found ${digest}.`);
3638
+ }
3639
+ await waitForDeepHealth(loaded.directory, loaded.state.mode, secretValues(loaded.env));
3640
+ const baseUrl = new URL(loaded.state.mode === "public" ? `https://${loaded.state.domain}` : `http://127.0.0.1:${loaded.state.port}`);
3641
+ await verifyPublicApplication(baseUrl, await verificationCredentials(loaded.directory, flags, loaded.env));
3642
+ const databaseCheck = await run("docker", [
3643
+ ...composeArgs(loaded.directory, loaded.state.mode, ["exec", "-T", "or3", ...containerNodeCommand(VERIFY_DATABASES_SCRIPT)])
3644
+ ], loaded.directory);
3645
+ if (!databaseCheck.ok)
3646
+ throw new Error(`SQLite integrity or runtime proxy verification failed. ${databaseCheck.stderr.trim()}`);
3647
+ const databases = JSON.parse(databaseCheck.stdout.trim());
3648
+ if (databases.length !== 2 || databases.some((entry) => entry.quickCheck !== "ok" || entry.tables < 1)) {
3649
+ throw new Error("SQLite verification did not confirm both managed databases.");
3650
+ }
3651
+ const services = loaded.state.mode === "public" ? ["or3", "caddy"] : ["or3"];
3652
+ const logs = await run("docker", composeArgs(loaded.directory, loaded.state.mode, [
3653
+ "logs",
3654
+ "--no-color",
3655
+ "--since",
3656
+ "10m",
3657
+ "--tail",
3658
+ "250",
3659
+ ...services
3660
+ ]), loaded.directory);
3661
+ if (!logs.ok)
3662
+ throw new Error(`Could not read bounded deployment logs. ${logs.stderr.trim()}`);
3663
+ const serious = `${logs.stdout}
3664
+ ${logs.stderr}`.split(/\r?\n/).filter((line) => /\b(?:fatal|panic|oomkilled)\b|unhandled (?:rejection|exception)|out of memory/i.test(line));
3665
+ if (serious.length)
3666
+ throw new Error(`Recent deployment logs contain serious errors:
3667
+ ${redact(serious.slice(-20).join(`
3668
+ `), secretValues(loaded.env))}`);
3669
+ console.log(`✓ OR3 ${loaded.state.appVersion} image digest matches managed state`);
3670
+ console.log(`✓ ${baseUrl.origin} root and deep health return HTTP 200 without redirects`);
3671
+ console.log("✓ Basic Auth sign-in, session hydration, and SQLite sync pull passed");
3672
+ console.log("✓ Filesystem storage write/read/delete probe passed");
3673
+ console.log("✓ auth.sqlite and sync.sqlite quick_check passed with managed ownership");
3674
+ console.log("✓ Recent bounded logs contain no fatal, panic, unhandled, or OOM events");
3675
+ console.log("OR3 production verification passed.");
3676
+ }
1974
3677
  async function readSourceVolume(sourceDirectory) {
1975
3678
  const composeResult = await run("docker", [
1976
3679
  "compose",
@@ -2026,6 +3729,9 @@ function assertSupportedSource(sourceDirectory, sourceEnv, providers) {
2026
3729
  if (sourceEnv.SSR_AUTH_ENABLED === "false" || sourceEnv.OR3_GUEST_ACCESS_ENABLED === "true") {
2027
3730
  throw new Error("V1 project does not require authenticated access; adoption expects the supported authenticated profile.");
2028
3731
  }
3732
+ if (sourceEnv.OR3_AUTH_REGISTRATION_MODE !== "invite_only" || sourceEnv.OR3_AUTH_AUTO_PROVISION !== "false") {
3733
+ throw new Error("V1 project does not use the managed invite-only registration policy. Adoption refuses to carry open registration into OR3 Cloud.");
3734
+ }
2029
3735
  if (sourceEnv.OR3_SYNC_PROVIDER !== "sqlite" || sourceEnv.OR3_STORAGE_FS_ROOT === undefined || sourceEnv.NUXT_PUBLIC_STORAGE_PROVIDER !== "fs") {
2030
3736
  throw new Error("V1 project is not the supported Basic Auth + SQLite + filesystem profile.");
2031
3737
  }
@@ -2086,6 +3792,9 @@ async function adoptCommand(positionals, flags) {
2086
3792
  const release = JSON.parse(await readText(join(sourceDirectory, "or3-release.json")));
2087
3793
  if (!release.or3Version || !isVersion(release.or3Version))
2088
3794
  throw new Error("V1 release metadata does not contain a usable OR3 version.");
3795
+ if (release.or3Version !== PACKAGE_VERSION) {
3796
+ throw new Error(`V1 is on OR3 ${release.or3Version}, but this CLI ships managed assets for ${PACKAGE_VERSION}. Run the matching @or3/cloud version or perform a qualified host CLI update before adoption.`);
3797
+ }
2089
3798
  const sourceEnv = parseEnv(await readText(join(sourceDirectory, ".env")));
2090
3799
  const providers = await readText(join(sourceDirectory, "or3.providers.generated.ts"));
2091
3800
  assertSupportedSource(sourceDirectory, sourceEnv, providers);
@@ -2110,15 +3819,17 @@ async function adoptCommand(positionals, flags) {
2110
3819
  ], sourceDirectory);
2111
3820
  if (!sourceConfig.ok)
2112
3821
  throw new Error(`The V1 Compose configuration is invalid. ${redact(sourceConfig.stderr, secretValues(sourceEnv))}`);
2113
- const image = imageFor(release.or3Version);
3822
+ const sourceImage = imageFor(release.or3Version);
2114
3823
  if (sourceEnv.OR3_VERSION && sourceEnv.OR3_VERSION !== release.or3Version) {
2115
3824
  throw new Error(`V1 OR3_VERSION ${sourceEnv.OR3_VERSION} does not match or3-release.json ${release.or3Version}.`);
2116
3825
  }
2117
- if (sourceEnv.OR3_IMAGE && sourceEnv.OR3_IMAGE !== image) {
2118
- throw new Error(`V1 OR3_IMAGE ${sourceEnv.OR3_IMAGE} does not match the published image for ${release.or3Version}.`);
3826
+ const digest = await pullImage(sourceImage, expectedImageDigest(release.or3Version));
3827
+ await assertSupportedHostArchitecture(sourceImage);
3828
+ await assertImageReleaseIdentity(sourceImage, release.or3Version);
3829
+ const image = imageAtDigest(sourceImage, digest);
3830
+ if (sourceEnv.OR3_IMAGE && sourceEnv.OR3_IMAGE !== sourceImage && sourceEnv.OR3_IMAGE !== image) {
3831
+ throw new Error(`V1 OR3_IMAGE ${sourceEnv.OR3_IMAGE} does not match the authenticated image for ${release.or3Version}.`);
2119
3832
  }
2120
- await pullImage(image);
2121
- await assertSupportedHostArchitecture(image);
2122
3833
  const sourceVolume = await readSourceVolume(sourceDirectory);
2123
3834
  const email = sourceEnv.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
2124
3835
  const password = sourceEnv.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD;
@@ -2130,15 +3841,40 @@ async function adoptCommand(positionals, flags) {
2130
3841
  if (mode === "public" && [80, 443].includes(sourcePort))
2131
3842
  throw new Error("V1 public deployment uses a Caddy port for OR3; adoption requires a separate OR3 port.");
2132
3843
  assertSupportedSourceCompose(sourceConfig.stdout, sourceVolume, sourcePort);
3844
+ const sourceInitiallyRunning = await sourceServiceRunning(sourceDirectory, sourceComposeFiles);
3845
+ const targetNames = composeProjectNames(targetDirectory);
3846
+ await assertDockerProjectAbsent(targetNames.project);
3847
+ for (const volume of [targetNames.volume, ...mode === "public" ? [targetNames.caddyData, targetNames.caddyConfig] : []]) {
3848
+ const existing = await run("docker", ["volume", "inspect", volume]);
3849
+ if (existing.ok)
3850
+ throw new Error(`Docker volume ${volume} already exists. Choose a new adoption target directory.`);
3851
+ if (existing.exitCode !== 1 || !/no such volume/i.test(`${existing.stdout}
3852
+ ${existing.stderr}`)) {
3853
+ throw new Error(`Could not confirm whether Docker volume ${volume} exists. Refusing adoption until Docker returns an explicit not-found result. ${existing.stderr.trim()}`);
3854
+ }
3855
+ }
2133
3856
  await mkdir(targetDirectory, { recursive: true, mode: 448 });
3857
+ await chmod(targetDirectory, 448);
2134
3858
  await mkdir(deploymentPaths(targetDirectory).operations, { recursive: true, mode: 448 });
2135
3859
  await mkdir(deploymentPaths(targetDirectory).backups, { recursive: true, mode: 448 });
3860
+ const operator = await prepareVerifiedDashboardOperator(targetDirectory, release.or3Version);
2136
3861
  await copyAssets(targetDirectory, mode);
2137
3862
  const copiedSecrets = {};
2138
3863
  for (const key of SECRET_KEYS)
2139
3864
  if (sourceEnv[key])
2140
3865
  copiedSecrets[key] = sourceEnv[key];
2141
- const targetEnv = buildEnv({ mode, version: release.or3Version, directory: targetDirectory, email, password, domain, port: sourcePort, secrets: copiedSecrets });
3866
+ const targetEnv = buildEnv({
3867
+ mode,
3868
+ version: release.or3Version,
3869
+ directory: targetDirectory,
3870
+ image,
3871
+ email,
3872
+ password,
3873
+ domain,
3874
+ port: sourcePort,
3875
+ secrets: copiedSecrets,
3876
+ dashboardOperator: operator
3877
+ });
2142
3878
  for (const key of ALLOWED_ENV_KEYS)
2143
3879
  if (sourceEnv[key] !== undefined)
2144
3880
  targetEnv[key] = sourceEnv[key];
@@ -2156,50 +3892,50 @@ async function adoptCommand(positionals, flags) {
2156
3892
  OR3_PUBLIC_DOMAIN: domain ?? "localhost",
2157
3893
  OR3_ALLOWED_ORIGINS: mode === "public" ? `https://${domain}` : `http://127.0.0.1:${sourcePort}`,
2158
3894
  OR3_FORCE_HTTPS: mode === "public" ? "true" : "false",
2159
- OR3_TRUST_PROXY: mode === "public" ? "true" : "false"
3895
+ OR3_TRUST_PROXY: mode === "public" ? "true" : "false",
3896
+ OR3_AUTH_REGISTRATION_MODE: "invite_only",
3897
+ OR3_AUTH_AUTO_PROVISION: "false",
3898
+ OR3_GUEST_ACCESS_ENABLED: "false"
2160
3899
  });
2161
- const targetNames = composeProjectNames(targetDirectory);
2162
- for (const volume of [targetNames.volume, ...mode === "public" ? [targetNames.caddyData, targetNames.caddyConfig] : []]) {
2163
- const existing = await run("docker", ["volume", "inspect", volume]);
2164
- if (existing.ok)
2165
- throw new Error(`Docker volume ${volume} already exists. Choose a new adoption target directory.`);
2166
- }
2167
3900
  await writeSecure(deploymentPaths(targetDirectory).env, serializeEnv(targetEnv));
2168
3901
  await writeSecure(join(targetDirectory, ".or3-initial-credentials"), serializeInitialCredentials({
2169
3902
  bootstrapEmail: email,
2170
3903
  bootstrapPassword: password,
2171
- adminUsername: email,
2172
- adminPassword: password
3904
+ adminUsername: targetEnv.OR3_ADMIN_USERNAME,
3905
+ adminPassword: targetEnv.OR3_ADMIN_PASSWORD
2173
3906
  }));
2174
- const digest = await imageDigest(image);
2175
3907
  const state = stateFromEnv(targetDirectory, targetEnv, mode, "adopt", digest);
3908
+ const sourceBackupId = id("backup-adopt-source");
3909
+ const sourceBackupDir = backupDirectory(targetDirectory, sourceBackupId);
2176
3910
  await markPending(targetDirectory, state, {
2177
3911
  id: id("adopt"),
2178
3912
  operation: "adopt",
2179
3913
  startedAt: now(),
2180
3914
  message: `Adopting ${sourceDirectory}`,
2181
- sourceDirectory
3915
+ sourceDirectory,
3916
+ sourceInitiallyRunning,
3917
+ backupId: sourceBackupId,
3918
+ backupPath: sourceBackupDir,
3919
+ phase: "prepared"
2182
3920
  });
2183
3921
  let sourceStopAttempted = false;
2184
- let sourceBackupDir;
2185
3922
  try {
2186
3923
  sourceStopAttempted = true;
2187
3924
  const sourceStop = await run("docker", [...sourceComposeArgs(sourceDirectory, sourceComposeFiles), "stop"], sourceDirectory);
2188
3925
  if (!sourceStop.ok)
2189
3926
  throw new Error(`Could not stop the V1 deployment. ${redact(sourceStop.stderr, secretValues(sourceEnv))}`);
2190
- if (!await portAvailable(Number(targetEnv.OR3_PORT)))
3927
+ const localDockerDaemon = await dockerDaemonIsLocal();
3928
+ if (localDockerDaemon && !await portAvailable(Number(targetEnv.OR3_PORT)))
2191
3929
  throw new Error(`Port ${targetEnv.OR3_PORT} is still in use after stopping the V1 deployment.`);
2192
- if (mode === "public") {
3930
+ if (mode === "public" && localDockerDaemon) {
2193
3931
  for (const publicPort of [80, 443]) {
2194
3932
  if (!await portAvailable(publicPort))
2195
3933
  throw new Error(`Public port ${publicPort} is still in use after stopping the V1 deployment.`);
2196
3934
  }
2197
3935
  }
2198
- const sourceBackupId = id("adopt-source");
2199
- sourceBackupDir = backupDirectory(targetDirectory, sourceBackupId);
2200
3936
  await mkdir(sourceBackupDir, { recursive: true, mode: 448 });
2201
3937
  await copySecure(join(sourceDirectory, ".env"), join(sourceBackupDir, "config.env"));
2202
- await archiveExternalVolume(image, sourceVolume, sourceBackupDir);
3938
+ await archiveExternalVolume(sourceImage, sourceVolume, sourceBackupDir);
2203
3939
  const sourceManifest = {
2204
3940
  schemaVersion: 1,
2205
3941
  backupId: sourceBackupId,
@@ -2217,11 +3953,19 @@ async function adoptCommand(positionals, flags) {
2217
3953
  caddyConfigVolume: sourceEnv.OR3_CADDY_CONFIG_VOLUME,
2218
3954
  port: sourcePort
2219
3955
  };
2220
- await writeSecure(join(sourceBackupDir, "manifest.json"), `${JSON.stringify(sourceManifest, null, 2)}
2221
- `);
2222
- await updatePending(targetDirectory, state, { backupId: sourceBackupId });
3956
+ const sourceManifestContents = `${JSON.stringify(sourceManifest, null, 2)}
3957
+ `;
3958
+ await writeSecure(join(sourceBackupDir, "manifest.json"), sourceManifestContents);
3959
+ await writeBackupAuthentication(targetDirectory, sourceBackupDir, sourceManifestContents);
3960
+ await readManifest(sourceBackupDir, targetDirectory);
3961
+ await updatePending(targetDirectory, state, { phase: "snapshot-created" });
2223
3962
  await restoreVolumeArchive(targetDirectory, mode, targetEnv, sourceBackupDir);
2224
3963
  await startProject(targetDirectory, mode, targetEnv);
3964
+ await provisionManagedCredentials(mode === "public" ? new URL(`https://${domain}`) : new URL(`http://127.0.0.1:${sourcePort}`), email, password, targetEnv.OR3_ADMIN_USERNAME, targetEnv.OR3_ADMIN_PASSWORD);
3965
+ const runtimeEnv = withoutProvisioningCredentials(targetEnv);
3966
+ await writeSecure(deploymentPaths(targetDirectory).env, serializeEnv(runtimeEnv));
3967
+ await stopProject(targetDirectory, mode);
3968
+ await startProject(targetDirectory, mode, runtimeEnv);
2225
3969
  await clearPending(targetDirectory, state);
2226
3970
  console.log(`Adopted ${sourceDirectory} into ${targetDirectory} at OR3 ${release.or3Version}.`);
2227
3971
  console.log(`Source backup: ${sourceBackupDir}`);
@@ -2231,7 +3975,7 @@ async function adoptCommand(positionals, flags) {
2231
3975
  return;
2232
3976
  });
2233
3977
  let sourceRecoveryError = "";
2234
- if (sourceStopAttempted) {
3978
+ if (sourceStopAttempted && sourceInitiallyRunning) {
2235
3979
  try {
2236
3980
  await restartSource(sourceDirectory, sourceComposeFiles, secretValues(sourceEnv));
2237
3981
  } catch (recovery) {
@@ -2246,27 +3990,58 @@ ${sourceRecoveryError ? "The original V1 deployment needs manual recovery. " : "
2246
3990
  }
2247
3991
  async function resolveResetPasswords(flags) {
2248
3992
  const ownerPassword = stringFlag(flags, "owner-password");
3993
+ const ownerPasswordFile = stringFlag(flags, "owner-password-file");
2249
3994
  const adminPassword = stringFlag(flags, "admin-password");
2250
- if (ownerPassword !== undefined || adminPassword !== undefined) {
2251
- if (ownerPassword === undefined || adminPassword === undefined) {
2252
- throw new Error("Use both --owner-password and --admin-password together so a reset never applies one credential without the other.");
3995
+ const adminPasswordFile = stringFlag(flags, "admin-password-file");
3996
+ if (flags["owner-password"] !== undefined && ownerPassword === undefined)
3997
+ throw new Error("--owner-password requires a value.");
3998
+ if (flags["admin-password"] !== undefined && adminPassword === undefined)
3999
+ throw new Error("--admin-password requires a value.");
4000
+ if (flags["owner-password-file"] !== undefined && !ownerPasswordFile)
4001
+ throw new Error("--owner-password-file requires a path.");
4002
+ if (flags["admin-password-file"] !== undefined && !adminPasswordFile)
4003
+ throw new Error("--admin-password-file requires a path.");
4004
+ if (ownerPassword !== undefined && ownerPasswordFile)
4005
+ throw new Error("Use either --owner-password or --owner-password-file, not both.");
4006
+ if (adminPassword !== undefined && adminPasswordFile)
4007
+ throw new Error("Use either --admin-password or --admin-password-file, not both.");
4008
+ const suppliedOwner = ownerPassword ?? (ownerPasswordFile ? (await readText(resolve(ownerPasswordFile))).trim() : undefined);
4009
+ const suppliedAdmin = adminPassword ?? (adminPasswordFile ? (await readText(resolve(adminPasswordFile))).trim() : undefined);
4010
+ if (suppliedOwner !== undefined || suppliedAdmin !== undefined) {
4011
+ if (suppliedOwner === undefined || suppliedAdmin === undefined) {
4012
+ throw new Error("Supply both owner and admin passwords so a reset never applies one credential without the other.");
2253
4013
  }
2254
- validatePassword(ownerPassword);
2255
- validatePassword(adminPassword);
2256
- return { ownerPassword, adminPassword };
4014
+ validatePassword(suppliedOwner);
4015
+ validatePassword(suppliedAdmin);
4016
+ return { ownerPassword: suppliedOwner, adminPassword: suppliedAdmin };
2257
4017
  }
2258
4018
  if (!input.isTTY || !output.isTTY) {
2259
- throw new Error("--owner-password and --admin-password are required in a non-interactive session. Credentials are never generated automatically.");
4019
+ throw new Error("--owner-password-file and --admin-password-file are required in a non-interactive session. Credentials are never generated automatically.");
2260
4020
  }
2261
- const prompt = readline.createInterface({ input, output });
4021
+ const owner = (await maskedQuestion("New owner (basic auth) password: ")).trim();
4022
+ validatePassword(owner);
4023
+ const admin = (await maskedQuestion("New admin password: ")).trim();
4024
+ validatePassword(admin);
4025
+ return { ownerPassword: owner, adminPassword: admin };
4026
+ }
4027
+ async function maskedQuestion(question) {
4028
+ let muted = false;
4029
+ const maskedOutput = new Writable({
4030
+ write(chunk, _encoding, callback) {
4031
+ if (!muted)
4032
+ output.write(chunk);
4033
+ callback();
4034
+ }
4035
+ });
4036
+ const prompt = readline.createInterface({ input, output: maskedOutput, terminal: true });
2262
4037
  try {
2263
- const owner = (await prompt.question("New owner (basic auth) password: ")).trim();
2264
- validatePassword(owner);
2265
- const admin = (await prompt.question("New admin password: ")).trim();
2266
- validatePassword(admin);
2267
- return { ownerPassword: owner, adminPassword: admin };
4038
+ const answer = prompt.question(question);
4039
+ muted = true;
4040
+ return await answer;
2268
4041
  } finally {
2269
4042
  prompt.close();
4043
+ output.write(`
4044
+ `);
2270
4045
  }
2271
4046
  }
2272
4047
  function buildCredentialsResetScript(input2) {
@@ -2334,24 +4109,32 @@ try {
2334
4109
  credentials.username = adminUsername;
2335
4110
  credentials.password_hash_bcrypt = adminHash;
2336
4111
  credentials.updated_at = new Date().toISOString();
2337
- try {
2338
- fs.writeFileSync(adminCredentialsPath, JSON.stringify(credentials, null, 2) + '\\n', { mode: 0o600 });
2339
- } catch (error) {
2340
- console.error('OR3 credentials reset failed: could not update the admin credentials file at ' + adminCredentialsPath + '. Nothing was changed. ' + (error && error.message ? error.message : String(error)));
2341
- process.exit(1);
2342
- }
4112
+ const atomicWrite = (target, contents) => {
4113
+ const temporary = target + '.reset-' + process.pid + '-' + Date.now();
4114
+ const descriptor = fs.openSync(temporary, 'w', 0o600);
4115
+ try {
4116
+ fs.writeFileSync(descriptor, contents, 'utf8');
4117
+ fs.fsyncSync(descriptor);
4118
+ } finally {
4119
+ fs.closeSync(descriptor);
4120
+ }
4121
+ fs.renameSync(temporary, target);
4122
+ const directory = fs.openSync(path.dirname(target), 'r');
4123
+ try { fs.fsyncSync(directory); } finally { fs.closeSync(directory); }
4124
+ };
2343
4125
  try {
2344
4126
  db.transaction(() => {
2345
4127
  db.prepare('UPDATE basic_auth_accounts SET password_hash = ?, token_version = token_version + 1, updated_at = ? WHERE id = ?').run(ownerHash, now, account.id);
2346
4128
  db.prepare('UPDATE basic_auth_sessions SET revoked_at = COALESCE(revoked_at, ?), rotation_grace_until = NULL, rotation_grace_refresh_token = NULL WHERE account_id = ?').run(now, account.id);
4129
+ atomicWrite(adminCredentialsPath, JSON.stringify(credentials, null, 2) + '\\n');
2347
4130
  })();
2348
4131
  } catch (error) {
2349
4132
  if (previousCredentials !== null) {
2350
- try { fs.writeFileSync(adminCredentialsPath, previousCredentials, { mode: 0o600 }); } catch {}
4133
+ try { atomicWrite(adminCredentialsPath, previousCredentials); } catch {}
2351
4134
  } else {
2352
4135
  try { fs.rmSync(adminCredentialsPath, { force: true }); } catch {}
2353
4136
  }
2354
- console.error('OR3 credentials reset failed: the authentication database rejected the update. Any partial change was rolled back. ' + (error && error.message ? error.message : String(error)));
4137
+ console.error('OR3 credentials reset failed: the atomic database/file update did not complete. Replay is safe through the managed recovery journal. ' + (error && error.message ? error.message : String(error)));
2355
4138
  process.exit(1);
2356
4139
  }
2357
4140
  db.close();
@@ -2359,7 +4142,7 @@ console.log('credentials-reset: owner hash, sessions, and admin credentials upda
2359
4142
  `;
2360
4143
  }
2361
4144
  function credentialResetValues(env) {
2362
- const ownerEmail = env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
4145
+ const ownerEmail = env.OR3_MANAGED_OWNER_EMAIL || env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
2363
4146
  const ownerPassword = env.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD;
2364
4147
  const adminUsername = env.OR3_ADMIN_USERNAME;
2365
4148
  const adminPassword = env.OR3_ADMIN_PASSWORD;
@@ -2385,43 +4168,57 @@ Promise.all([
2385
4168
  `;
2386
4169
  async function runCredentialsResetScript(directory, mode, values) {
2387
4170
  const script = buildCredentialsResetScript(values);
4171
+ const resetEnvironment = {
4172
+ ...composeProcessEnv(directory),
4173
+ OR3_RESET_OWNER_EMAIL: values.ownerEmail,
4174
+ OR3_RESET_OWNER_PASSWORD: values.ownerPassword,
4175
+ OR3_RESET_ADMIN_USERNAME: values.adminUsername,
4176
+ OR3_RESET_ADMIN_PASSWORD: values.adminPassword
4177
+ };
2388
4178
  const result = await run("docker", [
2389
4179
  ...composeArgs(directory, mode, [
2390
4180
  "exec",
2391
4181
  "-T",
2392
4182
  "-e",
2393
- `OR3_RESET_OWNER_EMAIL=${values.ownerEmail}`,
4183
+ "OR3_RESET_OWNER_EMAIL",
2394
4184
  "-e",
2395
- `OR3_RESET_OWNER_PASSWORD=${values.ownerPassword}`,
4185
+ "OR3_RESET_OWNER_PASSWORD",
2396
4186
  "-e",
2397
- `OR3_RESET_ADMIN_USERNAME=${values.adminUsername}`,
4187
+ "OR3_RESET_ADMIN_USERNAME",
2398
4188
  "-e",
2399
- `OR3_RESET_ADMIN_PASSWORD=${values.adminPassword}`,
4189
+ "OR3_RESET_ADMIN_PASSWORD",
2400
4190
  "or3",
2401
4191
  ...containerNodeCommand(script)
2402
4192
  ])
2403
- ], directory);
4193
+ ], directory, resetEnvironment);
2404
4194
  if (!result.ok)
2405
4195
  throw new Error(`${result.command}
2406
4196
  ${redact(result.stderr, [values.ownerPassword, values.adminPassword])}`);
2407
4197
  }
2408
4198
  async function verifyCredentialsInsideContainer(directory, mode, values) {
4199
+ const resetEnvironment = {
4200
+ ...composeProcessEnv(directory),
4201
+ OR3_RESET_OWNER_EMAIL: values.ownerEmail,
4202
+ OR3_RESET_OWNER_PASSWORD: values.ownerPassword,
4203
+ OR3_RESET_ADMIN_USERNAME: values.adminUsername,
4204
+ OR3_RESET_ADMIN_PASSWORD: values.adminPassword
4205
+ };
2409
4206
  const result = await run("docker", [
2410
4207
  ...composeArgs(directory, mode, [
2411
4208
  "exec",
2412
4209
  "-T",
2413
4210
  "-e",
2414
- `OR3_RESET_OWNER_EMAIL=${values.ownerEmail}`,
4211
+ "OR3_RESET_OWNER_EMAIL",
2415
4212
  "-e",
2416
- `OR3_RESET_OWNER_PASSWORD=${values.ownerPassword}`,
4213
+ "OR3_RESET_OWNER_PASSWORD",
2417
4214
  "-e",
2418
- `OR3_RESET_ADMIN_USERNAME=${values.adminUsername}`,
4215
+ "OR3_RESET_ADMIN_USERNAME",
2419
4216
  "-e",
2420
- `OR3_RESET_ADMIN_PASSWORD=${values.adminPassword}`,
4217
+ "OR3_RESET_ADMIN_PASSWORD",
2421
4218
  "or3",
2422
4219
  ...containerNodeCommand(CREDENTIALS_VERIFY_SCRIPT)
2423
4220
  ])
2424
- ], directory);
4221
+ ], directory, resetEnvironment);
2425
4222
  if (!result.ok)
2426
4223
  throw new Error(`Credential verification inside the OR3 container failed. ${redact(result.stderr, [values.ownerPassword, values.adminPassword])}`);
2427
4224
  }
@@ -2433,18 +4230,12 @@ async function applyCredentialReset(directory, state, nextEnv) {
2433
4230
  await startProject(directory, state.mode, nextEnv);
2434
4231
  }
2435
4232
  await runCredentialsResetScript(directory, state.mode, values);
2436
- await writeSecure(deploymentPaths(directory).env, serializeEnv(nextEnv));
4233
+ const runtimeEnv = withoutProvisioningCredentials(nextEnv);
4234
+ await writeSecure(deploymentPaths(directory).env, serializeEnv(runtimeEnv));
2437
4235
  const initialCredentials = join(directory, ".or3-initial-credentials");
2438
- if (await fileExists(initialCredentials)) {
2439
- await writeSecure(initialCredentials, serializeInitialCredentials({
2440
- bootstrapEmail: values.ownerEmail,
2441
- bootstrapPassword: values.ownerPassword,
2442
- adminUsername: values.adminUsername,
2443
- adminPassword: values.adminPassword
2444
- }));
2445
- }
4236
+ await rm(initialCredentials, { force: true });
2446
4237
  await stopProject(directory, state.mode);
2447
- await startProject(directory, state.mode, nextEnv);
4238
+ await startProject(directory, state.mode, runtimeEnv);
2448
4239
  await verifyCredentialsInsideContainer(directory, state.mode, values);
2449
4240
  }
2450
4241
  async function credentialsResetCommand(directory, flags) {
@@ -2454,15 +4245,16 @@ async function credentialsResetCommand(directory, flags) {
2454
4245
  const loaded = await loadManaged(directory);
2455
4246
  assertDeploymentDirectoryIdentity(directory, loaded.state);
2456
4247
  assertNoPending(loaded.state);
2457
- const ownerEmail = loaded.env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
4248
+ const ownerEmail = loaded.env.OR3_MANAGED_OWNER_EMAIL || loaded.env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
2458
4249
  const adminUsername = loaded.env.OR3_ADMIN_USERNAME;
2459
4250
  if (!ownerEmail)
2460
- throw new Error("OR3_BASIC_AUTH_BOOTSTRAP_EMAIL is missing from .env, so the owner account cannot be reset.");
4251
+ throw new Error("The managed owner email is missing from .env, so the owner account cannot be reset.");
2461
4252
  if (!adminUsername)
2462
4253
  throw new Error("OR3_ADMIN_USERNAME is missing from .env, so the admin password cannot be reset.");
2463
4254
  const { ownerPassword, adminPassword } = await resolveResetPasswords(flags);
2464
4255
  const nextEnv = {
2465
4256
  ...loaded.env,
4257
+ OR3_MANAGED_OWNER_EMAIL: ownerEmail,
2466
4258
  OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD: ownerPassword,
2467
4259
  OR3_ADMIN_PASSWORD: adminPassword,
2468
4260
  OR3_ADMIN_JWT_SECRET: randomSecret()
@@ -2479,7 +4271,7 @@ async function credentialsResetCommand(directory, flags) {
2479
4271
  await applyCredentialReset(loaded.directory, loaded.state, nextEnv);
2480
4272
  await clearPending(loaded.directory, loaded.state);
2481
4273
  console.log("Owner and admin credentials are now separate. The old owner password and all app sessions were revoked.");
2482
- console.log("Admin session cookies were invalidated by rotating OR3_ADMIN_JWT_SECRET. New passwords were written only to protected files; sign in again with them.");
4274
+ console.log("Admin session cookies were invalidated by rotating OR3_ADMIN_JWT_SECRET. New passwords persist only as account hashes; sign in again with them.");
2483
4275
  } catch (error) {
2484
4276
  loaded.state.lastError = redact(error instanceof Error ? error.message : String(error), secretValues(nextEnv));
2485
4277
  await writeState(loaded.directory, loaded.state);
@@ -2629,8 +4421,12 @@ async function assertPurgeHasVerifiedExport(directory, backups, nowMs) {
2629
4421
  const receipt = await readBackupExportReceipt(directory, backup.backupId);
2630
4422
  if (!receipt || !await fileExists(receipt.destination))
2631
4423
  continue;
4424
+ const deploymentRoot = await realpath(directory);
2632
4425
  try {
2633
- const exported = await readManifest(receipt.destination);
4426
+ const canonicalDestination = await realpath(receipt.destination);
4427
+ if (canonicalDestination === deploymentRoot || canonicalDestination.startsWith(`${deploymentRoot}${sep}`))
4428
+ continue;
4429
+ const exported = await readManifest(receipt.destination, directory);
2634
4430
  const sourceDevice = (await stat(backup.path)).dev;
2635
4431
  const destinationDevice = (await stat(receipt.destination)).dev;
2636
4432
  if (sourceDevice !== destinationDevice && receipt.destinationDevice === destinationDevice && exported.backupId === backup.backupId && exported.dataSha256 === receipt.dataSha256 && exported.dataSha256 === backup.dataSha256 && exported.configSha256 === receipt.configSha256)
@@ -2655,13 +4451,19 @@ async function removeCommand(directory, flags) {
2655
4451
  }
2656
4452
  const exportReceipt = await assertPurgeHasVerifiedExport(directory, await enumerateBackups(directory), Date.now());
2657
4453
  const volumes = purgeVolumesFromState(state);
2658
- const managedFiles = [".env", ".or3-cloud", "compose.yaml", ...state.mode === "public" ? ["compose.public.yaml", "Caddyfile"] : [], ".or3-initial-credentials"];
4454
+ const managedFiles = [
4455
+ ".env",
4456
+ ".or3-cloud",
4457
+ ...managedAssetNames(state.mode),
4458
+ ".or3-initial-credentials"
4459
+ ];
2659
4460
  console.log("Removing exactly these targets:");
2660
4461
  for (const volume of volumes)
2661
4462
  console.log(` docker volume ${volume}`);
2662
4463
  for (const file of managedFiles)
2663
4464
  console.log(` ${join(directory, file)}`);
2664
4465
  await compose(directory, state.mode, ["down"]);
4466
+ await assertPurgeHasVerifiedExport(directory, await enumerateBackups(directory), Date.now());
2665
4467
  for (const volume of volumes) {
2666
4468
  const result = await run("docker", ["volume", "rm", volume], directory);
2667
4469
  if (!result.ok)
@@ -2682,6 +4484,30 @@ async function probeDeepHealth(directory, mode) {
2682
4484
  return "degraded";
2683
4485
  return "unreachable";
2684
4486
  }
4487
+ var MUTATING_COMMANDS = new Set([
4488
+ "init",
4489
+ "update",
4490
+ "backup",
4491
+ "verify",
4492
+ "restore",
4493
+ "rollback",
4494
+ "credentials",
4495
+ "recover",
4496
+ "adopt",
4497
+ "start",
4498
+ "stop",
4499
+ "restart",
4500
+ "remove"
4501
+ ]);
4502
+ function mutationDirectory(command, positionals, flags) {
4503
+ if (command === "init")
4504
+ return resolve(process.cwd(), positionals[0] ?? "or3-cloud");
4505
+ if (command === "adopt") {
4506
+ const sourceDirectory = requireStringFlag(flags, "from");
4507
+ return resolve(process.cwd(), positionals[0] ?? `${basename(sourceDirectory)}-managed`);
4508
+ }
4509
+ return process.cwd();
4510
+ }
2685
4511
  async function main(argv = process.argv.slice(2)) {
2686
4512
  const [command = "help", ...rest] = argv;
2687
4513
  if (command === "--help" || command === "help")
@@ -2693,37 +4519,46 @@ async function main(argv = process.argv.slice(2)) {
2693
4519
  if (parsed.flags.help)
2694
4520
  return help();
2695
4521
  assertCommandFlags(command, parsed.flags);
2696
- if (command === "init")
2697
- return await initCommand(parsed.positionals, parsed.flags);
2698
- if (command === "update")
2699
- return await updateCommand(process.cwd(), parsed.flags);
2700
- if (command === "backup")
2701
- return await backupCommand(process.cwd(), parsed.positionals, parsed.flags);
2702
- if (command === "restore")
2703
- return await restoreCommand(process.cwd(), parsed.flags, parsed.positionals);
2704
- if (command === "rollback")
2705
- return await rollbackCommand(process.cwd(), parsed.flags);
2706
- if (command === "credentials")
2707
- return await credentialsCommand(process.cwd(), parsed.positionals, parsed.flags);
2708
- if (command === "doctor")
2709
- return await doctorCommand(process.cwd());
2710
- if (command === "recover")
2711
- return await recoverCommand(process.cwd());
2712
- if (command === "adopt")
2713
- return await adoptCommand(parsed.positionals, parsed.flags);
2714
- if (command === "status")
2715
- return await statusCommand(process.cwd());
2716
- if (command === "logs")
2717
- return await logsCommand(process.cwd(), parsed.flags, parsed.positionals);
2718
- if (command === "start")
2719
- return await startCommand(process.cwd());
2720
- if (command === "stop")
2721
- return await stopCommand(process.cwd());
2722
- if (command === "restart")
2723
- return await restartCommand(process.cwd());
2724
- if (command === "remove")
2725
- return await removeCommand(process.cwd(), parsed.flags);
2726
- throw new Error(`Unknown command "${command}". Run npx @or3/cloud --help.`);
4522
+ assertCommandPositionals(command, parsed.positionals);
4523
+ const dispatch = async () => {
4524
+ if (command === "init")
4525
+ return await initCommand(parsed.positionals, parsed.flags);
4526
+ if (command === "update")
4527
+ return await updateCommand(process.cwd(), parsed.flags);
4528
+ if (command === "backup")
4529
+ return await backupCommand(process.cwd(), parsed.positionals, parsed.flags);
4530
+ if (command === "restore")
4531
+ return await restoreCommand(process.cwd(), parsed.flags, parsed.positionals);
4532
+ if (command === "rollback")
4533
+ return await rollbackCommand(process.cwd(), parsed.flags);
4534
+ if (command === "credentials")
4535
+ return await credentialsCommand(process.cwd(), parsed.positionals, parsed.flags);
4536
+ if (command === "doctor")
4537
+ return await doctorCommand(process.cwd());
4538
+ if (command === "verify")
4539
+ return await verifyCommand(process.cwd(), parsed.flags, parsed.positionals);
4540
+ if (command === "recover")
4541
+ return await recoverCommand(process.cwd());
4542
+ if (command === "adopt")
4543
+ return await adoptCommand(parsed.positionals, parsed.flags);
4544
+ if (command === "status")
4545
+ return await statusCommand(process.cwd());
4546
+ if (command === "logs")
4547
+ return await logsCommand(process.cwd(), parsed.flags, parsed.positionals);
4548
+ if (command === "start")
4549
+ return await startCommand(process.cwd());
4550
+ if (command === "stop")
4551
+ return await stopCommand(process.cwd());
4552
+ if (command === "restart")
4553
+ return await restartCommand(process.cwd());
4554
+ if (command === "remove")
4555
+ return await removeCommand(process.cwd(), parsed.flags);
4556
+ throw new Error(`Unknown command "${command}". Run npx @or3/cloud --help.`);
4557
+ };
4558
+ if (MUTATING_COMMANDS.has(command)) {
4559
+ return await withDeploymentLease(mutationDirectory(command, parsed.positionals, parsed.flags), command, dispatch);
4560
+ }
4561
+ return await dispatch();
2727
4562
  } catch (error) {
2728
4563
  console.error(`
2729
4564
  OR3 Cloud failed: ${redact(error instanceof Error ? error.message : String(error))}`);
@@ -2735,27 +4570,39 @@ if (invokedAsCli || process.argv[1] && resolve(process.argv[1]) === resolve(file
2735
4570
  await main();
2736
4571
  }
2737
4572
  export {
4573
+ withoutProvisioningCredentials,
4574
+ validateVerificationHealth,
2738
4575
  validatePassword,
4576
+ updateRequiresVolumeRecreation,
2739
4577
  supportedImageArchitectures,
2740
4578
  stateFromEnv,
4579
+ snapshotManagedAssets,
2741
4580
  serializeInitialCredentials,
2742
4581
  serializeEnv,
2743
4582
  selectPruneTargets,
4583
+ sameOriginVerificationUrl,
4584
+ restoreRequiresVolumeRecreation,
4585
+ restoreManagedAssets,
2744
4586
  requiredArchiveSpace,
2745
4587
  redact,
2746
4588
  purgeVolumesFromState,
2747
4589
  parseFlags,
2748
4590
  parseEnv,
4591
+ managedAssetNames,
2749
4592
  isVersion,
4593
+ copyAssets,
2750
4594
  checkResolvedLoopbackBinding,
2751
4595
  buildEnv,
2752
4596
  buildCredentialsResetScript,
4597
+ assertVerificationGrant,
2753
4598
  assertSupportedSourceCompose,
2754
4599
  assertSupportedSource,
2755
4600
  assertSupportedArchitecture,
2756
4601
  assertRemovableArtifactName,
2757
4602
  assertPurgeBackupFreshness,
4603
+ assertImageReleaseLabels,
2758
4604
  assertEnoughFreeSpace,
4605
+ assertCommandPositionals,
2759
4606
  assertCommandFlags,
2760
4607
  assertBackupMatchesDeployment,
2761
4608
  PACKAGE_VERSION,