@or3/cloud 0.1.38 → 0.1.54

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.38";
32
+ var PACKAGE_ROOT = resolve(fileURLToPath(new URL("../", import.meta.url)));
33
+ var PACKAGE_VERSION = "0.1.54";
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) {
@@ -795,12 +1513,21 @@ async function readPassword(flags) {
795
1513
  return password;
796
1514
  }
797
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
+ }
798
1524
  return ["compose.yaml", ...mode === "public" ? ["compose.public.yaml", "Caddyfile"] : []];
799
1525
  }
800
1526
  async function installManagedAssets(directory, assets) {
801
1527
  await mkdir(directory, { recursive: true, mode: 448 });
802
1528
  const token = randomBytes(4).toString("hex");
803
1529
  const staged = [];
1530
+ let retainRollbackCopies = false;
804
1531
  try {
805
1532
  for (const [name, contents] of assets) {
806
1533
  const destination = join(directory, name);
@@ -821,16 +1548,29 @@ async function installManagedAssets(directory, assets) {
821
1548
  const applied = [];
822
1549
  try {
823
1550
  for (const entry of staged) {
824
- await rename(entry.replacement, entry.destination);
825
1551
  applied.push(entry);
826
- await chmod(entry.destination, 420);
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
+ }
827
1558
  }
828
1559
  } catch (error) {
1560
+ const rollbackErrors = [];
829
1561
  for (const entry of applied.reverse()) {
830
- if (entry.rollback)
831
- await rename(entry.rollback, entry.destination);
832
- else
833
- await rm(entry.destination, { force: true });
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.");
834
1574
  }
835
1575
  throw error;
836
1576
  }
@@ -839,7 +1579,7 @@ async function installManagedAssets(directory, assets) {
839
1579
  await rm(entry.replacement, { force: true }).catch(() => {
840
1580
  return;
841
1581
  });
842
- if (entry.rollback)
1582
+ if (entry.rollback && !retainRollbackCopies)
843
1583
  await rm(entry.rollback, { force: true }).catch(() => {
844
1584
  return;
845
1585
  });
@@ -859,6 +1599,8 @@ async function snapshotManagedAssets(directory, mode, backupDir) {
859
1599
  await chmod(assetDir, 448);
860
1600
  const checksums = {};
861
1601
  for (const name of managedAssetNames(mode)) {
1602
+ if ((name === "dashboard-operator.mjs" || name === "compose.operator.yaml") && !await fileExists(join(directory, name)))
1603
+ continue;
862
1604
  const destination = join(assetDir, name);
863
1605
  await copySecure(join(directory, name), destination);
864
1606
  checksums[name] = await sha256File(destination);
@@ -869,9 +1611,9 @@ async function verifiedManagedAssetContents(backupPath, manifest) {
869
1611
  const checksums = manifest.managedAssetSha256;
870
1612
  if (!checksums)
871
1613
  return;
872
- const expected = managedAssetNames(manifest.mode).sort();
1614
+ const expected = managedAssetNamesForInventory(manifest.mode, manifest.managedAssetInventoryVersion).sort();
873
1615
  const actual = Object.keys(checksums).sort();
874
- if (actual.length !== expected.length || actual.some((name, index) => name !== expected[index])) {
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])) {
875
1617
  throw new Error(`Backup ${manifest.backupId} has an invalid managed asset inventory.`);
876
1618
  }
877
1619
  const assets = new Map;
@@ -891,11 +1633,23 @@ async function verifiedManagedAssetContents(backupPath, manifest) {
891
1633
  }
892
1634
  async function restoreManagedAssets(directory, backupPath, manifest) {
893
1635
  const assets = await verifiedManagedAssetContents(backupPath, manifest);
894
- if (!assets)
895
- return false;
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
+ }
896
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
+ }
897
1646
  return true;
898
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.`);
1651
+ }
1652
+ }
899
1653
  async function checkPublicPrerequisites(domain) {
900
1654
  validateDomain(domain);
901
1655
  let address;
@@ -907,6 +1661,10 @@ async function checkPublicPrerequisites(domain) {
907
1661
  return address;
908
1662
  }
909
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;
910
1668
  state.incompleteOperation = operation;
911
1669
  await writeState(directory, state);
912
1670
  await writeSecure(join(deploymentPaths(directory).operations, `${operation.id}.json`), `${JSON.stringify(operation, null, 2)}
@@ -936,10 +1694,29 @@ async function clearPending(directory, state) {
936
1694
  async function stopProject(directory, mode) {
937
1695
  await compose(directory, mode, ["stop", "or3"]);
938
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
+ }
939
1714
  async function startProject(directory, mode, env) {
940
1715
  await assertSafeComposeBinding(directory, mode, env);
941
- 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));
942
1718
  await waitForDeepHealth(directory, mode, secretValues(env));
1719
+ await assertRunningAppImage(directory, mode, env.OR3_IMAGE);
943
1720
  }
944
1721
  function backupDirectory(directory, backupId) {
945
1722
  return join(deploymentPaths(directory).backups, backupId);
@@ -954,7 +1731,22 @@ async function dataVolumeSize(directory, mode, env) {
954
1731
  const exec = await run("docker", [...composeArgs(directory, mode, ["exec", "-T", "or3", "sh", "-c", "du -sb /data 2>/dev/null"])], directory);
955
1732
  if (exec.ok)
956
1733
  return parseDuOutput(exec.stdout);
957
- 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);
958
1750
  if (fallback.ok)
959
1751
  return parseDuOutput(fallback.stdout);
960
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}`)}`);
@@ -974,37 +1766,41 @@ async function removeNamedBackupArtifact(directory, backupId) {
974
1766
  await rm(target, { recursive: true, force: true });
975
1767
  await rm(join(deploymentPaths(directory).exports, `${backupId}.json`), { force: true });
976
1768
  }
977
- async function readBackupManifestMetadata(backupPath) {
978
- try {
979
- const manifest = JSON.parse(await readText(join(backupPath, "manifest.json")));
980
- if (manifest.schemaVersion !== 1 || !manifest.backupId || !manifest.createdAt)
981
- return null;
982
- return manifest;
983
- } catch {
984
- 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}.`);
985
1774
  }
1775
+ await rm(target, { recursive: true, force: true });
1776
+ await rm(join(deploymentPaths(directory).exports, `${backup.backupId}.json`), { force: true });
986
1777
  }
987
1778
  async function enumerateBackups(directory) {
988
1779
  const backupsRoot = deploymentPaths(directory).backups;
989
1780
  let entries = [];
990
1781
  try {
991
1782
  entries = await readdir(backupsRoot);
992
- } catch {
993
- 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)}`);
994
1787
  }
995
1788
  const result = [];
996
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
+ }
997
1793
  const path = join(backupsRoot, entry);
998
- const manifest = await readBackupManifestMetadata(path);
999
- if (!manifest)
1000
- 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
+ }
1001
1801
  let bytes = 0;
1002
- try {
1003
- for (const file of ["data.tgz", "config.env", "manifest.json"]) {
1004
- bytes += (await stat(join(path, file))).size;
1005
- }
1006
- } catch {
1007
- bytes = 0;
1802
+ for (const file of ["data.tgz", "config.env", "manifest.json", "manifest.auth"]) {
1803
+ bytes += (await stat(join(path, file))).size;
1008
1804
  }
1009
1805
  result.push({
1010
1806
  backupId: manifest.backupId,
@@ -1044,8 +1840,11 @@ async function pruneBackups(directory, state, keep, force) {
1044
1840
  console.log(`--force --yes will permanently delete: ${targets.join(", ")}`);
1045
1841
  }
1046
1842
  for (const backupId of targets) {
1047
- await removeNamedBackupArtifact(directory, backupId);
1048
- 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}`);
1049
1848
  }
1050
1849
  return targets.length;
1051
1850
  }
@@ -1065,9 +1864,31 @@ function assertDeploymentIdentity(state, env) {
1065
1864
  throw new Error(`Managed state does not match ${key} in .env. Refusing to operate on an unexpected deployment identity.`);
1066
1865
  }
1067
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
+ }
1068
1886
  }
1069
1887
  function assertDeploymentDirectoryIdentity(directory, state) {
1070
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
+ }
1071
1892
  const names = composeProjectNames(resolved);
1072
1893
  const mismatches = [];
1073
1894
  if (state.composeProject !== names.project) {
@@ -1121,6 +1942,12 @@ function assertBackupMatchesDeployment(manifest, backupEnv, state, env) {
1121
1942
  if (manifest.caddyConfigVolume !== undefined && manifest.caddyConfigVolume !== state.caddyConfigVolume) {
1122
1943
  throw new Error(`Backup ${manifest.backupId} belongs to a different Caddy config volume.`);
1123
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
+ }
1124
1951
  if (manifest.port !== undefined && manifest.port !== state.port) {
1125
1952
  throw new Error(`Backup ${manifest.backupId} belongs to port ${manifest.port}, not ${state.port}.`);
1126
1953
  }
@@ -1135,14 +1962,35 @@ async function sha256File(path) {
1135
1962
  });
1136
1963
  return digest.digest("hex");
1137
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
+ }
1138
1976
  async function streamCommandToFile(command, args, destination, cwd, secrets = []) {
1139
1977
  if (await fileExists(destination)) {
1140
1978
  throw new Error(`Refusing to overwrite existing archive ${destination}.`);
1141
1979
  }
1142
1980
  await mkdir(dirname(destination), { recursive: true, mode: 448 });
1143
1981
  const temporary = `${destination}.${randomBytes(8).toString("hex")}.partial`;
1144
- 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
+ });
1145
1988
  let stderr = "";
1989
+ let timedOut = false;
1990
+ const timeout = setTimeout(() => {
1991
+ timedOut = true;
1992
+ terminateChildProcess(child);
1993
+ }, STREAM_COMMAND_TIMEOUT_MS);
1146
1994
  child.stderr.setEncoding("utf8");
1147
1995
  child.stderr.on("data", (chunk) => {
1148
1996
  stderr = `${stderr}${chunk}`.slice(-64 * 1024);
@@ -1156,25 +2004,38 @@ async function streamCommandToFile(command, args, destination, cwd, secrets = []
1156
2004
  exit,
1157
2005
  pipeline(child.stdout, createWriteStream(temporary, { flags: "wx", mode: 384 }))
1158
2006
  ]);
2007
+ if (timedOut)
2008
+ throw new Error(`${command} exceeded the ${STREAM_COMMAND_TIMEOUT_MS / 1000}-second archive deadline.`);
1159
2009
  if (exitCode !== 0) {
1160
2010
  throw new Error(`${command} ${args.join(" ")} exited with ${exitCode}. ${redact(stderr, secrets)}`.trim());
1161
2011
  }
1162
2012
  await chmod(temporary, 384);
1163
- await rename(temporary, destination);
2013
+ await durableRename(temporary, destination);
1164
2014
  } catch (error) {
1165
- if (!child.killed)
1166
- child.kill("SIGTERM");
2015
+ terminateChildProcess(child);
1167
2016
  await rm(temporary, { force: true }).catch(() => {
1168
2017
  return;
1169
2018
  });
1170
2019
  const detail = error instanceof Error ? error.message : String(error);
1171
2020
  throw new Error(`${detail}${stderr && !detail.includes(stderr) ? `
1172
2021
  ${redact(stderr, secrets)}` : ""}`);
2022
+ } finally {
2023
+ clearTimeout(timeout);
1173
2024
  }
1174
2025
  }
1175
2026
  async function streamFileToCommand(command, args, source, cwd, secrets = []) {
1176
- 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
+ });
1177
2033
  let stderr = "";
2034
+ let timedOut = false;
2035
+ const timeout = setTimeout(() => {
2036
+ timedOut = true;
2037
+ terminateChildProcess(child);
2038
+ }, STREAM_COMMAND_TIMEOUT_MS);
1178
2039
  child.stderr.setEncoding("utf8");
1179
2040
  child.stderr.on("data", (chunk) => {
1180
2041
  stderr = `${stderr}${chunk}`.slice(-64 * 1024);
@@ -1188,15 +2049,18 @@ async function streamFileToCommand(command, args, source, cwd, secrets = []) {
1188
2049
  exit,
1189
2050
  pipeline(createReadStream(source), child.stdin)
1190
2051
  ]);
2052
+ if (timedOut)
2053
+ throw new Error(`${command} exceeded the ${STREAM_COMMAND_TIMEOUT_MS / 1000}-second archive deadline.`);
1191
2054
  if (exitCode !== 0) {
1192
2055
  throw new Error(`${command} ${args.join(" ")} exited with ${exitCode}. ${redact(stderr, secrets)}`.trim());
1193
2056
  }
1194
2057
  } catch (error) {
1195
- if (!child.killed)
1196
- child.kill("SIGTERM");
2058
+ terminateChildProcess(child);
1197
2059
  const detail = error instanceof Error ? error.message : String(error);
1198
2060
  throw new Error(`${detail}${stderr && !detail.includes(stderr) ? `
1199
2061
  ${redact(stderr, secrets)}` : ""}`);
2062
+ } finally {
2063
+ clearTimeout(timeout);
1200
2064
  }
1201
2065
  }
1202
2066
  async function gzipUncompressedBytes(path) {
@@ -1214,38 +2078,121 @@ async function volumeArchive(directory, mode, env, backupDir) {
1214
2078
  await streamCommandToFile("docker", composeArgs(directory, mode, [
1215
2079
  "run",
1216
2080
  "--rm",
1217
- "--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`,
1218
2157
  "--entrypoint",
1219
2158
  "sh",
1220
- "or3",
2159
+ env.OR3_IMAGE,
1221
2160
  "-c",
1222
- "tar czf - -C /data ."
1223
- ]), join(backupDir, "data.tgz"), directory, secretValues(env));
1224
- }
1225
- async function archiveExternalVolume(image, volume, backupDir) {
1226
- 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));
1227
2163
  }
1228
- async function restoreVolumeArchive(directory, mode, env, backupPath) {
1229
- await streamFileToCommand("docker", composeArgs(directory, mode, [
2164
+ async function validateVolumeArchive(directory, _mode, env, backupPath) {
2165
+ await streamFileToCommand("docker", [
1230
2166
  "run",
1231
2167
  "--rm",
1232
- "-T",
1233
- "--no-deps",
2168
+ "-i",
2169
+ "--network",
2170
+ "none",
2171
+ "--read-only",
2172
+ "--security-opt",
2173
+ "no-new-privileges:true",
2174
+ "--cap-drop",
2175
+ "ALL",
1234
2176
  "--entrypoint",
1235
2177
  "sh",
1236
- "or3",
2178
+ env.OR3_IMAGE,
1237
2179
  "-c",
1238
- "find /data -mindepth 1 -delete && tar xzf - -C /data"
1239
- ]), join(backupPath, "data.tgz"), directory, secretValues(env));
2180
+ "tar tzf - >/dev/null"
2181
+ ], join(backupPath, "data.tgz"), directory, secretValues(env));
1240
2182
  }
1241
- async function createBackup(directory, state, env) {
2183
+ async function createBackup(directory, state, env, options = {}) {
2184
+ const restartAfter = options.restartAfter ?? true;
1242
2185
  await requireImageDigest(state.image, state.imageDigest, "Current deployment");
1243
- 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.`);
1244
2189
  const backupDir = backupDirectory(directory, backupId);
2190
+ const initiallyRunning = options.initiallyRunning ?? await projectServiceRunning(directory, state.mode);
1245
2191
  const volumeSize = await dataVolumeSize(directory, state.mode, env);
1246
2192
  await assertFreeSpaceForArchive(backupDir, volumeSize, "Backup");
1247
2193
  let manifestWritten = false;
1248
2194
  let stopAttempted = false;
2195
+ let backupFailure;
1249
2196
  try {
1250
2197
  await mkdir(backupDir, { recursive: true, mode: 448 });
1251
2198
  await chmod(backupDir, 448);
@@ -1265,44 +2212,53 @@ async function createBackup(directory, state, env) {
1265
2212
  dataBytes: volumeSize,
1266
2213
  configSha256: await sha256File(join(backupDir, "config.env")),
1267
2214
  managedAssetSha256,
2215
+ managedAssetInventoryVersion: managedAssetSha256["compose.operator.yaml"] ? MANAGED_ASSET_INVENTORY_VERSION : managedAssetSha256["dashboard-operator.mjs"] ? 2 : undefined,
1268
2216
  mode: state.mode,
1269
2217
  domain: state.domain,
1270
2218
  composeProject: state.composeProject,
1271
2219
  volumeName: state.volumeName,
1272
2220
  caddyDataVolume: state.caddyDataVolume,
1273
2221
  caddyConfigVolume: state.caddyConfigVolume,
2222
+ deploymentId: state.deploymentId,
1274
2223
  port: state.port
1275
2224
  };
1276
- await writeSecure(join(backupDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
1277
- `);
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);
1278
2230
  manifestWritten = true;
1279
- try {
1280
- await pruneBackups(directory, state, BACKUP_RETENTION_KEEP, false);
1281
- } catch (error) {
1282
- console.log(`Warning: automatic backup retention could not run: ${error instanceof Error ? error.message : String(error)}`);
1283
- }
1284
- return { backupId, backupDir, manifest };
2231
+ return { backupId, backupDir, manifest: verifiedManifest };
1285
2232
  } catch (error) {
1286
2233
  if (!manifestWritten) {
1287
2234
  await removeNamedBackupArtifact(directory, backupId).catch(() => {
1288
2235
  return;
1289
2236
  });
1290
- 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;
1291
2239
  }
2240
+ backupFailure = error instanceof Error ? error : new Error(String(error));
1292
2241
  throw error;
1293
2242
  } finally {
1294
- if (stopAttempted) {
2243
+ if (stopAttempted && restartAfter && initiallyRunning) {
1295
2244
  try {
1296
2245
  await startProject(directory, state.mode, env);
1297
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
+ }
1298
2251
  throw new Error(`Backup was created but OR3 could not restart: ${error instanceof Error ? error.message : String(error)}`);
1299
2252
  }
1300
2253
  }
1301
2254
  }
1302
2255
  }
1303
- async function readManifest(backupPath) {
1304
- const manifest = JSON.parse(await readText(join(backupPath, "manifest.json")));
1305
- 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)) {
1306
2262
  throw new Error(`Invalid backup manifest at ${backupPath}.`);
1307
2263
  }
1308
2264
  const actual = await sha256File(join(backupPath, "data.tgz"));
@@ -1316,6 +2272,18 @@ async function readManifest(backupPath) {
1316
2272
  await verifiedManagedAssetContents(backupPath, manifest);
1317
2273
  return manifest;
1318
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
+ }
1319
2287
  async function dataVolumeFreeBytes(directory, mode, env) {
1320
2288
  const parse = (stdout) => {
1321
2289
  const blocks = Number(stdout.trim());
@@ -1327,7 +2295,22 @@ async function dataVolumeFreeBytes(directory, mode, env) {
1327
2295
  const exec = await run("docker", [...composeArgs(directory, mode, ["exec", "-T", "or3", "sh", "-c", script])], directory);
1328
2296
  if (exec.ok)
1329
2297
  return parse(exec.stdout);
1330
- 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);
1331
2314
  if (fallback.ok)
1332
2315
  return parse(fallback.stdout);
1333
2316
  throw new Error(`Could not measure free space in the Docker data volume. ${redact(`${exec.stderr} ${fallback.stderr}`, secretValues(env))}`);
@@ -1336,10 +2319,12 @@ async function assertRestoreFreeSpace(directory, state, env, manifest, backupPat
1336
2319
  const archiveBytes = await gzipUncompressedBytes(join(backupPath, "data.tgz"));
1337
2320
  const requiredBytes = Math.max(manifest.dataBytes ?? 0, archiveBytes);
1338
2321
  const freeBytes = await dataVolumeFreeBytes(directory, state.mode, env);
1339
- assertEnoughFreeSpace(freeBytes, requiredBytes, "Restore");
2322
+ const reclaimableBytes = await dataVolumeSize(directory, state.mode, env);
2323
+ assertEnoughFreeSpace(freeBytes + reclaimableBytes, requiredBytes, "Restore");
1340
2324
  }
1341
- async function restoreBackupData(directory, state, env, backupPath) {
1342
- const manifest = await readManifest(backupPath);
2325
+ async function restoreBackupData(directory, state, env, backupPath, options = {}) {
2326
+ const manifest = await readManifest(backupPath, directory);
2327
+ assertRestorableManagedAssets(manifest);
1343
2328
  const backupEnv = parseEnv(await readText(join(backupPath, "config.env")));
1344
2329
  if (backupEnv.OR3_VERSION !== manifest.appVersion || backupEnv.OR3_IMAGE !== manifest.image) {
1345
2330
  throw new Error(`Backup ${manifest.backupId} configuration does not match its manifest.`);
@@ -1347,30 +2332,28 @@ async function restoreBackupData(directory, state, env, backupPath) {
1347
2332
  assertBackupMatchesDeployment(manifest, backupEnv, state, env);
1348
2333
  if (manifest.imageDigest)
1349
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);
1350
2337
  await assertRestoreFreeSpace(directory, state, env, manifest, backupPath);
1351
- let started = false;
1352
- let stopAttempted = false;
1353
- try {
1354
- 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
1355
2349
  await stopProject(directory, state.mode);
1356
- await copySecure(join(backupPath, "config.env"), deploymentPaths(directory).env);
1357
- await restoreManagedAssets(directory, backupPath, manifest);
1358
- await restoreVolumeArchive(directory, state.mode, backupEnv, backupPath);
1359
- const restoredEnv = parseEnv(await readText(deploymentPaths(directory).env));
1360
- await startProject(directory, state.mode, restoredEnv);
1361
- started = true;
1362
- } catch (error) {
1363
- let restartError = "";
1364
- if (stopAttempted && !started) {
1365
- try {
1366
- const recoveryEnv = parseEnv(await readText(deploymentPaths(directory).env));
1367
- await startProject(directory, state.mode, recoveryEnv);
1368
- } catch (recovery) {
1369
- restartError = ` OR3 could not be restarted: ${recovery instanceof Error ? recovery.message : String(recovery)}`;
1370
- }
1371
- }
1372
- 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);
1373
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);
1374
2357
  return manifest;
1375
2358
  }
1376
2359
  function stateFromEnv(directory, env, mode, operation, digest) {
@@ -1381,6 +2364,8 @@ function stateFromEnv(directory, env, mode, operation, digest) {
1381
2364
  volumeName: env.OR3_VOLUME_NAME,
1382
2365
  caddyDataVolume: mode === "public" ? env.OR3_CADDY_DATA_VOLUME : undefined,
1383
2366
  caddyConfigVolume: mode === "public" ? env.OR3_CADDY_CONFIG_VOLUME : undefined,
2367
+ deploymentId: env.OR3_DEPLOYMENT_ID,
2368
+ deploymentRoot: resolve(directory),
1384
2369
  appVersion: env.OR3_VERSION,
1385
2370
  image: env.OR3_IMAGE,
1386
2371
  imageDigest: digest,
@@ -1400,8 +2385,9 @@ Usage:
1400
2385
  npx @or3/cloud backup [list|prune [--keep <n>]|export <backup-id> <destination-dir>]
1401
2386
  npx @or3/cloud restore <backup-id-or-path> --yes
1402
2387
  npx @or3/cloud rollback --yes
1403
- 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>]
1404
2389
  npx @or3/cloud doctor
2390
+ npx @or3/cloud verify [--public] [--verification-email <email> --verification-password-file <path>]
1405
2391
  npx @or3/cloud recover
1406
2392
  npx @or3/cloud adopt --from <v1-directory> [directory]
1407
2393
  npx @or3/cloud status
@@ -1412,12 +2398,16 @@ Usage:
1412
2398
  Options:
1413
2399
  --admin-email <email> Administrator email for first login
1414
2400
  --admin-password <password> Explicit password (prefer --admin-password-file); for credentials reset, the new admin password
1415
- --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
1416
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
1417
2406
  --port <port> Local OR3 port (default: 3000)
1418
2407
  --keep <n> Backups to retain when pruning (default: 5)
1419
2408
  --force Prune backups even when referenced by the rollback point
1420
2409
  --tail <n> Log lines to show (default: 200)
2410
+ --public Require verification through the public HTTPS origin
1421
2411
  --purge-data Remove data volumes and managed files (with remove)
1422
2412
  --yes Confirm a destructive restore, rollback, credentials reset, or purge
1423
2413
  --help Show this help
@@ -1447,9 +2437,10 @@ async function initCommand(positionals, flags) {
1447
2437
  throw new Error("--port must be a valid TCP port.");
1448
2438
  if (mode === "public" && [80, 443].includes(port))
1449
2439
  throw new Error("Public mode reserves ports 80 and 443 for Caddy; choose another --port for OR3.");
1450
- if (!await portAvailable(port))
2440
+ const localDockerDaemon = await dockerDaemonIsLocal();
2441
+ if (localDockerDaemon && !await portAvailable(port))
1451
2442
  throw new Error(`Port ${port} is already in use. Choose another port with --port.`);
1452
- if (mode === "public") {
2443
+ if (mode === "public" && localDockerDaemon) {
1453
2444
  for (const publicPort of [80, 443]) {
1454
2445
  if (!await portAvailable(publicPort))
1455
2446
  throw new Error(`Public port ${publicPort} is already in use. Stop the conflicting service before starting Caddy.`);
@@ -1457,23 +2448,31 @@ async function initCommand(positionals, flags) {
1457
2448
  }
1458
2449
  await ensureDocker();
1459
2450
  const names = composeProjectNames(directory);
2451
+ await assertDockerProjectAbsent(names.project);
1460
2452
  for (const volume of [names.volume, ...mode === "public" ? [names.caddyData, names.caddyConfig] : []]) {
1461
2453
  const existing = await run("docker", ["volume", "inspect", volume]);
1462
2454
  if (existing.ok)
1463
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
+ }
1464
2460
  }
1465
2461
  const email = await resolveAdminEmail(flags);
1466
2462
  const password = await readPassword(flags);
1467
2463
  const version = PACKAGE_VERSION;
1468
- const image = imageFor(version);
1469
- await pullImage(image);
1470
- 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);
1471
2469
  await mkdir(directory, { recursive: true, mode: 448 });
1472
2470
  await chmod(directory, 448);
1473
2471
  await mkdir(deploymentPaths(directory).operations, { recursive: true, mode: 448 });
1474
2472
  await mkdir(deploymentPaths(directory).backups, { recursive: true, mode: 448 });
2473
+ const operator = await prepareVerifiedDashboardOperator(directory, version);
1475
2474
  await copyAssets(directory, mode);
1476
- const env = buildEnv({ mode, version, directory, email, password, domain, port });
2475
+ const env = buildEnv({ mode, version, directory, image, email, password, domain, port, dashboardOperator: operator });
1477
2476
  await writeSecure(deploymentPaths(directory).env, serializeEnv(env));
1478
2477
  await writeSecure(join(directory, ".or3-initial-credentials"), serializeInitialCredentials({
1479
2478
  bootstrapEmail: email,
@@ -1481,7 +2480,6 @@ async function initCommand(positionals, flags) {
1481
2480
  adminUsername: email,
1482
2481
  adminPassword: password
1483
2482
  }));
1484
- const digest = await imageDigest(image);
1485
2483
  const state = stateFromEnv(directory, env, mode, "init", digest);
1486
2484
  await markPending(directory, state, {
1487
2485
  id: id("init"),
@@ -1491,12 +2489,19 @@ async function initCommand(positionals, flags) {
1491
2489
  });
1492
2490
  try {
1493
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);
1494
2497
  await clearPending(directory, state);
1495
2498
  console.log(`
1496
2499
  OR3 Cloud ${version} is running at ${mode === "public" ? `https://${domain}` : `http://127.0.0.1:${port}`}`);
1497
2500
  console.log(`Credentials were written to ${join(directory, ".or3-initial-credentials")} (mode 0600). Save them, then remove that file.`);
1498
2501
  console.log(`
1499
2502
  Check: cd ${quote(directory)} && npx @or3/cloud doctor`);
2503
+ if (operator)
2504
+ console.log("Dashboard updates are enabled for super admins in Operations.");
1500
2505
  } catch (error) {
1501
2506
  state.lastError = redact(error instanceof Error ? error.message : String(error), secretValues(env));
1502
2507
  await writeState(directory, state);
@@ -1512,6 +2517,7 @@ async function loadManaged(directory = process.cwd()) {
1512
2517
  const state = await readState(resolved);
1513
2518
  const env = parseEnv(await readText(deploymentPaths(resolved).env));
1514
2519
  assertDeploymentIdentity(state, env);
2520
+ assertDeploymentDirectoryIdentity(resolved, state);
1515
2521
  return { directory: resolved, state, env };
1516
2522
  }
1517
2523
  function operationToStateOperation(operation, fallback) {
@@ -1523,6 +2529,14 @@ async function commitRecoveredState(directory, previous, next) {
1523
2529
  await removeOperationRecord(directory, previous.incompleteOperation?.id);
1524
2530
  await writeState(directory, next);
1525
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
+ }
1526
2540
  async function recoverCommand(directory) {
1527
2541
  await ensureDocker();
1528
2542
  const loaded = await loadManaged(directory);
@@ -1532,6 +2546,14 @@ async function recoverCommand(directory) {
1532
2546
  return;
1533
2547
  }
1534
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
+ }
1535
2557
  if (pending.operation === "credentials-reset") {
1536
2558
  const nextEnv = pending.credentialReset?.nextEnv;
1537
2559
  if (!nextEnv)
@@ -1543,95 +2565,69 @@ async function recoverCommand(directory) {
1543
2565
  return;
1544
2566
  }
1545
2567
  if (pending.operation === "restore" || pending.operation === "rollback") {
1546
- if (!pending.backupId)
1547
- throw new Error(`The incomplete ${pending.operation} has no recorded backup ID.`);
1548
- const backupPath = await resolveBackup(loaded.directory, pending.backupId);
1549
- const manifest = await readManifest(backupPath);
1550
- const backupEnv = parseEnv(await readText(join(backupPath, "config.env")));
1551
- if (backupEnv.OR3_VERSION !== manifest.appVersion || backupEnv.OR3_IMAGE !== manifest.image) {
1552
- 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;
1553
2574
  }
1554
- if (pending.targetVersion && pending.targetVersion !== manifest.appVersion || pending.targetImage && pending.targetImage !== manifest.image || pending.targetImageDigest && pending.targetImageDigest !== manifest.imageDigest) {
1555
- 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.`);
1556
2577
  }
1557
- assertBackupMatchesDeployment(manifest, backupEnv, loaded.state, loaded.env);
1558
- await pullAndRequireImage(manifest.image, manifest.imageDigest, `Backup ${manifest.backupId}`);
1559
- await restoreBackupData(loaded.directory, loaded.state, loaded.env, backupPath);
1560
- const restoredEnv = parseEnv(await readText(deploymentPaths(loaded.directory).env));
1561
- const restored = stateFromEnv(loaded.directory, restoredEnv, loaded.state.mode, "restore", await imageDigest(restoredEnv.OR3_IMAGE));
1562
- restored.lastError = undefined;
1563
- await commitRecoveredState(loaded.directory, loaded.state, restored);
1564
- 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.`);
1565
2581
  return;
1566
2582
  }
1567
2583
  if (pending.operation === "update") {
1568
- const oldDeployment = loaded.env.OR3_VERSION === loaded.state.appVersion && loaded.env.OR3_IMAGE === loaded.state.image;
1569
- if (oldDeployment) {
2584
+ if (pending.phase === "prepared" || pending.phase === "snapshot-created") {
1570
2585
  await pullAndRequireImage(loaded.state.image, loaded.state.imageDigest, "Current deployment");
1571
2586
  await startProject(loaded.directory, loaded.state.mode, loaded.env);
1572
- const recovered2 = stateFromEnv(loaded.directory, loaded.env, loaded.state.mode, loaded.state.lastSuccessfulOperation, await imageDigest(loaded.env.OR3_IMAGE));
1573
- recovered2.rollback = loaded.state.rollback;
1574
- recovered2.lastError = undefined;
1575
- await commitRecoveredState(loaded.directory, loaded.state, recovered2);
1576
- 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.`);
1577
2592
  return;
1578
2593
  }
1579
- if (!pending.backupId || loaded.env.OR3_VERSION !== pending.targetVersion || loaded.env.OR3_IMAGE !== pending.targetImage) {
1580
- throw new Error("The incomplete update has an unknown .env version/image. Refusing to guess which image or data should be live.");
1581
- }
1582
- const backupPath = await resolveBackup(loaded.directory, pending.backupId);
1583
- const manifest = await readManifest(backupPath);
1584
- const backupEnv = parseEnv(await readText(join(backupPath, "config.env")));
1585
- if (backupEnv.OR3_VERSION !== manifest.appVersion || backupEnv.OR3_IMAGE !== manifest.image) {
1586
- throw new Error(`Backup ${manifest.backupId} configuration does not match its manifest.`);
1587
- }
1588
- assertBackupMatchesDeployment(manifest, backupEnv, loaded.state, loaded.env);
1589
- if (pending.targetVersion !== PACKAGE_VERSION) {
1590
- throw new Error(`This interrupted update targets OR3 ${pending.targetVersion}. Run \`npx --yes @or3/cloud@${pending.targetVersion} recover\` so the image and generated assets match.`);
1591
- }
1592
- try {
1593
- if (pending.targetImageDigest)
1594
- await pullAndRequireImage(pending.targetImage, pending.targetImageDigest, `OR3 ${pending.targetVersion}`);
1595
- await copyAssets(loaded.directory, loaded.state.mode);
1596
- await startProject(loaded.directory, loaded.state.mode, loaded.env);
1597
- } catch (replacementError) {
1598
- await restoreBackupData(loaded.directory, loaded.state, loaded.env, backupPath);
1599
- const restoredEnv = parseEnv(await readText(deploymentPaths(loaded.directory).env));
1600
- const restored = stateFromEnv(loaded.directory, restoredEnv, loaded.state.mode, loaded.state.lastSuccessfulOperation, await imageDigest(restoredEnv.OR3_IMAGE));
1601
- restored.rollback = loaded.state.rollback;
1602
- restored.lastError = `Interrupted update was restored: ${redact(replacementError instanceof Error ? replacementError.message : String(replacementError), secretValues(restoredEnv))}`;
1603
- await commitRecoveredState(loaded.directory, loaded.state, restored);
1604
- console.log(`Recovered the incomplete update by restoring backup ${manifest.backupId}. OR3 ${restored.appVersion} is deeply healthy.`);
1605
- return;
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.");
1606
2596
  }
1607
- const updated = stateFromEnv(loaded.directory, loaded.env, loaded.state.mode, "update", await imageDigest(loaded.env.OR3_IMAGE));
1608
- updated.rollback = {
1609
- appVersion: loaded.state.appVersion,
1610
- image: loaded.state.image,
1611
- imageDigest: loaded.state.imageDigest,
1612
- backupId: pending.backupId,
1613
- createdAt: now()
1614
- };
1615
- updated.lastError = undefined;
1616
- await commitRecoveredState(loaded.directory, loaded.state, updated);
1617
- 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.`);
1618
2600
  return;
1619
2601
  }
1620
2602
  if (pending.operation === "adopt") {
1621
2603
  if (!pending.backupId)
1622
2604
  throw new Error("The incomplete adoption has no source backup ID. Refusing to claim that data was transferred.");
1623
2605
  const sourceBackupPath = await resolveBackup(loaded.directory, pending.backupId);
1624
- const sourceManifest = await readManifest(sourceBackupPath);
2606
+ const sourceManifest = await readManifest(sourceBackupPath, loaded.directory);
1625
2607
  if (sourceManifest.mode !== loaded.state.mode || sourceManifest.appVersion !== loaded.state.appVersion || sourceManifest.image !== loaded.state.image || sourceManifest.imageDigest !== loaded.state.imageDigest) {
1626
2608
  throw new Error(`Source backup ${sourceManifest.backupId} does not match the managed adoption target.`);
1627
2609
  }
1628
2610
  await stopProject(loaded.directory, loaded.state.mode);
1629
2611
  await restoreVolumeArchive(loaded.directory, loaded.state.mode, loaded.env, sourceBackupPath);
1630
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
+ }
1631
2619
  await pullAndRequireImage(loaded.state.image, loaded.state.imageDigest, "Current deployment");
1632
2620
  await startProject(loaded.directory, loaded.state.mode, loaded.env);
1633
- const digest = await imageDigest(loaded.env.OR3_IMAGE);
1634
- 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);
1635
2631
  recovered.rollback = loaded.state.rollback;
1636
2632
  recovered.lastError = undefined;
1637
2633
  await commitRecoveredState(loaded.directory, loaded.state, recovered);
@@ -1649,7 +2645,9 @@ async function recoverCommand(directory) {
1649
2645
  const sourceModeValue = sourceMode(pending.sourceDirectory, sourceEnv);
1650
2646
  if (sourceModeValue === "public")
1651
2647
  sourceFiles.push("-f", join(pending.sourceDirectory, "compose.public.yaml"));
1652
- await restartSource(pending.sourceDirectory, sourceFiles, secretValues(sourceEnv));
2648
+ if (pending.sourceInitiallyRunning !== false) {
2649
+ await restartSource(pending.sourceDirectory, sourceFiles, secretValues(sourceEnv));
2650
+ }
1653
2651
  } catch (recovery) {
1654
2652
  loaded.state.lastError += ` Original deployment recovery failed: ${recovery instanceof Error ? recovery.message : String(recovery)}`;
1655
2653
  }
@@ -1667,16 +2665,23 @@ async function backupCreateCommand(directory) {
1667
2665
  await ensureDocker();
1668
2666
  const loaded = await loadManaged(directory);
1669
2667
  assertNoPending(loaded.state);
2668
+ const backupId = id("backup");
2669
+ const initialAppRunning = await projectServiceRunning(loaded.directory, loaded.state.mode);
1670
2670
  const pending = {
1671
- id: id("backup"),
2671
+ id: id("backup-operation"),
1672
2672
  operation: "backup",
1673
2673
  startedAt: now(),
1674
- 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"
1675
2679
  };
1676
2680
  await markPending(loaded.directory, loaded.state, pending);
1677
2681
  try {
1678
- const result = await createBackup(loaded.directory, loaded.state, loaded.env);
2682
+ const result = await createBackup(loaded.directory, loaded.state, loaded.env, { backupId, initiallyRunning: initialAppRunning });
1679
2683
  await clearPending(loaded.directory, loaded.state);
2684
+ await pruneBackups(loaded.directory, loaded.state, BACKUP_RETENTION_KEEP, false);
1680
2685
  console.log(`Backup ${result.backupId} created at ${result.backupDir}`);
1681
2686
  console.log(`SHA-256: ${result.manifest.dataSha256}`);
1682
2687
  } catch (error) {
@@ -1712,23 +2717,31 @@ async function backupPruneCommand(directory, flags) {
1712
2717
  }
1713
2718
  async function backupExportCommand(directory, backupId, destination) {
1714
2719
  const backupPath = await resolveBackup(directory, backupId);
1715
- const manifest = await readManifest(backupPath);
2720
+ const manifest = await readManifest(backupPath, directory);
1716
2721
  const dest = resolve(destination);
1717
2722
  const source = resolve(backupPath);
2723
+ const deploymentRoot = await realpath(directory);
1718
2724
  if (source === dest || dest.startsWith(`${source}${sep}`)) {
1719
2725
  throw new Error("Choose a destination directory different from the backup itself.");
1720
2726
  }
1721
2727
  if (await fileExists(dest))
1722
2728
  throw new Error(`Destination ${dest} already exists. Choose a new empty destination so an export can never merge with unrelated files.`);
1723
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
+ }
1724
2734
  await mkdir(dest, { mode: 448 });
1725
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
+ }
1726
2739
  await chmod(dest, 448);
1727
- for (const file of ["data.tgz", "config.env", "manifest.json"]) {
2740
+ for (const file of ["data.tgz", "config.env", "manifest.json", "manifest.auth"]) {
1728
2741
  await copySecure(join(backupPath, file), join(dest, file));
1729
2742
  }
1730
2743
  if (manifest.managedAssetSha256) {
1731
- for (const name of managedAssetNames(manifest.mode)) {
2744
+ for (const name of managedAssetNamesForInventory(manifest.mode, manifest.managedAssetInventoryVersion)) {
1732
2745
  await copySecure(join(backupPath, "managed-assets", name), join(dest, "managed-assets", name));
1733
2746
  }
1734
2747
  }
@@ -1742,7 +2755,7 @@ async function backupExportCommand(directory, backupId, destination) {
1742
2755
  throw new Error(`Exported config.env checksum mismatch for ${manifest.backupId}.`);
1743
2756
  }
1744
2757
  }
1745
- await readManifest(dest);
2758
+ await readManifest(dest, directory);
1746
2759
  const destinationDevice = (await stat(dest)).dev;
1747
2760
  await mkdir(deploymentPaths(directory).exports, { recursive: true, mode: 448 });
1748
2761
  const receipt = {
@@ -1756,9 +2769,9 @@ async function backupExportCommand(directory, backupId, destination) {
1756
2769
  };
1757
2770
  await writeSecure(join(deploymentPaths(directory).exports, `${manifest.backupId}.json`), `${JSON.stringify(receipt, null, 2)}
1758
2771
  `);
1759
- let 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;
1760
2773
  if (manifest.managedAssetSha256) {
1761
- for (const name of managedAssetNames(manifest.mode)) {
2774
+ for (const name of managedAssetNamesForInventory(manifest.mode, manifest.managedAssetInventoryVersion)) {
1762
2775
  bytes += (await stat(join(dest, "managed-assets", name))).size;
1763
2776
  }
1764
2777
  }
@@ -1813,10 +2826,14 @@ async function updateCommand(directory, flags) {
1813
2826
  }
1814
2827
  if (targetVersion === state.appVersion)
1815
2828
  throw new Error(`The deployment is already on OR3 ${targetVersion}.`);
1816
- const targetImage = imageFor(targetVersion);
1817
- const targetDigest = await pullImage(targetImage);
1818
- 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);
1819
2834
  const oldEnv = { ...env };
2835
+ const targetDeploymentId = env.OR3_DEPLOYMENT_ID ?? state.deploymentId ?? id("deployment");
2836
+ const backupId = id("backup");
1820
2837
  const pending = {
1821
2838
  id: id("update"),
1822
2839
  operation: "update",
@@ -1824,18 +2841,48 @@ async function updateCommand(directory, flags) {
1824
2841
  message: `Updating from ${state.appVersion} to ${targetVersion}`,
1825
2842
  targetVersion,
1826
2843
  targetImage,
1827
- targetImageDigest: targetDigest
2844
+ targetImageDigest: targetDigest,
2845
+ targetDeploymentId,
2846
+ backupId,
2847
+ backupPath: backupDirectory(loaded.directory, backupId),
2848
+ phase: "prepared"
1828
2849
  };
1829
2850
  await markPending(loaded.directory, state, pending);
1830
2851
  try {
1831
- const backup = await createBackup(loaded.directory, state, env);
1832
- await updatePending(loaded.directory, state, { backupId: backup.backupId });
1833
- 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
+ };
1834
2871
  const previousRootOwnership = await managedVolumeRootOwnership(targetImage, state.volumeName);
1835
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
+ });
1836
2879
  try {
1837
- await stopProject(loaded.directory, state.mode);
1838
- 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) {
1839
2886
  await setManagedVolumeRootOwnership(targetImage, state.volumeName, {
1840
2887
  uid: MANAGED_RUNTIME_UID,
1841
2888
  gid: MANAGED_RUNTIME_GID
@@ -1843,31 +2890,26 @@ async function updateCommand(directory, flags) {
1843
2890
  }
1844
2891
  await writeSecure(deploymentPaths(loaded.directory).env, serializeEnv(nextEnv));
1845
2892
  await copyAssets(loaded.directory, state.mode);
1846
- if (migrateLegacyVolume) {
2893
+ if (migrateLegacyVolume || recreateDataVolume) {
1847
2894
  await restoreVolumeArchive(loaded.directory, state.mode, nextEnv, backup.backupDir);
1848
2895
  }
1849
2896
  await startProject(loaded.directory, state.mode, nextEnv);
1850
2897
  } catch (error) {
1851
- await writeSecure(deploymentPaths(loaded.directory).env, serializeEnv(oldEnv));
2898
+ await updatePending(loaded.directory, state, { phase: "restoring-previous" });
1852
2899
  try {
1853
- await stopProject(loaded.directory, state.mode).catch(() => {
1854
- return;
1855
- });
1856
- if (migrateLegacyVolume) {
2900
+ if (!recreateDataVolume)
2901
+ await stopProject(loaded.directory, state.mode).catch(() => {
2902
+ return;
2903
+ });
2904
+ if (migrateLegacyVolume && !recreateDataVolume) {
1857
2905
  await setManagedVolumeRootOwnership(targetImage, state.volumeName, previousRootOwnership);
1858
2906
  }
1859
- await restoreBackupData(loaded.directory, state, oldEnv, backup.backupDir);
2907
+ await restoreBackupData(loaded.directory, state, oldEnv, backup.backupDir, { recreateDataVolume });
1860
2908
  } catch (restoreError) {
1861
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)}`);
1862
2910
  }
1863
- const oldDigest = await imageDigest(state.image);
1864
- state.imageDigest = oldDigest;
1865
- state.lastError = `Update to ${targetVersion} failed and was restored: ${redact(error instanceof Error ? error.message : String(error), secretValues(oldEnv))}`;
1866
- const operationId = pending.id;
1867
- delete state.incompleteOperation;
1868
- await removeOperationRecord(loaded.directory, operationId);
1869
- await writeState(loaded.directory, state);
1870
- 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);
1871
2913
  }
1872
2914
  const digest = await requireImageDigest(targetImage, targetDigest, `OR3 ${targetVersion}`);
1873
2915
  state.rollback = {
@@ -1880,9 +2922,12 @@ async function updateCommand(directory, flags) {
1880
2922
  state.appVersion = targetVersion;
1881
2923
  state.image = targetImage;
1882
2924
  state.imageDigest = digest;
2925
+ state.deploymentId = targetDeploymentId;
2926
+ state.deploymentRoot = resolve(loaded.directory);
1883
2927
  state.lastSuccessfulOperation = "update";
1884
2928
  state.lastError = undefined;
1885
2929
  await clearPending(loaded.directory, state);
2930
+ await pruneBackups(loaded.directory, state, BACKUP_RETENTION_KEEP, false);
1886
2931
  console.log(`OR3 updated to ${targetVersion}. Image digest: ${digest}`);
1887
2932
  console.log(`Rollback point: ${backup.backupId}. Keep it until login, chat, and file checks pass.`);
1888
2933
  } catch (error) {
@@ -1894,11 +2939,52 @@ async function updateCommand(directory, flags) {
1894
2939
  }
1895
2940
  }
1896
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
+ }
1897
2945
  const candidate = isAbsolute(value) ? value : backupDirectory(directory, value);
1898
2946
  if (!await fileExists(join(candidate, "manifest.json")))
1899
2947
  throw new Error(`Backup ${value} was not found in ${deploymentPaths(directory).backups}.`);
1900
2948
  return resolve(candidate);
1901
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
+ }
1902
2988
  async function restoreCommand(directory, flags, positionals) {
1903
2989
  if (!boolFlag(flags, "yes"))
1904
2990
  throw new Error("Restore replaces live data. Re-run with --yes after confirming the backup and data-loss boundary.");
@@ -1909,12 +2995,14 @@ async function restoreCommand(directory, flags, positionals) {
1909
2995
  if (!backupValue)
1910
2996
  throw new Error("restore requires a backup ID or path.");
1911
2997
  const backupPath = await resolveBackup(loaded.directory, backupValue);
1912
- const manifest = await readManifest(backupPath);
2998
+ const manifest = await readManifest(backupPath, loaded.directory);
2999
+ assertRestorableManagedAssets(manifest);
1913
3000
  const backupEnv = parseEnv(await readText(join(backupPath, "config.env")));
1914
3001
  if (backupEnv.OR3_VERSION !== manifest.appVersion || backupEnv.OR3_IMAGE !== manifest.image) {
1915
3002
  throw new Error(`Backup ${manifest.backupId} configuration does not match its manifest.`);
1916
3003
  }
1917
3004
  assertBackupMatchesDeployment(manifest, backupEnv, loaded.state, loaded.env);
3005
+ const recreateDataVolume = restoreRequiresVolumeRecreation(loaded.env, backupEnv);
1918
3006
  if (manifest.imageDigest)
1919
3007
  await pullAndRequireImage(manifest.image, manifest.imageDigest, `Backup ${manifest.backupId}`);
1920
3008
  await assertSupportedHostArchitecture(manifest.image);
@@ -1925,13 +3013,30 @@ async function restoreCommand(directory, flags, positionals) {
1925
3013
  startedAt: now(),
1926
3014
  message: `Restoring backup ${manifest.backupId}`,
1927
3015
  backupId: manifest.backupId,
3016
+ backupPath,
3017
+ backupDataSha256: manifest.dataSha256,
3018
+ backupConfigSha256: manifest.configSha256,
1928
3019
  targetVersion: manifest.appVersion,
1929
3020
  targetImage: manifest.image,
1930
- targetImageDigest: manifest.imageDigest
3021
+ targetImageDigest: manifest.imageDigest,
3022
+ recreateDataVolume,
3023
+ phase: "prepared"
1931
3024
  };
1932
3025
  await markPending(loaded.directory, loaded.state, pending);
1933
3026
  try {
1934
- 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 });
1935
3040
  const restoredEnv = parseEnv(await readText(deploymentPaths(loaded.directory).env));
1936
3041
  const digest = await imageDigest(restoredEnv.OR3_IMAGE);
1937
3042
  const nextState = stateFromEnv(loaded.directory, restoredEnv, loaded.state.mode, "restore", digest);
@@ -1940,8 +3045,33 @@ async function restoreCommand(directory, flags, positionals) {
1940
3045
  await writeState(loaded.directory, nextState);
1941
3046
  console.log(`Restored ${manifest.backupId}. Verify sign-in, a conversation, and a previously uploaded file.`);
1942
3047
  } catch (error) {
1943
- loaded.state.lastError = redact(error instanceof Error ? error.message : String(error), secretValues(loaded.env));
1944
- 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
+ }
1945
3075
  throw error;
1946
3076
  }
1947
3077
  }
@@ -1955,7 +3085,8 @@ async function rollbackCommand(directory, flags) {
1955
3085
  if (!point)
1956
3086
  throw new Error("No immediate rollback point is recorded for this deployment.");
1957
3087
  const backupPath = await resolveBackup(loaded.directory, point.backupId);
1958
- const manifest = await readManifest(backupPath);
3088
+ const manifest = await readManifest(backupPath, loaded.directory);
3089
+ assertRestorableManagedAssets(manifest);
1959
3090
  if (manifest.appVersion !== point.appVersion || manifest.image !== point.image || manifest.imageDigest !== point.imageDigest) {
1960
3091
  throw new Error(`Rollback point ${point.backupId} no longer matches its recorded image/version. Refusing to mutate the deployment.`);
1961
3092
  }
@@ -1964,6 +3095,7 @@ async function rollbackCommand(directory, flags) {
1964
3095
  throw new Error(`Backup ${manifest.backupId} configuration does not match its manifest.`);
1965
3096
  }
1966
3097
  assertBackupMatchesDeployment(manifest, backupEnv, loaded.state, loaded.env);
3098
+ const recreateDataVolume = restoreRequiresVolumeRecreation(loaded.env, backupEnv);
1967
3099
  await pullAndRequireImage(point.image, point.imageDigest, "Rollback");
1968
3100
  await assertSupportedHostArchitecture(point.image);
1969
3101
  await assertRestoreFreeSpace(loaded.directory, loaded.state, loaded.env, manifest, backupPath);
@@ -1973,13 +3105,30 @@ async function rollbackCommand(directory, flags) {
1973
3105
  startedAt: now(),
1974
3106
  message: `Rolling back to ${point.appVersion}`,
1975
3107
  backupId: point.backupId,
3108
+ backupPath,
3109
+ backupDataSha256: manifest.dataSha256,
3110
+ backupConfigSha256: manifest.configSha256,
1976
3111
  targetVersion: point.appVersion,
1977
3112
  targetImage: point.image,
1978
- targetImageDigest: point.imageDigest
3113
+ targetImageDigest: point.imageDigest,
3114
+ recreateDataVolume,
3115
+ phase: "prepared"
1979
3116
  };
1980
3117
  await markPending(loaded.directory, loaded.state, pending);
1981
3118
  try {
1982
- 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 });
1983
3132
  const restoredEnv = parseEnv(await readText(deploymentPaths(loaded.directory).env));
1984
3133
  const nextState = stateFromEnv(loaded.directory, restoredEnv, loaded.state.mode, "restore", await imageDigest(restoredEnv.OR3_IMAGE));
1985
3134
  nextState.rollback = undefined;
@@ -1987,8 +3136,33 @@ async function rollbackCommand(directory, flags) {
1987
3136
  await writeState(loaded.directory, nextState);
1988
3137
  console.log(`Rolled back to OR3 ${nextState.appVersion}. Verify sign-in, chat, and file access.`);
1989
3138
  } catch (error) {
1990
- loaded.state.lastError = redact(error instanceof Error ? error.message : String(error), secretValues(loaded.env));
1991
- 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
+ }
1992
3166
  throw error;
1993
3167
  }
1994
3168
  }
@@ -2016,7 +3190,7 @@ async function doctorCommand(directory) {
2016
3190
  failures.push(error instanceof Error ? error.message : String(error));
2017
3191
  console.log("✗ Managed state or .env is missing/invalid");
2018
3192
  }
2019
- for (const file of [paths.env, paths.state]) {
3193
+ for (const file of [paths.env, paths.state, join(resolved, ".or3-initial-credentials")]) {
2020
3194
  if (!existsSync(file))
2021
3195
  continue;
2022
3196
  const mode = (await stat(file)).mode & 511;
@@ -2057,6 +3231,53 @@ async function doctorCommand(directory) {
2057
3231
  failures.push(error instanceof Error ? error.message : String(error));
2058
3232
  console.log("✗ Deep health is not passing");
2059
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
+ }
2060
3281
  }
2061
3282
  if (state.mode === "public" && state.domain) {
2062
3283
  try {
@@ -2076,7 +3297,22 @@ async function doctorCommand(directory) {
2076
3297
  console.log(`✗ DNS: ${state.domain} does not resolve`);
2077
3298
  }
2078
3299
  for (const port of [80, 443]) {
2079
- 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");
2080
3316
  }
2081
3317
  console.log("ℹ Firewall: allow TCP 80/443 (and optional UDP 443) in your existing nftables or UFW rules; OR3 did not modify them.");
2082
3318
  }
@@ -2086,6 +3322,358 @@ async function doctorCommand(directory) {
2086
3322
  }
2087
3323
  console.log("OR3 Cloud doctor passed.");
2088
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
+ }
2089
3677
  async function readSourceVolume(sourceDirectory) {
2090
3678
  const composeResult = await run("docker", [
2091
3679
  "compose",
@@ -2141,6 +3729,9 @@ function assertSupportedSource(sourceDirectory, sourceEnv, providers) {
2141
3729
  if (sourceEnv.SSR_AUTH_ENABLED === "false" || sourceEnv.OR3_GUEST_ACCESS_ENABLED === "true") {
2142
3730
  throw new Error("V1 project does not require authenticated access; adoption expects the supported authenticated profile.");
2143
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
+ }
2144
3735
  if (sourceEnv.OR3_SYNC_PROVIDER !== "sqlite" || sourceEnv.OR3_STORAGE_FS_ROOT === undefined || sourceEnv.NUXT_PUBLIC_STORAGE_PROVIDER !== "fs") {
2145
3736
  throw new Error("V1 project is not the supported Basic Auth + SQLite + filesystem profile.");
2146
3737
  }
@@ -2201,6 +3792,9 @@ async function adoptCommand(positionals, flags) {
2201
3792
  const release = JSON.parse(await readText(join(sourceDirectory, "or3-release.json")));
2202
3793
  if (!release.or3Version || !isVersion(release.or3Version))
2203
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
+ }
2204
3798
  const sourceEnv = parseEnv(await readText(join(sourceDirectory, ".env")));
2205
3799
  const providers = await readText(join(sourceDirectory, "or3.providers.generated.ts"));
2206
3800
  assertSupportedSource(sourceDirectory, sourceEnv, providers);
@@ -2225,15 +3819,17 @@ async function adoptCommand(positionals, flags) {
2225
3819
  ], sourceDirectory);
2226
3820
  if (!sourceConfig.ok)
2227
3821
  throw new Error(`The V1 Compose configuration is invalid. ${redact(sourceConfig.stderr, secretValues(sourceEnv))}`);
2228
- const image = imageFor(release.or3Version);
3822
+ const sourceImage = imageFor(release.or3Version);
2229
3823
  if (sourceEnv.OR3_VERSION && sourceEnv.OR3_VERSION !== release.or3Version) {
2230
3824
  throw new Error(`V1 OR3_VERSION ${sourceEnv.OR3_VERSION} does not match or3-release.json ${release.or3Version}.`);
2231
3825
  }
2232
- if (sourceEnv.OR3_IMAGE && sourceEnv.OR3_IMAGE !== image) {
2233
- 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}.`);
2234
3832
  }
2235
- await pullImage(image);
2236
- await assertSupportedHostArchitecture(image);
2237
3833
  const sourceVolume = await readSourceVolume(sourceDirectory);
2238
3834
  const email = sourceEnv.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
2239
3835
  const password = sourceEnv.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD;
@@ -2245,15 +3841,40 @@ async function adoptCommand(positionals, flags) {
2245
3841
  if (mode === "public" && [80, 443].includes(sourcePort))
2246
3842
  throw new Error("V1 public deployment uses a Caddy port for OR3; adoption requires a separate OR3 port.");
2247
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
+ }
2248
3856
  await mkdir(targetDirectory, { recursive: true, mode: 448 });
3857
+ await chmod(targetDirectory, 448);
2249
3858
  await mkdir(deploymentPaths(targetDirectory).operations, { recursive: true, mode: 448 });
2250
3859
  await mkdir(deploymentPaths(targetDirectory).backups, { recursive: true, mode: 448 });
3860
+ const operator = await prepareVerifiedDashboardOperator(targetDirectory, release.or3Version);
2251
3861
  await copyAssets(targetDirectory, mode);
2252
3862
  const copiedSecrets = {};
2253
3863
  for (const key of SECRET_KEYS)
2254
3864
  if (sourceEnv[key])
2255
3865
  copiedSecrets[key] = sourceEnv[key];
2256
- 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
+ });
2257
3878
  for (const key of ALLOWED_ENV_KEYS)
2258
3879
  if (sourceEnv[key] !== undefined)
2259
3880
  targetEnv[key] = sourceEnv[key];
@@ -2271,50 +3892,50 @@ async function adoptCommand(positionals, flags) {
2271
3892
  OR3_PUBLIC_DOMAIN: domain ?? "localhost",
2272
3893
  OR3_ALLOWED_ORIGINS: mode === "public" ? `https://${domain}` : `http://127.0.0.1:${sourcePort}`,
2273
3894
  OR3_FORCE_HTTPS: mode === "public" ? "true" : "false",
2274
- 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"
2275
3899
  });
2276
- const targetNames = composeProjectNames(targetDirectory);
2277
- for (const volume of [targetNames.volume, ...mode === "public" ? [targetNames.caddyData, targetNames.caddyConfig] : []]) {
2278
- const existing = await run("docker", ["volume", "inspect", volume]);
2279
- if (existing.ok)
2280
- throw new Error(`Docker volume ${volume} already exists. Choose a new adoption target directory.`);
2281
- }
2282
3900
  await writeSecure(deploymentPaths(targetDirectory).env, serializeEnv(targetEnv));
2283
3901
  await writeSecure(join(targetDirectory, ".or3-initial-credentials"), serializeInitialCredentials({
2284
3902
  bootstrapEmail: email,
2285
3903
  bootstrapPassword: password,
2286
- adminUsername: email,
2287
- adminPassword: password
3904
+ adminUsername: targetEnv.OR3_ADMIN_USERNAME,
3905
+ adminPassword: targetEnv.OR3_ADMIN_PASSWORD
2288
3906
  }));
2289
- const digest = await imageDigest(image);
2290
3907
  const state = stateFromEnv(targetDirectory, targetEnv, mode, "adopt", digest);
3908
+ const sourceBackupId = id("backup-adopt-source");
3909
+ const sourceBackupDir = backupDirectory(targetDirectory, sourceBackupId);
2291
3910
  await markPending(targetDirectory, state, {
2292
3911
  id: id("adopt"),
2293
3912
  operation: "adopt",
2294
3913
  startedAt: now(),
2295
3914
  message: `Adopting ${sourceDirectory}`,
2296
- sourceDirectory
3915
+ sourceDirectory,
3916
+ sourceInitiallyRunning,
3917
+ backupId: sourceBackupId,
3918
+ backupPath: sourceBackupDir,
3919
+ phase: "prepared"
2297
3920
  });
2298
3921
  let sourceStopAttempted = false;
2299
- let sourceBackupDir;
2300
3922
  try {
2301
3923
  sourceStopAttempted = true;
2302
3924
  const sourceStop = await run("docker", [...sourceComposeArgs(sourceDirectory, sourceComposeFiles), "stop"], sourceDirectory);
2303
3925
  if (!sourceStop.ok)
2304
3926
  throw new Error(`Could not stop the V1 deployment. ${redact(sourceStop.stderr, secretValues(sourceEnv))}`);
2305
- if (!await portAvailable(Number(targetEnv.OR3_PORT)))
3927
+ const localDockerDaemon = await dockerDaemonIsLocal();
3928
+ if (localDockerDaemon && !await portAvailable(Number(targetEnv.OR3_PORT)))
2306
3929
  throw new Error(`Port ${targetEnv.OR3_PORT} is still in use after stopping the V1 deployment.`);
2307
- if (mode === "public") {
3930
+ if (mode === "public" && localDockerDaemon) {
2308
3931
  for (const publicPort of [80, 443]) {
2309
3932
  if (!await portAvailable(publicPort))
2310
3933
  throw new Error(`Public port ${publicPort} is still in use after stopping the V1 deployment.`);
2311
3934
  }
2312
3935
  }
2313
- const sourceBackupId = id("adopt-source");
2314
- sourceBackupDir = backupDirectory(targetDirectory, sourceBackupId);
2315
3936
  await mkdir(sourceBackupDir, { recursive: true, mode: 448 });
2316
3937
  await copySecure(join(sourceDirectory, ".env"), join(sourceBackupDir, "config.env"));
2317
- await archiveExternalVolume(image, sourceVolume, sourceBackupDir);
3938
+ await archiveExternalVolume(sourceImage, sourceVolume, sourceBackupDir);
2318
3939
  const sourceManifest = {
2319
3940
  schemaVersion: 1,
2320
3941
  backupId: sourceBackupId,
@@ -2332,11 +3953,19 @@ async function adoptCommand(positionals, flags) {
2332
3953
  caddyConfigVolume: sourceEnv.OR3_CADDY_CONFIG_VOLUME,
2333
3954
  port: sourcePort
2334
3955
  };
2335
- await writeSecure(join(sourceBackupDir, "manifest.json"), `${JSON.stringify(sourceManifest, null, 2)}
2336
- `);
2337
- 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" });
2338
3962
  await restoreVolumeArchive(targetDirectory, mode, targetEnv, sourceBackupDir);
2339
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);
2340
3969
  await clearPending(targetDirectory, state);
2341
3970
  console.log(`Adopted ${sourceDirectory} into ${targetDirectory} at OR3 ${release.or3Version}.`);
2342
3971
  console.log(`Source backup: ${sourceBackupDir}`);
@@ -2346,7 +3975,7 @@ async function adoptCommand(positionals, flags) {
2346
3975
  return;
2347
3976
  });
2348
3977
  let sourceRecoveryError = "";
2349
- if (sourceStopAttempted) {
3978
+ if (sourceStopAttempted && sourceInitiallyRunning) {
2350
3979
  try {
2351
3980
  await restartSource(sourceDirectory, sourceComposeFiles, secretValues(sourceEnv));
2352
3981
  } catch (recovery) {
@@ -2361,27 +3990,58 @@ ${sourceRecoveryError ? "The original V1 deployment needs manual recovery. " : "
2361
3990
  }
2362
3991
  async function resolveResetPasswords(flags) {
2363
3992
  const ownerPassword = stringFlag(flags, "owner-password");
3993
+ const ownerPasswordFile = stringFlag(flags, "owner-password-file");
2364
3994
  const adminPassword = stringFlag(flags, "admin-password");
2365
- if (ownerPassword !== undefined || adminPassword !== undefined) {
2366
- if (ownerPassword === undefined || adminPassword === undefined) {
2367
- 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.");
2368
4013
  }
2369
- validatePassword(ownerPassword);
2370
- validatePassword(adminPassword);
2371
- return { ownerPassword, adminPassword };
4014
+ validatePassword(suppliedOwner);
4015
+ validatePassword(suppliedAdmin);
4016
+ return { ownerPassword: suppliedOwner, adminPassword: suppliedAdmin };
2372
4017
  }
2373
4018
  if (!input.isTTY || !output.isTTY) {
2374
- 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.");
2375
4020
  }
2376
- 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 });
2377
4037
  try {
2378
- const owner = (await prompt.question("New owner (basic auth) password: ")).trim();
2379
- validatePassword(owner);
2380
- const admin = (await prompt.question("New admin password: ")).trim();
2381
- validatePassword(admin);
2382
- return { ownerPassword: owner, adminPassword: admin };
4038
+ const answer = prompt.question(question);
4039
+ muted = true;
4040
+ return await answer;
2383
4041
  } finally {
2384
4042
  prompt.close();
4043
+ output.write(`
4044
+ `);
2385
4045
  }
2386
4046
  }
2387
4047
  function buildCredentialsResetScript(input2) {
@@ -2449,24 +4109,32 @@ try {
2449
4109
  credentials.username = adminUsername;
2450
4110
  credentials.password_hash_bcrypt = adminHash;
2451
4111
  credentials.updated_at = new Date().toISOString();
2452
- try {
2453
- fs.writeFileSync(adminCredentialsPath, JSON.stringify(credentials, null, 2) + '\\n', { mode: 0o600 });
2454
- } catch (error) {
2455
- 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)));
2456
- process.exit(1);
2457
- }
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
+ };
2458
4125
  try {
2459
4126
  db.transaction(() => {
2460
4127
  db.prepare('UPDATE basic_auth_accounts SET password_hash = ?, token_version = token_version + 1, updated_at = ? WHERE id = ?').run(ownerHash, now, account.id);
2461
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');
2462
4130
  })();
2463
4131
  } catch (error) {
2464
4132
  if (previousCredentials !== null) {
2465
- try { fs.writeFileSync(adminCredentialsPath, previousCredentials, { mode: 0o600 }); } catch {}
4133
+ try { atomicWrite(adminCredentialsPath, previousCredentials); } catch {}
2466
4134
  } else {
2467
4135
  try { fs.rmSync(adminCredentialsPath, { force: true }); } catch {}
2468
4136
  }
2469
- 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)));
2470
4138
  process.exit(1);
2471
4139
  }
2472
4140
  db.close();
@@ -2474,7 +4142,7 @@ console.log('credentials-reset: owner hash, sessions, and admin credentials upda
2474
4142
  `;
2475
4143
  }
2476
4144
  function credentialResetValues(env) {
2477
- const ownerEmail = env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
4145
+ const ownerEmail = env.OR3_MANAGED_OWNER_EMAIL || env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
2478
4146
  const ownerPassword = env.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD;
2479
4147
  const adminUsername = env.OR3_ADMIN_USERNAME;
2480
4148
  const adminPassword = env.OR3_ADMIN_PASSWORD;
@@ -2500,43 +4168,57 @@ Promise.all([
2500
4168
  `;
2501
4169
  async function runCredentialsResetScript(directory, mode, values) {
2502
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
+ };
2503
4178
  const result = await run("docker", [
2504
4179
  ...composeArgs(directory, mode, [
2505
4180
  "exec",
2506
4181
  "-T",
2507
4182
  "-e",
2508
- `OR3_RESET_OWNER_EMAIL=${values.ownerEmail}`,
4183
+ "OR3_RESET_OWNER_EMAIL",
2509
4184
  "-e",
2510
- `OR3_RESET_OWNER_PASSWORD=${values.ownerPassword}`,
4185
+ "OR3_RESET_OWNER_PASSWORD",
2511
4186
  "-e",
2512
- `OR3_RESET_ADMIN_USERNAME=${values.adminUsername}`,
4187
+ "OR3_RESET_ADMIN_USERNAME",
2513
4188
  "-e",
2514
- `OR3_RESET_ADMIN_PASSWORD=${values.adminPassword}`,
4189
+ "OR3_RESET_ADMIN_PASSWORD",
2515
4190
  "or3",
2516
4191
  ...containerNodeCommand(script)
2517
4192
  ])
2518
- ], directory);
4193
+ ], directory, resetEnvironment);
2519
4194
  if (!result.ok)
2520
4195
  throw new Error(`${result.command}
2521
4196
  ${redact(result.stderr, [values.ownerPassword, values.adminPassword])}`);
2522
4197
  }
2523
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
+ };
2524
4206
  const result = await run("docker", [
2525
4207
  ...composeArgs(directory, mode, [
2526
4208
  "exec",
2527
4209
  "-T",
2528
4210
  "-e",
2529
- `OR3_RESET_OWNER_EMAIL=${values.ownerEmail}`,
4211
+ "OR3_RESET_OWNER_EMAIL",
2530
4212
  "-e",
2531
- `OR3_RESET_OWNER_PASSWORD=${values.ownerPassword}`,
4213
+ "OR3_RESET_OWNER_PASSWORD",
2532
4214
  "-e",
2533
- `OR3_RESET_ADMIN_USERNAME=${values.adminUsername}`,
4215
+ "OR3_RESET_ADMIN_USERNAME",
2534
4216
  "-e",
2535
- `OR3_RESET_ADMIN_PASSWORD=${values.adminPassword}`,
4217
+ "OR3_RESET_ADMIN_PASSWORD",
2536
4218
  "or3",
2537
4219
  ...containerNodeCommand(CREDENTIALS_VERIFY_SCRIPT)
2538
4220
  ])
2539
- ], directory);
4221
+ ], directory, resetEnvironment);
2540
4222
  if (!result.ok)
2541
4223
  throw new Error(`Credential verification inside the OR3 container failed. ${redact(result.stderr, [values.ownerPassword, values.adminPassword])}`);
2542
4224
  }
@@ -2548,18 +4230,12 @@ async function applyCredentialReset(directory, state, nextEnv) {
2548
4230
  await startProject(directory, state.mode, nextEnv);
2549
4231
  }
2550
4232
  await runCredentialsResetScript(directory, state.mode, values);
2551
- await writeSecure(deploymentPaths(directory).env, serializeEnv(nextEnv));
4233
+ const runtimeEnv = withoutProvisioningCredentials(nextEnv);
4234
+ await writeSecure(deploymentPaths(directory).env, serializeEnv(runtimeEnv));
2552
4235
  const initialCredentials = join(directory, ".or3-initial-credentials");
2553
- if (await fileExists(initialCredentials)) {
2554
- await writeSecure(initialCredentials, serializeInitialCredentials({
2555
- bootstrapEmail: values.ownerEmail,
2556
- bootstrapPassword: values.ownerPassword,
2557
- adminUsername: values.adminUsername,
2558
- adminPassword: values.adminPassword
2559
- }));
2560
- }
4236
+ await rm(initialCredentials, { force: true });
2561
4237
  await stopProject(directory, state.mode);
2562
- await startProject(directory, state.mode, nextEnv);
4238
+ await startProject(directory, state.mode, runtimeEnv);
2563
4239
  await verifyCredentialsInsideContainer(directory, state.mode, values);
2564
4240
  }
2565
4241
  async function credentialsResetCommand(directory, flags) {
@@ -2569,15 +4245,16 @@ async function credentialsResetCommand(directory, flags) {
2569
4245
  const loaded = await loadManaged(directory);
2570
4246
  assertDeploymentDirectoryIdentity(directory, loaded.state);
2571
4247
  assertNoPending(loaded.state);
2572
- 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;
2573
4249
  const adminUsername = loaded.env.OR3_ADMIN_USERNAME;
2574
4250
  if (!ownerEmail)
2575
- 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.");
2576
4252
  if (!adminUsername)
2577
4253
  throw new Error("OR3_ADMIN_USERNAME is missing from .env, so the admin password cannot be reset.");
2578
4254
  const { ownerPassword, adminPassword } = await resolveResetPasswords(flags);
2579
4255
  const nextEnv = {
2580
4256
  ...loaded.env,
4257
+ OR3_MANAGED_OWNER_EMAIL: ownerEmail,
2581
4258
  OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD: ownerPassword,
2582
4259
  OR3_ADMIN_PASSWORD: adminPassword,
2583
4260
  OR3_ADMIN_JWT_SECRET: randomSecret()
@@ -2594,7 +4271,7 @@ async function credentialsResetCommand(directory, flags) {
2594
4271
  await applyCredentialReset(loaded.directory, loaded.state, nextEnv);
2595
4272
  await clearPending(loaded.directory, loaded.state);
2596
4273
  console.log("Owner and admin credentials are now separate. The old owner password and all app sessions were revoked.");
2597
- 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.");
2598
4275
  } catch (error) {
2599
4276
  loaded.state.lastError = redact(error instanceof Error ? error.message : String(error), secretValues(nextEnv));
2600
4277
  await writeState(loaded.directory, loaded.state);
@@ -2744,8 +4421,12 @@ async function assertPurgeHasVerifiedExport(directory, backups, nowMs) {
2744
4421
  const receipt = await readBackupExportReceipt(directory, backup.backupId);
2745
4422
  if (!receipt || !await fileExists(receipt.destination))
2746
4423
  continue;
4424
+ const deploymentRoot = await realpath(directory);
2747
4425
  try {
2748
- 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);
2749
4430
  const sourceDevice = (await stat(backup.path)).dev;
2750
4431
  const destinationDevice = (await stat(receipt.destination)).dev;
2751
4432
  if (sourceDevice !== destinationDevice && receipt.destinationDevice === destinationDevice && exported.backupId === backup.backupId && exported.dataSha256 === receipt.dataSha256 && exported.dataSha256 === backup.dataSha256 && exported.configSha256 === receipt.configSha256)
@@ -2770,13 +4451,19 @@ async function removeCommand(directory, flags) {
2770
4451
  }
2771
4452
  const exportReceipt = await assertPurgeHasVerifiedExport(directory, await enumerateBackups(directory), Date.now());
2772
4453
  const volumes = purgeVolumesFromState(state);
2773
- 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
+ ];
2774
4460
  console.log("Removing exactly these targets:");
2775
4461
  for (const volume of volumes)
2776
4462
  console.log(` docker volume ${volume}`);
2777
4463
  for (const file of managedFiles)
2778
4464
  console.log(` ${join(directory, file)}`);
2779
4465
  await compose(directory, state.mode, ["down"]);
4466
+ await assertPurgeHasVerifiedExport(directory, await enumerateBackups(directory), Date.now());
2780
4467
  for (const volume of volumes) {
2781
4468
  const result = await run("docker", ["volume", "rm", volume], directory);
2782
4469
  if (!result.ok)
@@ -2797,6 +4484,30 @@ async function probeDeepHealth(directory, mode) {
2797
4484
  return "degraded";
2798
4485
  return "unreachable";
2799
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
+ }
2800
4511
  async function main(argv = process.argv.slice(2)) {
2801
4512
  const [command = "help", ...rest] = argv;
2802
4513
  if (command === "--help" || command === "help")
@@ -2808,37 +4519,46 @@ async function main(argv = process.argv.slice(2)) {
2808
4519
  if (parsed.flags.help)
2809
4520
  return help();
2810
4521
  assertCommandFlags(command, parsed.flags);
2811
- if (command === "init")
2812
- return await initCommand(parsed.positionals, parsed.flags);
2813
- if (command === "update")
2814
- return await updateCommand(process.cwd(), parsed.flags);
2815
- if (command === "backup")
2816
- return await backupCommand(process.cwd(), parsed.positionals, parsed.flags);
2817
- if (command === "restore")
2818
- return await restoreCommand(process.cwd(), parsed.flags, parsed.positionals);
2819
- if (command === "rollback")
2820
- return await rollbackCommand(process.cwd(), parsed.flags);
2821
- if (command === "credentials")
2822
- return await credentialsCommand(process.cwd(), parsed.positionals, parsed.flags);
2823
- if (command === "doctor")
2824
- return await doctorCommand(process.cwd());
2825
- if (command === "recover")
2826
- return await recoverCommand(process.cwd());
2827
- if (command === "adopt")
2828
- return await adoptCommand(parsed.positionals, parsed.flags);
2829
- if (command === "status")
2830
- return await statusCommand(process.cwd());
2831
- if (command === "logs")
2832
- return await logsCommand(process.cwd(), parsed.flags, parsed.positionals);
2833
- if (command === "start")
2834
- return await startCommand(process.cwd());
2835
- if (command === "stop")
2836
- return await stopCommand(process.cwd());
2837
- if (command === "restart")
2838
- return await restartCommand(process.cwd());
2839
- if (command === "remove")
2840
- return await removeCommand(process.cwd(), parsed.flags);
2841
- 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();
2842
4562
  } catch (error) {
2843
4563
  console.error(`
2844
4564
  OR3 Cloud failed: ${redact(error instanceof Error ? error.message : String(error))}`);
@@ -2850,13 +4570,18 @@ if (invokedAsCli || process.argv[1] && resolve(process.argv[1]) === resolve(file
2850
4570
  await main();
2851
4571
  }
2852
4572
  export {
4573
+ withoutProvisioningCredentials,
4574
+ validateVerificationHealth,
2853
4575
  validatePassword,
4576
+ updateRequiresVolumeRecreation,
2854
4577
  supportedImageArchitectures,
2855
4578
  stateFromEnv,
2856
4579
  snapshotManagedAssets,
2857
4580
  serializeInitialCredentials,
2858
4581
  serializeEnv,
2859
4582
  selectPruneTargets,
4583
+ sameOriginVerificationUrl,
4584
+ restoreRequiresVolumeRecreation,
2860
4585
  restoreManagedAssets,
2861
4586
  requiredArchiveSpace,
2862
4587
  redact,
@@ -2869,12 +4594,15 @@ export {
2869
4594
  checkResolvedLoopbackBinding,
2870
4595
  buildEnv,
2871
4596
  buildCredentialsResetScript,
4597
+ assertVerificationGrant,
2872
4598
  assertSupportedSourceCompose,
2873
4599
  assertSupportedSource,
2874
4600
  assertSupportedArchitecture,
2875
4601
  assertRemovableArtifactName,
2876
4602
  assertPurgeBackupFreshness,
4603
+ assertImageReleaseLabels,
2877
4604
  assertEnoughFreeSpace,
4605
+ assertCommandPositionals,
2878
4606
  assertCommandFlags,
2879
4607
  assertBackupMatchesDeployment,
2880
4608
  PACKAGE_VERSION,