@or3/cloud 0.1.106 → 0.1.112

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
@@ -15,7 +15,7 @@ var __export = (target, all) => {
15
15
  };
16
16
 
17
17
  // src/cli.ts
18
- import { basename as basename8, resolve as resolve17 } from "node:path";
18
+ import { basename as basename9, resolve as resolve18 } from "node:path";
19
19
  import { fileURLToPath as fileURLToPath2 } from "node:url";
20
20
 
21
21
  // src/cli/args.ts
@@ -67,6 +67,7 @@ var COMMAND_FLAGS = {
67
67
  init: ["local", "public", "domain", "admin-email", "admin-password", "admin-password-file", "port"],
68
68
  update: ["to", "dry-run", "json"],
69
69
  backup: ["keep", "force", "yes", "json"],
70
+ "vault-key": ["out"],
70
71
  restore: ["yes"],
71
72
  rollback: ["yes"],
72
73
  doctor: [],
@@ -92,7 +93,7 @@ function assertCommandFlags(command, flags) {
92
93
  }
93
94
  }
94
95
  function assertCommandPositionals(command, positionals) {
95
- if (command === "backup" || command === "credentials")
96
+ if (command === "backup" || command === "credentials" || command === "vault-key")
96
97
  return;
97
98
  if (command === "init" || command === "adopt") {
98
99
  if (positionals.length > 1)
@@ -128,11 +129,11 @@ function requireStringFlag(flags, key) {
128
129
  }
129
130
 
130
131
  // src/cli/dispatch.ts
131
- import { basename as basename7, resolve as resolve16 } from "node:path";
132
+ import { basename as basename8, resolve as resolve17 } from "node:path";
132
133
 
133
134
  // src/commands/adopt.ts
134
135
  import { chmod as chmod5, mkdir as mkdir5 } from "node:fs/promises";
135
- import { basename as basename3, join as join13, resolve as resolve7 } from "node:path";
136
+ import { basename as basename3, join as join16, resolve as resolve7 } from "node:path";
136
137
 
137
138
  // src/backup/archive.ts
138
139
  import { createReadStream as createReadStream3 } from "node:fs";
@@ -164,9 +165,23 @@ function quote(value) {
164
165
  return `'${value.replaceAll("'", "'\\''")}'`;
165
166
  }
166
167
  function redact(text, secrets = []) {
167
- let output = text;
168
- for (const secret of secrets.filter(Boolean))
169
- output = output.replaceAll(secret, "[REDACTED]");
168
+ const mask = new Uint8Array(text.length);
169
+ for (const secret of secrets.filter(Boolean)) {
170
+ for (let index = text.indexOf(secret);index !== -1; index = text.indexOf(secret, index + 1))
171
+ mask.fill(1, index, index + secret.length);
172
+ }
173
+ let output = "", cursor = 0;
174
+ for (let index = 0;index < mask.length; ) {
175
+ if (!mask[index]) {
176
+ index += 1;
177
+ continue;
178
+ }
179
+ output += `${text.slice(cursor, index)}[REDACTED]`;
180
+ while (index < mask.length && mask[index])
181
+ index += 1;
182
+ cursor = index;
183
+ }
184
+ output += text.slice(cursor);
170
185
  output = output.replace(/((?:PASSWORD|SECRET|TOKEN|JWT)\s*[=:]\s*)(?!\[REDACTED\])([^\r\n]+)/gi, "$1[REDACTED]");
171
186
  return output;
172
187
  }
@@ -179,7 +194,7 @@ import { readFileSync } from "node:fs";
179
194
  import { join, resolve } from "node:path";
180
195
  import { fileURLToPath } from "node:url";
181
196
  var PACKAGE_ROOT = resolve(fileURLToPath(new URL("../", import.meta.url)));
182
- var PACKAGE_VERSION = "0.1.106";
197
+ var PACKAGE_VERSION = "0.1.112";
183
198
  var IMAGE_REPOSITORY = "ghcr.io/saluana/or3-chat";
184
199
  var ASSET_ROOT = resolve(fileURLToPath(new URL("../assets/", import.meta.url)));
185
200
  var SHA256_DIGEST = /^sha256:[0-9a-f]{64}$/;
@@ -293,6 +308,9 @@ function composeProjectNames(directory) {
293
308
  caddyConfig: `${base}-caddy-config`
294
309
  };
295
310
  }
311
+ function managedVaultVolumeNames(dataVolume) {
312
+ return ["vault-data", "vault-key", "vault-run", "vault-client"].map((suffix) => `${dataVolume}-${suffix}`);
313
+ }
296
314
  function assertDeploymentIdentity(state, env) {
297
315
  const expected = {
298
316
  OR3_COMPOSE_PROJECT: state.composeProject,
@@ -366,10 +384,149 @@ var SECRET_KEYS = [
366
384
  "OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD",
367
385
  "OR3_AUTH_INVITE_TOKEN_SECRET",
368
386
  "OR3_STORAGE_FS_TOKEN_SECRET",
387
+ "OR3_STORAGE_S3_ACCESS_KEY_ID",
388
+ "OR3_STORAGE_S3_SECRET_ACCESS_KEY",
389
+ "OR3_STORAGE_S3_SESSION_TOKEN",
390
+ "NUXT_CLERK_SECRET_KEY",
391
+ "NUXT_AUTH_INVITE_TOKEN_SECRET",
392
+ "NUXT_ADMIN_AUTH_PASSWORD",
393
+ "NUXT_ADMIN_AUTH_JWT_SECRET",
394
+ "OR3_LIBRARY_LINK_SECRET",
395
+ "NUXT_ADMIN_LIBRARY_LINK_SECRET",
396
+ "OR3_PLUGIN_CONNECTION_SECRET",
397
+ "NUXT_ADMIN_PLUGIN_CONNECTION_SECRET",
398
+ "OR3_BACKGROUND_ENCRYPTION_KEY",
399
+ "NUXT_BACKGROUND_JOBS_ENCRYPTION_KEY",
400
+ "OR3_WEBHOOKS_ENCRYPTION_KEY",
401
+ "NUXT_WEBHOOKS_ENCRYPTION_KEY",
402
+ "OR3_CONNECT_ENCRYPTION_KEY",
403
+ "NUXT_CONNECT_ENCRYPTION_KEY",
404
+ "OR3_CONNECT_CLOUDFLARE_API_TOKEN",
405
+ "NUXT_CONNECT_CLOUDFLARE_API_TOKEN",
406
+ "OPENROUTER_API_KEY",
407
+ "NUXT_OPENROUTER_API_KEY",
408
+ "OR3_OPENAI_API_KEY",
409
+ "NUXT_OPENAI_API_KEY",
410
+ "OR3_BILLING_STRIPE_SECRET_KEY",
411
+ "OR3_BILLING_STRIPE_WEBHOOK_SECRET",
412
+ "CONVEX_SELF_HOSTED_ADMIN_KEY",
413
+ "CONVEX_DEPLOY_KEY",
369
414
  "OR3_ADMIN_JWT_SECRET",
370
415
  "OR3_ADMIN_PASSWORD"
371
416
  ];
417
+ function managedProviderProfile(env) {
418
+ const auth = env.AUTH_PROVIDER ?? env.OR3_AUTH_PROVIDER ?? "basic-auth";
419
+ const sync = env.OR3_SYNC_PROVIDER ?? "sqlite";
420
+ const storage = env.NUXT_PUBLIC_STORAGE_PROVIDER ?? "fs";
421
+ if (env.AUTH_PROVIDER && env.OR3_AUTH_PROVIDER && env.AUTH_PROVIDER !== env.OR3_AUTH_PROVIDER || sync !== "sqlite") {
422
+ throw new Error("backup-profile-mismatch: unsupported or inconsistent managed provider profile.");
423
+ }
424
+ if (auth === "basic-auth" && storage === "fs")
425
+ return "basic-sqlite-fs";
426
+ if (auth === "clerk" && storage === "s3")
427
+ return "clerk-sqlite-s3";
428
+ throw new Error("backup-profile-mismatch: unsupported managed provider profile.");
429
+ }
430
+ function assertLegacyManagedProfile(env) {
431
+ const overrides = {
432
+ SSR_AUTH_ENABLED: "true",
433
+ OR3_SYNC_ENABLED: "true",
434
+ OR3_CLOUD_SYNC_ENABLED: "true",
435
+ OR3_STORAGE_ENABLED: "true",
436
+ OR3_CLOUD_STORAGE_ENABLED: "true",
437
+ OR3_SQLITE_DRIVER: "better-sqlite3",
438
+ OR3_STORAGE_PROVIDER: "fs",
439
+ NUXT_AUTH_ENABLED: "true",
440
+ NUXT_AUTH_PROVIDER: "basic-auth",
441
+ NUXT_PUBLIC_AUTH_PROVIDER: "basic-auth",
442
+ NUXT_PUBLIC_SSR_AUTH_ENABLED: "true",
443
+ NUXT_SYNC_ENABLED: "true",
444
+ NUXT_PUBLIC_SYNC_ENABLED: "true",
445
+ NUXT_SYNC_PROVIDER: "sqlite",
446
+ NUXT_PUBLIC_SYNC_PROVIDER: "sqlite",
447
+ NUXT_STORAGE_ENABLED: "true",
448
+ NUXT_PUBLIC_STORAGE_ENABLED: "true",
449
+ NUXT_STORAGE_PROVIDER: "fs"
450
+ };
451
+ const ambiguous = /^(?:RUNTIME_CONFIG$|NUXT_(?:AUTH|SYNC|STORAGE|PUBLIC)$|NITRO_ENV_(?:PREFIX|EXPANSION)$|NITRO_(?:AUTH|SYNC|STORAGE|PUBLIC)(?:_|$))/;
452
+ if (managedProviderProfile(env) !== "basic-sqlite-fs" || Object.entries(overrides).some(([key, expected]) => env[key] !== undefined && env[key] !== expected) || Object.keys(env).some((key) => ambiguous.test(key))) {
453
+ throw new Error("Legacy managed state supports only the Basic/SQLite/filesystem profile; provider conversion requires a separate qualified deployment.");
454
+ }
455
+ }
456
+ function restoreStorageConfiguration(backup, current) {
457
+ const sourceProvider = backup.NUXT_PUBLIC_STORAGE_PROVIDER;
458
+ const targetProvider = current.NUXT_PUBLIC_STORAGE_PROVIDER;
459
+ if (sourceProvider !== targetProvider && (sourceProvider === "s3" || targetProvider === "s3" || sourceProvider && targetProvider)) {
460
+ throw new Error("backup-profile-mismatch: backup and deployment use different storage providers.");
461
+ }
462
+ if (sourceProvider !== "s3")
463
+ return { ...backup };
464
+ const prefix = (value) => {
465
+ const trimmed = (value ?? "").trim().replace(/^\/+/, "").replace(/\/+$/, "");
466
+ return trimmed ? `${trimmed}/` : "";
467
+ };
468
+ if (prefix(backup.OR3_STORAGE_S3_KEY_PREFIX) !== prefix(current.OR3_STORAGE_S3_KEY_PREFIX)) {
469
+ throw new Error("backup-prefix-mismatch: backup and target S3 key prefixes differ.");
470
+ }
471
+ const restored = Object.fromEntries(Object.entries(backup).filter(([key]) => !key.startsWith("OR3_STORAGE_S3_")));
472
+ for (const [key, value] of Object.entries(current))
473
+ if (key.startsWith("OR3_STORAGE_S3_"))
474
+ restored[key] = value;
475
+ return restored;
476
+ }
477
+ var ADOPTION_SECRET_KEYS = [
478
+ "OR3_BASIC_AUTH_JWT_SECRET",
479
+ "OR3_BASIC_AUTH_REFRESH_SECRET",
480
+ "OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD",
481
+ "OR3_AUTH_INVITE_TOKEN_SECRET",
482
+ "OR3_STORAGE_FS_TOKEN_SECRET",
483
+ "OR3_ADMIN_PASSWORD",
484
+ "OR3_ADMIN_JWT_SECRET",
485
+ "NUXT_AUTH_INVITE_TOKEN_SECRET",
486
+ "NUXT_ADMIN_AUTH_JWT_SECRET",
487
+ "OR3_LIBRARY_LINK_SECRET",
488
+ "NUXT_ADMIN_LIBRARY_LINK_SECRET",
489
+ "OR3_PLUGIN_CONNECTION_SECRET",
490
+ "NUXT_ADMIN_PLUGIN_CONNECTION_SECRET",
491
+ "OR3_BACKGROUND_ENCRYPTION_KEY",
492
+ "NUXT_BACKGROUND_JOBS_ENCRYPTION_KEY",
493
+ "OR3_WEBHOOKS_ENCRYPTION_KEY",
494
+ "NUXT_WEBHOOKS_ENCRYPTION_KEY",
495
+ "OR3_CONNECT_ENCRYPTION_KEY",
496
+ "NUXT_CONNECT_ENCRYPTION_KEY",
497
+ "OR3_CONNECT_CLOUDFLARE_API_TOKEN",
498
+ "NUXT_CONNECT_CLOUDFLARE_API_TOKEN",
499
+ "OPENROUTER_API_KEY",
500
+ "NUXT_OPENROUTER_API_KEY",
501
+ "OR3_OPENAI_API_KEY",
502
+ "NUXT_OPENAI_API_KEY"
503
+ ];
504
+ var ADOPTION_RUNTIME_KEYS = [
505
+ "OR3_MARKETPLACE_REGISTRY_ORIGIN",
506
+ "OR3_MARKETPLACE_INSTALL_ENABLED",
507
+ "OR3_MARKETPLACE_RELEASE_KEYS",
508
+ "OR3_INSTANCE_ID",
509
+ "OR3_MARKETPLACE_MAX_ARTIFACT_BYTES",
510
+ "OR3_MARKETPLACE_RESERVE_BYTES",
511
+ "OR3_BACKGROUND_STREAMING_ENABLED",
512
+ "OR3_BACKGROUND_STREAMING_PROVIDER",
513
+ "OR3_BACKGROUND_MAX_JOBS",
514
+ "OR3_BACKGROUND_MAX_JOBS_PER_USER",
515
+ "OR3_BACKGROUND_JOB_TIMEOUT",
516
+ "NUXT_BACKGROUND_JOBS_ENABLED",
517
+ "NUXT_PUBLIC_BACKGROUND_STREAMING_ENABLED",
518
+ "NUXT_BACKGROUND_JOBS_STORAGE_PROVIDER",
519
+ "NUXT_BACKGROUND_JOBS_MAX_CONCURRENT_JOBS",
520
+ "NUXT_BACKGROUND_JOBS_MAX_CONCURRENT_JOBS_PER_USER",
521
+ "NUXT_BACKGROUND_JOBS_JOB_TIMEOUT_MS",
522
+ "OR3_PLUGIN_ALLOWED_MODELS",
523
+ "OR3_PLUGIN_MODEL_PRICES",
524
+ "NUXT_ADMIN_PLUGIN_ALLOWED_MODELS",
525
+ "NUXT_ADMIN_PLUGIN_MODEL_PRICES"
526
+ ];
372
527
  var ALLOWED_ENV_KEYS = new Set([
528
+ ...ADOPTION_SECRET_KEYS,
529
+ ...ADOPTION_RUNTIME_KEYS,
373
530
  "SSR_AUTH_ENABLED",
374
531
  "NUXT_SECRETS_PROVIDER",
375
532
  "NUXT_SECRETS_NAMESPACE",
@@ -386,6 +543,7 @@ var ALLOWED_ENV_KEYS = new Set([
386
543
  "OR3_BASIC_AUTH_REFRESH_SECRET",
387
544
  "OR3_BASIC_AUTH_ACCESS_TTL_SECONDS",
388
545
  "OR3_BASIC_AUTH_REFRESH_TTL_SECONDS",
546
+ "OR3_SQLITE_DRIVER",
389
547
  "OR3_BASIC_AUTH_DB_PATH",
390
548
  "OR3_BASIC_AUTH_BOOTSTRAP_EMAIL",
391
549
  "OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD",
@@ -402,6 +560,8 @@ var ALLOWED_ENV_KEYS = new Set([
402
560
  "OR3_CLOUD_STORAGE_ENABLED",
403
561
  "NUXT_PUBLIC_STORAGE_PROVIDER",
404
562
  "OR3_STORAGE_FS_ROOT",
563
+ "OR3_STORAGE_FS_CLEANUP_NAMESPACE_ID",
564
+ "OR3_STORAGE_RETENTION_WORKSPACES",
405
565
  "OR3_STORAGE_FS_TOKEN_SECRET",
406
566
  "OR3_STORAGE_FS_URL_TTL_SECONDS",
407
567
  "OR3_ADMIN_USERNAME",
@@ -548,7 +708,9 @@ function buildEnv(input) {
548
708
  return values;
549
709
  }
550
710
  function secretValues(env) {
551
- return SECRET_KEYS.map((key) => env[key]).filter((value) => Boolean(value));
711
+ const known = new Set(SECRET_KEYS);
712
+ const secretName = /(?:^|_)(?:PASSWORD|SECRET|TOKEN|PRIVATE_KEY|API_KEY|ACCESS_KEY|AUTH_KEY)(?:_|$)/i;
713
+ return Object.entries(env).filter(([key, value]) => Boolean(value) && (known.has(key) || secretName.test(key))).map(([, value]) => value);
552
714
  }
553
715
 
554
716
  // src/deployment/state-store.ts
@@ -842,6 +1004,12 @@ import { dirname as dirname2, join as join5 } from "node:path";
842
1004
  var execFile = promisify(execFileCallback);
843
1005
  var COMMAND_TIMEOUT_MS = 180000;
844
1006
  var STREAM_COMMAND_TIMEOUT_MS = 15 * 60000;
1007
+ var MAX_STREAM_COMMAND_TIMEOUT_MS = 4 * 60 * 60000;
1008
+ function streamDeadline(timeoutMs) {
1009
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_STREAM_COMMAND_TIMEOUT_MS)
1010
+ throw new Error("Invalid archive stream deadline.");
1011
+ return timeoutMs;
1012
+ }
845
1013
  var PROCESS_ENV_PASSTHROUGH = [
846
1014
  "PATH",
847
1015
  "HOME",
@@ -938,7 +1106,8 @@ function terminateChildProcess(child) {
938
1106
  }
939
1107
  child.kill("SIGKILL");
940
1108
  }
941
- async function streamCommandToFile(command, args, destination, cwd, secrets = []) {
1109
+ async function streamCommandToFile(command, args, destination, cwd, secrets = [], environment, timeoutMs = STREAM_COMMAND_TIMEOUT_MS) {
1110
+ streamDeadline(timeoutMs);
942
1111
  if (await fileExists(destination)) {
943
1112
  throw new Error(`Refusing to overwrite existing archive ${destination}.`);
944
1113
  }
@@ -946,7 +1115,7 @@ async function streamCommandToFile(command, args, destination, cwd, secrets = []
946
1115
  const temporary = `${destination}.${randomBytes3(8).toString("hex")}.partial`;
947
1116
  const child = spawn(command, args, {
948
1117
  cwd,
949
- env: commandEnvironment(command, args, cwd),
1118
+ env: commandEnvironment(command, args, cwd, environment),
950
1119
  detached: process.platform !== "win32",
951
1120
  stdio: ["ignore", "pipe", "pipe"]
952
1121
  });
@@ -956,7 +1125,7 @@ async function streamCommandToFile(command, args, destination, cwd, secrets = []
956
1125
  const timeout = setTimeout(() => {
957
1126
  timedOut = true;
958
1127
  terminateChildProcess(child);
959
- }, STREAM_COMMAND_TIMEOUT_MS);
1128
+ }, timeoutMs);
960
1129
  child.stderr.setEncoding("utf8");
961
1130
  child.stderr.on("data", (chunk) => {
962
1131
  stderr = `${stderr}${chunk}`.slice(-64 * 1024);
@@ -972,7 +1141,7 @@ async function streamCommandToFile(command, args, destination, cwd, secrets = []
972
1141
  transfer
973
1142
  ]);
974
1143
  if (hasTimedOut())
975
- throw new Error(`${command} exceeded the ${STREAM_COMMAND_TIMEOUT_MS / 1000}-second archive deadline.`);
1144
+ throw new Error(`${command} exceeded the ${timeoutMs / 1000}-second archive deadline.`);
976
1145
  if (exitCode !== 0) {
977
1146
  throw new Error(`${command} ${args.join(" ")} exited with ${exitCode}. ${redact(stderr, secrets)}`.trim());
978
1147
  }
@@ -996,10 +1165,11 @@ Temporary archive cleanup failed: ${redact(cleanupError instanceof Error ? clean
996
1165
  clearTimeout(timeout);
997
1166
  }
998
1167
  }
999
- async function streamFileToCommand(command, args, source, cwd, secrets = []) {
1168
+ async function streamFileToCommand(command, args, source, cwd, secrets = [], environment, timeoutMs = STREAM_COMMAND_TIMEOUT_MS) {
1169
+ streamDeadline(timeoutMs);
1000
1170
  const child = spawn(command, args, {
1001
1171
  cwd,
1002
- env: commandEnvironment(command, args, cwd),
1172
+ env: commandEnvironment(command, args, cwd, environment),
1003
1173
  detached: process.platform !== "win32",
1004
1174
  stdio: ["pipe", "ignore", "pipe"]
1005
1175
  });
@@ -1009,7 +1179,7 @@ async function streamFileToCommand(command, args, source, cwd, secrets = []) {
1009
1179
  const timeout = setTimeout(() => {
1010
1180
  timedOut = true;
1011
1181
  terminateChildProcess(child);
1012
- }, STREAM_COMMAND_TIMEOUT_MS);
1182
+ }, timeoutMs);
1013
1183
  child.stderr.setEncoding("utf8");
1014
1184
  child.stderr.on("data", (chunk) => {
1015
1185
  stderr = `${stderr}${chunk}`.slice(-64 * 1024);
@@ -1024,13 +1194,13 @@ async function streamFileToCommand(command, args, source, cwd, secrets = []) {
1024
1194
  pipeline(createReadStream2(source), child.stdin)
1025
1195
  ]);
1026
1196
  if (hasTimedOut())
1027
- throw new Error(`${command} exceeded the ${STREAM_COMMAND_TIMEOUT_MS / 1000}-second archive deadline.`);
1197
+ throw new Error(`${command} exceeded the ${timeoutMs / 1000}-second archive deadline.`);
1028
1198
  if (exitCode !== 0) {
1029
1199
  throw new Error(`${command} ${args.join(" ")} exited with ${exitCode}. ${redact(stderr, secrets)}`.trim());
1030
1200
  }
1031
1201
  } catch (error) {
1032
1202
  terminateChildProcess(child);
1033
- const detail = error instanceof Error ? error.message : String(error);
1203
+ const detail = redact(error instanceof Error ? error.message : String(error), secrets);
1034
1204
  throw new Error(`${detail}${stderr && !detail.includes(stderr) ? `
1035
1205
  ${redact(stderr, secrets)}` : ""}`);
1036
1206
  } finally {
@@ -1119,6 +1289,31 @@ function parseComposeConfig(text) {
1119
1289
  throw new Error(`Docker Compose did not return a readable JSON configuration: ${error instanceof Error ? error.message : String(error)}`);
1120
1290
  }
1121
1291
  }
1292
+ function assertCapturedComposeMounts(service) {
1293
+ const mounts = service.volumes ?? [], tmpfs = service.tmpfs;
1294
+ for (const field of [service.configs, service.secrets, service.volumes_from]) {
1295
+ if (field != null && (!Array.isArray(field) || field.length !== 0))
1296
+ throw new Error("Source has additional Compose mounts that cannot be captured safely.");
1297
+ }
1298
+ if (mounts.some((mount) => {
1299
+ const volume = mount.volume;
1300
+ return volume && typeof volume === "object" && volume.subpath;
1301
+ }))
1302
+ throw new Error("Source volume subpaths cannot be captured safely.");
1303
+ if (tmpfs != null && (!Array.isArray(tmpfs) || tmpfs.some((entry) => typeof entry !== "string" || entry.split(":", 1)[0] !== "/tmp")))
1304
+ throw new Error("Source has an unexpected temporary filesystem.");
1305
+ }
1306
+ function assertCapturedHostMounts(tmpfs, hostMounts) {
1307
+ if (tmpfs != null && (typeof tmpfs !== "object" || Array.isArray(tmpfs) || Object.keys(tmpfs).some((path) => path !== "/tmp")))
1308
+ throw new Error("Source has an unexpected temporary filesystem.");
1309
+ if (hostMounts != null && (!Array.isArray(hostMounts) || hostMounts.some((mount) => {
1310
+ if (!mount || typeof mount !== "object" || Array.isArray(mount))
1311
+ return true;
1312
+ const options = mount.VolumeOptions;
1313
+ return options && typeof options === "object" && options.Subpath;
1314
+ })))
1315
+ throw new Error("Source volume subpaths cannot be captured safely.");
1316
+ }
1122
1317
  function composeEnvironmentValue(service, key) {
1123
1318
  const environment = service.environment;
1124
1319
  if (Array.isArray(environment)) {
@@ -1169,6 +1364,41 @@ async function resolvedComposeConfig(directory, mode, deadline = performance.now
1169
1364
  ${result.stderr}`);
1170
1365
  return result.stdout;
1171
1366
  }
1367
+ async function assertLegacyComposeProfile(directory, mode, env, deadline) {
1368
+ assertLegacyManagedProfile(env);
1369
+ let resolved;
1370
+ try {
1371
+ resolved = await resolvedComposeConfig(directory, mode, deadline);
1372
+ } catch (error) {
1373
+ throw new Error(redact(error instanceof Error ? error.message : String(error), secretValues(env)));
1374
+ }
1375
+ let service;
1376
+ try {
1377
+ service = parseComposeConfig(resolved).services?.or3;
1378
+ } catch {
1379
+ throw new Error("Resolved Compose provider configuration is unreadable.");
1380
+ }
1381
+ if (!service || !service.environment || typeof service.environment !== "object")
1382
+ throw new Error("Resolved Compose provider environment is missing.");
1383
+ const entries = Array.isArray(service.environment) ? service.environment.map((entry) => {
1384
+ if (typeof entry !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*=/.test(entry))
1385
+ throw new Error("Resolved Compose provider environment is ambiguous.");
1386
+ const index = entry.indexOf("=");
1387
+ return [entry.slice(0, index), entry.slice(index + 1)];
1388
+ }) : Object.entries(service.environment);
1389
+ const effective = {};
1390
+ const seen = new Set;
1391
+ for (const [key, value] of entries) {
1392
+ if (typeof key !== "string" || typeof value !== "string" || seen.has(key))
1393
+ throw new Error("Resolved Compose provider environment is ambiguous.");
1394
+ seen.add(key);
1395
+ effective[key] = value;
1396
+ }
1397
+ assertLegacyManagedProfile(effective);
1398
+ if ((effective.OR3_AUTH_PROVIDER || effective.AUTH_PROVIDER) !== "basic-auth" || effective.SSR_AUTH_ENABLED !== "true" || (effective.OR3_CLOUD_SYNC_ENABLED || effective.OR3_SYNC_ENABLED) !== "true" || effective.OR3_SYNC_PROVIDER !== "sqlite" || (effective.OR3_CLOUD_STORAGE_ENABLED || effective.OR3_STORAGE_ENABLED) !== "true" || (effective.OR3_STORAGE_PROVIDER || effective.NUXT_PUBLIC_STORAGE_PROVIDER) !== "fs") {
1399
+ throw new Error("Resolved Compose provider profile is missing required Basic/SQLite/filesystem settings.");
1400
+ }
1401
+ }
1172
1402
  async function assertSafeComposeBinding(directory, mode, env, deadline) {
1173
1403
  const config = await resolvedComposeConfig(directory, mode, deadline);
1174
1404
  const parsed = parseComposeConfig(config);
@@ -1568,8 +1798,14 @@ async function restoreVolumeArchive(directory, mode, env, backupPath) {
1568
1798
  ], join7(backupPath, "data.tgz"), directory, secretValues(env));
1569
1799
  }
1570
1800
  async function validateVolumeArchive(directory, _mode, env, backupPath, archivedStorageRoot) {
1801
+ const auth = env.AUTH_PROVIDER ?? env.OR3_AUTH_PROVIDER ?? "basic-auth";
1802
+ const sync = env.OR3_SYNC_PROVIDER ?? "sqlite";
1803
+ const storage = env.NUXT_PUBLIC_STORAGE_PROVIDER ?? "fs";
1804
+ if (env.AUTH_PROVIDER && env.OR3_AUTH_PROVIDER && env.AUTH_PROVIDER !== env.OR3_AUTH_PROVIDER || sync !== "sqlite" || !(auth === "basic-auth" && storage === "fs" || auth === "clerk" && storage === "s3"))
1805
+ throw new Error("Unsupported backup archive provider profile.");
1806
+ const requireAuth = auth === "basic-auth";
1571
1807
  const storageRoot = posix.relative("/data", archivedStorageRoot ?? env.OR3_STORAGE_FS_ROOT ?? "/data/storage");
1572
- const storagePrefix = storageRoot === ".." || storageRoot.startsWith("../") || posix.isAbsolute(storageRoot) ? "" : `${storageRoot ? `${storageRoot}/` : ""}workspaces/`;
1808
+ const storagePrefix = storage !== "fs" || storageRoot === ".." || storageRoot.startsWith("../") || posix.isAbsolute(storageRoot) ? "" : `${storageRoot ? `${storageRoot}/` : ""}workspaces/`;
1573
1809
  await streamFileToCommand("docker", [
1574
1810
  "run",
1575
1811
  "--rm",
@@ -1585,7 +1821,7 @@ async function validateVolumeArchive(directory, _mode, env, backupPath, archived
1585
1821
  "sh",
1586
1822
  env.OR3_IMAGE,
1587
1823
  "-c",
1588
- `{ tar tzf -; printf '\\nOR3_TAR_RESULT:%s\\n' "$?"; } | awk -v prefix="$1" '
1824
+ `{ tar tzf -; printf '\\nOR3_TAR_RESULT:%s\\n' "$?"; } | awk -v prefix="$1" -v require_auth="$2" '
1589
1825
  {
1590
1826
  entry = $0
1591
1827
  sub(/^\\.\\//, "", entry)
@@ -1610,8 +1846,8 @@ async function validateVolumeArchive(directory, _mode, env, backupPath, archived
1610
1846
  }
1611
1847
  END {
1612
1848
  if (!seen_status || tar_status != "0") exit 1
1613
- if (!auth || !sync) {
1614
- print "Backup archive is missing required managed database payloads (auth.sqlite and sync.sqlite)." > "/dev/stderr"
1849
+ if (!sync || (require_auth == "1" && !auth)) {
1850
+ print "Backup archive is missing required managed database payloads for its provider profile." > "/dev/stderr"
1615
1851
  exit 1
1616
1852
  }
1617
1853
  for (entry in metadata) {
@@ -1624,14 +1860,15 @@ END {
1624
1860
  }
1625
1861
  }'`,
1626
1862
  "or3-archive-validation",
1627
- storagePrefix
1863
+ storagePrefix,
1864
+ requireAuth ? "1" : "0"
1628
1865
  ], join7(backupPath, "data.tgz"), directory, secretValues(env));
1629
1866
  }
1630
1867
 
1631
1868
  // src/backup/manifests.ts
1632
- import { createHmac, randomBytes as randomBytes5, timingSafeEqual } from "node:crypto";
1633
- import { lstat, readdir as readdir2 } from "node:fs/promises";
1634
- import { isAbsolute, join as join9, resolve as resolve4 } from "node:path";
1869
+ import { createHash as createHash3, createHmac, randomBytes as randomBytes5, timingSafeEqual } from "node:crypto";
1870
+ import { lstat as lstat3, readdir as readdir2 } from "node:fs/promises";
1871
+ import { isAbsolute as isAbsolute3, join as join12, resolve as resolve4 } from "node:path";
1635
1872
 
1636
1873
  // src/deployment/assets.ts
1637
1874
  import { randomBytes as randomBytes4 } from "node:crypto";
@@ -1791,6 +2028,572 @@ function assertRestorableManagedAssets(manifest2) {
1791
2028
  }
1792
2029
  }
1793
2030
 
2031
+ // src/backup/remote.ts
2032
+ import { randomUUID as randomUUID2 } from "node:crypto";
2033
+ import { readFile as readFile3, rm as rm4, stat as stat2 } from "node:fs/promises";
2034
+ import { join as join10 } from "node:path";
2035
+
2036
+ // src/backup/helper.ts
2037
+ import { randomUUID } from "node:crypto";
2038
+ import { lstat, realpath } from "node:fs/promises";
2039
+ import { isAbsolute } from "node:path";
2040
+ var INSPECTOR = "/app/scripts/docker/backup-inspect.mjs";
2041
+ var NODE = 'if [ -x /nodejs/bin/node ]; then exec /nodejs/bin/node "$@"; else exec node "$@"; fi';
2042
+ var S3_ENV = new Set([
2043
+ "OR3_STORAGE_S3_ENDPOINT",
2044
+ "OR3_STORAGE_S3_REGION",
2045
+ "OR3_STORAGE_S3_BUCKET",
2046
+ "OR3_STORAGE_S3_ACCESS_KEY_ID",
2047
+ "OR3_STORAGE_S3_SECRET_ACCESS_KEY",
2048
+ "OR3_STORAGE_S3_SESSION_TOKEN",
2049
+ "OR3_STORAGE_S3_FORCE_PATH_STYLE",
2050
+ "OR3_STORAGE_S3_KEY_PREFIX",
2051
+ "OR3_STORAGE_S3_ALLOW_INSECURE_HTTP",
2052
+ "OR3_BACKUP_S3_ENDPOINT_ORIGIN",
2053
+ "OR3_BACKUP_S3_BUCKET",
2054
+ "OR3_BACKUP_S3_KEY_PREFIX",
2055
+ "OR3_BACKUP_ARCHIVE_SHA256"
2056
+ ]);
2057
+ var S3_SECRETS = new Set(["OR3_STORAGE_S3_ACCESS_KEY_ID", "OR3_STORAGE_S3_SECRET_ACCESS_KEY", "OR3_STORAGE_S3_SESSION_TOKEN"]);
2058
+ async function withBackupHelper(directory, image, env, options, body) {
2059
+ const name = `or3-backup-inspection-${randomUUID()}`;
2060
+ const names = options.passEnv ?? [];
2061
+ if (names.some((key) => !S3_ENV.has(key) || options.network === "none" && S3_SECRETS.has(key)))
2062
+ throw new Error("Invalid backup helper environment selection.");
2063
+ const lifetime = options.lifetimeSeconds ?? (options.network === "none" ? 240 : 14400);
2064
+ if (!Number.isSafeInteger(lifetime) || lifetime < 1 || lifetime > 14400)
2065
+ throw new Error("Invalid backup helper lifetime.");
2066
+ const streamTimeout = options.network === "egress" || options.lifetimeSeconds !== undefined ? lifetime * 1000 : undefined;
2067
+ let archiveMount = [];
2068
+ let helperUid = 65532, helperGid = 65532;
2069
+ if (options.archiveFile !== undefined) {
2070
+ if (!isAbsolute(options.archiveFile) || /[,\r\n]/.test(options.archiveFile))
2071
+ throw new Error("Invalid recovery archive path.");
2072
+ const info = await lstat(options.archiveFile);
2073
+ if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || (info.mode & 63) !== 0 || typeof process.getuid === "function" && info.uid !== process.getuid())
2074
+ throw new Error("Recovery archive must be a private regular file owned by the operator.");
2075
+ const canonical = await realpath(options.archiveFile);
2076
+ if (/[,\r\n]/.test(canonical))
2077
+ throw new Error("Invalid recovery archive path.");
2078
+ archiveMount = ["--mount", `type=bind,src=${canonical},dst=/or3-backup.tar,readonly`];
2079
+ helperUid = info.uid;
2080
+ helperGid = info.gid;
2081
+ }
2082
+ const environment = { ...process.env };
2083
+ for (const key of S3_ENV)
2084
+ delete environment[key];
2085
+ for (const key of names) {
2086
+ if (env[key] !== undefined)
2087
+ environment[key] = env[key];
2088
+ }
2089
+ const secrets = secretValues(env);
2090
+ const checked = async (args) => {
2091
+ const result = await run("docker", args, directory, environment);
2092
+ if (!result.ok)
2093
+ throw new Error(`Offline backup reference inspection failed or is unsupported. ${redact(result.stderr, secrets)}`);
2094
+ return result.stdout.trim();
2095
+ };
2096
+ let primary;
2097
+ let value;
2098
+ try {
2099
+ await checked([
2100
+ "run",
2101
+ "--pull",
2102
+ "never",
2103
+ "--rm",
2104
+ "--detach",
2105
+ "--name",
2106
+ name,
2107
+ "--network",
2108
+ options.network === "none" ? "none" : "bridge",
2109
+ "--read-only",
2110
+ "--security-opt",
2111
+ "no-new-privileges:true",
2112
+ "--cap-drop",
2113
+ "ALL",
2114
+ "--memory",
2115
+ "256m",
2116
+ "--cpus",
2117
+ "0.5",
2118
+ "--pids-limit",
2119
+ "32",
2120
+ ...options.archiveFile ? ["--user", `${helperUid}:${helperGid}`] : [],
2121
+ "--tmpfs",
2122
+ `/scratch:rw,noexec,nosuid,nodev,size=512m,mode=700,uid=${helperUid},gid=${helperGid}`,
2123
+ ...archiveMount,
2124
+ ...names.filter((key) => env[key] !== undefined).flatMap((key) => ["--env", key]),
2125
+ "--entrypoint",
2126
+ "sh",
2127
+ image,
2128
+ "-c",
2129
+ `sleep ${lifetime}`
2130
+ ]);
2131
+ const helper = {
2132
+ exec: (args) => checked(["exec", name, ...args]),
2133
+ execFromFile: async (args, file) => streamFileToCommand("docker", ["exec", "-i", name, ...args], file, directory, secrets, environment, streamTimeout),
2134
+ execToFile: async (args, file) => streamCommandToFile("docker", ["exec", name, ...args], file, directory, secrets, environment, streamTimeout),
2135
+ async loadSqlite(dataTgz) {
2136
+ await this.execFromFile(["sh", "-c", "ulimit -f 16384; tar tvzf - > /scratch/inventory"], dataTgz);
2137
+ const inventory = JSON.parse(await this.exec(["sh", "-c", NODE, "or3-backup-inspection", INSPECTOR, "inventory"]));
2138
+ if (!Array.isArray(inventory.selected) || !inventory.selected.includes("./sync.sqlite") && !inventory.selected.includes("sync.sqlite") || inventory.selected.some((item) => !/^(?:\.\/)?sync[.]sqlite(?:-wal)?$/.test(item)) || inventory.selected.length > 2 || !Number.isSafeInteger(inventory.bytes) || inventory.bytes < 0)
2139
+ throw new Error("Invalid offline backup inspection inventory response.");
2140
+ const freeKiB = Number(await this.exec(["sh", "-c", "df -Pk /scratch | awk 'NR == 2 { print $4 }'"]));
2141
+ if (!Number.isFinite(freeKiB) || freeKiB < 0)
2142
+ throw new Error("Could not verify offline backup scratch capacity.");
2143
+ assertEnoughFreeSpace(freeKiB * 1024, inventory.bytes, "Offline backup inspection scratch");
2144
+ await this.execFromFile(["tar", "-xz", "-f", "-", "-C", "/scratch", ...inventory.selected], dataTgz);
2145
+ }
2146
+ };
2147
+ value = await body(helper);
2148
+ } catch (error) {
2149
+ primary = error;
2150
+ }
2151
+ const cleanup = await run("docker", ["rm", "--force", name], directory, environment);
2152
+ if (!cleanup.ok && !/No such container/i.test(cleanup.stderr)) {
2153
+ throw new AggregateError(primary ? [primary] : [], `Offline backup inspection helper cleanup failed. ${redact(cleanup.stderr, secrets)}`);
2154
+ }
2155
+ if (primary)
2156
+ throw primary;
2157
+ return value;
2158
+ }
2159
+
2160
+ // src/backup/remote.ts
2161
+ var INSPECTOR2 = "/app/scripts/docker/backup-inspect.mjs";
2162
+ var REMOTE = "/app/scripts/docker/remote-objects.mjs";
2163
+ var NODE2 = 'if [ -x /nodejs/bin/node ]; then exec /nodejs/bin/node "$@"; else exec node "$@"; fi';
2164
+ var INPUT_NODE = 'if [ -x /nodejs/bin/node ]; then exec /nodejs/bin/node "$@" < /or3-backup.tar; else exec node "$@" < /or3-backup.tar; fi';
2165
+ var SOURCE_ENV = ["OR3_STORAGE_S3_ENDPOINT", "OR3_STORAGE_S3_REGION", "OR3_STORAGE_S3_BUCKET", "OR3_STORAGE_S3_KEY_PREFIX", "OR3_STORAGE_S3_ALLOW_INSECURE_HTTP"];
2166
+ var TRANSFER_ENV = [...SOURCE_ENV, "OR3_STORAGE_S3_ACCESS_KEY_ID", "OR3_STORAGE_S3_SECRET_ACCESS_KEY", "OR3_STORAGE_S3_SESSION_TOKEN", "OR3_STORAGE_S3_FORCE_PATH_STYLE"];
2167
+ function remoteSource(env) {
2168
+ if (env.OR3_SYNC_PROVIDER !== "sqlite" || env.NUXT_PUBLIC_STORAGE_PROVIDER !== "s3" || env.OR3_SQLITE_DRIVER && env.OR3_SQLITE_DRIVER !== "better-sqlite3")
2169
+ throw new Error("Unsupported offline S3 backup provider profile.");
2170
+ let endpoint;
2171
+ try {
2172
+ endpoint = new URL((env.OR3_STORAGE_S3_ENDPOINT ?? "").trim());
2173
+ } catch {
2174
+ throw new Error("Invalid S3 recovery configuration.");
2175
+ }
2176
+ if (!["http:", "https:"].includes(endpoint.protocol) || endpoint.username || endpoint.password || endpoint.pathname !== "/" || endpoint.search || endpoint.hash || endpoint.protocol === "http:" && env.OR3_STORAGE_S3_ALLOW_INSECURE_HTTP !== "true")
2177
+ throw new Error("Invalid S3 recovery configuration.");
2178
+ const bucket = (env.OR3_STORAGE_S3_BUCKET ?? "").trim();
2179
+ const prefix = (env.OR3_STORAGE_S3_KEY_PREFIX ?? "").trim().replace(/^\/+/, "").replace(/\/+$/, "");
2180
+ if (!bucket || bucket.length > 255 || /[\x00-\x20\x7f/\\]/.test(bucket) || prefix.length > 511 || /[\x00-\x1f\\]/.test(prefix) || prefix && prefix.split("/").some((part) => !part || part === "." || part === ".."))
2181
+ throw new Error("Invalid S3 recovery configuration.");
2182
+ return { endpointOrigin: endpoint.origin, bucket, keyPrefix: prefix ? `${prefix}/` : "" };
2183
+ }
2184
+ function assertRemoteTransferConfiguration(env) {
2185
+ const source = remoteSource(env);
2186
+ if (!env.OR3_STORAGE_S3_REGION?.trim() || !env.OR3_STORAGE_S3_ACCESS_KEY_ID?.trim() || !env.OR3_STORAGE_S3_SECRET_ACCESS_KEY?.trim())
2187
+ throw new Error("Invalid S3 recovery configuration.");
2188
+ return source;
2189
+ }
2190
+ async function preflightRemoteRestore(directory, manifest2, target, backupDir) {
2191
+ if (manifest2.remote.kind === "s3-copy-v1") {
2192
+ try {
2193
+ return await reconcileRemoteTarget(directory, manifest2, target, backupDir, manifest2.remote);
2194
+ } catch (error) {
2195
+ const detail = redact(error instanceof Error ? error.message : String(error), secretValues(target));
2196
+ throw new Error(`S3 reconciliation did not complete; additive objects may remain in the target. ${detail}`, { cause: error });
2197
+ }
2198
+ }
2199
+ const result = await checkRemoteTarget(directory, manifest2, target, backupDir, manifest2.remote);
2200
+ if (result.missing || result.mismatched)
2201
+ throw new Error("S3 reference target has missing or mismatched objects.");
2202
+ return result;
2203
+ }
2204
+ function checkedCounts(result, expected) {
2205
+ if (result.checked !== expected || !Array.isArray(result.missing) || !Array.isArray(result.mismatched) || result.missing.length + result.mismatched.length > result.checked || [...result.missing, ...result.mismatched].some((value) => typeof value !== "string" || value.length > 1024))
2206
+ throw new Error("Invalid S3 reference check receipt.");
2207
+ return { checked: result.checked, missing: result.missing.length, mismatched: result.mismatched.length };
2208
+ }
2209
+ function sourceEnvironment(source) {
2210
+ const env = { OR3_SYNC_PROVIDER: "sqlite", NUXT_PUBLIC_STORAGE_PROVIDER: "s3", OR3_STORAGE_S3_ENDPOINT: source.endpointOrigin, OR3_STORAGE_S3_BUCKET: source.bucket, OR3_STORAGE_S3_KEY_PREFIX: source.keyPrefix, OR3_STORAGE_S3_ALLOW_INSECURE_HTTP: source.endpointOrigin.startsWith("http:") ? "true" : "false" };
2211
+ const normalized = remoteSource(env);
2212
+ if (normalized.endpointOrigin !== source.endpointOrigin || normalized.bucket !== source.bucket || normalized.keyPrefix !== source.keyPrefix)
2213
+ throw new Error("Invalid S3 backup source.");
2214
+ return env;
2215
+ }
2216
+ async function assertArchive(backupDir, remote) {
2217
+ const unvalidated = remote;
2218
+ if (unvalidated.kind !== "s3-copy-v1" || unvalidated.file !== "remote-objects.tar" || !/^[a-f0-9]{64}$/.test(remote.sha256) || !Number.isSafeInteger(remote.bytes) || remote.bytes < 1)
2219
+ throw new Error("Invalid S3 backup archive metadata.");
2220
+ const archive = join10(backupDir, remote.file);
2221
+ if ((await stat2(archive)).size !== remote.bytes || await sha256File(archive) !== remote.sha256)
2222
+ throw new Error("S3 backup archive checksum or size mismatch.");
2223
+ return archive;
2224
+ }
2225
+ async function verifyRemoteSnapshot(directory, image, backupDir, remote) {
2226
+ const env = sourceEnvironment(remote.source);
2227
+ const archive = await assertArchive(backupDir, remote);
2228
+ const receipt = join10(deploymentPaths(directory).operations, `.s3-verify-${randomUUID2()}.json`);
2229
+ let verified;
2230
+ try {
2231
+ verified = await withBackupHelper(directory, imageAtDigest(image.image, image.imageDigest), env, { network: "none", passEnv: SOURCE_ENV, archiveFile: archive, lifetimeSeconds: 14400 }, async (helper) => {
2232
+ await helper.loadSqlite(join10(backupDir, "data.tgz"));
2233
+ await helper.execToFile(["sh", "-c", INPUT_NODE, "or3-s3-verify", REMOTE, "verify", "/scratch/sync.sqlite"], receipt);
2234
+ if ((await stat2(receipt)).size > 64 * 1024)
2235
+ throw new Error("Invalid S3 backup verification receipt.");
2236
+ return JSON.parse(await readFile3(receipt, "utf8"));
2237
+ });
2238
+ } finally {
2239
+ await rm4(receipt, { force: true });
2240
+ }
2241
+ if (verified.contract !== 2 || verified.kind !== "s3-copy-v1" || verified.objectCount !== remote.objectCount || verified.payloadBytes !== remote.payloadBytes || verified.inventorySha256 !== remote.inventorySha256)
2242
+ throw new Error("S3 backup verification did not match the authenticated manifest.");
2243
+ await assertArchive(backupDir, remote);
2244
+ }
2245
+ async function reconcileRemoteTarget(directory, image, target, backupDir, remote) {
2246
+ const destination = assertRemoteTransferConfiguration(target);
2247
+ if (destination.keyPrefix !== remote.source.keyPrefix)
2248
+ throw new Error("S3 backup and target key prefix mismatch.");
2249
+ await verifyRemoteSnapshot(directory, image, backupDir, remote);
2250
+ const archive = await assertArchive(backupDir, remote);
2251
+ const env = { ...target, OR3_BACKUP_S3_ENDPOINT_ORIGIN: remote.source.endpointOrigin, OR3_BACKUP_S3_BUCKET: remote.source.bucket, OR3_BACKUP_S3_KEY_PREFIX: remote.source.keyPrefix, OR3_BACKUP_ARCHIVE_SHA256: remote.sha256 };
2252
+ const receipt = join10(deploymentPaths(directory).operations, `.s3-reconcile-${randomUUID2()}.json`);
2253
+ let result;
2254
+ try {
2255
+ result = await withBackupHelper(directory, imageAtDigest(image.image, image.imageDigest), env, { network: "egress", passEnv: [...TRANSFER_ENV, "OR3_BACKUP_S3_ENDPOINT_ORIGIN", "OR3_BACKUP_S3_BUCKET", "OR3_BACKUP_S3_KEY_PREFIX", "OR3_BACKUP_ARCHIVE_SHA256"], archiveFile: archive, lifetimeSeconds: 14400 }, async (helper) => {
2256
+ await helper.loadSqlite(join10(backupDir, "data.tgz"));
2257
+ await helper.execToFile(["sh", "-c", NODE2, "or3-s3-reconcile", REMOTE, "reconcile", "/scratch/sync.sqlite", "/or3-backup.tar"], receipt);
2258
+ if ((await stat2(receipt)).size > 64 * 1024)
2259
+ throw new Error("Invalid S3 reconciliation receipt.");
2260
+ return JSON.parse(await readFile3(receipt, "utf8"));
2261
+ });
2262
+ } finally {
2263
+ await rm4(receipt, { force: true });
2264
+ }
2265
+ if (!Number.isSafeInteger(result.uploaded) || result.uploaded < 0 || result.uploaded > remote.objectCount || !Number.isSafeInteger(result.markersCreated) || result.markersCreated < 0 || result.markersCreated > remote.objectCount)
2266
+ throw new Error("Invalid S3 reconciliation receipt.");
2267
+ return { uploaded: result.uploaded, markersCreated: result.markersCreated };
2268
+ }
2269
+ async function checkRemoteCopyTarget(directory, image, target, backupDir, remote) {
2270
+ const destination = assertRemoteTransferConfiguration(target);
2271
+ if (destination.keyPrefix !== remote.source.keyPrefix)
2272
+ throw new Error("S3 backup and target key prefix mismatch.");
2273
+ await verifyRemoteSnapshot(directory, image, backupDir, remote);
2274
+ const archive = await assertArchive(backupDir, remote);
2275
+ const env = { ...target, OR3_BACKUP_S3_ENDPOINT_ORIGIN: remote.source.endpointOrigin, OR3_BACKUP_S3_BUCKET: remote.source.bucket, OR3_BACKUP_S3_KEY_PREFIX: remote.source.keyPrefix, OR3_BACKUP_ARCHIVE_SHA256: remote.sha256 };
2276
+ const receipt = join10(deploymentPaths(directory).operations, `.s3-check-copy-${randomUUID2()}.json`);
2277
+ try {
2278
+ const result = await withBackupHelper(directory, imageAtDigest(image.image, image.imageDigest), env, { network: "egress", passEnv: [...TRANSFER_ENV, "OR3_BACKUP_S3_ENDPOINT_ORIGIN", "OR3_BACKUP_S3_BUCKET", "OR3_BACKUP_S3_KEY_PREFIX", "OR3_BACKUP_ARCHIVE_SHA256"], archiveFile: archive, lifetimeSeconds: 14400 }, async (helper) => {
2279
+ await helper.loadSqlite(join10(backupDir, "data.tgz"));
2280
+ await helper.execToFile(["sh", "-c", NODE2, "or3-s3-check-copy", REMOTE, "check-copy", "/scratch/sync.sqlite", "/or3-backup.tar"], receipt);
2281
+ if ((await stat2(receipt)).size > 64 * 1024 * 1024)
2282
+ throw new Error("Invalid S3 reference check receipt.");
2283
+ return JSON.parse(await readFile3(receipt, "utf8"));
2284
+ });
2285
+ return checkedCounts(result, remote.objectCount);
2286
+ } finally {
2287
+ await rm4(receipt, { force: true });
2288
+ }
2289
+ }
2290
+ async function checkRemoteTarget(directory, image, target, backupDir, remote) {
2291
+ const source = assertRemoteTransferConfiguration(target);
2292
+ if (source.endpointOrigin !== remote.source.endpointOrigin || source.bucket !== remote.source.bucket || source.keyPrefix !== remote.source.keyPrefix)
2293
+ throw new Error("S3 reference snapshot can only use its original source target.");
2294
+ const unvalidated = remote;
2295
+ if (unvalidated.kind !== "s3-reference-v1" || unvalidated.file !== "remote-inventory.json" || !/^[a-f0-9]{64}$/.test(remote.sha256) || remote.inventorySha256 !== remote.sha256 || !Number.isSafeInteger(remote.bytes) || remote.bytes < 1 || remote.bytes > 64 * 1024 * 1024)
2296
+ throw new Error("Invalid S3 backup reference inventory.");
2297
+ const inventory = join10(backupDir, remote.file);
2298
+ const assertInventory = async () => {
2299
+ if ((await stat2(inventory)).size !== remote.bytes || await sha256File(inventory) !== remote.sha256)
2300
+ throw new Error("S3 backup inventory checksum or size mismatch.");
2301
+ };
2302
+ await assertInventory();
2303
+ const offlineEnv = sourceEnvironment(remote.source);
2304
+ const receipt = join10(deploymentPaths(directory).operations, `.s3-check-${randomUUID2()}.json`);
2305
+ try {
2306
+ const verified = await withBackupHelper(directory, imageAtDigest(image.image, image.imageDigest), offlineEnv, { network: "none", passEnv: SOURCE_ENV, archiveFile: inventory, lifetimeSeconds: 14400 }, async (helper) => {
2307
+ await helper.loadSqlite(join10(backupDir, "data.tgz"));
2308
+ await helper.execToFile(["sh", "-c", INPUT_NODE, "or3-s3-verify-inventory", REMOTE, "verify-inventory", "/scratch/sync.sqlite"], receipt);
2309
+ if ((await stat2(receipt)).size > 64 * 1024)
2310
+ throw new Error("Invalid S3 backup verification receipt.");
2311
+ return JSON.parse(await readFile3(receipt, "utf8"));
2312
+ });
2313
+ if (verified.contract !== 2 || verified.kind !== "s3-reference-v1" || verified.objectCount !== remote.objectCount || verified.payloadBytes !== remote.payloadBytes || verified.inventorySha256 !== remote.inventorySha256)
2314
+ throw new Error("S3 inventory verification did not match the authenticated manifest.");
2315
+ await rm4(receipt, { force: true });
2316
+ await assertInventory();
2317
+ const result = await withBackupHelper(directory, imageAtDigest(image.image, image.imageDigest), target, { network: "egress", passEnv: TRANSFER_ENV, archiveFile: inventory, lifetimeSeconds: 14400 }, async (helper) => {
2318
+ await helper.loadSqlite(join10(backupDir, "data.tgz"));
2319
+ await helper.execToFile(["sh", "-c", INPUT_NODE, "or3-s3-check", REMOTE, "check", "/scratch/sync.sqlite"], receipt);
2320
+ if ((await stat2(receipt)).size > 64 * 1024 * 1024)
2321
+ throw new Error("Invalid S3 reference check receipt.");
2322
+ return JSON.parse(await readFile3(receipt, "utf8"));
2323
+ });
2324
+ return checkedCounts(result, remote.objectCount);
2325
+ } finally {
2326
+ await rm4(receipt, { force: true });
2327
+ }
2328
+ }
2329
+ function assertSummary(summary) {
2330
+ if (summary.contract !== 2 || !Number.isSafeInteger(summary.references) || summary.references < 0 || summary.references > 1e5 || !Number.isSafeInteger(summary.payloadBytes) || summary.payloadBytes < 0 || summary.payloadBytes > summary.references * 2 ** 30)
2331
+ throw new Error("Invalid S3 backup reference summary.");
2332
+ }
2333
+ async function captureRemoteInventory(directory, state, env, backupDir) {
2334
+ const source = remoteSource(env);
2335
+ const image = imageAtDigest(state.image, state.imageDigest);
2336
+ const data = join10(backupDir, "data.tgz");
2337
+ const inventory = join10(backupDir, "remote-inventory.json");
2338
+ const summary = await withBackupHelper(directory, image, env, { network: "none", passEnv: SOURCE_ENV, lifetimeSeconds: 14400 }, async (helper) => {
2339
+ await helper.loadSqlite(data);
2340
+ const result = JSON.parse(await helper.exec(["sh", "-c", NODE2, "or3-s3-summary", INSPECTOR2, "s3-summary", source.keyPrefix]));
2341
+ assertSummary(result);
2342
+ await helper.execToFile(["sh", "-c", NODE2, "or3-s3-inventory", REMOTE, "inventory", "/scratch/sync.sqlite"], inventory);
2343
+ return result;
2344
+ });
2345
+ const bytes = (await stat2(inventory)).size;
2346
+ if (!Number.isSafeInteger(bytes) || bytes < 1 || bytes > 64 * 1024 * 1024)
2347
+ throw new Error("Invalid S3 backup reference inventory.");
2348
+ const before = await sha256File(inventory);
2349
+ const receipt = join10(backupDir, `.s3-inventory-verification-${randomUUID2()}.json`);
2350
+ let verified;
2351
+ try {
2352
+ verified = await withBackupHelper(directory, image, env, { network: "none", passEnv: SOURCE_ENV, archiveFile: inventory, lifetimeSeconds: 14400 }, async (helper) => {
2353
+ await helper.loadSqlite(data);
2354
+ await helper.execToFile(["sh", "-c", INPUT_NODE, "or3-s3-verify-inventory", REMOTE, "verify-inventory", "/scratch/sync.sqlite"], receipt);
2355
+ if ((await stat2(receipt)).size > 64 * 1024)
2356
+ throw new Error("Invalid S3 backup verification receipt.");
2357
+ return JSON.parse(await readFile3(receipt, "utf8"));
2358
+ });
2359
+ } finally {
2360
+ await rm4(receipt, { force: true });
2361
+ }
2362
+ if (verified.contract !== 2 || verified.kind !== "s3-reference-v1" || verified.objectCount !== summary.references || verified.payloadBytes !== summary.payloadBytes || verified.inventorySha256 !== before)
2363
+ throw new Error("S3 backup verification did not match the canonical reference summary.");
2364
+ if (await sha256File(inventory) !== before || (await stat2(inventory)).size !== bytes)
2365
+ throw new Error("S3 backup inventory changed during verification.");
2366
+ return { kind: "s3-reference-v1", file: "remote-inventory.json", sha256: before, bytes, inventorySha256: verified.inventorySha256, objectCount: verified.objectCount, payloadBytes: verified.payloadBytes, source, providerVersion: "0.0.15" };
2367
+ }
2368
+ async function captureRemoteSnapshot(directory, state, env, backupDir) {
2369
+ const source = assertRemoteTransferConfiguration(env);
2370
+ const image = imageAtDigest(state.image, state.imageDigest);
2371
+ const data = join10(backupDir, "data.tgz");
2372
+ const archive = join10(backupDir, "remote-objects.tar");
2373
+ const summary = await withBackupHelper(directory, image, env, { network: "none", passEnv: SOURCE_ENV }, async (helper) => {
2374
+ await helper.loadSqlite(data);
2375
+ return JSON.parse(await helper.exec(["sh", "-c", NODE2, "or3-s3-summary", INSPECTOR2, "s3-summary", source.keyPrefix]));
2376
+ });
2377
+ assertSummary(summary);
2378
+ const required = summary.payloadBytes + summary.references * (64 * 1024 + 4096) + 64 * 1024 * 1024;
2379
+ await assertFreeSpaceForArchive(archive, required, "S3 backup");
2380
+ await withBackupHelper(directory, image, env, { network: "egress", passEnv: TRANSFER_ENV }, async (helper) => {
2381
+ await helper.loadSqlite(data);
2382
+ await helper.execToFile(["sh", "-c", NODE2, "or3-s3-capture", REMOTE, "capture", "/scratch/sync.sqlite"], archive);
2383
+ });
2384
+ const before = await sha256File(archive);
2385
+ const receipt = join10(backupDir, `.s3-verification-${randomUUID2()}.json`);
2386
+ let verified;
2387
+ try {
2388
+ verified = await withBackupHelper(directory, image, env, { network: "none", passEnv: SOURCE_ENV, archiveFile: archive, lifetimeSeconds: 14400 }, async (helper) => {
2389
+ await helper.loadSqlite(data);
2390
+ await helper.execToFile(["sh", "-c", INPUT_NODE, "or3-s3-verify", REMOTE, "verify", "/scratch/sync.sqlite"], receipt);
2391
+ const size = (await stat2(receipt)).size;
2392
+ if (size > 64 * 1024)
2393
+ throw new Error("Invalid S3 backup verification receipt.");
2394
+ return JSON.parse(await readFile3(receipt, "utf8"));
2395
+ });
2396
+ } finally {
2397
+ await rm4(receipt, { force: true });
2398
+ }
2399
+ if (verified.contract !== 2 || verified.kind !== "s3-copy-v1" || verified.objectCount !== summary.references || verified.payloadBytes !== summary.payloadBytes || !/^[a-f0-9]{64}$/.test(verified.inventorySha256))
2400
+ throw new Error("S3 backup verification did not match the canonical reference summary.");
2401
+ const bytes = (await stat2(archive)).size;
2402
+ if (!Number.isSafeInteger(bytes) || bytes < 0 || await sha256File(archive) !== before)
2403
+ throw new Error("S3 backup archive changed during verification.");
2404
+ return { kind: "s3-copy-v1", file: "remote-objects.tar", sha256: before, bytes, inventorySha256: verified.inventorySha256, objectCount: verified.objectCount, payloadBytes: verified.payloadBytes, source, providerVersion: "0.0.15" };
2405
+ }
2406
+
2407
+ // src/backup/vault.ts
2408
+ import { createHash as createHash2, randomUUID as randomUUID3 } from "node:crypto";
2409
+ import { constants } from "node:fs";
2410
+ import { lstat as lstat2, open as open2, readFile as readFile4 } from "node:fs/promises";
2411
+ import { isAbsolute as isAbsolute2, join as join11 } from "node:path";
2412
+ async function readAuthenticatedVaultId(path, expectedSha256) {
2413
+ if (!/^[a-f0-9]{64}$/.test(expectedSha256))
2414
+ throw new Error("Invalid vault backup checksum.");
2415
+ const file = await open2(path, constants.O_RDONLY | constants.O_NOFOLLOW);
2416
+ try {
2417
+ const before = await file.stat({ bigint: true });
2418
+ const identity = (info) => [info.dev, info.ino, info.size, info.mtimeNs, info.ctimeNs].join(":");
2419
+ const pathInfo = await lstat2(path, { bigint: true });
2420
+ if (!before.isFile() || !pathInfo.isFile() || pathInfo.isSymbolicLink() || before.nlink !== 1n || before.size < 113n || before.size > BigInt(2 ** 30 + 112) || identity(before) !== identity(pathInfo))
2421
+ throw new Error("invalid vault backup");
2422
+ const hash = createHash2("sha256"), buffer = Buffer.alloc(1024 * 1024), header = Buffer.alloc(96);
2423
+ let offset = 0;
2424
+ while (offset < Number(before.size)) {
2425
+ const count = (await file.read(buffer, 0, Math.min(buffer.length, Number(before.size) - offset), offset)).bytesRead;
2426
+ if (count === 0)
2427
+ throw new Error("truncated vault backup");
2428
+ if (offset < header.length)
2429
+ buffer.copy(header, offset, 0, Math.min(count, header.length - offset));
2430
+ hash.update(buffer.subarray(0, count));
2431
+ offset += count;
2432
+ }
2433
+ if (hash.digest("hex") !== expectedSha256 || identity(before) !== identity(await file.stat({ bigint: true })) || identity(before) !== identity(await lstat2(path, { bigint: true })))
2434
+ throw new Error("vault backup changed");
2435
+ const vaultId = header.subarray(8, 40).toString("latin1");
2436
+ if (!header.subarray(0, 8).equals(Buffer.from("OR3BAK01")) || !/^[a-f0-9]{32}$/.test(vaultId) || header.readBigUInt64BE(84) + 112n !== before.size || header.subarray(92, 96).some((byte) => byte !== 0))
2437
+ throw new Error("invalid vault header");
2438
+ return vaultId;
2439
+ } catch {
2440
+ throw new Error("Vault backup checksum, header, or file identity is invalid.");
2441
+ } finally {
2442
+ await file.close();
2443
+ }
2444
+ }
2445
+ function backupHasVaultCoverage(manifest2, env, archivedConfig) {
2446
+ if (Object.keys(env).some((key) => /^(?:RUNTIME_CONFIG|NUXT_SECRETS|NITRO_ENV_(?:PREFIX|EXPANSION)|NITRO_SECRETS(?:_.*)?)$/.test(key)))
2447
+ return false;
2448
+ const provider = env.NUXT_SECRETS_PROVIDER;
2449
+ if (provider === undefined && manifest2.appVersion === "0.1.90" && manifest2.image === "ghcr.io/saluana/or3-chat:0.1.90" && manifest2.imageDigest === "sha256:d50b70d6522574798f1a3081894b5bf8fe9eca7aa5f183ad5a562de2e4f41c12" && archivedConfig !== undefined && createHash2("sha256").update(archivedConfig).digest("hex") === manifest2.configSha256) {
2450
+ const keys = new Set;
2451
+ for (const line of archivedConfig.split(/\r?\n/)) {
2452
+ const text = line.trim();
2453
+ if (!text || text.startsWith("#"))
2454
+ continue;
2455
+ const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(text);
2456
+ if (!match || keys.has(match[1]) || !/^(?:'[^'\r\n]*'|"[^"$\\\r\n]*"|[^'"$\\\s]*)$/.test(match[2]))
2457
+ return false;
2458
+ keys.add(match[1]);
2459
+ }
2460
+ return !keys.has("NUXT_SECRETS_PROVIDER");
2461
+ }
2462
+ return provider === "" || (provider === undefined || provider === "or3-secrets") && Boolean(manifest2.vaultSha256);
2463
+ }
2464
+ async function assertVaultVolumesExist(directory, state) {
2465
+ const dataVolume = `${state.volumeName}-vault-data`;
2466
+ const keyVolume = `${state.volumeName}-vault-key`;
2467
+ for (const volume of [dataVolume, keyVolume]) {
2468
+ const result = await run("docker", ["volume", "inspect", volume], directory);
2469
+ if (!result.ok)
2470
+ throw new Error("Vault custody is missing or cannot be inspected. The operation is refused; preserve the encrypted backup and recover the vault key and volumes first.");
2471
+ }
2472
+ }
2473
+ async function assertExistingVaultCustody(directory, state, manifest2, backupPath, backupEnv) {
2474
+ const archivedConfig = await readFile4(join11(backupPath, "config.env"), "utf8");
2475
+ if (!backupHasVaultCoverage(manifest2, backupEnv, archivedConfig))
2476
+ throw new Error("Backup lacks the vault archive required by its saved configuration. Preserve it and use a separately reviewed recovery procedure.");
2477
+ if (!manifest2.vaultSha256)
2478
+ return;
2479
+ const expectedVaultId = await readAuthenticatedVaultId(join11(backupPath, "vault.backup"), manifest2.vaultSha256);
2480
+ const dataVolume = `${state.volumeName}-vault-data`;
2481
+ const keyVolume = `${state.volumeName}-vault-key`;
2482
+ await assertVaultVolumesExist(directory, state);
2483
+ const script = `
2484
+ const fs = require('node:fs');
2485
+ const { spawnSync } = require('node:child_process');
2486
+ for (const path of ['/vault-data/live/complete','/vault-key/master.key','/vault-key/operator.token']) {
2487
+ const st = fs.lstatSync(path);
2488
+ if (!st.isFile() || st.isSymbolicLink() || st.uid !== 1000 || (st.mode & 0o777) !== 0o600 || st.nlink !== 1 || st.size < 1) process.exit(1);
2489
+ }
2490
+ if (!/^[a-f0-9]{32}$/.test(fs.readFileSync('/vault-data/live/complete', 'utf8').trim())) process.exit(1);
2491
+ // Never open the running vault database for maintenance. Copy a stable view to
2492
+ // private tmpfs and verify that copy with the existing vault's master key.
2493
+ const db = '/vault-data/live/vault.sqlite';
2494
+ const journal = db + '-journal';
2495
+ const identity = (s) => [s.dev, s.ino, s.size, s.mtimeNs, s.ctimeNs].join(':');
2496
+ const journalIdentity = () => {
2497
+ let input;
2498
+ try { input = fs.openSync(journal, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); }
2499
+ catch (e) { if (e.code === 'ENOENT') return 'absent'; throw e; }
2500
+ try {
2501
+ const before = fs.fstatSync(input, { bigint: true });
2502
+ if (!before.isFile() || before.uid !== 1000n || (before.mode & 0o777n) !== 0o600n || before.nlink !== 1n) process.exit(1);
2503
+ if (before.size !== 0n) {
2504
+ if (before.size < 8n) process.exit(1);
2505
+ const header = Buffer.alloc(8);
2506
+ if (fs.readSync(input, header, 0, 8, 0) !== 8 || !header.every(byte => byte === 0)) process.exit(1);
2507
+ }
2508
+ if (identity(before) !== identity(fs.fstatSync(input, { bigint: true })) || identity(before) !== identity(fs.lstatSync(journal, { bigint: true }))) process.exit(1);
2509
+ return identity(before);
2510
+ } finally { fs.closeSync(input); }
2511
+ };
2512
+ const copy = (source, destination, maxSize) => {
2513
+ const input = fs.openSync(source, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
2514
+ try {
2515
+ const before = fs.fstatSync(input, { bigint: true });
2516
+ if (!before.isFile() || before.uid !== 1000n || (before.mode & 0o777n) !== 0o600n || before.nlink !== 1n || before.size < 1n || before.size > BigInt(maxSize)) process.exit(1);
2517
+ const output = fs.openSync(destination, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
2518
+ try {
2519
+ const buffer = Buffer.alloc(1024 * 1024);
2520
+ let remaining = Number(before.size);
2521
+ while (remaining > 0) {
2522
+ const n = fs.readSync(input, buffer, 0, Math.min(buffer.length, remaining), null);
2523
+ if (n === 0) process.exit(1);
2524
+ let offset = 0;
2525
+ while (offset < n) offset += fs.writeSync(output, buffer, offset, n - offset);
2526
+ remaining -= n;
2527
+ }
2528
+ } finally { fs.closeSync(output); }
2529
+ if (identity(before) !== identity(fs.fstatSync(input, { bigint: true })) || identity(before) !== identity(fs.lstatSync(source, { bigint: true }))) process.exit(1);
2530
+ return identity(before);
2531
+ } finally { fs.closeSync(input); }
2532
+ };
2533
+ const journalBefore = journalIdentity();
2534
+ fs.mkdirSync('/scratch/vault', { mode: 0o700 });
2535
+ const dbIdentity = copy(db, '/scratch/vault/vault.sqlite', 128 * 1024 * 1024);
2536
+ const markerIdentity = copy('/vault-data/live/complete', '/scratch/vault/complete', 128);
2537
+ const keyIdentity = copy('/vault-key/master.key', '/scratch/master.key', 4096);
2538
+ if (fs.readFileSync('/scratch/vault/complete', 'utf8').trim() !== process.env.OR3_EXPECTED_VAULT_ID) process.exit(1);
2539
+ if (journalIdentity() !== journalBefore) process.exit(1);
2540
+ const audit = '/app/vault/cli.js audit verify'.split(' ');
2541
+ const checked = spawnSync('/usr/local/bin/bun', ['--no-env-file', ...audit, '--vault-dir', '/scratch/vault', '--key-file', '/scratch/master.key'], { stdio: 'ignore', timeout: 120000 });
2542
+ if (checked.status !== 0 || checked.error) process.exit(1);
2543
+ if (journalIdentity() !== journalBefore) process.exit(1);
2544
+ if (dbIdentity !== identity(fs.lstatSync(db, { bigint: true })) || markerIdentity !== identity(fs.lstatSync('/vault-data/live/complete', { bigint: true })) || keyIdentity !== identity(fs.lstatSync('/vault-key/master.key', { bigint: true }))) process.exit(1);
2545
+ `;
2546
+ const name = `or3-vault-inspection-${randomUUID3()}`;
2547
+ let failure;
2548
+ try {
2549
+ const result = await run("docker", [
2550
+ "run",
2551
+ "--rm",
2552
+ "--name",
2553
+ name,
2554
+ "--pull",
2555
+ "never",
2556
+ "--log-driver",
2557
+ "none",
2558
+ "--network",
2559
+ "none",
2560
+ "--read-only",
2561
+ "--user",
2562
+ "1000:65532",
2563
+ "--security-opt",
2564
+ "no-new-privileges:true",
2565
+ "--cap-drop",
2566
+ "ALL",
2567
+ "--memory",
2568
+ "256m",
2569
+ "--pids-limit",
2570
+ "32",
2571
+ "--tmpfs",
2572
+ "/scratch:rw,noexec,nosuid,nodev,size=192m,mode=700,uid=1000,gid=65532",
2573
+ "--env",
2574
+ `OR3_EXPECTED_VAULT_ID=${expectedVaultId}`,
2575
+ "--mount",
2576
+ `type=volume,src=${dataVolume},dst=/vault-data,readonly`,
2577
+ "--mount",
2578
+ `type=volume,src=${keyVolume},dst=/vault-key,readonly`,
2579
+ "--entrypoint",
2580
+ "/nodejs/bin/node",
2581
+ imageAtDigest(manifest2.image, manifest2.imageDigest),
2582
+ "-e",
2583
+ script
2584
+ ], directory);
2585
+ if (!result.ok)
2586
+ throw new Error("Vault custody is empty, incomplete, or unreadable. Restore is refused; preserve the encrypted backup and recover the vault key and volumes first.");
2587
+ } catch (error) {
2588
+ failure = error;
2589
+ }
2590
+ const cleanup = await run("docker", ["rm", "--force", name], directory);
2591
+ if (!cleanup.ok && !/No such container/i.test(cleanup.stderr))
2592
+ throw new AggregateError(failure ? [failure] : [], "Vault custody inspection cleanup failed. Inspect the named helper before retrying.");
2593
+ if (failure)
2594
+ throw failure;
2595
+ }
2596
+
1794
2597
  // src/backup/manifests.ts
1795
2598
  var BACKUP_ID_PATTERN = /^backup-[0-9A-Za-z-]+$/;
1796
2599
  async function backupAuthenticationKey(directory, create = false) {
@@ -1818,14 +2621,14 @@ function backupAuthenticationTag(key, manifestContents) {
1818
2621
  }
1819
2622
  async function writeBackupAuthentication(directory, backupPath, manifestContents) {
1820
2623
  const key = await backupAuthenticationKey(directory, true);
1821
- await writeSecure(join9(backupPath, "manifest.auth"), `${backupAuthenticationTag(key, manifestContents)}
2624
+ await writeSecure(join12(backupPath, "manifest.auth"), `${backupAuthenticationTag(key, manifestContents)}
1822
2625
  `);
1823
2626
  }
1824
2627
  async function assertBackupAuthentication(directory, backupPath, manifestContents) {
1825
2628
  const key = await backupAuthenticationKey(directory);
1826
2629
  let provided;
1827
2630
  try {
1828
- const value = (await readText(join9(backupPath, "manifest.auth"))).trim();
2631
+ const value = (await readText(join12(backupPath, "manifest.auth"))).trim();
1829
2632
  if (!/^[0-9a-f]{64}$/i.test(value))
1830
2633
  throw new Error("invalid authentication tag");
1831
2634
  provided = Buffer.from(value, "hex");
@@ -1854,10 +2657,10 @@ function classifyBackupError(error) {
1854
2657
  return { code: "backup-entry-invalid", message };
1855
2658
  }
1856
2659
  async function inspectBackupEntry(backupsRoot, entry, directory) {
1857
- const path = join9(backupsRoot, entry);
2660
+ const path = join12(backupsRoot, entry);
1858
2661
  let info;
1859
2662
  try {
1860
- info = await lstat(path);
2663
+ info = await lstat3(path);
1861
2664
  } catch (error) {
1862
2665
  return { kind: "unreadable", entryName: entry, ...classifyBackupError(error) };
1863
2666
  }
@@ -1886,7 +2689,7 @@ async function inspectBackupEntry(backupsRoot, entry, directory) {
1886
2689
  };
1887
2690
  }
1888
2691
  try {
1889
- await lstat(join9(path, "manifest.auth"));
2692
+ await lstat3(join12(path, "manifest.auth"));
1890
2693
  } catch (error) {
1891
2694
  if (error.code === "ENOENT") {
1892
2695
  return {
@@ -1899,8 +2702,8 @@ async function inspectBackupEntry(backupsRoot, entry, directory) {
1899
2702
  return { kind: "unreadable", entryName: entry, ...classifyBackupError(error) };
1900
2703
  }
1901
2704
  try {
1902
- const raw = JSON.parse(await readText(join9(path, "manifest.json")));
1903
- if (typeof raw.schemaVersion === "number" && raw.schemaVersion > 1) {
2705
+ const raw = JSON.parse(await readText(join12(path, "manifest.json")));
2706
+ if (typeof raw.schemaVersion === "number" && raw.schemaVersion > 2) {
1904
2707
  return {
1905
2708
  kind: "unsupported",
1906
2709
  entryName: entry,
@@ -1930,9 +2733,13 @@ async function inspectBackupEntry(backupsRoot, entry, directory) {
1930
2733
  };
1931
2734
  }
1932
2735
  let bytes = 0;
1933
- for (const file of ["data.tgz", "config.env", "manifest.json", "manifest.auth", ...manifest2.vaultSha256 ? ["vault.backup"] : []]) {
1934
- bytes += (await lstat(join9(path, file))).size;
2736
+ for (const file of ["data.tgz", "config.env", "manifest.json", "manifest.auth", ...manifest2.vaultSha256 ? ["vault.backup"] : [], ...manifest2.schemaVersion === 2 ? [manifest2.remote.file] : []]) {
2737
+ bytes += (await lstat3(join12(path, file))).size;
1935
2738
  }
2739
+ const savedConfig = await readText(join12(path, "config.env"));
2740
+ if (createHash3("sha256").update(savedConfig).digest("hex") !== manifest2.configSha256)
2741
+ throw new Error(`Backup configuration changed during inventory for ${manifest2.backupId}.`);
2742
+ const recoveryArtifactsComplete = Boolean(manifest2.managedAssetSha256) && backupHasVaultCoverage(manifest2, parseEnv(savedConfig), savedConfig);
1936
2743
  return {
1937
2744
  kind: "verified",
1938
2745
  backup: {
@@ -1941,7 +2748,11 @@ async function inspectBackupEntry(backupsRoot, entry, directory) {
1941
2748
  appVersion: manifest2.appVersion,
1942
2749
  path,
1943
2750
  bytes,
1944
- dataSha256: manifest2.dataSha256
2751
+ dataSha256: manifest2.dataSha256,
2752
+ schemaVersion: manifest2.schemaVersion,
2753
+ recoveryArtifactsComplete,
2754
+ ...manifest2.snapshotKind ? { snapshotKind: manifest2.snapshotKind } : {},
2755
+ ...manifest2.schemaVersion === 2 ? { remoteKind: manifest2.remote.kind } : {}
1945
2756
  }
1946
2757
  };
1947
2758
  } catch (error) {
@@ -1987,15 +2798,15 @@ async function inventoryBackups(directory) {
1987
2798
  });
1988
2799
  return { entries, storeErrors: [] };
1989
2800
  }
1990
- async function enumerateBackups(directory) {
1991
- const inventory = await inventoryBackups(directory);
1992
- return inventory.entries.filter((entry) => entry.kind === "verified").map((entry) => entry.backup).sort((a, b) => a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0);
1993
- }
1994
2801
  function assertBackupMatchesDeployment(manifest2, backupEnv, state, env) {
1995
2802
  if (manifest2.mode !== state.mode || manifest2.appVersion === undefined || manifest2.image === undefined) {
1996
2803
  throw new Error(`Backup ${manifest2.backupId} does not match the managed deployment mode.`);
1997
2804
  }
1998
2805
  assertDeploymentIdentity(state, env);
2806
+ if (managedProviderProfile(backupEnv) !== managedProviderProfile(env)) {
2807
+ throw new Error("backup-profile-mismatch: backup and deployment use different managed provider profiles.");
2808
+ }
2809
+ restoreStorageConfiguration(backupEnv, env);
1999
2810
  const expected = {
2000
2811
  OR3_COMPOSE_PROJECT: state.composeProject,
2001
2812
  OR3_VOLUME_NAME: state.volumeName,
@@ -2038,32 +2849,53 @@ function assertBackupMatchesDeployment(manifest2, backupEnv, state, env) {
2038
2849
  }
2039
2850
  async function readManifest(backupPath, authenticatedForDirectory) {
2040
2851
  await lifecycleFaults.beforeArchiveRead?.();
2041
- const manifestContents = await readText(join9(backupPath, "manifest.json"));
2852
+ const manifestContents = await readText(join12(backupPath, "manifest.json"));
2042
2853
  if (authenticatedForDirectory)
2043
2854
  await assertBackupAuthentication(authenticatedForDirectory, backupPath, manifestContents);
2044
2855
  const manifest2 = JSON.parse(manifestContents);
2045
- if (manifest2.schemaVersion !== 1 || !BACKUP_ID_PATTERN.test(manifest2.backupId) || !Number.isFinite(Date.parse(manifest2.createdAt)) || !isVersion(manifest2.appVersion) || !manifest2.image || !/^sha256:[0-9a-f]{64}$/i.test(manifest2.imageDigest) || !/^[0-9a-f]{64}$/i.test(manifest2.dataSha256) || !/^[0-9a-f]{64}$/i.test(manifest2.configSha256 ?? "") || manifest2.vaultSha256 !== undefined && !/^[0-9a-f]{64}$/i.test(manifest2.vaultSha256) || manifest2.dataBytes !== undefined && (!Number.isSafeInteger(manifest2.dataBytes) || manifest2.dataBytes < 0)) {
2856
+ const unvalidated = manifest2;
2857
+ if (unvalidated.schemaVersion !== 1 && unvalidated.schemaVersion !== 2 || !BACKUP_ID_PATTERN.test(manifest2.backupId) || unvalidated.snapshotKind !== undefined && unvalidated.snapshotKind !== "full" && unvalidated.snapshotKind !== "safety" || !Number.isFinite(Date.parse(manifest2.createdAt)) || !isVersion(manifest2.appVersion) || !manifest2.image || !/^sha256:[0-9a-f]{64}$/i.test(manifest2.imageDigest) || !/^[0-9a-f]{64}$/i.test(manifest2.dataSha256) || !/^[0-9a-f]{64}$/i.test(manifest2.configSha256 ?? "") || manifest2.vaultSha256 !== undefined && !/^[0-9a-f]{64}$/i.test(manifest2.vaultSha256) || manifest2.dataBytes !== undefined && (!Number.isSafeInteger(manifest2.dataBytes) || manifest2.dataBytes < 0)) {
2046
2858
  throw new Error(`Invalid backup manifest at ${backupPath}.`);
2047
2859
  }
2048
- const actual = await sha256File(join9(backupPath, "data.tgz"));
2860
+ const actual = await sha256File(join12(backupPath, "data.tgz"));
2049
2861
  if (actual !== manifest2.dataSha256)
2050
2862
  throw new Error(`Backup checksum mismatch for ${manifest2.backupId}. Expected ${manifest2.dataSha256}, got ${actual}.`);
2051
2863
  if (manifest2.configSha256) {
2052
- const configActual = await sha256File(join9(backupPath, "config.env"));
2864
+ const configActual = await sha256File(join12(backupPath, "config.env"));
2053
2865
  if (configActual !== manifest2.configSha256)
2054
2866
  throw new Error(`Backup configuration checksum mismatch for ${manifest2.backupId}.`);
2055
2867
  }
2056
- if (manifest2.vaultSha256 && await sha256File(join9(backupPath, "vault.backup")) !== manifest2.vaultSha256)
2868
+ const config = parseEnv(await readText(join12(backupPath, "config.env")));
2869
+ const storage = (config.NUXT_PUBLIC_STORAGE_PROVIDER ?? "").trim().toLowerCase();
2870
+ if (manifest2.schemaVersion === 1 && storage === "s3") {
2871
+ throw new Error(`Invalid backup manifest for ${manifest2.backupId}: schema-1 S3 snapshots do not contain recoverable remote objects.`);
2872
+ }
2873
+ if (manifest2.schemaVersion === 2) {
2874
+ const remote = manifest2.remote;
2875
+ if (unvalidated.storage !== "s3" || storage !== "s3" || !remote || !(remote.kind === "s3-copy-v1" && remote.file === "remote-objects.tar" || remote.kind === "s3-reference-v1" && remote.file === "remote-inventory.json") || !/^[a-f0-9]{64}$/.test(remote.sha256) || !/^[a-f0-9]{64}$/.test(remote.inventorySha256) || !Number.isSafeInteger(remote.bytes) || remote.bytes < 1 || !Number.isSafeInteger(remote.objectCount) || remote.objectCount < 0 || remote.objectCount > 1e5 || !Number.isSafeInteger(remote.payloadBytes) || remote.payloadBytes < 0 || remote.payloadBytes > remote.objectCount * 2 ** 30 || remote.providerVersion !== "0.0.15" || remote.kind === "s3-reference-v1" && (remote.bytes > 64 * 1024 * 1024 || remote.inventorySha256 !== remote.sha256)) {
2876
+ throw new Error(`Invalid backup manifest for ${manifest2.backupId}: invalid S3 remote snapshot.`);
2877
+ }
2878
+ const source = remoteSource(config);
2879
+ if (!remote.source || remote.source.endpointOrigin !== source.endpointOrigin || remote.source.bucket !== source.bucket || remote.source.keyPrefix !== source.keyPrefix) {
2880
+ throw new Error(`Invalid backup manifest for ${manifest2.backupId}: S3 source differs from the authenticated configuration.`);
2881
+ }
2882
+ const file = join12(backupPath, remote.file);
2883
+ const info = await lstat3(file);
2884
+ if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.size !== remote.bytes || await sha256File(file) !== remote.sha256) {
2885
+ throw new Error(`Backup remote checksum mismatch or size mismatch for ${manifest2.backupId}.`);
2886
+ }
2887
+ }
2888
+ if (manifest2.vaultSha256 && await sha256File(join12(backupPath, "vault.backup")) !== manifest2.vaultSha256)
2057
2889
  throw new Error(`Vault backup checksum mismatch for ${manifest2.backupId}.`);
2058
2890
  await verifiedManagedAssetContents(backupPath, manifest2);
2059
2891
  return manifest2;
2060
2892
  }
2061
2893
  async function resolveBackup(directory, value) {
2062
- if (!isAbsolute(value) && !BACKUP_ID_PATTERN.test(value)) {
2894
+ if (!isAbsolute3(value) && !BACKUP_ID_PATTERN.test(value)) {
2063
2895
  throw new Error(`Backup ID "${value}" is invalid. Use an OR3-generated backup ID or an absolute external backup path.`);
2064
2896
  }
2065
- const candidate = isAbsolute(value) ? value : backupDirectory(directory, value);
2066
- if (!await fileExists(join9(candidate, "manifest.json")))
2897
+ const candidate = isAbsolute3(value) ? value : backupDirectory(directory, value);
2898
+ if (!await fileExists(join12(candidate, "manifest.json")))
2067
2899
  throw new Error(`Backup ${value} was not found in ${deploymentPaths(directory).backups}.`);
2068
2900
  return resolve4(candidate);
2069
2901
  }
@@ -2072,7 +2904,7 @@ async function recordedBackupPath(directory, pending) {
2072
2904
  const path = previous ? pending.previousBackupPath : pending.backupPath;
2073
2905
  const backupId = previous ? pending.previousBackupId : pending.backupId;
2074
2906
  const resolved = path ? resolve4(path) : backupId ? await resolveBackup(directory, backupId) : undefined;
2075
- if (!resolved || !await fileExists(join9(resolved, "manifest.json"))) {
2907
+ if (!resolved || !await fileExists(join12(resolved, "manifest.json"))) {
2076
2908
  throw new Error(`The incomplete ${pending.operation} has no readable ${previous ? "pre-mutation" : "target"} backup source.`);
2077
2909
  }
2078
2910
  const manifest2 = await readManifest(resolved, directory);
@@ -2214,16 +3046,21 @@ async function maskedQuestion(question) {
2214
3046
 
2215
3047
  // src/deployment/adoption-source.ts
2216
3048
  import { existsSync as existsSync2 } from "node:fs";
2217
- import { basename as basename2, join as join10 } from "node:path";
3049
+ import { basename as basename2, join as join13 } from "node:path";
2218
3050
 
2219
3051
  // src/runtime/health.ts
2220
3052
  var DEEP_HEALTH_TIMEOUT_MS = 180000;
2221
- 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))";
3053
+ var HEALTH_SCRIPT = "const fs=require('node:fs');try{const auth=process.env.AUTH_PROVIDER||process.env.OR3_AUTH_PROVIDER||'basic-auth',storage=process.env.NUXT_PUBLIC_STORAGE_PROVIDER||'fs',sync=process.env.OR3_SYNC_PROVIDER||'sqlite';if(sync!=='sqlite'||!(auth==='basic-auth'&&storage==='fs'||auth==='clerk'&&storage==='s3')||process.env.AUTH_PROVIDER&&process.env.OR3_AUTH_PROVIDER&&process.env.AUTH_PROVIDER!==process.env.OR3_AUTH_PROVIDER)throw Error('profile');fs.accessSync('/data',fs.constants.R_OK|fs.constants.W_OK);const paths=auth==='basic-auth'?[process.env.OR3_BASIC_AUTH_DB_PATH,process.env.OR3_SQLITE_DB_PATH]:[process.env.OR3_SQLITE_DB_PATH];if(paths.some(p=>!p))throw Error('database path');for(const path of paths){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'||body.providers?.auth?.provider!==(process.env.AUTH_PROVIDER||process.env.OR3_AUTH_PROVIDER||'basic-auth')||body.providers?.sync?.provider!=='sqlite'||body.providers?.storage?.provider!==(process.env.NUXT_PUBLIC_STORAGE_PROVIDER||'fs'))process.exit(1)}).catch(()=>process.exit(1))";
2222
3054
  var VERIFY_DATABASES_SCRIPT = `
2223
3055
  const fs = require('node:fs');
2224
3056
  const Database = require('/app/.output/server/node_modules/better-sqlite3');
2225
3057
  const results = [];
2226
- for (const path of [process.env.OR3_BASIC_AUTH_DB_PATH, process.env.OR3_SQLITE_DB_PATH].filter(Boolean)) {
3058
+ const auth = process.env.AUTH_PROVIDER || process.env.OR3_AUTH_PROVIDER || 'basic-auth';
3059
+ const storage = process.env.NUXT_PUBLIC_STORAGE_PROVIDER || 'fs', sync = process.env.OR3_SYNC_PROVIDER || 'sqlite';
3060
+ if (sync !== 'sqlite' || !(auth === 'basic-auth' && storage === 'fs' || auth === 'clerk' && storage === 's3') || process.env.AUTH_PROVIDER && process.env.OR3_AUTH_PROVIDER && process.env.AUTH_PROVIDER !== process.env.OR3_AUTH_PROVIDER) throw new Error('Unsupported auth profile');
3061
+ const paths = auth === 'basic-auth' ? [process.env.OR3_BASIC_AUTH_DB_PATH, process.env.OR3_SQLITE_DB_PATH] : [process.env.OR3_SQLITE_DB_PATH];
3062
+ if (paths.some(path => !path)) throw new Error('Missing managed database path');
3063
+ for (const path of paths) {
2227
3064
  const info = fs.statSync(path);
2228
3065
  if (info.uid !== 65532 || info.gid !== 65532) throw new Error(path + ' must be owned by 65532:65532');
2229
3066
  const db = new Database(path, { readonly: true, fileMustExist: true });
@@ -2296,15 +3133,112 @@ async function sourceServiceRunning(directory, composeFiles) {
2296
3133
  throw new Error(`Could not determine whether the V1 OR3 service is running. ${result.stderr.trim()}`);
2297
3134
  return Boolean(result.stdout.trim());
2298
3135
  }
3136
+ async function assertSourceContainer(directory, composeFiles, expectedImage, configText, sourceVolume, expectedPort, sourceEnv) {
3137
+ const listed = await run("docker", [...sourceComposeArgs(directory, composeFiles), "ps", "-aq", "or3"], directory);
3138
+ const ids = listed.stdout.trim().split(/\s+/).filter(Boolean);
3139
+ if (!listed.ok || ids.length !== 1)
3140
+ throw new Error("Could not identify exactly one existing source container.");
3141
+ const inspect = await run("docker", ["inspect", "--format", `{{json .Image}}
3142
+ {{json .Config.Env}}
3143
+ {{json .Mounts}}
3144
+ {{json .Config.Labels}}
3145
+ {{json .Config.Entrypoint}}
3146
+ {{json .Config.Cmd}}
3147
+ {{json .Config.WorkingDir}}
3148
+ {{json .HostConfig.ReadonlyRootfs}}
3149
+ {{json .HostConfig.Tmpfs}}
3150
+ {{json .HostConfig.Mounts}}`, ids[0]], directory);
3151
+ const expected = await run("docker", ["image", "inspect", "--format", `{{.Id}}
3152
+ {{json .Config.Entrypoint}}
3153
+ {{json .Config.Cmd}}
3154
+ {{json .Config.WorkingDir}}
3155
+ {{json .Config.Env}}`, expectedImage], directory);
3156
+ if (!inspect.ok || !expected.ok || !expected.stdout.trim())
3157
+ throw new Error("Could not inspect the existing source container or its authenticated image.");
3158
+ let image, env, mounts, labels, entrypoint, command, cwd, readonly, tmpfs, hostMounts, imageId, defaultEntrypoint, defaultCommand, defaultCwd, defaultEnv;
3159
+ try {
3160
+ const lines = inspect.stdout.trimEnd().split(`
3161
+ `);
3162
+ const expectedLines = expected.stdout.trimEnd().split(`
3163
+ `);
3164
+ if (lines.length !== 10 || expectedLines.length !== 5)
3165
+ throw new Error("invalid inspection");
3166
+ [image, env, mounts, labels, entrypoint, command, cwd, readonly, tmpfs, hostMounts] = lines.map((line) => JSON.parse(line));
3167
+ imageId = expectedLines[0];
3168
+ defaultEntrypoint = JSON.parse(expectedLines[1]);
3169
+ defaultCommand = JSON.parse(expectedLines[2]);
3170
+ defaultCwd = JSON.parse(expectedLines[3]);
3171
+ defaultEnv = JSON.parse(expectedLines[4]);
3172
+ } catch {
3173
+ throw new Error("The source container inspection is incomplete or malformed.");
3174
+ }
3175
+ if (image !== imageId)
3176
+ throw new Error("The source container image does not match the authenticated release image.");
3177
+ if (readonly !== true)
3178
+ throw new Error("The source container root filesystem is not read-only; its code cannot be qualified for adoption.");
3179
+ assertCapturedHostMounts(tmpfs, hostMounts);
3180
+ const diff = await run("docker", ["diff", ids[0]], directory);
3181
+ if (!diff.ok || diff.stdout.trim())
3182
+ throw new Error("The source container writable layer is changed or cannot be inspected; adoption is refused.");
3183
+ const validCommand = (value) => value === null || Array.isArray(value) && value.every((part) => typeof part === "string");
3184
+ if (![entrypoint, command, defaultEntrypoint, defaultCommand].every(validCommand) || JSON.stringify(entrypoint) !== JSON.stringify(defaultEntrypoint) || JSON.stringify(command) !== JSON.stringify(defaultCommand)) {
3185
+ throw new Error("The source container changes the authenticated image entrypoint or command.");
3186
+ }
3187
+ if (typeof cwd !== "string" || cwd !== defaultCwd)
3188
+ throw new Error("The source container changes the authenticated image working directory.");
3189
+ const config = parseComposeConfig(configText);
3190
+ if (!config.name || !labels || typeof labels !== "object" || Array.isArray(labels) || labels["com.docker.compose.project"] !== config.name || labels["com.docker.compose.service"] !== "or3") {
3191
+ throw new Error("The source container Compose labels do not match the selected project and service.");
3192
+ }
3193
+ const parseInspectionEnv = (value) => {
3194
+ if (value === null)
3195
+ return {};
3196
+ if (!Array.isArray(value) || !value.every((line) => typeof line === "string" && /^[A-Za-z_][A-Za-z0-9_]*=/.test(line)))
3197
+ throw new Error("The inspected environment is incomplete or malformed.");
3198
+ const result = {};
3199
+ for (const line of value) {
3200
+ const split = line.indexOf("=");
3201
+ const key = line.slice(0, split);
3202
+ if (Object.hasOwn(result, key))
3203
+ throw new Error(`The inspected environment contains a duplicate ${key} entry.`);
3204
+ result[key] = line.slice(split + 1);
3205
+ }
3206
+ return result;
3207
+ };
3208
+ const actualEnv = parseInspectionEnv(env), imageEnv = parseInspectionEnv(defaultEnv);
3209
+ const executionKey = /^(?:PATH|NODE_.+|BUN_.+|LD_.+|DYLD_.+)$/;
3210
+ for (const key of new Set([...Object.keys(actualEnv), ...Object.keys(imageEnv)])) {
3211
+ if (executionKey.test(key) && actualEnv[key] !== imageEnv[key])
3212
+ throw new Error(`The source container changes execution environment ${key}.`);
3213
+ }
3214
+ if (!Array.isArray(mounts) || !mounts.every((mount) => mount && typeof mount === "object" && typeof mount.Type === "string" && typeof mount.Destination === "string")) {
3215
+ throw new Error("The source container mounts are incomplete or malformed.");
3216
+ }
3217
+ const actualMounts = mounts;
3218
+ if (new Set(actualMounts.map((mount) => mount.Destination)).size !== actualMounts.length)
3219
+ throw new Error("The source container has ambiguous duplicate mount destinations.");
3220
+ if (actualMounts.length !== 1)
3221
+ throw new Error("The source container has additional mounts that adoption cannot safely migrate.");
3222
+ const actualDataMount = actualMounts.find((mount) => mount.Destination === "/data");
3223
+ if (!actualDataMount || actualDataMount.Type !== "volume" || actualDataMount.Name !== sourceVolume) {
3224
+ throw new Error("The source container data mount does not match the selected Docker volume.");
3225
+ }
3226
+ const actualService = config.services?.or3;
3227
+ if (!actualService)
3228
+ throw new Error("V1 Compose configuration does not define an or3 service.");
3229
+ actualService.environment = actualEnv;
3230
+ actualService.volumes = actualMounts.map((mount) => ({ type: mount.Type, source: mount.Name, target: mount.Destination }));
3231
+ assertSupportedSourceCompose(JSON.stringify(config), sourceVolume, expectedPort, sourceEnv, [actualService.image ?? ""]);
3232
+ }
2299
3233
  async function readSourceVolume(sourceDirectory) {
2300
3234
  const composeResult = await run("docker", [
2301
3235
  "compose",
2302
3236
  "--project-directory",
2303
3237
  sourceDirectory,
2304
3238
  "--env-file",
2305
- join10(sourceDirectory, ".env"),
3239
+ join13(sourceDirectory, ".env"),
2306
3240
  "-f",
2307
- join10(sourceDirectory, "compose.yaml"),
3241
+ join13(sourceDirectory, "compose.yaml"),
2308
3242
  "ps",
2309
3243
  "-aq",
2310
3244
  "or3"
@@ -2325,7 +3259,7 @@ async function readSourceVolume(sourceDirectory) {
2325
3259
  }
2326
3260
  function sourceMode(sourceDirectory, sourceEnv) {
2327
3261
  const publicDeployment = sourceEnv.OR3_PUBLIC_DOMAIN && sourceEnv.OR3_PUBLIC_DOMAIN !== "localhost" && sourceEnv.OR3_FORCE_HTTPS !== "false";
2328
- return publicDeployment && existsSync2(join10(sourceDirectory, "compose.public.yaml")) ? "public" : "local";
3262
+ return publicDeployment && existsSync2(join13(sourceDirectory, "compose.public.yaml")) ? "public" : "local";
2329
3263
  }
2330
3264
  function sourceComposeArgs(sourceDirectory, sourceComposeFiles) {
2331
3265
  return [
@@ -2333,7 +3267,7 @@ function sourceComposeArgs(sourceDirectory, sourceComposeFiles) {
2333
3267
  "--project-directory",
2334
3268
  sourceDirectory,
2335
3269
  "--env-file",
2336
- join10(sourceDirectory, ".env"),
3270
+ join13(sourceDirectory, ".env"),
2337
3271
  ...sourceComposeFiles
2338
3272
  ];
2339
3273
  }
@@ -2346,8 +3280,8 @@ async function restartSource(sourceDirectory, sourceComposeFiles, secrets) {
2346
3280
  }
2347
3281
  function assertSupportedSource(sourceDirectory, sourceEnv, providers) {
2348
3282
  const values = [sourceEnv.AUTH_PROVIDER, sourceEnv.OR3_AUTH_PROVIDER].filter(Boolean);
2349
- if (!values.includes("basic-auth"))
2350
- throw new Error(`V1 project uses ${values.join(", ") || "an unknown auth provider"}, not Basic Auth.`);
3283
+ if (values.length === 0 || values.some((value) => value !== "basic-auth"))
3284
+ throw new Error(`V1 project uses ${values.join(", ") || "an unknown auth provider"}, not the supported Basic Auth profile.`);
2351
3285
  if (sourceEnv.SSR_AUTH_ENABLED === "false" || sourceEnv.OR3_GUEST_ACCESS_ENABLED === "true") {
2352
3286
  throw new Error("V1 project does not require authenticated access; adoption expects the supported authenticated profile.");
2353
3287
  }
@@ -2372,21 +3306,94 @@ function assertSupportedSource(sourceDirectory, sourceEnv, providers) {
2372
3306
  throw new Error("V1 project is missing OR3 version metadata.");
2373
3307
  }
2374
3308
  }
2375
- function assertSupportedSourceCompose(configText, sourceVolume, expectedPort) {
3309
+ function assertSupportedSourceCompose(configText, sourceVolume, expectedPort, sourceEnv, allowedImages) {
2376
3310
  const config = parseComposeConfig(configText);
2377
3311
  const service = config.services?.or3;
2378
3312
  if (!service)
2379
3313
  throw new Error("V1 Compose configuration does not define an or3 service.");
3314
+ assertCapturedComposeMounts(service);
3315
+ if (!service.image || !allowedImages.includes(service.image))
3316
+ throw new Error("V1 Compose image does not match the authenticated release image.");
3317
+ const vault = composeEnvironmentValue(service, "NUXT_SECRETS_PROVIDER");
3318
+ const vaultMount = (service.volumes ?? []).some((mount) => typeof mount.target === "string" && /^\/(?:run\/or3-secrets(?:-client)?|vault-data|vault-key)(?:\/|$)/.test(mount.target));
3319
+ if (sourceEnv.NUXT_SECRETS_PROVIDER || vault !== "" || vaultMount || Object.keys(config.services ?? {}).some((name) => /vault/i.test(name))) {
3320
+ throw new Error("V1 source may use a secret vault; adoption cannot migrate vault custody. Preserve the source and use a separately reviewed migration.");
3321
+ }
3322
+ if (Object.keys(config.services ?? {}).some((name) => name !== "or3")) {
3323
+ throw new Error("V1 Compose has additional services that adoption cannot safely migrate.");
3324
+ }
3325
+ const selected = (key) => composeEnvironmentValue(service, key);
3326
+ const regenerated = new Set(["OR3_BASIC_AUTH_DB_PATH", "OR3_SQLITE_DB_PATH", "OR3_STORAGE_FS_ROOT", "OR3_PUBLIC_DOMAIN", "OR3_ALLOWED_ORIGINS", "OR3_FORCE_HTTPS", "OR3_TRUST_PROXY"]);
3327
+ for (const key of ALLOWED_ENV_KEYS) {
3328
+ if (regenerated.has(key))
3329
+ continue;
3330
+ if (selected(key) !== sourceEnv[key])
3331
+ throw new Error(`V1 Compose ${key} differs from its source environment; adoption refuses configuration drift.`);
3332
+ }
3333
+ if (!sourceEnv.OR3_INSTANCE_ID?.trim())
3334
+ throw new Error("V1 source needs an explicitly configured stable acquisition identity (OR3_INSTANCE_ID) before adoption.");
3335
+ for (const key of ["NUXT_ADMIN_AUTH_USERNAME", "NUXT_ADMIN_AUTH_PASSWORD"]) {
3336
+ if (selected(key) !== undefined || sourceEnv[key] !== undefined)
3337
+ throw new Error(`V1 Compose ${key} cannot be safely provisioned during adoption.`);
3338
+ }
3339
+ const effectiveKeys = Array.isArray(service.environment) ? service.environment.map((entry) => entry.split("=", 1)[0]) : Object.keys(service.environment ?? {});
3340
+ const ambiguous = /^(?:RUNTIME_CONFIG$|NUXT_(?:ADMIN|ADMIN_AUTH|AUTH|AUTH_INVITE|SYNC|STORAGE|SECRETS|PUBLIC|BACKGROUND_JOBS|CONNECT|CONNECT_CLOUDFLARE|WEBHOOKS)$|NITRO_ENV_(?:PREFIX|EXPANSION)$|NITRO_(?:ADMIN|AUTH|SYNC|STORAGE|SECRETS|PUBLIC|BACKGROUND_JOBS|CONNECT|WEBHOOKS)(?:_|$))/;
3341
+ if (effectiveKeys.some((key) => ambiguous.test(key)))
3342
+ throw new Error("V1 Compose contains an unsupported aggregate or Nitro runtime profile override.");
3343
+ const allowedOverrides = {
3344
+ OR3_STORAGE_PROVIDER: "fs",
3345
+ NUXT_AUTH_ENABLED: "true",
3346
+ NUXT_AUTH_PROVIDER: "basic-auth",
3347
+ NUXT_PUBLIC_SSR_AUTH_ENABLED: "true",
3348
+ NUXT_PUBLIC_AUTH_PROVIDER: "basic-auth",
3349
+ NUXT_PUBLIC_GUEST_ACCESS_ENABLED: "false",
3350
+ NUXT_AUTH_REGISTRATION_MODE: "invite_only",
3351
+ NUXT_AUTH_AUTO_PROVISION: "false",
3352
+ NUXT_SYNC_ENABLED: "true",
3353
+ NUXT_PUBLIC_SYNC_ENABLED: "true",
3354
+ NUXT_SYNC_PROVIDER: "sqlite",
3355
+ NUXT_PUBLIC_SYNC_PROVIDER: "sqlite",
3356
+ NUXT_STORAGE_ENABLED: "true",
3357
+ NUXT_PUBLIC_STORAGE_ENABLED: "true",
3358
+ NUXT_STORAGE_PROVIDER: "fs"
3359
+ };
3360
+ for (const [key, expected] of Object.entries(allowedOverrides)) {
3361
+ const value = selected(key);
3362
+ if (value !== undefined && value !== expected)
3363
+ throw new Error(`V1 Compose ${key} selects an unsupported runtime profile.`);
3364
+ }
3365
+ if (selected("OR3_SQLITE_DRIVER") && selected("OR3_SQLITE_DRIVER") !== "better-sqlite3") {
3366
+ throw new Error("V1 Compose selects an unsupported SQLite driver. Adoption requires better-sqlite3.");
3367
+ }
3368
+ const managedOnly = new Set(["OR3_VERSION", "OR3_IMAGE", "OR3_PORT", "OR3_COMPOSE_PROJECT", "OR3_VOLUME_NAME", "OR3_CADDY_DATA_VOLUME", "OR3_CADDY_CONFIG_VOLUME", "OR3_DEPLOYMENT_ID", "OR3_ADMIN_DATA_DIR", "OR3_EXTENSIONS_ROOT"]);
3369
+ for (const key of effectiveKeys) {
3370
+ if (/^(?:OR3|NUXT|NITRO)_/.test(key) && !ALLOWED_ENV_KEYS.has(key) && !managedOnly.has(key) && !(key in allowedOverrides)) {
3371
+ throw new Error(`V1 Compose has unsupported application setting ${key}; adoption refuses to drop it.`);
3372
+ }
3373
+ }
3374
+ const auth = [selected("AUTH_PROVIDER"), selected("OR3_AUTH_PROVIDER")].filter((value) => Boolean(value));
3375
+ const guest = selected("OR3_GUEST_ACCESS_ENABLED");
3376
+ if (!auth.length || auth.some((value) => value !== "basic-auth") || selected("OR3_SYNC_PROVIDER") !== "sqlite" || selected("NUXT_PUBLIC_STORAGE_PROVIDER") !== "fs" || selected("OR3_AUTH_REGISTRATION_MODE") !== "invite_only" || selected("OR3_AUTH_AUTO_PROVISION") !== "false" || selected("SSR_AUTH_ENABLED") !== "true" || guest && guest !== "false" || (selected("OR3_CLOUD_SYNC_ENABLED") || selected("OR3_SYNC_ENABLED")) !== "true" || (selected("OR3_CLOUD_STORAGE_ENABLED") || selected("OR3_STORAGE_ENABLED")) !== "true") {
3377
+ throw new Error("V1 Compose resolves to an unsupported authentication, sync, storage or registration profile.");
3378
+ }
3379
+ for (const key of ["AUTH_PROVIDER", "OR3_AUTH_PROVIDER", "OR3_SYNC_PROVIDER", "NUXT_PUBLIC_STORAGE_PROVIDER", "OR3_AUTH_REGISTRATION_MODE", "OR3_AUTH_AUTO_PROVISION", "SSR_AUTH_ENABLED", "OR3_GUEST_ACCESS_ENABLED", "OR3_SYNC_ENABLED", "OR3_CLOUD_SYNC_ENABLED", "OR3_STORAGE_ENABLED", "OR3_CLOUD_STORAGE_ENABLED"]) {
3380
+ if (sourceEnv[key] !== undefined && selected(key) !== sourceEnv[key])
3381
+ throw new Error(`V1 Compose resolves ${key} differently from its source environment; adoption refuses profile drift.`);
3382
+ }
2380
3383
  for (const [key, expected] of [
2381
3384
  ["OR3_BASIC_AUTH_DB_PATH", "/data/auth.sqlite"],
2382
3385
  ["OR3_SQLITE_DB_PATH", "/data/sync.sqlite"],
2383
- ["OR3_STORAGE_FS_ROOT", "/data/storage"]
3386
+ ["OR3_STORAGE_FS_ROOT", "/data/storage"],
3387
+ ["OR3_ADMIN_DATA_DIR", "/data/admin"],
3388
+ ["OR3_EXTENSIONS_ROOT", "/data/extensions"]
2384
3389
  ]) {
2385
3390
  if (composeEnvironmentValue(service, key) !== expected) {
2386
3391
  throw new Error(`V1 Compose configuration does not resolve ${key} to ${expected}. Adoption refuses custom data layouts.`);
2387
3392
  }
2388
3393
  }
2389
3394
  const mounts = service.volumes ?? [];
3395
+ if (mounts.length !== 1)
3396
+ throw new Error("V1 Compose has additional mounts that adoption cannot safely migrate.");
2390
3397
  const dataMount = mounts.find((mount) => mount.target === "/data");
2391
3398
  if (!dataMount || dataMount.type !== "volume") {
2392
3399
  throw new Error("V1 Compose configuration must mount one named volume at /data.");
@@ -2406,8 +3413,8 @@ function assertSupportedSourceCompose(configText, sourceVolume, expectedPort) {
2406
3413
 
2407
3414
  // src/runtime/dashboard-operator.ts
2408
3415
  import { randomBytes as randomBytes6 } from "node:crypto";
2409
- import { chmod as chmod4, mkdir as mkdir4, rm as rm4, stat as stat2 } from "node:fs/promises";
2410
- import { isAbsolute as isAbsolute2, join as join11, resolve as resolve6 } from "node:path";
3416
+ import { chmod as chmod4, mkdir as mkdir4, rm as rm5, stat as stat3 } from "node:fs/promises";
3417
+ import { isAbsolute as isAbsolute4, join as join14, resolve as resolve6 } from "node:path";
2411
3418
 
2412
3419
  // src/runtime/images.ts
2413
3420
  async function pullImage(image, expectedDigest2) {
@@ -2549,13 +3556,13 @@ async function dashboardOperatorEnv(directory, version) {
2549
3556
  if (configured && !configured.startsWith("unix://"))
2550
3557
  return;
2551
3558
  const socket = configured ? configured.slice("unix://".length) : "/var/run/docker.sock";
2552
- if (!isAbsolute2(socket))
3559
+ if (!isAbsolute4(socket))
2553
3560
  return;
2554
3561
  let socketStat;
2555
3562
  let uid;
2556
3563
  let gid;
2557
3564
  try {
2558
- socketStat = await stat2(socket);
3565
+ socketStat = await stat3(socket);
2559
3566
  if (!socketStat.isSocket())
2560
3567
  return;
2561
3568
  uid = process.getuid();
@@ -2633,7 +3640,7 @@ async function prepareVerifiedDashboardOperator(directory, version) {
2633
3640
  if (await verifyDashboardOperatorBridge(directory, candidate))
2634
3641
  return candidate;
2635
3642
  } catch {}
2636
- await rm4(deploymentPaths(directory).operatorIpc, { recursive: true, force: true }).catch(() => {
3643
+ await rm5(deploymentPaths(directory).operatorIpc, { recursive: true, force: true }).catch(() => {
2637
3644
  return;
2638
3645
  });
2639
3646
  console.warn("Dashboard updates are unavailable on this Docker setup; the deployment will remain host-CLI managed.");
@@ -2641,7 +3648,7 @@ async function prepareVerifiedDashboardOperator(directory, version) {
2641
3648
  }
2642
3649
  function dashboardOperatorHandoffArgs(directory, env, jobId) {
2643
3650
  const project = env.OR3_COMPOSE_PROJECT;
2644
- if (!isAbsolute2(directory) || !DASHBOARD_JOB_ID_PATTERN.test(jobId) || !COMPOSE_PROJECT_PATTERN.test(project ?? "") || !/^\d+$/.test(env.OR3_OPERATOR_UID ?? "") || !/^\d+$/.test(env.OR3_OPERATOR_GID ?? "") || !/^\d+$/.test(env.OR3_DOCKER_GID ?? "") || !isAbsolute2(env.OR3_DOCKER_SOCKET ?? "") || !/@sha256:[0-9a-f]{64}$/i.test(env.OR3_OPERATOR_IMAGE ?? "")) {
3651
+ if (!isAbsolute4(directory) || !DASHBOARD_JOB_ID_PATTERN.test(jobId) || !COMPOSE_PROJECT_PATTERN.test(project ?? "") || !/^\d+$/.test(env.OR3_OPERATOR_UID ?? "") || !/^\d+$/.test(env.OR3_OPERATOR_GID ?? "") || !/^\d+$/.test(env.OR3_DOCKER_GID ?? "") || !isAbsolute4(env.OR3_DOCKER_SOCKET ?? "") || !/@sha256:[0-9a-f]{64}$/i.test(env.OR3_OPERATOR_IMAGE ?? "")) {
2645
3652
  throw new Error("Refusing to schedule an invalid dashboard operator handoff.");
2646
3653
  }
2647
3654
  return [
@@ -2672,7 +3679,7 @@ function dashboardOperatorHandoffArgs(directory, env, jobId) {
2672
3679
  "--entrypoint",
2673
3680
  "/usr/local/bin/node",
2674
3681
  env.OR3_OPERATOR_IMAGE,
2675
- join11(directory, "dashboard-operator.mjs"),
3682
+ join14(directory, "dashboard-operator.mjs"),
2676
3683
  "--complete-handoff",
2677
3684
  jobId,
2678
3685
  project
@@ -2758,10 +3765,10 @@ async function checkPublicPrerequisites(domain) {
2758
3765
 
2759
3766
  // src/runtime/project.ts
2760
3767
  import { readFileSync as readFileSync5 } from "node:fs";
2761
- import { join as join12 } from "node:path";
3768
+ import { join as join15 } from "node:path";
2762
3769
  function managedVaultEnabled(directory, env) {
2763
- const selected = env ?? parseEnv(readFileSync5(join12(directory, ".env"), "utf8"));
2764
- return selected.SSR_AUTH_ENABLED === "true" && (selected.NUXT_SECRETS_PROVIDER ?? "or3-secrets") === "or3-secrets" && readFileSync5(join12(directory, "compose.yaml"), "utf8").includes(`
3770
+ const selected = env ?? parseEnv(readFileSync5(join15(directory, ".env"), "utf8"));
3771
+ return selected.SSR_AUTH_ENABLED === "true" && (selected.NUXT_SECRETS_PROVIDER ?? "or3-secrets") === "or3-secrets" && readFileSync5(join15(directory, "compose.yaml"), "utf8").includes(`
2765
3772
  or3-vault:
2766
3773
  `);
2767
3774
  }
@@ -2783,11 +3790,11 @@ async function startProject(directory, mode, env) {
2783
3790
  }
2784
3791
 
2785
3792
  // src/runtime/verification.ts
2786
- import { createHash as createHash2, randomBytes as randomBytes7, randomUUID } from "node:crypto";
2787
- function validateVerificationHealth(value) {
3793
+ import { createHash as createHash4, randomBytes as randomBytes7, randomUUID as randomUUID4 } from "node:crypto";
3794
+ function validateVerificationHealth(value, profile = "basic-sqlite-fs") {
2788
3795
  const health = value;
2789
- if (!health || health.status !== "ok" || health.providers?.auth?.provider !== "basic-auth" || health.providers.sync?.provider !== "sqlite" || health.providers.storage?.provider !== "fs") {
2790
- throw new Error("Public deep health does not report the managed Basic Auth + SQLite + filesystem profile.");
3796
+ if (!health || health.status !== "ok" || health.providers?.auth?.provider !== (profile === "basic-sqlite-fs" ? "basic-auth" : "clerk") || health.providers.sync?.provider !== "sqlite" || health.providers.storage?.provider !== (profile === "basic-sqlite-fs" ? "fs" : "s3")) {
3797
+ throw new Error(`Public deep health does not report the managed ${profile === "basic-sqlite-fs" ? "Basic Auth + SQLite + filesystem" : "Clerk + SQLite + S3"} profile.`);
2791
3798
  }
2792
3799
  return health;
2793
3800
  }
@@ -2882,7 +3889,7 @@ async function verifyPublicApplication(baseUrl, credentials) {
2882
3889
  Buffer.from("89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489", "hex"),
2883
3890
  randomBytes7(8)
2884
3891
  ]);
2885
- const hash = `sha256:${createHash2("sha256").update(probeBytes).digest("hex")}`;
3892
+ const hash = `sha256:${createHash4("sha256").update(probeBytes).digest("hex")}`;
2886
3893
  let storageId;
2887
3894
  let metadataAttempted = false;
2888
3895
  let physicalCleanupDeferred = false;
@@ -2924,7 +3931,7 @@ async function verifyPublicApplication(baseUrl, credentials) {
2924
3931
  }
2925
3932
  });
2926
3933
  const metadataCreatedAt = Date.now();
2927
- const metadataOpId = randomUUID();
3934
+ const metadataOpId = randomUUID4();
2928
3935
  const pushed = await verificationJson(baseUrl, "/api/sync/push", {
2929
3936
  cookie,
2930
3937
  body: {
@@ -2974,7 +3981,7 @@ async function verifyPublicApplication(baseUrl, credentials) {
2974
3981
  if (storageId) {
2975
3982
  if (metadataAttempted) {
2976
3983
  const metadataDeleteAt = Date.now();
2977
- const metadataDeleteOpId = randomUUID();
3984
+ const metadataDeleteOpId = randomUUID4();
2978
3985
  const deleted = await verificationJson(baseUrl, "/api/sync/push", {
2979
3986
  cookie,
2980
3987
  body: {
@@ -3074,31 +4081,31 @@ async function adoptCommand(positionals, flags) {
3074
4081
  if (targetDirectory === sourceDirectory)
3075
4082
  throw new Error("Choose a separate target directory for adoption.");
3076
4083
  await readDirectoryEmpty(targetDirectory);
3077
- if (!await fileExists(join13(sourceDirectory, "or3-release.json")))
4084
+ if (!await fileExists(join16(sourceDirectory, "or3-release.json")))
3078
4085
  throw new Error(`V1 release metadata is missing at ${sourceDirectory}/or3-release.json.`);
3079
- const release = JSON.parse(await readText(join13(sourceDirectory, "or3-release.json")));
4086
+ const release = JSON.parse(await readText(join16(sourceDirectory, "or3-release.json")));
3080
4087
  if (!release.or3Version || !isVersion(release.or3Version))
3081
4088
  throw new Error("V1 release metadata does not contain a usable OR3 version.");
3082
4089
  if (release.or3Version !== PACKAGE_VERSION) {
3083
4090
  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.`);
3084
4091
  }
3085
- const sourceEnv = parseEnv(await readText(join13(sourceDirectory, ".env")));
3086
- const providers = await readText(join13(sourceDirectory, "or3.providers.generated.ts"));
4092
+ const sourceEnv = parseEnv(await readText(join16(sourceDirectory, ".env")));
4093
+ const providers = await readText(join16(sourceDirectory, "or3.providers.generated.ts"));
3087
4094
  assertSupportedSource(sourceDirectory, sourceEnv, providers);
3088
4095
  const mode = sourceMode(sourceDirectory, sourceEnv);
3089
4096
  const domain = mode === "public" ? sourceEnv.OR3_PUBLIC_DOMAIN : undefined;
3090
4097
  if (domain)
3091
4098
  await checkPublicPrerequisites(domain);
3092
- const sourceComposeFiles = ["-f", join13(sourceDirectory, "compose.yaml")];
4099
+ const sourceComposeFiles = ["-f", join16(sourceDirectory, "compose.yaml")];
3093
4100
  if (mode === "public")
3094
- sourceComposeFiles.push("-f", join13(sourceDirectory, "compose.public.yaml"));
4101
+ sourceComposeFiles.push("-f", join16(sourceDirectory, "compose.public.yaml"));
3095
4102
  await ensureDocker();
3096
4103
  const sourceConfig = await run("docker", [
3097
4104
  "compose",
3098
4105
  "--project-directory",
3099
4106
  sourceDirectory,
3100
4107
  "--env-file",
3101
- join13(sourceDirectory, ".env"),
4108
+ join16(sourceDirectory, ".env"),
3102
4109
  ...sourceComposeFiles,
3103
4110
  "config",
3104
4111
  "--format",
@@ -3127,11 +4134,12 @@ async function adoptCommand(positionals, flags) {
3127
4134
  throw new Error("V1 project has an invalid OR3 port.");
3128
4135
  if (mode === "public" && [80, 443].includes(sourcePort))
3129
4136
  throw new Error("V1 public deployment uses a Caddy port for OR3; adoption requires a separate OR3 port.");
3130
- assertSupportedSourceCompose(sourceConfig.stdout, sourceVolume, sourcePort);
4137
+ assertSupportedSourceCompose(sourceConfig.stdout, sourceVolume, sourcePort, sourceEnv, [sourceImage, image]);
3131
4138
  const sourceInitiallyRunning = await sourceServiceRunning(sourceDirectory, sourceComposeFiles);
4139
+ await assertSourceContainer(sourceDirectory, sourceComposeFiles, image, sourceConfig.stdout, sourceVolume, sourcePort, sourceEnv);
3132
4140
  const targetNames = composeProjectNames(targetDirectory);
3133
4141
  await assertDockerProjectAbsent(targetNames.project);
3134
- for (const volume of [targetNames.volume, ...mode === "public" ? [targetNames.caddyData, targetNames.caddyConfig] : []]) {
4142
+ for (const volume of [targetNames.volume, ...managedVaultVolumeNames(targetNames.volume), ...mode === "public" ? [targetNames.caddyData, targetNames.caddyConfig] : []]) {
3135
4143
  const existing = await run("docker", ["volume", "inspect", volume]);
3136
4144
  if (existing.ok)
3137
4145
  throw new Error(`Docker volume ${volume} already exists. Choose a new adoption target directory.`);
@@ -3185,7 +4193,7 @@ ${existing.stderr}`)) {
3185
4193
  OR3_GUEST_ACCESS_ENABLED: "false"
3186
4194
  });
3187
4195
  await writeSecure(deploymentPaths(targetDirectory).env, serializeEnv(targetEnv));
3188
- await writeSecure(join13(targetDirectory, ".or3-initial-credentials"), serializeInitialCredentials({
4196
+ await writeSecure(join16(targetDirectory, ".or3-initial-credentials"), serializeInitialCredentials({
3189
4197
  bootstrapEmail: email,
3190
4198
  bootstrapPassword: password,
3191
4199
  adminUsername: targetEnv.OR3_ADMIN_USERNAME,
@@ -3206,6 +4214,7 @@ ${existing.stderr}`)) {
3206
4214
  phase: "prepared"
3207
4215
  });
3208
4216
  let sourceStopAttempted = false;
4217
+ let targetMayHaveStarted = false;
3209
4218
  try {
3210
4219
  sourceStopAttempted = true;
3211
4220
  const sourceStop = await run("docker", [...sourceComposeArgs(sourceDirectory, sourceComposeFiles), "stop"], sourceDirectory);
@@ -3221,7 +4230,7 @@ ${existing.stderr}`)) {
3221
4230
  }
3222
4231
  }
3223
4232
  await mkdir5(sourceBackupDir, { recursive: true, mode: 448 });
3224
- await copySecure(join13(sourceDirectory, ".env"), join13(sourceBackupDir, "config.env"));
4233
+ await copySecure(join16(sourceDirectory, ".env"), join16(sourceBackupDir, "config.env"));
3225
4234
  await archiveExternalVolume(sourceImage, sourceVolume, sourceBackupDir);
3226
4235
  const sourceManifest = {
3227
4236
  schemaVersion: 1,
@@ -3230,8 +4239,8 @@ ${existing.stderr}`)) {
3230
4239
  appVersion: release.or3Version,
3231
4240
  image,
3232
4241
  imageDigest: digest,
3233
- dataSha256: await sha256File(join13(sourceBackupDir, "data.tgz")),
3234
- configSha256: await sha256File(join13(sourceBackupDir, "config.env")),
4242
+ dataSha256: await sha256File(join16(sourceBackupDir, "data.tgz")),
4243
+ configSha256: await sha256File(join16(sourceBackupDir, "config.env")),
3235
4244
  mode,
3236
4245
  domain,
3237
4246
  composeProject: sourceEnv.OR3_COMPOSE_PROJECT ?? sanitizeName(basename3(sourceDirectory)),
@@ -3242,11 +4251,13 @@ ${existing.stderr}`)) {
3242
4251
  };
3243
4252
  const sourceManifestContents = `${JSON.stringify(sourceManifest, null, 2)}
3244
4253
  `;
3245
- await writeSecure(join13(sourceBackupDir, "manifest.json"), sourceManifestContents);
4254
+ await writeSecure(join16(sourceBackupDir, "manifest.json"), sourceManifestContents);
3246
4255
  await writeBackupAuthentication(targetDirectory, sourceBackupDir, sourceManifestContents);
3247
4256
  await readManifest(sourceBackupDir, targetDirectory);
3248
4257
  await updatePending(targetDirectory, state, { phase: "snapshot-created" });
4258
+ await updatePending(targetDirectory, state, { phase: "target-mutating" });
3249
4259
  await restoreVolumeArchive(targetDirectory, mode, targetEnv, sourceBackupDir);
4260
+ targetMayHaveStarted = true;
3250
4261
  await startProject(targetDirectory, mode, targetEnv);
3251
4262
  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);
3252
4263
  const runtimeEnv = withoutProvisioningCredentials(targetEnv);
@@ -3258,11 +4269,12 @@ ${existing.stderr}`)) {
3258
4269
  console.log(`Source backup: ${sourceBackupDir}`);
3259
4270
  console.log(`The original deployment is preserved and stopped. Verify sign-in, chat, and file access before removing anything.`);
3260
4271
  } catch (error) {
3261
- await compose(targetDirectory, mode, ["down"]).catch(() => {
3262
- return;
3263
- });
4272
+ if (!targetMayHaveStarted)
4273
+ await compose(targetDirectory, mode, ["down"]).catch(() => {
4274
+ return;
4275
+ });
3264
4276
  let sourceRecoveryError = "";
3265
- if (sourceStopAttempted && sourceInitiallyRunning) {
4277
+ if (!targetMayHaveStarted && sourceStopAttempted && sourceInitiallyRunning) {
3266
4278
  try {
3267
4279
  await restartSource(sourceDirectory, sourceComposeFiles, secretValues(sourceEnv));
3268
4280
  } catch (recovery) {
@@ -3271,88 +4283,297 @@ ${existing.stderr}`)) {
3271
4283
  }
3272
4284
  state.lastError = redact(`${error instanceof Error ? error.message : String(error)}${sourceRecoveryError}`, secretValues(targetEnv));
3273
4285
  await writeState(targetDirectory, state);
4286
+ const recoveryDetail = targetMayHaveStarted ? "The target may contain new writes. Both deployments were left as found; inspect the operation before deciding which data to retain. " : sourceRecoveryError ? "The original V1 deployment needs manual recovery. " : sourceInitiallyRunning ? "The original V1 deployment was restarted and deeply healthy. " : "The original V1 deployment remains stopped. ";
3274
4287
  throw new Error(`${state.lastError}
3275
- ${sourceRecoveryError ? "The original V1 deployment needs manual recovery. " : "The original V1 deployment was restarted and deeply healthy. "}Managed files remain at ${targetDirectory}.${sourceBackupDir ? ` Source backup: ${sourceBackupDir}.` : ""}`);
4288
+ ${recoveryDetail}Managed files remain at ${targetDirectory}.${sourceBackupDir ? ` Source backup: ${sourceBackupDir}.` : ""}`);
3276
4289
  }
3277
4290
  }
3278
4291
 
3279
4292
  // src/backup/create.ts
3280
- import { chmod as chmod6, lstat as lstat2, mkdir as mkdir6 } from "node:fs/promises";
3281
- import { join as join16, resolve as resolve9 } from "node:path";
4293
+ import { chmod as chmod6, lstat as lstat4, mkdir as mkdir6 } from "node:fs/promises";
4294
+ import { join as join19, resolve as resolve10 } from "node:path";
3282
4295
 
3283
4296
  // src/backup/references.ts
3284
- import { randomUUID as randomUUID2 } from "node:crypto";
3285
- import { join as join14 } from "node:path";
3286
- var INSPECTOR = "/app/scripts/docker/backup-inspect.mjs";
3287
- var NODE = 'if [ -x /nodejs/bin/node ]; then exec /nodejs/bin/node "$@"; else exec node "$@"; fi';
3288
- async function validateCanonicalBackupReferences(directory, env, backupPath) {
3289
- if (env.OR3_SYNC_PROVIDER !== "sqlite" || env.NUXT_PUBLIC_STORAGE_PROVIDER !== "fs" || env.OR3_SQLITE_DRIVER && env.OR3_SQLITE_DRIVER !== "better-sqlite3") {
4297
+ import { join as join17, resolve as resolve8 } from "node:path";
4298
+ var INSPECTOR3 = "/app/scripts/docker/backup-inspect.mjs";
4299
+ var NODE3 = 'if [ -x /nodejs/bin/node ]; then exec /nodejs/bin/node "$@"; else exec node "$@"; fi';
4300
+ function assertBackupOperatorMount(directory, enabled, composeMounts, actualMounts) {
4301
+ const expected = resolve8(directory, ".or3-cloud/operator-ipc");
4302
+ const configured = composeMounts.filter((mount) => mount.target === "/run/or3-operator");
4303
+ const actual = actualMounts.filter((mount) => mount.Destination === "/run/or3-operator");
4304
+ if (!enabled && configured.length === 0 && actual.length === 0)
4305
+ return;
4306
+ if (!enabled || configured.length !== 1 || actual.length !== 1 || configured.at(0)?.type !== "bind" || configured.at(0)?.source !== expected || configured.at(0)?.read_only !== true || actual.at(0)?.Type !== "bind" || actual.at(0)?.Source !== expected || actual.at(0)?.RW !== false) {
4307
+ throw new Error("Backup source operator mount does not match the managed read-only socket.");
4308
+ }
4309
+ }
4310
+ function assertBackupContainerDiff(diff, mounts) {
4311
+ const allowed = new Set(["/data", "/run/or3-operator", "/run/or3-secrets", "/run/or3-secrets-client", "/vault-data", "/vault-key"]);
4312
+ const targets = new Set(mounts.map((mount) => mount.Destination).filter((value) => typeof value === "string" && allowed.has(value)));
4313
+ const permitted = new Set([...targets].map((target) => `A ${target}`));
4314
+ if ([...targets].some((target) => target.startsWith("/run/")))
4315
+ permitted.add("C /run");
4316
+ if (diff.trim() && diff.trim().split(`
4317
+ `).some((line) => !permitted.has(line)))
4318
+ throw new Error("Backup source container writable layer is changed or cannot be inspected.");
4319
+ }
4320
+ function assertSupportedBackupProfile(env) {
4321
+ let profile;
4322
+ try {
4323
+ profile = managedProviderProfile(env);
4324
+ } catch {}
4325
+ const expectedOverrides = {
4326
+ OR3_STORAGE_PROVIDER: "fs",
4327
+ NUXT_AUTH_ENABLED: "true",
4328
+ NUXT_AUTH_PROVIDER: "basic-auth",
4329
+ NUXT_PUBLIC_AUTH_PROVIDER: "basic-auth",
4330
+ NUXT_PUBLIC_SSR_AUTH_ENABLED: "true",
4331
+ NUXT_SYNC_ENABLED: "true",
4332
+ NUXT_PUBLIC_SYNC_ENABLED: "true",
4333
+ NUXT_SYNC_PROVIDER: "sqlite",
4334
+ NUXT_PUBLIC_SYNC_PROVIDER: "sqlite",
4335
+ NUXT_STORAGE_ENABLED: "true",
4336
+ NUXT_PUBLIC_STORAGE_ENABLED: "true",
4337
+ NUXT_STORAGE_PROVIDER: "fs"
4338
+ };
4339
+ const ambiguous = /^(?:RUNTIME_CONFIG$|NUXT_(?:AUTH|SYNC|STORAGE|SECRETS|PUBLIC)$|NITRO_ENV_(?:PREFIX|EXPANSION)$|NITRO_(?:AUTH|SYNC|STORAGE|SECRETS|PUBLIC)(?:_|$))/;
4340
+ if (profile !== "basic-sqlite-fs" || !(env.AUTH_PROVIDER || env.OR3_AUTH_PROVIDER) || env.OR3_SYNC_PROVIDER !== "sqlite" || env.NUXT_PUBLIC_STORAGE_PROVIDER !== "fs" || env.OR3_SQLITE_DRIVER && env.OR3_SQLITE_DRIVER !== "better-sqlite3" || env.SSR_AUTH_ENABLED !== "true" || (env.OR3_CLOUD_SYNC_ENABLED ?? env.OR3_SYNC_ENABLED) !== "true" || (env.OR3_CLOUD_STORAGE_ENABLED ?? env.OR3_STORAGE_ENABLED) !== "true" || Object.entries(expectedOverrides).some(([key, expected]) => env[key] !== undefined && env[key] !== expected) || Object.keys(env).some((key) => ambiguous.test(key))) {
3290
4341
  throw new Error("Unsupported offline backup inspection provider profile; SQLite/FS is required.");
3291
4342
  }
3292
- const helper = `or3-backup-inspection-${randomUUID2()}`;
3293
- const checked = async (args) => {
3294
- const result = await run("docker", args, directory);
3295
- if (!result.ok)
3296
- throw new Error(`Offline backup reference inspection failed or is unsupported. ${redact(result.stderr, secretValues(env))}`);
3297
- return result.stdout.trim();
3298
- };
3299
- let primary;
4343
+ }
4344
+ async function assertBackupSourceProfile(directory, state, env) {
4345
+ assertSupportedBackupProfile(env);
4346
+ if (state.image !== env.OR3_IMAGE || state.volumeName !== env.OR3_VOLUME_NAME)
4347
+ throw new Error("Backup source identity differs from managed state.");
4348
+ await assertSafeComposeBinding(directory, state.mode, env);
4349
+ const config = parseComposeConfig(await resolvedComposeConfig(directory, state.mode));
4350
+ const service = config.services?.or3;
4351
+ if (!service)
4352
+ throw new Error("Backup source Compose service is missing.");
4353
+ const readEnv = (input2) => {
4354
+ const values = {};
4355
+ const entries = Array.isArray(input2) ? input2.map((line) => {
4356
+ if (typeof line !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*=/.test(line))
4357
+ throw new Error("Backup source environment is malformed.");
4358
+ const index = line.indexOf("=");
4359
+ return [line.slice(0, index), line.slice(index + 1)];
4360
+ }) : input2 && typeof input2 === "object" ? Object.entries(input2) : [];
4361
+ for (const [key, value] of entries) {
4362
+ if (typeof key !== "string" || typeof value !== "string" || Object.hasOwn(values, key))
4363
+ throw new Error("Backup source environment is incomplete or ambiguous.");
4364
+ values[key] = value;
4365
+ }
4366
+ return values;
4367
+ };
4368
+ const resolved = readEnv(service.environment);
4369
+ assertSupportedBackupProfile(resolved);
4370
+ if (service.entrypoint != null || service.command != null || service.working_dir != null || service.read_only !== true)
4371
+ throw new Error("Backup source Compose changes the managed application startup.");
4372
+ const matches = (candidate) => {
4373
+ assertSupportedBackupProfile(candidate);
4374
+ for (const [key, value] of [["OR3_BASIC_AUTH_DB_PATH", "/data/auth.sqlite"], ["OR3_SQLITE_DB_PATH", "/data/sync.sqlite"], ["OR3_STORAGE_FS_ROOT", "/data/storage"]]) {
4375
+ if (env[key] !== value || resolved[key] !== value || candidate[key] !== value)
4376
+ throw new Error(`Backup source ${key} does not use the captured managed path.`);
4377
+ }
4378
+ for (const [key, value] of [["OR3_ADMIN_DATA_DIR", "/data/admin"], ["OR3_EXTENSIONS_ROOT", "/data/extensions"]]) {
4379
+ if (env[key] !== undefined && env[key] !== value || resolved[key] !== value || candidate[key] !== value)
4380
+ throw new Error(`Backup source ${key} does not use the captured managed path.`);
4381
+ }
4382
+ const profileKey = /^(?:AUTH_PROVIDER|SSR_AUTH_ENABLED|OR3_(?:AUTH_PROVIDER|SYNC_(?:ENABLED|PROVIDER)|CLOUD_SYNC_ENABLED|STORAGE_(?:ENABLED|PROVIDER|FS_ROOT)|CLOUD_STORAGE_ENABLED|SQLITE_(?:DB_PATH|DRIVER)|BASIC_AUTH_DB_PATH)|NUXT_(?:AUTH|SYNC|STORAGE|PUBLIC)(?:_.*)?|NITRO_(?:AUTH|SYNC|STORAGE|PUBLIC|ENV)(?:_.*)?|RUNTIME_CONFIG)$/;
4383
+ for (const key of new Set([...Object.keys(env), ...Object.keys(resolved), ...Object.keys(candidate)])) {
4384
+ if (profileKey.test(key) && (resolved[key] !== env[key] || candidate[key] !== resolved[key]))
4385
+ throw new Error(`Backup source profile configuration differs for ${key}.`);
4386
+ }
4387
+ };
4388
+ matches(resolved);
4389
+ if ((service.volumes ?? []).some((mount) => typeof mount.target === "string" && mount.target.startsWith("/data/")))
4390
+ throw new Error("Backup source Compose has an uncaptured mount inside /data.");
4391
+ const listed = await run("docker", composeArgs(directory, state.mode, ["ps", "-aq", "or3"]), directory);
4392
+ const ids = listed.stdout.trim().split(/\s+/).filter(Boolean);
4393
+ if (!listed.ok || ids.length !== 1)
4394
+ throw new Error("Backup requires exactly one existing source container, running or stopped.");
4395
+ const inspect = await run("docker", ["inspect", "--format", `{{json .Config.Env}}
4396
+ {{json .Image}}
4397
+ {{json .Mounts}}
4398
+ {{json .Config.Labels}}`, ids[0]], directory);
4399
+ const image = await run("docker", ["image", "inspect", "--format", "{{.Id}}", env.OR3_IMAGE], directory);
4400
+ if (!inspect.ok || !image.ok || !image.stdout.trim())
4401
+ throw new Error("Backup source container or image cannot be inspected.");
4402
+ let actual, imageId, mounts, labels;
3300
4403
  try {
3301
- await checked([
3302
- "run",
3303
- "--pull",
3304
- "never",
3305
- "--rm",
3306
- "--detach",
3307
- "--name",
3308
- helper,
3309
- "--network",
3310
- "none",
3311
- "--read-only",
3312
- "--security-opt",
3313
- "no-new-privileges:true",
3314
- "--cap-drop",
3315
- "ALL",
3316
- "--memory",
3317
- "256m",
3318
- "--cpus",
3319
- "0.5",
3320
- "--pids-limit",
3321
- "32",
3322
- "--tmpfs",
3323
- "/scratch:rw,noexec,nosuid,nodev,size=512m,mode=700,uid=65532,gid=65532",
3324
- "--entrypoint",
3325
- "sh",
3326
- env.OR3_IMAGE,
3327
- "-c",
3328
- "sleep 240"
3329
- ]);
3330
- await streamFileToCommand("docker", ["exec", "-i", helper, "sh", "-c", "ulimit -f 16384; tar tvzf - > /scratch/inventory"], join14(backupPath, "data.tgz"), directory, secretValues(env));
3331
- const inventory = JSON.parse(await checked(["exec", helper, "sh", "-c", NODE, "or3-backup-inspection", INSPECTOR, "inventory"]));
3332
- if (!Array.isArray(inventory.selected) || !inventory.selected.includes("./sync.sqlite") && !inventory.selected.includes("sync.sqlite") || inventory.selected.some((name) => !/^(?:\.\/)?sync[.]sqlite(?:-wal)?$/.test(name)) || inventory.selected.length > 2 || !Number.isSafeInteger(inventory.bytes) || inventory.bytes < 0)
3333
- throw new Error("Invalid offline backup inspection inventory response.");
3334
- const freeKiB = Number(await checked(["exec", helper, "sh", "-c", "df -Pk /scratch | awk 'NR == 2 { print $4 }'"]));
3335
- if (!Number.isFinite(freeKiB) || freeKiB < 0)
3336
- throw new Error("Could not verify offline backup scratch capacity.");
3337
- assertEnoughFreeSpace(freeKiB * 1024, inventory.bytes, "Offline backup inspection scratch");
3338
- await streamFileToCommand("docker", ["exec", "-i", helper, "tar", "-xz", "-f", "-", "-C", "/scratch", ...inventory.selected], join14(backupPath, "data.tgz"), directory, secretValues(env));
3339
- const result = JSON.parse(await checked(["exec", helper, "sh", "-c", NODE, "or3-backup-inspection", INSPECTOR, "references", env.OR3_STORAGE_FS_ROOT ?? "/data/storage"]));
4404
+ const lines = inspect.stdout.trimEnd().split(`
4405
+ `);
4406
+ if (lines.length !== 4)
4407
+ throw new Error("incomplete");
4408
+ [actual, imageId, mounts, labels] = lines.map((line) => JSON.parse(line));
4409
+ } catch {
4410
+ throw new Error("Backup source container inspection is incomplete or malformed.");
4411
+ }
4412
+ if (imageId !== image.stdout.trim() || !labels || typeof labels !== "object" || Array.isArray(labels) || labels["com.docker.compose.project"] !== state.composeProject || labels["com.docker.compose.service"] !== "or3")
4413
+ throw new Error("Backup source container identity does not match the managed deployment.");
4414
+ if (!Array.isArray(mounts))
4415
+ throw new Error("Backup source container mounts are unavailable.");
4416
+ const onlyMounts = (items, property, allowed) => {
4417
+ if (items.some((item) => !item || typeof item !== "object" || Array.isArray(item) || !allowed.includes(item[property])))
4418
+ throw new Error("Backup source has an unexpected mount.");
4419
+ };
4420
+ onlyMounts(service.volumes ?? [], "target", ["/data", "/run/or3-secrets", "/run/or3-secrets-client", "/run/or3-operator"]);
4421
+ assertCapturedComposeMounts(service);
4422
+ onlyMounts(mounts, "Destination", ["/data", "/run/or3-secrets", "/run/or3-secrets-client", "/run/or3-operator"]);
4423
+ assertBackupOperatorMount(directory, dashboardUpdatesEnabled(directory), service.volumes ?? [], mounts);
4424
+ const data = mounts.filter((mount) => mount && typeof mount === "object" && mount.Destination === "/data");
4425
+ if (data.length !== 1 || data[0].Type !== "volume" || data[0].Name !== state.volumeName || mounts.some((mount) => typeof mount?.Destination === "string" && mount.Destination.startsWith("/data/")))
4426
+ throw new Error("Backup source container does not use the captured data volume exclusively.");
4427
+ matches(readEnv(actual));
4428
+ const actualEnv = readEnv(actual);
4429
+ const imageExecution = await run("docker", ["image", "inspect", "--format", `{{json .Config.Entrypoint}}
4430
+ {{json .Config.Cmd}}
4431
+ {{json .Config.WorkingDir}}
4432
+ {{json .Config.Env}}`, env.OR3_IMAGE], directory);
4433
+ if (!imageExecution.ok)
4434
+ throw new Error("Backup source image startup cannot be inspected.");
4435
+ let imageEntry, imageCommand, imageWorkingDir, imageEnvironment;
4436
+ try {
4437
+ const lines = imageExecution.stdout.trimEnd().split(`
4438
+ `);
4439
+ if (lines.length !== 4)
4440
+ throw new Error("incomplete");
4441
+ [imageEntry, imageCommand, imageWorkingDir, imageEnvironment] = lines.map((line) => JSON.parse(line));
4442
+ } catch {
4443
+ throw new Error("Backup source image startup inspection is malformed.");
4444
+ }
4445
+ const defaultEnv = readEnv(imageEnvironment);
4446
+ const executionKey = /^(?:PATH|NODE_.+|BUN_.+|LD_.+|DYLD_.+)$/;
4447
+ const checkExecutionEnv = (source, isResolved) => {
4448
+ for (const key of new Set([...Object.keys(source), ...Object.keys(defaultEnv)])) {
4449
+ if (executionKey.test(key) && (isResolved ? source[key] ?? defaultEnv[key] : source[key]) !== defaultEnv[key])
4450
+ throw new Error(`Backup source changes execution environment ${key}.`);
4451
+ }
4452
+ };
4453
+ checkExecutionEnv(actualEnv, false);
4454
+ checkExecutionEnv(resolved, true);
4455
+ const checkExecution = async (container, expectedEntry, expectedCommand, containerMounts) => {
4456
+ const result = await run("docker", ["inspect", "--format", `{{json .Config.Entrypoint}}
4457
+ {{json .Config.Cmd}}
4458
+ {{json .Config.WorkingDir}}
4459
+ {{json .HostConfig.ReadonlyRootfs}}
4460
+ {{json .HostConfig.Tmpfs}}
4461
+ {{json .HostConfig.Mounts}}`, container], directory);
4462
+ let entry, command, cwd, readonly, tmpfs, hostMounts;
4463
+ try {
4464
+ const lines = result.stdout.trimEnd().split(`
4465
+ `);
4466
+ if (!result.ok || lines.length !== 6)
4467
+ throw new Error("incomplete");
4468
+ [entry, command, cwd, readonly, tmpfs, hostMounts] = lines.map((line) => JSON.parse(line));
4469
+ } catch {
4470
+ throw new Error("Backup source startup inspection is incomplete.");
4471
+ }
4472
+ if (JSON.stringify(entry) !== JSON.stringify(expectedEntry) || JSON.stringify(command) !== JSON.stringify(expectedCommand) || cwd !== imageWorkingDir || readonly !== true)
4473
+ throw new Error("Backup source container changes the managed startup or writable root.");
4474
+ assertCapturedHostMounts(tmpfs, hostMounts);
4475
+ const diff = await run("docker", ["diff", container], directory);
4476
+ if (!diff.ok)
4477
+ throw new Error("Backup source container writable layer is changed or cannot be inspected.");
4478
+ assertBackupContainerDiff(diff.stdout, containerMounts);
4479
+ };
4480
+ await checkExecution(ids[0], imageEntry, imageCommand, mounts);
4481
+ const vaultEnabled = managedVaultEnabled(directory, env);
4482
+ const legacyWithoutVault = !vaultEnabled && !config.services?.["or3-vault"] && [env, resolved, actualEnv].every((values) => !Object.keys(values).some((key) => key.startsWith("NUXT_SECRETS_"))) && !(service.volumes ?? []).some((mount) => typeof mount.target === "string" && mount.target.startsWith("/run/or3-secrets")) && !mounts.some((mount) => typeof mount?.Destination === "string" && mount.Destination.startsWith("/run/or3-secrets"));
4483
+ const provider = env.NUXT_SECRETS_PROVIDER ?? "or3-secrets";
4484
+ if (!legacyWithoutVault && (provider !== "" && provider !== "or3-secrets" || resolved.NUXT_SECRETS_PROVIDER !== provider || actualEnv.NUXT_SECRETS_PROVIDER !== provider || provider === "or3-secrets" !== vaultEnabled)) {
4485
+ throw new Error("Backup source secret provider differs from managed vault custody.");
4486
+ }
4487
+ if (vaultEnabled) {
4488
+ for (const [key, value] of [["NUXT_SECRETS_SOCKET_PATH", "/run/or3-secrets/api.sock"], ["NUXT_SECRETS_TOKEN_FILE", "/run/or3-secrets-client/service.token"], ["OR3_MANAGED_VAULT_CONFIG", "/run/or3-secrets-client/config.json"]]) {
4489
+ if (resolved[key] !== value || actualEnv[key] !== value)
4490
+ throw new Error("Backup source vault connection is not the managed connection.");
4491
+ }
4492
+ for (const key of new Set([...Object.keys(resolved), ...Object.keys(actualEnv)])) {
4493
+ if (key.startsWith("NUXT_SECRETS_") && resolved[key] !== actualEnv[key])
4494
+ throw new Error(`Backup source vault configuration differs for ${key}.`);
4495
+ }
4496
+ const expected = [
4497
+ ["/vault-data", `${state.volumeName}-vault-data`],
4498
+ ["/vault-key", `${state.volumeName}-vault-key`],
4499
+ ["/run/or3-secrets", `${state.volumeName}-vault-run`],
4500
+ ["/run/or3-secrets-client", `${state.volumeName}-vault-client`]
4501
+ ];
4502
+ const checkComposeMounts = (mountsToCheck, required) => {
4503
+ for (const [target, volume] of required) {
4504
+ const matches2 = mountsToCheck.filter((mount) => mount.target === target);
4505
+ const source = matches2[0]?.source;
4506
+ if (matches2.length !== 1 || matches2[0]?.type !== "volume" || typeof source !== "string" || (config.volumes?.[source]?.name ?? source) !== volume || mountsToCheck.some((mount) => typeof mount.target === "string" && mount.target.startsWith(`${target}/`)))
4507
+ throw new Error("Backup source Compose vault mounts do not match managed custody.");
4508
+ }
4509
+ };
4510
+ const checkActualMounts = (mountsToCheck, required) => {
4511
+ if (!mountsToCheck.every((mount) => mount && typeof mount === "object" && !Array.isArray(mount)))
4512
+ throw new Error("Backup source container vault mounts are malformed.");
4513
+ const typed = mountsToCheck;
4514
+ for (const [target, volume] of required) {
4515
+ const matches2 = typed.filter((mount) => mount.Destination === target);
4516
+ if (matches2.length !== 1 || matches2.at(0)?.Type !== "volume" || matches2.at(0)?.Name !== volume || typed.some((mount) => typeof mount.Destination === "string" && mount.Destination.startsWith(`${target}/`)))
4517
+ throw new Error("Backup source container vault mounts do not match managed custody.");
4518
+ }
4519
+ };
4520
+ checkComposeMounts(service.volumes ?? [], expected.slice(2));
4521
+ checkActualMounts(mounts, expected.slice(2));
4522
+ const vaultService = config.services?.["or3-vault"];
4523
+ if (!vaultService || vaultService.image !== env.OR3_IMAGE)
4524
+ throw new Error("Backup source vault service does not use the managed image.");
4525
+ const vaultResolved = readEnv(vaultService.environment);
4526
+ checkExecutionEnv(vaultResolved, true);
4527
+ if (JSON.stringify(vaultService.entrypoint) !== JSON.stringify(["/nodejs/bin/node", "/app/vault/entrypoint.mjs"]) || vaultService.command != null || vaultService.working_dir != null || vaultService.read_only !== true)
4528
+ throw new Error("Backup source Compose changes the managed vault startup.");
4529
+ onlyMounts(vaultService.volumes ?? [], "target", expected.map(([target]) => target));
4530
+ assertCapturedComposeMounts(vaultService);
4531
+ checkComposeMounts(vaultService.volumes ?? [], expected);
4532
+ const vaultListed = await run("docker", composeArgs(directory, state.mode, ["ps", "-aq", "or3-vault"]), directory);
4533
+ const vaultIds = vaultListed.stdout.trim().split(/\s+/).filter(Boolean);
4534
+ if (!vaultListed.ok || vaultIds.length !== 1)
4535
+ throw new Error("Backup requires exactly one existing source vault container.");
4536
+ const vaultInspect = await run("docker", ["inspect", "--format", `{{json .Config.Env}}
4537
+ {{json .Image}}
4538
+ {{json .Mounts}}
4539
+ {{json .Config.Labels}}`, vaultIds[0]], directory);
4540
+ if (!vaultInspect.ok)
4541
+ throw new Error("Backup source vault container cannot be inspected.");
4542
+ let vaultEnv, vaultImage, vaultMounts, vaultLabels;
4543
+ try {
4544
+ const lines = vaultInspect.stdout.trimEnd().split(`
4545
+ `);
4546
+ if (lines.length !== 4)
4547
+ throw new Error("incomplete");
4548
+ [vaultEnv, vaultImage, vaultMounts, vaultLabels] = lines.map((line) => JSON.parse(line));
4549
+ } catch {
4550
+ throw new Error("Backup source vault inspection is incomplete or malformed.");
4551
+ }
4552
+ if (vaultImage !== image.stdout.trim() || !vaultLabels || typeof vaultLabels !== "object" || Array.isArray(vaultLabels) || vaultLabels["com.docker.compose.project"] !== state.composeProject || vaultLabels["com.docker.compose.service"] !== "or3-vault" || !Array.isArray(vaultMounts)) {
4553
+ throw new Error("Backup source vault container identity does not match the managed deployment.");
4554
+ }
4555
+ checkActualMounts(vaultMounts, expected);
4556
+ const actualVaultEnv = readEnv(vaultEnv);
4557
+ checkExecutionEnv(actualVaultEnv, false);
4558
+ if (vaultResolved.OR3_VAULT_NAMESPACE !== (resolved.NUXT_SECRETS_NAMESPACE || "or3") || actualVaultEnv.OR3_VAULT_NAMESPACE !== vaultResolved.OR3_VAULT_NAMESPACE)
4559
+ throw new Error("Backup source vault namespace differs from the managed connection.");
4560
+ onlyMounts(vaultMounts, "Destination", [...expected.map(([target]) => target), "/data"]);
4561
+ await checkExecution(vaultIds[0], ["/nodejs/bin/node", "/app/vault/entrypoint.mjs"], null, vaultMounts);
4562
+ }
4563
+ }
4564
+ async function validateCanonicalBackupReferences(directory, env, backupPath) {
4565
+ assertSupportedBackupProfile(env);
4566
+ await withBackupHelper(directory, env.OR3_IMAGE, env, { network: "none" }, async (helper) => {
4567
+ await helper.loadSqlite(join17(backupPath, "data.tgz"));
4568
+ const result = JSON.parse(await helper.exec(["sh", "-c", NODE3, "or3-backup-inspection", INSPECTOR3, "references", env.OR3_STORAGE_FS_ROOT ?? "/data/storage"]));
3340
4569
  if (result.contract !== 1 || result.complete !== true || !Number.isSafeInteger(result.liveAttachments) || result.liveAttachments < 0)
3341
4570
  throw new Error("Offline canonical backup reference inspection did not complete.");
3342
- } catch (error) {
3343
- primary = error;
3344
- }
3345
- const cleanup = await run("docker", ["rm", "--force", helper], directory);
3346
- if (!cleanup.ok && !/No such container/i.test(cleanup.stderr)) {
3347
- throw new AggregateError(primary ? [primary] : [], `Offline backup inspection helper cleanup failed. ${redact(cleanup.stderr, secretValues(env))}`);
3348
- }
3349
- if (primary)
3350
- throw primary;
4571
+ });
3351
4572
  }
3352
4573
 
3353
4574
  // src/backup/retention.ts
3354
- import { rm as rm5 } from "node:fs/promises";
3355
- import { basename as basename4, dirname as dirname4, join as join15, resolve as resolve8 } from "node:path";
4575
+ import { rm as rm6 } from "node:fs/promises";
4576
+ import { basename as basename4, dirname as dirname4, join as join18, resolve as resolve9 } from "node:path";
3356
4577
  var BACKUP_RETENTION_KEEP = 5;
3357
4578
  function assertRemovableArtifactName(name) {
3358
4579
  if (!BACKUP_ID_PATTERN.test(name)) {
@@ -3367,18 +4588,18 @@ async function removeNamedBackupArtifact(directory, backupId) {
3367
4588
  if (dirname4(target) !== deploymentPaths(directory).backups) {
3368
4589
  throw new Error(`Refusing to remove a path outside the backups directory: ${target}.`);
3369
4590
  }
3370
- await rm5(target, { recursive: true, force: true });
3371
- await rm5(join15(deploymentPaths(directory).exports, `${backupId}.json`), { force: true });
4591
+ await rm6(target, { recursive: true, force: true });
4592
+ await rm6(join18(deploymentPaths(directory).exports, `${backupId}.json`), { force: true });
3372
4593
  }
3373
4594
  async function removeEnumeratedBackupArtifact(directory, backup) {
3374
4595
  await lifecycleFaults.beforeArtifactDelete?.();
3375
- const backupsRoot = resolve8(deploymentPaths(directory).backups);
3376
- const target = resolve8(backup.path);
4596
+ const backupsRoot = resolve9(deploymentPaths(directory).backups);
4597
+ const target = resolve9(backup.path);
3377
4598
  if (dirname4(target) !== backupsRoot || basename4(target) !== backup.backupId || !BACKUP_ID_PATTERN.test(backup.backupId)) {
3378
4599
  throw new Error(`Refusing to prune an untrusted backup path ${backup.path}.`);
3379
4600
  }
3380
- await rm5(target, { recursive: true, force: true });
3381
- await rm5(join15(deploymentPaths(directory).exports, `${backup.backupId}.json`), { force: true });
4601
+ await rm6(target, { recursive: true, force: true });
4602
+ await rm6(join18(deploymentPaths(directory).exports, `${backup.backupId}.json`), { force: true });
3382
4603
  }
3383
4604
  function planRetention(entries, keep, protectedIds, options = {}) {
3384
4605
  if (!Number.isInteger(keep) || keep < 1)
@@ -3402,9 +4623,13 @@ function planRetention(entries, keep, protectedIds, options = {}) {
3402
4623
  if (automatic)
3403
4624
  canPrune = false;
3404
4625
  }
3405
- const verified = entries.filter((entry) => entry.kind === "verified").map((entry) => entry.backup).sort((a, b) => a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0);
3406
- const removable = verified.filter((backup) => !protectedIds.has(backup.backupId));
3407
- const remove = removable.slice(keep).map((backup) => backup.backupId);
4626
+ const observedAt = Date.now();
4627
+ const verified = entries.filter((entry) => entry.kind === "verified").map((entry) => entry.backup).sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
4628
+ const removable = verified.filter((backup) => !protectedIds.has(backup.backupId) && Date.parse(backup.createdAt) <= observedAt);
4629
+ const full = removable.filter((backup) => backup.recoveryArtifactsComplete === true && backup.snapshotKind === "full" && backup.remoteKind !== "s3-reference-v1");
4630
+ const safety = removable.filter((backup) => backup.recoveryArtifactsComplete === true && backup.snapshotKind === "safety" && backup.remoteKind !== "s3-reference-v1");
4631
+ const references = removable.filter((backup) => backup.recoveryArtifactsComplete === true && backup.snapshotKind === "safety" && backup.remoteKind === "s3-reference-v1");
4632
+ const remove = [...full.slice(keep), ...safety.slice(keep), ...references.slice(keep)].map((backup) => backup.backupId);
3408
4633
  const removeSet = new Set(remove);
3409
4634
  return {
3410
4635
  keep: verified.filter((backup) => !removeSet.has(backup.backupId)).map((backup) => backup.backupId),
@@ -3453,7 +4678,7 @@ async function pruneBackups(directory, state, keep, force, options = {}) {
3453
4678
  throw new Error(`Retention selected backup ${backupId}, but its verified path disappeared before deletion.`);
3454
4679
  const revalidated = await inspectBackupEntry(deploymentPaths(directory).backups, backupId, directory);
3455
4680
  const current = revalidated.kind === "verified" ? revalidated.backup : undefined;
3456
- if (!current || current.path !== backup.path || current.dataSha256 !== backup.dataSha256) {
4681
+ if (!current || current.path !== backup.path || current.dataSha256 !== backup.dataSha256 || current.createdAt !== backup.createdAt || current.schemaVersion !== backup.schemaVersion || current.remoteKind !== backup.remoteKind || current.snapshotKind !== backup.snapshotKind || current.recoveryArtifactsComplete !== backup.recoveryArtifactsComplete) {
3457
4682
  throw new Error(`Backup ${backupId} changed while pruning was in progress. Aborting retention before deletion.`);
3458
4683
  }
3459
4684
  await removeEnumeratedBackupArtifact(directory, current);
@@ -3468,7 +4693,7 @@ var asError = (error) => error instanceof Error ? error : new Error(String(error
3468
4693
  var RECOVERY_STEPS = 'Keep the operation record. Inspect the named paths and their permissions, then run "npx @or3/cloud recover --dry-run" and "npx @or3/cloud recover" from the deployment directory. Do not restore an unverified artifact or remove recovery locks manually.';
3469
4694
  async function artifactStatus(path) {
3470
4695
  try {
3471
- await lstat2(path);
4696
+ await lstat4(path);
3472
4697
  return { absent: false, detail: `Still present: ${path}.` };
3473
4698
  } catch (error) {
3474
4699
  if (error.code === "ENOENT")
@@ -3485,7 +4710,7 @@ async function cleanupPartialBackup(directory, backupId) {
3485
4710
  }
3486
4711
  const statuses = await Promise.all([
3487
4712
  artifactStatus(backupDirectory(directory, backupId)),
3488
- artifactStatus(join16(deploymentPaths(directory).exports, `${backupId}.json`))
4713
+ artifactStatus(join19(deploymentPaths(directory).exports, `${backupId}.json`))
3489
4714
  ]);
3490
4715
  const removed = statuses.every((status) => status.absent);
3491
4716
  const detail = statuses.map((status) => status.detail).join(" ");
@@ -3494,7 +4719,17 @@ async function cleanupPartialBackup(directory, backupId) {
3494
4719
  return { error, removed, detail };
3495
4720
  }
3496
4721
  async function createBackup(directory, state, env, options = {}) {
4722
+ const remoteStorage = env.NUXT_PUBLIC_STORAGE_PROVIDER === "s3";
4723
+ if (!remoteStorage) {
4724
+ assertSupportedBackupProfile(env);
4725
+ await assertBackupSourceProfile(directory, state, env);
4726
+ } else if (options.safetySnapshot)
4727
+ remoteSource(env);
4728
+ else
4729
+ assertRemoteTransferConfiguration(env);
3497
4730
  const restartAfter = options.restartAfter ?? true;
4731
+ if (managedVaultEnabled(directory, env))
4732
+ await assertVaultVolumesExist(directory, state);
3498
4733
  await requireImageDigest(state.image, state.imageDigest, "Current deployment");
3499
4734
  const backupId = options.backupId ?? id("backup");
3500
4735
  if (!BACKUP_ID_PATTERN.test(backupId))
@@ -3534,6 +4769,7 @@ async function createBackup(directory, state, env, options = {}) {
3534
4769
  await report("capturing", `Backup capture: OR3 is stopped; writing ${backupDir}.`);
3535
4770
  await volumeArchive(directory, state.mode, env, backupDir);
3536
4771
  if (managedVaultEnabled(directory, env)) {
4772
+ await assertVaultVolumesExist(directory, state);
3537
4773
  await streamCommandToFile("docker", composeArgs(directory, state.mode, [
3538
4774
  "run",
3539
4775
  "--rm",
@@ -3544,9 +4780,9 @@ async function createBackup(directory, state, env, options = {}) {
3544
4780
  "or3-vault",
3545
4781
  "/app/vault/entrypoint.mjs",
3546
4782
  "backup"
3547
- ]), join16(backupDir, "vault.backup"), directory, secretValues(env));
4783
+ ]), join19(backupDir, "vault.backup"), directory, secretValues(env));
3548
4784
  }
3549
- await copySecure(deploymentPaths(directory).env, join16(backupDir, "config.env"));
4785
+ await copySecure(deploymentPaths(directory).env, join19(backupDir, "config.env"));
3550
4786
  managedAssetSha256 = await snapshotManagedAssets(directory, state.mode, backupDir);
3551
4787
  artifact = "captured";
3552
4788
  } catch (error) {
@@ -3560,6 +4796,8 @@ async function createBackup(directory, state, env, options = {}) {
3560
4796
  progressFailures.push(asError(error));
3561
4797
  }
3562
4798
  try {
4799
+ if (managedVaultEnabled(directory, env))
4800
+ await assertVaultVolumesExist(directory, state);
3563
4801
  await startProject(directory, state.mode, env);
3564
4802
  service = "healthy";
3565
4803
  downtimeMs = Date.now() - stopStartedAt;
@@ -3569,20 +4807,30 @@ async function createBackup(directory, state, env, options = {}) {
3569
4807
  }
3570
4808
  }
3571
4809
  }
4810
+ if (!primaryFailure && restartFailure && remoteStorage)
4811
+ primaryFailure = new Error("S3 capture requires successful service recovery before remote transfer.");
3572
4812
  if (!primaryFailure) {
3573
4813
  try {
4814
+ let remote;
4815
+ if (remoteStorage) {
4816
+ await report("remote-capture", options.safetySnapshot ? "Remote inventory: recording and verifying S3 references for rollback." : "Remote capture: copying and verifying the referenced S3 objects.");
4817
+ remote = options.safetySnapshot ? await captureRemoteInventory(directory, state, env, backupDir) : await captureRemoteSnapshot(directory, state, env, backupDir);
4818
+ }
3574
4819
  await report("verifying", `Verification: checking the captured backup; ${service === "healthy" ? "OR3 is deeply healthy" : service === "stopped" ? "OR3 remains stopped" : "service recovery is unverified"}.`);
4820
+ if (!options.safetySnapshot && !remoteStorage)
4821
+ await validateCanonicalBackupReferences(directory, { ...env, OR3_IMAGE: imageAtDigest(state.image, state.imageDigest) }, backupDir);
3575
4822
  const manifest2 = {
3576
- schemaVersion: 1,
4823
+ ...remote ? { schemaVersion: 2, storage: "s3", remote } : { schemaVersion: 1 },
3577
4824
  backupId,
4825
+ snapshotKind: options.safetySnapshot ? "safety" : "full",
3578
4826
  createdAt: capturedAt,
3579
4827
  appVersion: state.appVersion,
3580
4828
  image: state.image,
3581
4829
  imageDigest: state.imageDigest,
3582
- dataSha256: await sha256File(join16(backupDir, "data.tgz")),
3583
- ...managedVaultEnabled(directory, env) ? { vaultSha256: await sha256File(join16(backupDir, "vault.backup")) } : {},
4830
+ dataSha256: await sha256File(join19(backupDir, "data.tgz")),
4831
+ ...managedVaultEnabled(directory, env) ? { vaultSha256: await sha256File(join19(backupDir, "vault.backup")) } : {},
3584
4832
  dataBytes: volumeSize,
3585
- configSha256: await sha256File(join16(backupDir, "config.env")),
4833
+ configSha256: await sha256File(join19(backupDir, "config.env")),
3586
4834
  managedAssetSha256,
3587
4835
  managedAssetInventoryVersion: managedAssetSha256?.["dashboard-lifecycle.mjs"] ? MANAGED_ASSET_INVENTORY_VERSION : managedAssetSha256?.["compose.operator.yaml"] ? 3 : managedAssetSha256?.["dashboard-operator.mjs"] ? 2 : undefined,
3588
4836
  mode: state.mode,
@@ -3596,10 +4844,8 @@ async function createBackup(directory, state, env, options = {}) {
3596
4844
  };
3597
4845
  const manifestContents = `${JSON.stringify(manifest2, null, 2)}
3598
4846
  `;
3599
- await writeSecure(join16(backupDir, "manifest.json"), manifestContents);
4847
+ await writeSecure(join19(backupDir, "manifest.json"), manifestContents);
3600
4848
  await writeBackupAuthentication(directory, backupDir, manifestContents);
3601
- if (!options.safetySnapshot)
3602
- await validateCanonicalBackupReferences(directory, { ...env, OR3_IMAGE: imageAtDigest(state.image, state.imageDigest) }, backupDir);
3603
4849
  verifiedManifest = await readManifest(backupDir, directory);
3604
4850
  artifact = "verified";
3605
4851
  } catch (error) {
@@ -3648,12 +4894,12 @@ async function createBackup(directory, state, env, options = {}) {
3648
4894
  async function inspectUnclaimedBackupArtifacts(directory, backupId, backupPath) {
3649
4895
  if (!backupId || !backupPath || !BACKUP_ID_PATTERN.test(backupId))
3650
4896
  return;
3651
- const expectedPath = resolve9(backupDirectory(directory, backupId));
3652
- if (resolve9(backupPath) !== expectedPath)
4897
+ const expectedPath = resolve10(backupDirectory(directory, backupId));
4898
+ if (resolve10(backupPath) !== expectedPath)
3653
4899
  return;
3654
4900
  const statuses = await Promise.all([
3655
4901
  artifactStatus(expectedPath),
3656
- artifactStatus(join16(deploymentPaths(directory).exports, `${backupId}.json`))
4902
+ artifactStatus(join19(deploymentPaths(directory).exports, `${backupId}.json`))
3657
4903
  ]);
3658
4904
  const detail = statuses.map((status) => status.detail).join(" ");
3659
4905
  return statuses.every((status) => status.absent) ? detail : `Maintenance warning: backup artifact ownership was not recorded. ${detail} This recovery did not verify or remove these paths. Inspect them with "npx @or3/cloud backup list".`;
@@ -3661,8 +4907,8 @@ async function inspectUnclaimedBackupArtifacts(directory, backupId, backupPath)
3661
4907
  async function cleanupJournaledPartialBackup(directory, backupId, backupPath) {
3662
4908
  if (!backupId || !backupPath || !BACKUP_ID_PATTERN.test(backupId))
3663
4909
  return;
3664
- const expectedPath = resolve9(backupDirectory(directory, backupId));
3665
- if (resolve9(backupPath) !== expectedPath)
4910
+ const expectedPath = resolve10(backupDirectory(directory, backupId));
4911
+ if (resolve10(backupPath) !== expectedPath)
3666
4912
  return;
3667
4913
  try {
3668
4914
  await readManifest(expectedPath, directory);
@@ -3679,51 +4925,53 @@ async function createPreMutationSnapshot(directory, state, env, backupId) {
3679
4925
  }
3680
4926
 
3681
4927
  // src/backup/export.ts
3682
- import { chmod as chmod7, mkdir as mkdir7, realpath, rm as rm6, stat as stat3 } from "node:fs/promises";
3683
- import { basename as basename5, dirname as dirname5, isAbsolute as isAbsolute3, join as join17, resolve as resolve10, sep } from "node:path";
4928
+ import { chmod as chmod7, mkdir as mkdir7, realpath as realpath2, rm as rm7, stat as stat4 } from "node:fs/promises";
4929
+ import { basename as basename5, dirname as dirname5, isAbsolute as isAbsolute5, join as join20, resolve as resolve11, sep } from "node:path";
3684
4930
  var PURGE_REQUIRES_BACKUP_WITHIN_MS = 24 * 60 * 60 * 1000;
3685
4931
  async function exportBackup(directory, backupId, destination) {
3686
4932
  const backupPath = await resolveBackup(directory, backupId);
3687
4933
  const manifest2 = await readManifest(backupPath, directory);
3688
- const dest = resolve10(destination);
3689
- const source = resolve10(backupPath);
3690
- const deploymentRoot = await realpath(directory);
4934
+ if (manifest2.schemaVersion === 2 && manifest2.remote.kind === "s3-reference-v1")
4935
+ throw new Error("S3 reference-only snapshots cannot be exported as recovery backups.");
4936
+ const dest = resolve11(destination);
4937
+ const source = resolve11(backupPath);
4938
+ const deploymentRoot = await realpath2(directory);
3691
4939
  if (source === dest || dest.startsWith(`${source}${sep}`)) {
3692
4940
  throw new Error("Choose a destination directory different from the backup itself.");
3693
4941
  }
3694
4942
  if (await fileExists(dest))
3695
4943
  throw new Error(`Destination ${dest} already exists. Choose a new empty destination so an export can never merge with unrelated files.`);
3696
4944
  await mkdir7(dirname5(dest), { recursive: true, mode: 448 });
3697
- const canonicalDestination = join17(await realpath(dirname5(dest)), basename5(dest));
4945
+ const canonicalDestination = join20(await realpath2(dirname5(dest)), basename5(dest));
3698
4946
  if (canonicalDestination === deploymentRoot || canonicalDestination.startsWith(`${deploymentRoot}${sep}`)) {
3699
4947
  throw new Error("Backup exports must live outside the managed deployment directory so `remove --purge-data` can never delete the remaining copy.");
3700
4948
  }
3701
4949
  await mkdir7(dest, { mode: 448 });
3702
4950
  try {
3703
- if (await realpath(dest) !== canonicalDestination) {
4951
+ if (await realpath2(dest) !== canonicalDestination) {
3704
4952
  throw new Error("Backup export destination changed while it was being created. Refusing to write an export through an unexpected path.");
3705
4953
  }
3706
4954
  await chmod7(dest, 448);
3707
- for (const file of ["data.tgz", "config.env", "manifest.json", "manifest.auth", ...manifest2.vaultSha256 ? ["vault.backup"] : []]) {
3708
- await copySecure(join17(backupPath, file), join17(dest, file));
4955
+ for (const file of ["data.tgz", "config.env", "manifest.json", "manifest.auth", ...manifest2.vaultSha256 ? ["vault.backup"] : [], ...manifest2.schemaVersion === 2 ? [manifest2.remote.file] : []]) {
4956
+ await copySecure(join20(backupPath, file), join20(dest, file));
3709
4957
  }
3710
4958
  if (manifest2.managedAssetSha256) {
3711
4959
  for (const name of managedAssetNamesForInventory(manifest2.mode, manifest2.managedAssetInventoryVersion)) {
3712
- await copySecure(join17(backupPath, "managed-assets", name), join17(dest, "managed-assets", name));
4960
+ await copySecure(join20(backupPath, "managed-assets", name), join20(dest, "managed-assets", name));
3713
4961
  }
3714
4962
  }
3715
- const dataSha = await sha256File(join17(dest, "data.tgz"));
4963
+ const dataSha = await sha256File(join20(dest, "data.tgz"));
3716
4964
  if (dataSha !== manifest2.dataSha256) {
3717
4965
  throw new Error(`Exported data.tgz checksum mismatch for ${manifest2.backupId}. Expected ${manifest2.dataSha256}, got ${dataSha}.`);
3718
4966
  }
3719
4967
  if (manifest2.configSha256) {
3720
- const configSha = await sha256File(join17(dest, "config.env"));
4968
+ const configSha = await sha256File(join20(dest, "config.env"));
3721
4969
  if (configSha !== manifest2.configSha256) {
3722
4970
  throw new Error(`Exported config.env checksum mismatch for ${manifest2.backupId}.`);
3723
4971
  }
3724
4972
  }
3725
4973
  await readManifest(dest, directory);
3726
- const destinationDevice = (await stat3(dest)).dev;
4974
+ const destinationDevice = (await stat4(dest)).dev;
3727
4975
  await mkdir7(deploymentPaths(directory).exports, { recursive: true, mode: 448 });
3728
4976
  const receipt = {
3729
4977
  schemaVersion: 1,
@@ -3734,65 +4982,28 @@ async function exportBackup(directory, backupId, destination) {
3734
4982
  dataSha256: manifest2.dataSha256,
3735
4983
  configSha256: manifest2.configSha256
3736
4984
  };
3737
- await writeSecure(join17(deploymentPaths(directory).exports, `${manifest2.backupId}.json`), `${JSON.stringify(receipt, null, 2)}
4985
+ await writeSecure(join20(deploymentPaths(directory).exports, `${manifest2.backupId}.json`), `${JSON.stringify(receipt, null, 2)}
3738
4986
  `);
3739
- let bytes = (manifest2.vaultSha256 ? (await stat3(join17(dest, "vault.backup"))).size : 0) + (await stat3(join17(dest, "data.tgz"))).size + (await stat3(join17(dest, "config.env"))).size + (await stat3(join17(dest, "manifest.json"))).size + (await stat3(join17(dest, "manifest.auth"))).size;
4987
+ let bytes = (manifest2.vaultSha256 ? (await stat4(join20(dest, "vault.backup"))).size : 0) + (await stat4(join20(dest, "data.tgz"))).size + (await stat4(join20(dest, "config.env"))).size + (await stat4(join20(dest, "manifest.json"))).size + (await stat4(join20(dest, "manifest.auth"))).size;
4988
+ if (manifest2.schemaVersion === 2)
4989
+ bytes += (await stat4(join20(dest, manifest2.remote.file))).size;
3740
4990
  if (manifest2.managedAssetSha256) {
3741
4991
  for (const name of managedAssetNamesForInventory(manifest2.mode, manifest2.managedAssetInventoryVersion)) {
3742
- bytes += (await stat3(join17(dest, "managed-assets", name))).size;
4992
+ bytes += (await stat4(join20(dest, "managed-assets", name))).size;
3743
4993
  }
3744
4994
  }
3745
4995
  console.log(`Exported backup ${manifest2.backupId} (${bytes} bytes) to ${dest}`);
3746
4996
  console.log("The exported copy contains credentials and secrets. It is owner-only (0600); keep it off-host.");
3747
- if ((await stat3(backupPath)).dev === destinationDevice) {
3748
- console.log("This export is on the same filesystem as the deployment and cannot authorize `remove --purge-data`. Copy it to a mounted backup disk or another host, then export again there.");
4997
+ if ((await stat4(backupPath)).dev === destinationDevice) {
4998
+ console.log("This export is on the same filesystem as the deployment. Keep a separate off-host copy; data purge remains disabled until reconstruction is supported.");
3749
4999
  } else {
3750
- console.log("Verified export recorded. It can authorize `remove --purge-data --yes` while this backup remains fresh.");
5000
+ console.log("Verified export recorded. Data purge remains disabled until reconstruction from an export is supported.");
3751
5001
  }
3752
5002
  } catch (error) {
3753
- await rm6(dest, { recursive: true, force: true });
5003
+ await rm7(dest, { recursive: true, force: true });
3754
5004
  throw error;
3755
5005
  }
3756
5006
  }
3757
- function assertPurgeBackupFreshness(backups, nowMs) {
3758
- const cutoff = nowMs - PURGE_REQUIRES_BACKUP_WITHIN_MS;
3759
- if (!backups.some((backup) => new Date(backup.createdAt).getTime() >= cutoff)) {
3760
- throw new Error('No backup newer than 24 hours exists for this deployment. Run "npx @or3/cloud backup" and "npx @or3/cloud backup export <backup-id> <destination-dir>" before destroying local data.');
3761
- }
3762
- }
3763
- async function readBackupExportReceipt(directory, backupId) {
3764
- try {
3765
- const receipt = JSON.parse(await readText(join17(deploymentPaths(directory).exports, `${backupId}.json`)));
3766
- if (receipt.schemaVersion !== 1 || receipt.backupId !== backupId || typeof receipt.destination !== "string" || !isAbsolute3(receipt.destination) || typeof receipt.destinationDevice !== "number" || !receipt.dataSha256)
3767
- return;
3768
- return receipt;
3769
- } catch {
3770
- return;
3771
- }
3772
- }
3773
- async function assertPurgeHasVerifiedExport(directory, backups, nowMs) {
3774
- assertPurgeBackupFreshness(backups, nowMs);
3775
- const cutoff = nowMs - PURGE_REQUIRES_BACKUP_WITHIN_MS;
3776
- for (const backup of backups) {
3777
- if (new Date(backup.createdAt).getTime() < cutoff)
3778
- continue;
3779
- const receipt = await readBackupExportReceipt(directory, backup.backupId);
3780
- if (!receipt || !await fileExists(receipt.destination))
3781
- continue;
3782
- const deploymentRoot = await realpath(directory);
3783
- try {
3784
- const canonicalDestination = await realpath(receipt.destination);
3785
- if (canonicalDestination === deploymentRoot || canonicalDestination.startsWith(`${deploymentRoot}${sep}`))
3786
- continue;
3787
- const exported = await readManifest(receipt.destination, directory);
3788
- const sourceDevice = (await stat3(backup.path)).dev;
3789
- const destinationDevice = (await stat3(receipt.destination)).dev;
3790
- if (sourceDevice !== destinationDevice && receipt.destinationDevice === destinationDevice && exported.backupId === backup.backupId && exported.dataSha256 === receipt.dataSha256 && exported.dataSha256 === backup.dataSha256 && exported.configSha256 === receipt.configSha256)
3791
- return receipt;
3792
- } catch {}
3793
- }
3794
- throw new Error("No fresh checksum-verified backup export on another filesystem is available. Run `npx @or3/cloud backup export <backup-id> <new-directory-on-a-mounted-backup-disk>` before purging local data.");
3795
- }
3796
5007
 
3797
5008
  // src/commands/backup.ts
3798
5009
  function parseKeep(flags) {
@@ -3806,6 +5017,10 @@ async function backupCreateCommand(directory) {
3806
5017
  await ensureDocker();
3807
5018
  const loaded = await loadManaged(directory);
3808
5019
  assertNoPending(loaded.state);
5020
+ assertSupportedBackupProfile(loaded.env);
5021
+ await assertBackupSourceProfile(loaded.directory, loaded.state, loaded.env);
5022
+ if (managedVaultEnabled(loaded.directory, loaded.env))
5023
+ await assertVaultVolumesExist(loaded.directory, loaded.state);
3809
5024
  const backupId = id("backup");
3810
5025
  const initialAppRunning = await projectServiceRunning(loaded.directory, loaded.state.mode);
3811
5026
  const pending = {
@@ -3880,6 +5095,10 @@ async function backupListCommand(directory, flags = {}) {
3880
5095
  appVersion: backup.appVersion,
3881
5096
  bytes: backup.bytes,
3882
5097
  dataSha256: backup.dataSha256,
5098
+ manifestSchemaVersion: backup.schemaVersion,
5099
+ snapshotKind: backup.snapshotKind ?? null,
5100
+ recoveryArtifactsComplete: backup.recoveryArtifactsComplete === true,
5101
+ ...backup.remoteKind ? { remoteKind: backup.remoteKind } : {},
3883
5102
  trust: "verified"
3884
5103
  })),
3885
5104
  findings: findings.map((entry) => ({ entryName: entry.entryName, kind: entry.kind, code: entry.code, message: entry.message })),
@@ -3895,10 +5114,16 @@ async function backupListCommand(directory, flags = {}) {
3895
5114
  console.log(`No authenticated backups are available for ${loaded.directory}.`);
3896
5115
  } else {
3897
5116
  console.log(`OR3 Cloud backups for ${loaded.directory} (${verified.length} verified):`);
3898
- console.log("backupId createdAt version bytes trust checksum");
5117
+ console.log("backupId createdAt version bytes kind snapshot coverage trust checksum");
3899
5118
  for (const backup of verified) {
3900
- console.log(`${backup.backupId.padEnd(30)} ${backup.createdAt.padEnd(30)} ${backup.appVersion.padEnd(8)} ${String(backup.bytes).padStart(9)} ${"verified".padEnd(9)} ${backup.dataSha256.slice(0, 12)}`);
5119
+ const kind = backup.remoteKind === "s3-reference-v1" ? "reference-only" : backup.remoteKind === "s3-copy-v1" ? "s3-copy" : "filesystem";
5120
+ const snapshot = backup.snapshotKind ?? "unmarked";
5121
+ const coverage = backup.recoveryArtifactsComplete ? "complete" : "incomplete";
5122
+ console.log(`${backup.backupId.padEnd(30)} ${backup.createdAt.padEnd(30)} ${backup.appVersion.padEnd(8)} ${String(backup.bytes).padStart(9)} ${kind.padEnd(19)} ${snapshot.padEnd(9)} ${coverage.padEnd(11)} ${"verified".padEnd(9)} ${backup.dataSha256.slice(0, 12)}`);
3901
5123
  }
5124
+ console.log("Verified checksums and artifact coverage are not proof of a successful recovery.");
5125
+ if (verified.some((backup) => backup.remoteKind === "s3-reference-v1"))
5126
+ console.log("Reference-only snapshots require their original bucket and cannot be exported as recovery copies.");
3902
5127
  }
3903
5128
  if (findings.length > 0) {
3904
5129
  console.log(`
@@ -3965,8 +5190,8 @@ async function backupCommand(directory, positionals, flags) {
3965
5190
  }
3966
5191
 
3967
5192
  // src/credentials/reset.ts
3968
- import { rm as rm7 } from "node:fs/promises";
3969
- import { join as join18 } from "node:path";
5193
+ import { rm as rm8 } from "node:fs/promises";
5194
+ import { join as join21 } from "node:path";
3970
5195
 
3971
5196
  // src/credentials/reset-script.ts
3972
5197
  function buildCredentialsResetScript(input2) {
@@ -4150,7 +5375,10 @@ async function verifyCredentialsInsideContainer(directory, mode, values) {
4150
5375
  throw new Error(`Credential verification inside the OR3 container failed. ${redact(result.stderr, [values.ownerPassword, values.adminPassword])}`);
4151
5376
  }
4152
5377
  async function applyCredentialReset(directory, state, nextEnv) {
5378
+ assertLegacyManagedProfile(nextEnv);
4153
5379
  const values = credentialResetValues(nextEnv);
5380
+ if (managedVaultEnabled(directory, nextEnv))
5381
+ await assertVaultVolumesExist(directory, state);
4154
5382
  const running = await run("docker", [...composeArgs(directory, state.mode, ["ps", "-q", "or3"])], directory);
4155
5383
  if (!running.ok || !running.stdout.trim()) {
4156
5384
  await writeSecure(deploymentPaths(directory).env, serializeEnv(nextEnv));
@@ -4159,9 +5387,11 @@ async function applyCredentialReset(directory, state, nextEnv) {
4159
5387
  await runCredentialsResetScript(directory, state.mode, values);
4160
5388
  const runtimeEnv = withoutProvisioningCredentials(nextEnv);
4161
5389
  await writeSecure(deploymentPaths(directory).env, serializeEnv(runtimeEnv));
4162
- const initialCredentials = join18(directory, ".or3-initial-credentials");
4163
- await rm7(initialCredentials, { force: true });
5390
+ const initialCredentials = join21(directory, ".or3-initial-credentials");
5391
+ await rm8(initialCredentials, { force: true });
4164
5392
  await stopProject(directory, state.mode);
5393
+ if (managedVaultEnabled(directory, runtimeEnv))
5394
+ await assertVaultVolumesExist(directory, state);
4165
5395
  await startProject(directory, state.mode, runtimeEnv);
4166
5396
  await verifyCredentialsInsideContainer(directory, state.mode, values);
4167
5397
  }
@@ -4174,6 +5404,10 @@ async function credentialsResetCommand(directory, flags) {
4174
5404
  const loaded = await loadManaged(directory);
4175
5405
  assertDeploymentDirectoryIdentity(directory, loaded.state);
4176
5406
  assertNoPending(loaded.state);
5407
+ assertLegacyManagedProfile(loaded.env);
5408
+ await assertLegacyComposeProfile(loaded.directory, loaded.state.mode, loaded.env);
5409
+ if (managedVaultEnabled(loaded.directory, loaded.env))
5410
+ await assertVaultVolumesExist(loaded.directory, loaded.state);
4177
5411
  const ownerEmail = loaded.env.OR3_MANAGED_OWNER_EMAIL || loaded.env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
4178
5412
  const adminUsername = loaded.env.OR3_ADMIN_USERNAME;
4179
5413
  if (!ownerEmail)
@@ -4220,10 +5454,10 @@ async function credentialsCommand(directory, positionals, flags) {
4220
5454
 
4221
5455
  // src/commands/doctor.ts
4222
5456
  import { existsSync as existsSync3 } from "node:fs";
4223
- import { stat as stat4 } from "node:fs/promises";
4224
- import { basename as basename6, join as join19, resolve as resolve11 } from "node:path";
5457
+ import { stat as stat5 } from "node:fs/promises";
5458
+ import { basename as basename6, join as join22, resolve as resolve12 } from "node:path";
4225
5459
  async function doctorCommand(directory) {
4226
- const resolved = resolve11(directory);
5460
+ const resolved = resolve12(directory);
4227
5461
  const paths = deploymentPaths(resolved);
4228
5462
  const failures = [];
4229
5463
  let state;
@@ -4246,10 +5480,10 @@ async function doctorCommand(directory) {
4246
5480
  failures.push(error instanceof Error ? error.message : String(error));
4247
5481
  console.log("✗ Managed state or .env is missing/invalid");
4248
5482
  }
4249
- for (const file of [paths.env, paths.state, join19(resolved, ".or3-initial-credentials")]) {
5483
+ for (const file of [paths.env, paths.state, join22(resolved, ".or3-initial-credentials")]) {
4250
5484
  if (!existsSync3(file))
4251
5485
  continue;
4252
- const mode = (await stat4(file)).mode & 511;
5486
+ const mode = (await stat5(file)).mode & 511;
4253
5487
  if (mode !== 384) {
4254
5488
  failures.push(`${file} must be mode 0600 (currently ${mode.toString(8)}).`);
4255
5489
  console.log(`✗ Permissions: ${file} is ${mode.toString(8)}`);
@@ -4317,8 +5551,8 @@ async function doctorCommand(directory) {
4317
5551
  throw new Error("Dashboard operator deployment identity does not match managed state.");
4318
5552
  }
4319
5553
  assertDashboardOperatorMounts(container.Mounts ?? [], resolved);
4320
- const ipcDirectory = await stat4(paths.operatorIpc);
4321
- const operatorSocket = await stat4(join19(paths.operatorIpc, "operator.sock"));
5554
+ const ipcDirectory = await stat5(paths.operatorIpc);
5555
+ const operatorSocket = await stat5(join22(paths.operatorIpc, "operator.sock"));
4322
5556
  const operatorUid = Number(env.OR3_OPERATOR_UID);
4323
5557
  const operatorGid = Number(env.OR3_OPERATOR_GID);
4324
5558
  if ((ipcDirectory.mode & 511) !== 456 || ipcDirectory.uid !== operatorUid || ipcDirectory.gid !== operatorGid || !operatorSocket.isSocket() || (operatorSocket.mode & 511) !== 432 || operatorSocket.uid !== operatorUid || operatorSocket.gid !== operatorGid) {
@@ -4377,15 +5611,15 @@ async function doctorCommand(directory) {
4377
5611
 
4378
5612
  // src/commands/init.ts
4379
5613
  import { chmod as chmod8, mkdir as mkdir8 } from "node:fs/promises";
4380
- import { join as join20, relative, resolve as resolve12 } from "node:path";
5614
+ import { join as join23, relative, resolve as resolve13 } from "node:path";
4381
5615
  async function initCommand(positionals, flags) {
4382
5616
  const hasLocal = boolFlag(flags, "local");
4383
5617
  const hasPublic = boolFlag(flags, "public");
4384
5618
  if (hasLocal === hasPublic)
4385
5619
  throw new Error("Choose exactly one of --local or --public.");
4386
5620
  const mode = hasPublic ? "public" : "local";
4387
- const directory = resolve12(process.cwd(), positionals[0] ?? "or3-cloud");
4388
- if (await fileExists(join20(directory, ".or3-cloud", "state.json"))) {
5621
+ const directory = resolve13(process.cwd(), positionals[0] ?? "or3-cloud");
5622
+ if (await fileExists(join23(directory, ".or3-cloud", "state.json"))) {
4389
5623
  throw new Error(`${directory} is already a managed deployment. Use update, doctor, or adopt.`);
4390
5624
  }
4391
5625
  await readDirectoryEmpty(directory);
@@ -4411,7 +5645,7 @@ async function initCommand(positionals, flags) {
4411
5645
  await ensureDocker();
4412
5646
  const names = composeProjectNames(directory);
4413
5647
  await assertDockerProjectAbsent(names.project);
4414
- for (const volume of [names.volume, ...mode === "public" ? [names.caddyData, names.caddyConfig] : []]) {
5648
+ for (const volume of [names.volume, ...managedVaultVolumeNames(names.volume), ...mode === "public" ? [names.caddyData, names.caddyConfig] : []]) {
4415
5649
  const existing = await run("docker", ["volume", "inspect", volume]);
4416
5650
  if (existing.ok)
4417
5651
  throw new Error(`Docker volume ${volume} already exists. Choose a new directory or inspect it before initializing.`);
@@ -4436,7 +5670,7 @@ ${existing.stderr}`)) {
4436
5670
  await copyAssets(directory, mode);
4437
5671
  const env = buildEnv({ mode, version, directory, image, email, password, domain, port, dashboardOperator: operator });
4438
5672
  await writeSecure(deploymentPaths(directory).env, serializeEnv(env));
4439
- await writeSecure(join20(directory, ".or3-initial-credentials"), serializeInitialCredentials({
5673
+ await writeSecure(join23(directory, ".or3-initial-credentials"), serializeInitialCredentials({
4440
5674
  bootstrapEmail: email,
4441
5675
  bootstrapPassword: password,
4442
5676
  adminUsername: email,
@@ -4461,7 +5695,7 @@ ${existing.stderr}`)) {
4461
5695
  const folder = relative(process.cwd(), directory) || ".";
4462
5696
  console.log(`
4463
5697
  OR3 Cloud ${version} is running at ${url}`);
4464
- console.log(`Sign in as ${email} with the password in ${join20(folder, ".or3-initial-credentials")}; the same login opens ${url}/admin.`);
5698
+ console.log(`Sign in as ${email} with the password in ${join23(folder, ".or3-initial-credentials")}; the same login opens ${url}/admin.`);
4465
5699
  console.log("Save it in a password manager, then delete that file.");
4466
5700
  console.log(`
4467
5701
  Run later commands from the deployment folder: cd ${quote(folder)} && npx @or3/cloud doctor`);
@@ -4482,6 +5716,10 @@ async function startCommand(directory) {
4482
5716
  const loaded = await loadManaged(directory);
4483
5717
  assertDeploymentDirectoryIdentity(directory, loaded.state);
4484
5718
  assertPhaseAllowsCommand("start", loaded.state);
5719
+ assertLegacyManagedProfile(loaded.env);
5720
+ await assertLegacyComposeProfile(loaded.directory, loaded.state.mode, loaded.env);
5721
+ if (managedVaultEnabled(loaded.directory, loaded.env))
5722
+ await assertVaultVolumesExist(loaded.directory, loaded.state);
4485
5723
  await startProject(directory, loaded.state.mode, loaded.env);
4486
5724
  const url = loaded.state.mode === "public" ? `https://${loaded.state.domain}` : `http://127.0.0.1:${loaded.state.port}`;
4487
5725
  console.log(`OR3 started and is deeply healthy at ${url}.`);
@@ -4499,6 +5737,8 @@ async function restartCommand(directory, lifecycle) {
4499
5737
  const loaded = await loadManaged(directory);
4500
5738
  assertDeploymentDirectoryIdentity(directory, loaded.state);
4501
5739
  assertPhaseAllowsCommand("restart", loaded.state);
5740
+ assertLegacyManagedProfile(loaded.env);
5741
+ await assertLegacyComposeProfile(loaded.directory, loaded.state.mode, loaded.env, lifecycle?.deadline);
4502
5742
  if (lifecycle) {
4503
5743
  await assertSafeComposeBinding(directory, loaded.state.mode, loaded.env, lifecycle.deadline);
4504
5744
  await assertRunningAppImage(directory, loaded.state.mode, loaded.state.image, lifecycle.deadline);
@@ -4513,19 +5753,211 @@ async function restartCommand(directory, lifecycle) {
4513
5753
  console.log("OR3 restarted and is deeply healthy.");
4514
5754
  }
4515
5755
 
4516
- // src/commands/recover.ts
5756
+ // src/backup/restore.ts
4517
5757
  import { join as join24 } from "node:path";
4518
5758
 
5759
+ // src/backup/generation-restore.ts
5760
+ function rebindRestoredGenerations(fs, path, crypto, db, root, options = {}) {
5761
+ const preserveMissing = options.previousSafetySnapshot === true;
5762
+ const fail = () => {
5763
+ throw new Error("Restored generation identity or payload is invalid");
5764
+ };
5765
+ const info = (name) => {
5766
+ try {
5767
+ return fs.lstatSync(name, { bigint: true });
5768
+ } catch (error) {
5769
+ if (error.code === "ENOENT")
5770
+ return null;
5771
+ throw error;
5772
+ }
5773
+ };
5774
+ const dir = (name) => {
5775
+ const s = info(name);
5776
+ if (!s?.isDirectory())
5777
+ fail();
5778
+ return s;
5779
+ };
5780
+ const json = (name) => {
5781
+ const s = info(name);
5782
+ if (!s?.isFile() || s.nlink !== 1n || s.size > 4096n)
5783
+ fail();
5784
+ return JSON.parse(fs.readFileSync(name, "utf8"));
5785
+ };
5786
+ const base = path.join(root, "generations-v1");
5787
+ const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((row) => row.name));
5788
+ const required = new Set;
5789
+ if (tables.has("storage_object_generations")) {
5790
+ for (const row of db.prepare("SELECT generation_id FROM storage_object_generations WHERE storage_provider_id = 'fs'").all())
5791
+ required.add(String(row.generation_id));
5792
+ }
5793
+ if (tables.has("storage_generation_uploads")) {
5794
+ for (const row of db.prepare("SELECT generation_id FROM storage_generation_uploads WHERE storage_provider_id = 'fs' AND ready_receipt_id IS NOT NULL").all())
5795
+ required.add(String(row.generation_id));
5796
+ }
5797
+ if (!info(base)) {
5798
+ if (required.size && !preserveMissing)
5799
+ fail();
5800
+ return;
5801
+ }
5802
+ dir(root);
5803
+ dir(base);
5804
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
5805
+ const namespaceFile = path.join(base, "namespace.json");
5806
+ if (!info(namespaceFile) && preserveMissing)
5807
+ return;
5808
+ const ns = json(namespaceFile);
5809
+ if (ns.version !== 1 || !uuid.test(ns.namespaceId))
5810
+ fail();
5811
+ if (!tables.has("storage_object_generations"))
5812
+ fail();
5813
+ const workspaceRoot = path.join(base, "workspaces");
5814
+ if (!info(workspaceRoot) && preserveMissing)
5815
+ return;
5816
+ dir(workspaceRoot);
5817
+ const changes = [];
5818
+ for (const workspace of fs.readdirSync(workspaceRoot)) {
5819
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(workspace))
5820
+ fail();
5821
+ const work = path.join(workspaceRoot, workspace);
5822
+ dir(work);
5823
+ for (const id2 of fs.readdirSync(work)) {
5824
+ if (!uuid.test(id2))
5825
+ fail();
5826
+ const slot = path.join(work, id2);
5827
+ dir(slot);
5828
+ const file = path.join(slot, "ready.json");
5829
+ if (!info(file)) {
5830
+ if (required.has(id2) && !preserveMissing)
5831
+ fail();
5832
+ continue;
5833
+ }
5834
+ const ready = json(file);
5835
+ if (ready.version !== 1 || ready.namespaceId !== ns.namespaceId || ready.workspaceId !== workspace || ready.generationId !== id2 || !/^sha256:[a-f0-9]{64}$/.test(ready.hash) || !Number.isSafeInteger(ready.sizeBytes) || ready.sizeBytes < 0 || !uuid.test(ready.readyReceiptId) || ready.storageId !== `fs-generation-v1:${ns.namespaceId}:${workspace}:${id2}:${ready.hash.slice(7)}`)
5836
+ fail();
5837
+ const generation = db.prepare("SELECT * FROM storage_object_generations WHERE generation_id = ?").get(id2);
5838
+ const upload = tables.has("storage_generation_uploads") ? db.prepare("SELECT * FROM storage_generation_uploads WHERE generation_id = ?").get(id2) : null;
5839
+ const record = generation ?? upload;
5840
+ if (!record || record.workspace_id !== workspace || record.storage_provider_id !== "fs" || record.storage_id !== ready.storageId || record.hash.replace(/^sha256:/i, "").toLowerCase() !== ready.hash.slice(7) || record.size_bytes !== ready.sizeBytes)
5841
+ fail();
5842
+ if (upload && (upload.namespace_id !== ns.namespaceId || upload.ready_receipt_id && upload.ready_receipt_id !== ready.readyReceiptId))
5843
+ fail();
5844
+ required.delete(id2);
5845
+ const payload = path.join(slot, "payload");
5846
+ const payloadInfo = info(payload);
5847
+ const needsBlob = generation?.state === "verified";
5848
+ if (!payloadInfo) {
5849
+ if (needsBlob && !preserveMissing)
5850
+ fail();
5851
+ continue;
5852
+ }
5853
+ dir(payload);
5854
+ if (generation?.state === "deleted" || !generation && upload?.state === "abandoned")
5855
+ fail();
5856
+ const entries = fs.readdirSync(payload);
5857
+ const links = new Map;
5858
+ for (const entry of entries) {
5859
+ if (entry !== "blob" && !/^\.upload-[0-9a-f-]{36}$/.test(entry))
5860
+ fail();
5861
+ const s = info(path.join(payload, entry));
5862
+ if (!s?.isFile())
5863
+ fail();
5864
+ const key = `${s.dev}:${s.ino}`;
5865
+ const previous = links.get(key);
5866
+ links.set(key, { count: (previous?.count ?? 0n) + 1n, expected: s.nlink });
5867
+ }
5868
+ if ([...links.values()].some((value) => value.count !== value.expected))
5869
+ fail();
5870
+ const blob = path.join(payload, "blob");
5871
+ if (info(blob)) {
5872
+ const fd = fs.openSync(blob, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
5873
+ try {
5874
+ const hash = crypto.createHash("sha256"), buffer = Buffer.alloc(65536);
5875
+ let size = 0, count = 0;
5876
+ while ((count = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) {
5877
+ hash.update(buffer.subarray(0, count));
5878
+ size += count;
5879
+ }
5880
+ if (size !== ready.sizeBytes || hash.digest("hex") !== ready.hash.slice(7))
5881
+ fail();
5882
+ } finally {
5883
+ fs.closeSync(fd);
5884
+ }
5885
+ } else if (needsBlob) {
5886
+ if (!preserveMissing)
5887
+ fail();
5888
+ continue;
5889
+ }
5890
+ changes.push({ file, ready: { ...ready, payloadDevice: String(payloadInfo.dev), payloadInode: String(payloadInfo.ino) } });
5891
+ }
5892
+ }
5893
+ if (required.size && !preserveMissing)
5894
+ fail();
5895
+ for (const { file, ready } of changes) {
5896
+ const temp = file + ".restore-" + crypto.randomUUID();
5897
+ const fd = fs.openSync(temp, "wx", 384);
5898
+ try {
5899
+ fs.writeFileSync(fd, JSON.stringify(ready));
5900
+ fs.fsyncSync(fd);
5901
+ } finally {
5902
+ fs.closeSync(fd);
5903
+ }
5904
+ fs.renameSync(temp, file);
5905
+ const directory = fs.openSync(path.dirname(file), "r");
5906
+ try {
5907
+ fs.fsyncSync(directory);
5908
+ } finally {
5909
+ fs.closeSync(directory);
5910
+ }
5911
+ }
5912
+ }
5913
+
4519
5914
  // src/backup/restore.ts
4520
- import { join as join21 } from "node:path";
4521
- function assertFilesystemPayloadHash(key, readChunk, createHash3) {
5915
+ async function restoreGenerationIdentities(directory, state, env, backupPath, options = {}) {
5916
+ await readManifest(backupPath, directory);
5917
+ const script = `
5918
+ const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto');
5919
+ const root = path.resolve(process.env.OR3_STORAGE_FS_ROOT || '/data/storage');
5920
+ if (root !== '/data' && !root.startsWith('/data/')) throw new Error('Generation restore requires managed storage');
5921
+ {
5922
+ const Database = require('/app/.output/server/node_modules/better-sqlite3');
5923
+ const db = new Database(process.env.OR3_SQLITE_DB_PATH, { readonly: true, fileMustExist: true });
5924
+ try {
5925
+ if (db.pragma('quick_check', { simple: true }) !== 'ok') throw new Error('Restored SQLite integrity check failed');
5926
+ (${rebindRestoredGenerations.toString()})(fs, path, crypto, db, root, ${JSON.stringify({ previousSafetySnapshot: options.previousSafetySnapshot === true })});
5927
+ } finally { db.close(); }
5928
+ }`;
5929
+ const result = await run("docker", [
5930
+ "run",
5931
+ "--rm",
5932
+ "--network",
5933
+ "none",
5934
+ "--read-only",
5935
+ "--security-opt",
5936
+ "no-new-privileges:true",
5937
+ "--cap-drop",
5938
+ "ALL",
5939
+ "-v",
5940
+ `${state.volumeName}:/data`,
5941
+ "--env",
5942
+ `OR3_SQLITE_DB_PATH=${env.OR3_SQLITE_DB_PATH ?? ""}`,
5943
+ "--env",
5944
+ `OR3_STORAGE_FS_ROOT=${env.OR3_STORAGE_FS_ROOT ?? "/data/storage"}`,
5945
+ "--entrypoint",
5946
+ "sh",
5947
+ env.OR3_IMAGE,
5948
+ ...containerNodeCommand(script).slice(1)
5949
+ ], directory);
5950
+ if (!result.ok)
5951
+ throw new Error(`Restored generation verification failed. ${redact(result.stderr, secretValues(env))}`);
5952
+ }
5953
+ function assertFilesystemPayloadHash(key, readChunk, createHash5) {
4522
5954
  const match = /^(sha256|md5)_([a-fA-F0-9]+)$/.exec(key);
4523
5955
  const algorithm = match?.[1];
4524
5956
  const expected = match?.[2];
4525
5957
  if (algorithm !== "sha256" && algorithm !== "md5" || expected?.length !== (algorithm === "sha256" ? 64 : 32)) {
4526
5958
  throw new Error("Invalid canonical filesystem payload key");
4527
5959
  }
4528
- const hash = createHash3(algorithm);
5960
+ const hash = createHash5(algorithm);
4529
5961
  for (let chunk = readChunk();chunk !== undefined; chunk = readChunk())
4530
5962
  hash.update(chunk);
4531
5963
  if (hash.digest("hex") !== expected.toLowerCase())
@@ -4535,8 +5967,10 @@ var VERIFY_RESTORED_DATA_SCRIPT = `
4535
5967
  const fs = require('node:fs');
4536
5968
  const Database = require('/app/.output/server/node_modules/better-sqlite3');
4537
5969
  const validFilesystemPayload = ${assertFilesystemPayloadHash.toString()};
4538
- const paths = [process.env.OR3_BASIC_AUTH_DB_PATH, process.env.OR3_SQLITE_DB_PATH].filter(Boolean);
4539
- if (paths.length !== 2) throw new Error('Both managed database paths must be configured');
5970
+ const auth = process.env.AUTH_PROVIDER || 'basic-auth', storage = process.env.NUXT_PUBLIC_STORAGE_PROVIDER || 'fs';
5971
+ if (!(auth === 'basic-auth' && storage === 'fs' || auth === 'clerk' && storage === 's3')) throw new Error('Unsupported restored provider profile');
5972
+ const paths = auth === 'basic-auth' ? [process.env.OR3_BASIC_AUTH_DB_PATH, process.env.OR3_SQLITE_DB_PATH] : [process.env.OR3_SQLITE_DB_PATH];
5973
+ if (paths.some(path => !path)) throw new Error('Managed database paths must be configured');
4540
5974
  for (const path of paths) {
4541
5975
  const databasePath = fs.realpathSync(path);
4542
5976
  if (!fs.statSync(databasePath).isFile()) throw new Error('Restored database payload must be a regular file');
@@ -4557,7 +5991,7 @@ const path = require('node:path');
4557
5991
  const crypto = require('node:crypto');
4558
5992
  const storageRoot = path.resolve(process.env.OR3_STORAGE_FS_ROOT || '/data/storage');
4559
5993
  const workspacesRoot = path.join(storageRoot, 'workspaces');
4560
- if ((storageRoot === '/data' || storageRoot.startsWith('/data/')) && fs.existsSync(workspacesRoot)) {
5994
+ if (storage === 'fs' && (storageRoot === '/data' || storageRoot.startsWith('/data/')) && fs.existsSync(workspacesRoot)) {
4561
5995
  const chunk = Buffer.alloc(65536);
4562
5996
  for (const workspace of fs.readdirSync(workspacesRoot, { withFileTypes: true }).filter(entry => /^[a-zA-Z0-9_-]+$/.test(entry.name))) {
4563
5997
  const directory = path.join(workspacesRoot, workspace.name);
@@ -4579,7 +6013,7 @@ if ((storageRoot === '/data' || storageRoot.startsWith('/data/')) && fs.existsSy
4579
6013
  }
4580
6014
  `;
4581
6015
  async function assertRestoreFreeSpace(directory, state, env, manifest2, backupPath) {
4582
- const archiveBytes = await gzipUncompressedBytes(join21(backupPath, "data.tgz"));
6016
+ const archiveBytes = await gzipUncompressedBytes(join24(backupPath, "data.tgz"));
4583
6017
  const requiredBytes = Math.max(manifest2.dataBytes ?? 0, archiveBytes);
4584
6018
  const freeBytes = await dataVolumeFreeBytes(directory, state.mode, env);
4585
6019
  const reclaimableBytes = await dataVolumeSize(directory, state.mode, env, "allocated");
@@ -4588,15 +6022,32 @@ async function assertRestoreFreeSpace(directory, state, env, manifest2, backupPa
4588
6022
  async function restoreBackupData(directory, state, env, backupPath, options = {}) {
4589
6023
  const manifest2 = await readManifest(backupPath, directory);
4590
6024
  assertRestorableManagedAssets(manifest2);
4591
- const backupEnv = parseEnv(await readText(join21(backupPath, "config.env")));
6025
+ const backupEnv = parseEnv(await readText(join24(backupPath, "config.env")));
4592
6026
  if (backupEnv.OR3_VERSION !== manifest2.appVersion || backupEnv.OR3_IMAGE !== manifest2.image) {
4593
6027
  throw new Error(`Backup ${manifest2.backupId} configuration does not match its manifest.`);
4594
6028
  }
4595
6029
  assertBackupMatchesDeployment(manifest2, backupEnv, state, env);
6030
+ if (manifest2.schemaVersion === 1)
6031
+ assertSupportedBackupProfile(backupEnv);
6032
+ else if (!options.beforeStart && options.remoteReferencePolicy !== "warn" || options.remoteReferencePolicy === "warn" && (!options.previousSafetySnapshot || options.beforeStart) || options.previousSafetySnapshot && manifest2.remote.kind !== "s3-reference-v1") {
6033
+ throw new Error("S3 restore requires a journaled startup or a reference-only safety snapshot.");
6034
+ }
4596
6035
  if (manifest2.imageDigest)
4597
6036
  await requireImageDigest(manifest2.image, manifest2.imageDigest, `Backup ${manifest2.backupId}`);
6037
+ await assertExistingVaultCustody(directory, state, manifest2, backupPath, backupEnv);
4598
6038
  await validateVolumeArchive(directory, state.mode, env, backupPath, backupEnv.OR3_STORAGE_FS_ROOT);
4599
- if (!options.previousSafetySnapshot) {
6039
+ if (manifest2.schemaVersion === 2 && manifest2.remote.kind === "s3-copy-v1") {
6040
+ const result = await checkRemoteCopyTarget(directory, manifest2, env, backupPath, manifest2.remote);
6041
+ if (result.missing || result.mismatched)
6042
+ throw new Error("S3 restore target has missing or mismatched objects.");
6043
+ } else if (manifest2.schemaVersion === 2 && manifest2.remote.kind === "s3-reference-v1") {
6044
+ const result = await checkRemoteTarget(directory, manifest2, env, backupPath, manifest2.remote);
6045
+ if (result.missing || result.mismatched) {
6046
+ if (options.remoteReferencePolicy !== "warn")
6047
+ throw new Error("S3 reference target has missing or mismatched objects.");
6048
+ console.warn(`Restoring the pre-mutation snapshot with ${result.missing} missing and ${result.mismatched} mismatched S3 objects; attachment completeness is not certified.`);
6049
+ }
6050
+ } else if (!options.previousSafetySnapshot) {
4600
6051
  await validateCanonicalBackupReferences(directory, { ...backupEnv, OR3_IMAGE: imageAtDigest(manifest2.image, manifest2.imageDigest) }, backupPath);
4601
6052
  } else {
4602
6053
  console.warn("Restoring the recorded byte-preservation safety snapshot; canonical attachment completeness is not certified. Verify previously uploaded files.");
@@ -4605,10 +6056,10 @@ async function restoreBackupData(directory, state, env, backupPath, options = {}
4605
6056
  await assertRestoreFreeSpace(directory, state, env, manifest2, backupPath);
4606
6057
  const restoresOperatorOverlay = [3, MANAGED_ASSET_INVENTORY_VERSION].includes(manifest2.managedAssetInventoryVersion ?? 1) && Boolean(manifest2.managedAssetSha256?.["compose.operator.yaml"]);
4607
6058
  const restoredEnv = restoresOperatorOverlay ? {
4608
- ...backupEnv,
6059
+ ...restoreStorageConfiguration(backupEnv, env),
4609
6060
  OR3_IMAGE: imageAtDigest(manifest2.image, manifest2.imageDigest)
4610
6061
  } : withoutDashboardOperator({
4611
- ...backupEnv,
6062
+ ...restoreStorageConfiguration(backupEnv, env),
4612
6063
  OR3_IMAGE: imageAtDigest(manifest2.image, manifest2.imageDigest)
4613
6064
  });
4614
6065
  if (options.recreateDataVolume)
@@ -4638,6 +6089,10 @@ async function restoreBackupData(directory, state, env, backupPath, options = {}
4638
6089
  "--env",
4639
6090
  `OR3_SQLITE_DB_PATH=${restoredEnv.OR3_SQLITE_DB_PATH ?? ""}`,
4640
6091
  "--env",
6092
+ `AUTH_PROVIDER=${restoredEnv.AUTH_PROVIDER ?? restoredEnv.OR3_AUTH_PROVIDER ?? "basic-auth"}`,
6093
+ "--env",
6094
+ `NUXT_PUBLIC_STORAGE_PROVIDER=${restoredEnv.NUXT_PUBLIC_STORAGE_PROVIDER ?? "fs"}`,
6095
+ "--env",
4641
6096
  `OR3_STORAGE_FS_ROOT=${restoredEnv.OR3_STORAGE_FS_ROOT ?? "/data/storage"}`,
4642
6097
  "--entrypoint",
4643
6098
  "sh",
@@ -4647,8 +6102,19 @@ async function restoreBackupData(directory, state, env, backupPath, options = {}
4647
6102
  if (!snapshotCheck.ok) {
4648
6103
  throw new Error(`Restored snapshot integrity verification failed. ${redact(snapshotCheck.stderr, secretValues(restoredEnv))}`);
4649
6104
  }
6105
+ if (manifest2.schemaVersion === 1)
6106
+ await restoreGenerationIdentities(directory, state, restoredEnv, backupPath, { previousSafetySnapshot: options.previousSafetySnapshot });
4650
6107
  await options.beforeStart?.();
4651
6108
  await startProject(directory, state.mode, restoredEnv);
6109
+ if (manifest2.schemaVersion === 2 && manifest2.remote.kind === "s3-copy-v1") {
6110
+ const result = await checkRemoteCopyTarget(directory, manifest2, restoredEnv, backupPath, manifest2.remote);
6111
+ if (result.missing || result.mismatched)
6112
+ throw new Error("S3 restore target has missing or mismatched objects after startup.");
6113
+ } else if (manifest2.schemaVersion === 2 && manifest2.remote.kind === "s3-reference-v1" && options.remoteReferencePolicy !== "warn") {
6114
+ const result = await checkRemoteTarget(directory, manifest2, restoredEnv, backupPath, manifest2.remote);
6115
+ if (result.missing || result.mismatched)
6116
+ throw new Error("S3 reference target has missing or mismatched objects after startup.");
6117
+ }
4652
6118
  return manifest2;
4653
6119
  }
4654
6120
  async function restorePreMutationSnapshot(directory, state, env) {
@@ -4658,30 +6124,30 @@ async function restorePreMutationSnapshot(directory, state, env) {
4658
6124
  const previous = await recordedBackupPath(directory, pending);
4659
6125
  await updatePending(directory, state, { phase: "restoring-previous" });
4660
6126
  if (pending.recreateDataVolume) {
4661
- await restoreBackupData(directory, state, env, previous.path, { recreateDataVolume: true, previousSafetySnapshot: true });
6127
+ await restoreBackupData(directory, state, env, previous.path, { recreateDataVolume: true, previousSafetySnapshot: true, remoteReferencePolicy: "warn" });
4662
6128
  return previous;
4663
6129
  }
4664
6130
  if (pending.previousRootOwnership) {
4665
6131
  await pullAndRequireImage(state.image, state.imageDigest, "Previous deployment");
4666
6132
  await setManagedVolumeRootOwnership(directory, state, env, state.image, pending.previousRootOwnership);
4667
6133
  }
4668
- await restoreBackupData(directory, state, env, previous.path, { previousSafetySnapshot: true });
6134
+ await restoreBackupData(directory, state, env, previous.path, { previousSafetySnapshot: true, remoteReferencePolicy: "warn" });
4669
6135
  return previous;
4670
6136
  }
4671
6137
 
4672
6138
  // src/deployment/observation.ts
4673
- import { createHash as createHash4 } from "node:crypto";
4674
- import { resolve as resolve13 } from "node:path";
6139
+ import { createHash as createHash6 } from "node:crypto";
6140
+ import { resolve as resolve14 } from "node:path";
4675
6141
 
4676
6142
  // src/deployment/lease.ts
4677
6143
  import { randomBytes as randomBytes8 } from "node:crypto";
4678
- import { mkdir as mkdir10, rename as rename3, rm as rm9 } from "node:fs/promises";
4679
- import { dirname as dirname7, join as join23 } from "node:path";
6144
+ import { mkdir as mkdir10, rename as rename3, rm as rm10 } from "node:fs/promises";
6145
+ import { dirname as dirname7, join as join26 } from "node:path";
4680
6146
 
4681
6147
  // ../../shared/server/restarts/controller.ts
4682
- import { randomUUID as randomUUID3, createHash as createHash3 } from "node:crypto";
4683
- import { mkdir as mkdir9, open as open3, readFile as readFile3, rename as rename2, rm as rm8 } from "node:fs/promises";
4684
- import { dirname as dirname6, join as join22 } from "node:path";
6148
+ import { randomUUID as randomUUID5, createHash as createHash5 } from "node:crypto";
6149
+ import { mkdir as mkdir9, open as open4, readFile as readFile5, rename as rename2, rm as rm9 } from "node:fs/promises";
6150
+ import { dirname as dirname6, join as join25 } from "node:path";
4685
6151
 
4686
6152
  // ../../node_modules/zod/v4/classic/external.js
4687
6153
  var exports_external = {};
@@ -9730,7 +11196,7 @@ var recursive = /* @__PURE__ */ new WeakMap;
9730
11196
  var NONE = 0;
9731
11197
  var ASSUMED = 1;
9732
11198
  var PROVEN = 2;
9733
- function isRecursive(inst, stack, resolve13) {
11199
+ function isRecursive(inst, stack, resolve14) {
9734
11200
  const cached2 = recursive.get(inst);
9735
11201
  if (cached2 !== undefined)
9736
11202
  return cached2 ? PROVEN : NONE;
@@ -9740,7 +11206,7 @@ function isRecursive(inst, stack, resolve13) {
9740
11206
  let result = NONE;
9741
11207
  const check = (child) => {
9742
11208
  if (result !== PROVEN && child?._zod) {
9743
- const answer = isRecursive(child, stack, resolve13);
11209
+ const answer = isRecursive(child, stack, resolve14);
9744
11210
  if (answer > result)
9745
11211
  result = answer;
9746
11212
  }
@@ -9751,7 +11217,7 @@ function isRecursive(inst, stack, resolve13) {
9751
11217
  const desc = Object.getOwnPropertyDescriptor(sh, key);
9752
11218
  if (spread && !desc.enumerable)
9753
11219
  continue;
9754
- const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve13) : NONE;
11220
+ const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve14) : NONE;
9755
11221
  if (child > answer)
9756
11222
  answer = child;
9757
11223
  }
@@ -9814,7 +11280,7 @@ function isRecursive(inst, stack, resolve13) {
9814
11280
  check(def.output);
9815
11281
  break;
9816
11282
  case "lazy": {
9817
- const inner = def._cachedInner ?? (resolve13 ? inst._zod.innerType : undefined);
11283
+ const inner = def._cachedInner ?? (resolve14 ? inst._zod.innerType : undefined);
9818
11284
  merge2(inner ? isRecursive(inner, stack, false) : ASSUMED);
9819
11285
  break;
9820
11286
  }
@@ -9875,7 +11341,7 @@ function bucketFor(state, inst) {
9875
11341
  return bucket;
9876
11342
  }
9877
11343
  var handoff;
9878
- var open2 = [];
11344
+ var open3 = [];
9879
11345
  var memo = {
9880
11346
  alloc(_inst, payload, empty) {
9881
11347
  const bucket = handoff;
@@ -9884,7 +11350,7 @@ var memo = {
9884
11350
  handoff = undefined;
9885
11351
  const entry = { value: empty, issues: null };
9886
11352
  bucket.set(payload.value, entry);
9887
- open2.push(entry);
11353
+ open3.push(entry);
9888
11354
  return empty;
9889
11355
  },
9890
11356
  guard(inst) {
@@ -9955,10 +11421,10 @@ var memo = {
9955
11421
  return payload;
9956
11422
  }
9957
11423
  handoff = bucket;
9958
- const depth = open2.length;
11424
+ const depth = open3.length;
9959
11425
  const result = base(payload, ctx);
9960
11426
  handoff = undefined;
9961
- const entry = open2.length > depth ? open2.pop() : undefined;
11427
+ const entry = open3.length > depth ? open3.pop() : undefined;
9962
11428
  if (result instanceof Promise) {
9963
11429
  return result.then((r) => {
9964
11430
  if (entry)
@@ -24130,7 +25596,7 @@ var recordSchema = exports_external.object({
24130
25596
  expected: exports_external.record(exports_external.string(), exports_external.union([exports_external.string().max(128), exports_external.boolean()])),
24131
25597
  executionIntent: exports_external.boolean(),
24132
25598
  runtimeIdentity: exports_external.string().max(512).optional()
24133
- }).strict().refine((record2) => record2.requestId === record2.request.requestId && record2.fingerprint === createHash3("sha256").update(JSON.stringify(record2.request)).digest("hex"), "Receipt identity mismatch");
25599
+ }).strict().refine((record2) => record2.requestId === record2.request.requestId && record2.fingerprint === createHash5("sha256").update(JSON.stringify(record2.request)).digest("hex"), "Receipt identity mismatch");
24134
25600
 
24135
25601
  class RestartError extends Error {
24136
25602
  code;
@@ -24144,15 +25610,15 @@ class RestartError extends Error {
24144
25610
  var resolved = new Set(["succeeded", "cancelled", "failed"]);
24145
25611
  var replayWindowMs = 24 * 60 * 60 * 1000;
24146
25612
  async function readRestartJournal(path) {
24147
- const text = await readFile3(path, "utf8");
25613
+ const text = await readFile5(path, "utf8");
24148
25614
  if (Buffer.byteLength(text) > 512 * 1024)
24149
25615
  throw new RestartError("journal_invalid");
24150
25616
  return exports_external.object({ schemaVersion: exports_external.literal(1), identity: exports_external.string().min(1), operations: exports_external.array(recordSchema).max(256) }).strict().parse(JSON.parse(text));
24151
25617
  }
24152
25618
  async function writePrivateJson(path, value) {
24153
- const temporary = `${path}.${randomUUID3()}.tmp`;
25619
+ const temporary = `${path}.${randomUUID5()}.tmp`;
24154
25620
  try {
24155
- const file2 = await open3(temporary, "wx", 384);
25621
+ const file2 = await open4(temporary, "wx", 384);
24156
25622
  try {
24157
25623
  await file2.writeFile(JSON.stringify(value));
24158
25624
  await file2.sync();
@@ -24160,14 +25626,14 @@ async function writePrivateJson(path, value) {
24160
25626
  await file2.close();
24161
25627
  }
24162
25628
  await rename2(temporary, path);
24163
- const directory = await open3(dirname6(path), "r");
25629
+ const directory = await open4(dirname6(path), "r");
24164
25630
  try {
24165
25631
  await directory.sync();
24166
25632
  } finally {
24167
25633
  await directory.close();
24168
25634
  }
24169
25635
  } finally {
24170
- await rm8(temporary, { force: true });
25636
+ await rm9(temporary, { force: true });
24171
25637
  }
24172
25638
  }
24173
25639
  function publicOperation(o) {
@@ -24194,7 +25660,7 @@ class RestartController {
24194
25660
  constructor(directory, driver) {
24195
25661
  this.directory = directory;
24196
25662
  this.driver = driver;
24197
- this.path = join22(directory, "restarts.json");
25663
+ this.path = join25(directory, "restarts.json");
24198
25664
  }
24199
25665
  lock(action) {
24200
25666
  const next = this.serial.then(action, action);
@@ -24284,7 +25750,7 @@ class RestartController {
24284
25750
  if (!parsed.success)
24285
25751
  throw new RestartError("invalid_request", 400);
24286
25752
  const request = parsed.data;
24287
- const fingerprint = createHash3("sha256").update(JSON.stringify(request)).digest("hex");
25753
+ const fingerprint = createHash5("sha256").update(JSON.stringify(request)).digest("hex");
24288
25754
  return this.lock(async () => {
24289
25755
  const old = this.operations.find((o2) => o2.requestId === request.requestId);
24290
25756
  if (old) {
@@ -24410,7 +25876,7 @@ class RestartController {
24410
25876
  });
24411
25877
  return;
24412
25878
  }
24413
- await new Promise((resolve13) => setTimeout(resolve13, 250));
25879
+ await new Promise((resolve14) => setTimeout(resolve14, 250));
24414
25880
  }
24415
25881
  if (this.disposed)
24416
25882
  return;
@@ -24433,7 +25899,7 @@ class RestartController {
24433
25899
  throw new RestartError("configuration_unsupported", 501);
24434
25900
  if ((await this.driver.configuration()).revision !== expectedRevision)
24435
25901
  throw new RestartError("stale_revision");
24436
- const release = await this.driver.acquire(randomUUID3());
25902
+ const release = await this.driver.acquire(randomUUID5());
24437
25903
  try {
24438
25904
  await this.driver.save(updates, expectedRevision);
24439
25905
  } finally {
@@ -24492,7 +25958,7 @@ class RestartController {
24492
25958
  var DASHBOARD_LEASE_STALE_MS = 30000;
24493
25959
  async function readLeaseOwner(leasePath) {
24494
25960
  try {
24495
- const owner = JSON.parse(await readText(join23(leasePath, "owner.json")));
25961
+ const owner = JSON.parse(await readText(join26(leasePath, "owner.json")));
24496
25962
  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")
24497
25963
  return;
24498
25964
  return owner;
@@ -24540,7 +26006,7 @@ async function acquireDeploymentLease(directory, command, dashboardJobId) {
24540
26006
  const stalePath = `${leasePath}.stale-${randomBytes8(8).toString("hex")}`;
24541
26007
  try {
24542
26008
  await rename3(leasePath, stalePath);
24543
- await rm9(stalePath, { recursive: true, force: true });
26009
+ await rm10(stalePath, { recursive: true, force: true });
24544
26010
  await mkdir10(leasePath, { recursive: false, mode: 448 });
24545
26011
  } catch (reclaimError) {
24546
26012
  throw new Error(`Another OR3 Cloud operation owns ${directory}. Refusing to race its lifecycle lock. ${reclaimError instanceof Error ? reclaimError.message : String(reclaimError)}`);
@@ -24551,17 +26017,17 @@ async function acquireDeploymentLease(directory, command, dashboardJobId) {
24551
26017
  }
24552
26018
  }
24553
26019
  try {
24554
- await writeSecure(join23(leasePath, "owner.json"), `${JSON.stringify(owner, null, 2)}
26020
+ await writeSecure(join26(leasePath, "owner.json"), `${JSON.stringify(owner, null, 2)}
24555
26021
  `);
24556
26022
  } catch (error62) {
24557
- await rm9(leasePath, { recursive: true, force: true }).catch(() => {
26023
+ await rm10(leasePath, { recursive: true, force: true }).catch(() => {
24558
26024
  return;
24559
26025
  });
24560
26026
  throw error62;
24561
26027
  }
24562
26028
  const heartbeat = setInterval(() => {
24563
26029
  owner.heartbeatAt = now();
24564
- writeSecure(join23(leasePath, "owner.json"), `${JSON.stringify(owner, null, 2)}
26030
+ writeSecure(join26(leasePath, "owner.json"), `${JSON.stringify(owner, null, 2)}
24565
26031
  `).catch(() => {
24566
26032
  return;
24567
26033
  });
@@ -24571,7 +26037,7 @@ async function acquireDeploymentLease(directory, command, dashboardJobId) {
24571
26037
  clearInterval(heartbeat);
24572
26038
  const current = await readLeaseOwner(leasePath);
24573
26039
  if (current?.nonce === nonce)
24574
- await rm9(leasePath, { recursive: true, force: true });
26040
+ await rm10(leasePath, { recursive: true, force: true });
24575
26041
  };
24576
26042
  }
24577
26043
  async function withDeploymentLease(directory, command, action) {
@@ -24586,7 +26052,7 @@ async function withDeploymentLease(directory, command, action) {
24586
26052
  }
24587
26053
  async function assertRestartJournalSettled(directory) {
24588
26054
  try {
24589
- const journal = await readRestartJournal(join23(directory, ".or3-cloud", "restarts", "restarts.json"));
26055
+ const journal = await readRestartJournal(join26(directory, ".or3-cloud", "restarts", "restarts.json"));
24590
26056
  if (journal.operations.some((operation) => !["succeeded", "cancelled", "failed"].includes(operation.phase))) {
24591
26057
  throw new Error("A dashboard restart is unresolved. Run the compatible CLI recover --dry-run to inspect and recover --finish to reconcile it before another mutation.");
24592
26058
  }
@@ -24600,8 +26066,19 @@ async function assertRestartJournalSettled(directory) {
24600
26066
  }
24601
26067
 
24602
26068
  // src/deployment/observation.ts
26069
+ function latestFullSnapshot(backups, observedAt) {
26070
+ const cutoff = Date.parse(observedAt);
26071
+ const candidates = (backups?.entries ?? []).filter((entry) => entry.kind === "verified").map((entry) => entry.backup).filter((backup2) => backup2.recoveryArtifactsComplete === true && backup2.snapshotKind === "full" && backup2.schemaVersion === 1 && Date.parse(backup2.createdAt) <= cutoff).sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt) || a.backupId.localeCompare(b.backupId));
26072
+ const backup = candidates[0];
26073
+ return backup ? {
26074
+ backupId: backup.backupId,
26075
+ createdAt: backup.createdAt,
26076
+ schemaVersion: backup.schemaVersion,
26077
+ remoteKind: backup.remoteKind ?? "none"
26078
+ } : null;
26079
+ }
24603
26080
  async function observeDeployment(directory, options = {}) {
24604
- const resolved2 = resolve13(directory);
26081
+ const resolved2 = resolve14(directory);
24605
26082
  const observedAt = now();
24606
26083
  let state = null;
24607
26084
  let stateError = null;
@@ -24728,6 +26205,7 @@ function publicStateProjection(state) {
24728
26205
  dashboardJobId: pending.dashboardJobId ?? null,
24729
26206
  targetVersion: pending.targetVersion ?? null,
24730
26207
  targetImageDigest: pending.targetImageDigest ?? null,
26208
+ remoteReconcile: pending.remoteReconcile ?? null,
24731
26209
  backupProgress: pending.backupProgress ? {
24732
26210
  stage: pending.backupProgress.stage,
24733
26211
  service: pending.backupProgress.service,
@@ -24757,7 +26235,7 @@ function observationFindings(observation) {
24757
26235
  return findings;
24758
26236
  }
24759
26237
  function stateFingerprint(state) {
24760
- return createHash4("sha256").update(JSON.stringify({
26238
+ return createHash6("sha256").update(JSON.stringify({
24761
26239
  schema: state.schemaVersion,
24762
26240
  appVersion: state.appVersion,
24763
26241
  image: state.image,
@@ -24789,6 +26267,9 @@ function decideRecoveryAction(state) {
24789
26267
  }
24790
26268
  return { action: "none", detail: "No incomplete operation is recorded." };
24791
26269
  }
26270
+ if (pending.operation === "adopt") {
26271
+ return { action: "require-explicit-restore", detail: "The adoption target may contain newer writes. Replaying the source backup can discard them.", snapshotId: pending.backupId };
26272
+ }
24792
26273
  if (pending.operation === "update" && pending.phase === "target-ready" && pending.evidence) {
24793
26274
  return { action: "finish", detail: "A completed replacement carries durable target-ready evidence and can be committed without restoring data." };
24794
26275
  }
@@ -24830,6 +26311,7 @@ async function reconcileOperatorHandoff(directory, state, report = console.log)
24830
26311
  return detail;
24831
26312
  }
24832
26313
  async function collectTargetReadyChecks(directory, mode, env) {
26314
+ const profile = managedProviderProfile(env);
24833
26315
  const checks3 = [];
24834
26316
  let containerId;
24835
26317
  const container = await run("docker", composeArgs(directory, mode, ["ps", "-q", "or3"]), directory);
@@ -24842,16 +26324,16 @@ async function collectTargetReadyChecks(directory, mode, env) {
24842
26324
  if (databaseCheck.ok) {
24843
26325
  try {
24844
26326
  const databases = JSON.parse(databaseCheck.stdout.trim());
24845
- databasesOk = databases.length === 2 && databases.every((entry) => entry.quickCheck === "ok" && entry.tables >= 1);
26327
+ databasesOk = databases.length === (profile === "basic-sqlite-fs" ? 2 : 1) && databases.every((entry) => entry.quickCheck === "ok" && entry.tables >= 1);
24846
26328
  } catch {
24847
26329
  databasesOk = false;
24848
26330
  }
24849
26331
  }
24850
- checks3.push(databasesOk ? { code: "database-integrity", status: "passed", detail: "auth.sqlite and sync.sqlite quick_check passed with managed ownership." } : { code: "database-integrity", status: "failed", detail: `SQLite integrity or ownership verification failed. ${redact(databaseCheck.stderr.trim(), secretValues(env))}` });
26332
+ checks3.push(databasesOk ? { code: "database-integrity", status: "passed", detail: `${profile === "basic-sqlite-fs" ? "auth.sqlite and sync.sqlite" : "sync.sqlite"} quick_check passed with managed ownership.` } : { code: "database-integrity", status: "failed", detail: `SQLite integrity or ownership verification failed. ${redact(databaseCheck.stderr.trim(), secretValues(env))}` });
24851
26333
  if (mode === "public") {
24852
26334
  const baseUrl = new URL(`https://${env.OR3_PUBLIC_DOMAIN}`);
24853
26335
  try {
24854
- validateVerificationHealth(await verificationJson(baseUrl, "/api/health?deep=true"));
26336
+ validateVerificationHealth(await verificationJson(baseUrl, "/api/health?deep=true"), profile);
24855
26337
  checks3.push({ code: "public-health", status: "passed", detail: `${baseUrl.origin} deep health reports the managed profile.` });
24856
26338
  } catch (error62) {
24857
26339
  checks3.push({ code: "public-health", status: "failed", detail: redact(error62 instanceof Error ? error62.message : String(error62)) });
@@ -25049,6 +26531,8 @@ async function runRecovery(directory, context) {
25049
26531
  const loaded = await loadManaged(directory);
25050
26532
  const pending = loaded.state.incompleteOperation;
25051
26533
  const decision = decideRecoveryAction(loaded.state);
26534
+ if (pending || decision.action === "reconcile-handoff")
26535
+ assertLegacyManagedProfile(loaded.env);
25052
26536
  if (decision.action === "reconcile-handoff") {
25053
26537
  if (explicitRestore)
25054
26538
  throw new Error("This deployment completed its application update; only the dashboard operator handoff needs reconciliation. Run `npx @or3/cloud recover --finish` without --restore.");
@@ -25075,7 +26559,18 @@ async function runRecovery(directory, context) {
25075
26559
  const detail = await finishTargetReadyUpdate(loaded.directory, loaded.state, loaded.env, report);
25076
26560
  return { kind: "recovered", operation: "update", detail };
25077
26561
  }
26562
+ const resumesExistingVault = pending.operation === "credentials-reset" || (pending.operation === "backup" ? pending.initialAppRunning !== false : ["update", "restore", "rollback"].includes(pending.operation) && (pending.phase === "prepared" || pending.phase === "snapshot-created"));
26563
+ if (resumesExistingVault && managedVaultEnabled(loaded.directory, loaded.env))
26564
+ await assertVaultVolumesExist(loaded.directory, loaded.state);
26565
+ if (resumesExistingVault || pending.operation === "init" || pending.operation === "adopt") {
26566
+ await assertLegacyComposeProfile(loaded.directory, loaded.state.mode, loaded.env);
26567
+ }
26568
+ if (pending.operation === "credentials-reset" && pending.credentialReset?.nextEnv)
26569
+ assertLegacyManagedProfile(pending.credentialReset.nextEnv);
25078
26570
  const requiresExplicitRestore = decision.action === "require-explicit-restore";
26571
+ if (pending.operation === "adopt" && requiresExplicitRestore && !explicitRestore) {
26572
+ throw new Error("The adoption target may contain newer writes. Nothing was changed. Review recover --dry-run; recover --restore --yes explicitly discards target writes after the source backup.");
26573
+ }
25079
26574
  try {
25080
26575
  if (pending.operation === "backup") {
25081
26576
  const failures = [];
@@ -25196,6 +26691,7 @@ async function runRecovery(directory, context) {
25196
26691
  if (sourceManifest.mode !== loaded.state.mode || sourceManifest.appVersion !== loaded.state.appVersion || sourceManifest.image !== loaded.state.image || sourceManifest.imageDigest !== loaded.state.imageDigest) {
25197
26692
  throw new Error(`Source backup ${sourceManifest.backupId} does not match the managed adoption target.`);
25198
26693
  }
26694
+ await updatePending(loaded.directory, loaded.state, { phase: "target-mutating" });
25199
26695
  await stopProject(loaded.directory, loaded.state.mode);
25200
26696
  await restoreVolumeArchive(loaded.directory, loaded.state.mode, loaded.env, sourceBackupPath);
25201
26697
  }
@@ -25220,84 +26716,29 @@ async function runRecovery(directory, context) {
25220
26716
  } catch (error62) {
25221
26717
  const recoverySecrets = pending.operation === "credentials-reset" ? secretValues(pending.credentialReset?.nextEnv ?? loaded.env) : secretValues(loaded.env);
25222
26718
  loaded.state.lastError = redact(error62 instanceof Error ? error62.message : String(error62), recoverySecrets);
25223
- if (pending.operation === "adopt" && pending.sourceDirectory) {
25224
- try {
25225
- await compose(loaded.directory, loaded.state.mode, ["down"]).catch(() => {
25226
- return;
25227
- });
25228
- const sourceEnv = parseEnv(await readText(join24(pending.sourceDirectory, ".env")));
25229
- const sourceFiles = ["-f", join24(pending.sourceDirectory, "compose.yaml")];
25230
- const sourceModeValue = sourceMode(pending.sourceDirectory, sourceEnv);
25231
- if (sourceModeValue === "public")
25232
- sourceFiles.push("-f", join24(pending.sourceDirectory, "compose.public.yaml"));
25233
- if (pending.sourceInitiallyRunning !== false) {
25234
- await restartSource(pending.sourceDirectory, sourceFiles, secretValues(sourceEnv));
25235
- }
25236
- } catch (recovery) {
25237
- loaded.state.lastError += ` Original deployment recovery failed: ${recovery instanceof Error ? recovery.message : String(recovery)}`;
25238
- }
25239
- }
25240
26719
  await writeState(loaded.directory, loaded.state);
25241
26720
  throw new Error(`Recovery could not safely complete ${pending.operation}. Keep the operation record and run doctor. ${loaded.state.lastError}`);
25242
26721
  }
25243
26722
  }
25244
26723
 
25245
26724
  // src/commands/remove.ts
25246
- import { rm as rm10 } from "node:fs/promises";
25247
- import { join as join25 } from "node:path";
25248
- function purgeVolumesFromState(state) {
25249
- const volumes = [state.volumeName];
25250
- if (state.mode === "public") {
25251
- if (state.caddyDataVolume)
25252
- volumes.push(state.caddyDataVolume);
25253
- if (state.caddyConfigVolume)
25254
- volumes.push(state.caddyConfigVolume);
25255
- }
25256
- return volumes;
25257
- }
25258
26725
  async function removeCommand(directory, flags) {
25259
26726
  if (boolFlag(flags, "purge-data") && !boolFlag(flags, "yes")) {
25260
- throw new Error("--purge-data deletes the data volume, every backup, and the managed files. Re-run with --purge-data --yes after confirming the data-loss boundary.");
26727
+ throw new Error("--purge-data is disabled because reconstruction from an export is not yet supported. Preserve the deployment and backups.");
25261
26728
  }
25262
26729
  await ensureDocker();
25263
26730
  const loaded = await loadManaged(directory);
25264
26731
  assertDeploymentDirectoryIdentity(directory, loaded.state);
25265
26732
  assertNoPending(loaded.state);
25266
- const { state } = loaded;
25267
- if (!boolFlag(flags, "purge-data")) {
25268
- await compose(directory, state.mode, ["down"]);
25269
- console.log(`Removed runtime containers and network. Data volume, backups, configuration, and managed state are retained at ${directory}. Re-run \`npx @or3/cloud start\` to restore service.`);
25270
- return;
26733
+ if (boolFlag(flags, "purge-data")) {
26734
+ throw new Error("Purge is disabled because reconstruction from an export is not yet supported. Preserve the deployment and backups.");
25271
26735
  }
25272
- const exportReceipt = await assertPurgeHasVerifiedExport(directory, await enumerateBackups(directory), Date.now());
25273
- const volumes = purgeVolumesFromState(state);
25274
- const managedFiles = [
25275
- ".env",
25276
- ".or3-cloud",
25277
- ...managedAssetNames(state.mode),
25278
- ".or3-initial-credentials"
25279
- ];
25280
- console.log("Removing exactly these targets:");
25281
- for (const volume of volumes)
25282
- console.log(` docker volume ${volume}`);
25283
- for (const file2 of managedFiles)
25284
- console.log(` ${join25(directory, file2)}`);
25285
- await compose(directory, state.mode, ["down"]);
25286
- await assertPurgeHasVerifiedExport(directory, await enumerateBackups(directory), Date.now());
25287
- for (const volume of volumes) {
25288
- const result = await run("docker", ["volume", "rm", volume], directory);
25289
- if (!result.ok)
25290
- throw new Error(`${result.command}
25291
- ${result.stderr}`);
25292
- }
25293
- for (const file2 of managedFiles) {
25294
- await rm10(join25(directory, file2), { recursive: true, force: true });
25295
- }
25296
- console.log(`Purged: data volumes, backups, .env, managed state, and compose files were deleted. The verified export at ${exportReceipt.destination} is the remaining copy.`);
26736
+ await compose(directory, loaded.state.mode, ["down"]);
26737
+ console.log(`Removed runtime containers and network. Data volume, backups, configuration, and managed state are retained at ${directory}. Re-run \`npx @or3/cloud start\` to restore service.`);
25297
26738
  }
25298
26739
 
25299
26740
  // src/commands/restore.ts
25300
- import { join as join26 } from "node:path";
26741
+ import { join as join27 } from "node:path";
25301
26742
  async function restoreCommand(directory, flags, positionals) {
25302
26743
  if (!boolFlag(flags, "yes"))
25303
26744
  throw new Error("Restore replaces live data. Re-run with --yes after confirming the backup and data-loss boundary.");
@@ -25310,18 +26751,26 @@ async function restoreCommand(directory, flags, positionals) {
25310
26751
  const backupPath = await resolveBackup(loaded.directory, backupValue);
25311
26752
  const manifest2 = await readManifest(backupPath, loaded.directory);
25312
26753
  assertRestorableManagedAssets(manifest2);
25313
- const backupEnv = parseEnv(await readText(join26(backupPath, "config.env")));
26754
+ const backupEnv = parseEnv(await readText(join27(backupPath, "config.env")));
25314
26755
  if (backupEnv.OR3_VERSION !== manifest2.appVersion || backupEnv.OR3_IMAGE !== manifest2.image) {
25315
26756
  throw new Error(`Backup ${manifest2.backupId} configuration does not match its manifest.`);
25316
26757
  }
25317
26758
  assertBackupMatchesDeployment(manifest2, backupEnv, loaded.state, loaded.env);
26759
+ assertSupportedBackupProfile(loaded.env);
26760
+ await assertBackupSourceProfile(loaded.directory, loaded.state, loaded.env);
25318
26761
  const recreateDataVolume = restoreRequiresVolumeRecreation(loaded.env, backupEnv);
25319
26762
  if (manifest2.imageDigest)
25320
26763
  await pullAndRequireImage(manifest2.image, manifest2.imageDigest, `Backup ${manifest2.backupId}`);
25321
26764
  await assertSupportedHostArchitecture(manifest2.image);
26765
+ await assertExistingVaultCustody(loaded.directory, loaded.state, manifest2, backupPath, backupEnv);
25322
26766
  await assertRestoreFreeSpace(loaded.directory, loaded.state, loaded.env, manifest2, backupPath);
25323
26767
  await validateVolumeArchive(loaded.directory, loaded.state.mode, loaded.env, backupPath, backupEnv.OR3_STORAGE_FS_ROOT);
25324
- await validateCanonicalBackupReferences(loaded.directory, { ...backupEnv, OR3_IMAGE: imageAtDigest(manifest2.image, manifest2.imageDigest) }, backupPath);
26768
+ const remotePreflight = manifest2.schemaVersion === 2 ? await preflightRemoteRestore(loaded.directory, manifest2, loaded.env, backupPath) : undefined;
26769
+ if (manifest2.schemaVersion === 1)
26770
+ await validateCanonicalBackupReferences(loaded.directory, { ...backupEnv, OR3_IMAGE: imageAtDigest(manifest2.image, manifest2.imageDigest) }, backupPath);
26771
+ const remoteReconcile = remotePreflight && "uploaded" in remotePreflight ? remotePreflight : undefined;
26772
+ if (remoteReconcile)
26773
+ console.log(`S3 reconciliation added ${remoteReconcile.uploaded} objects and ${remoteReconcile.markersCreated} markers; added objects remain if restore fails.`);
25325
26774
  const pending = {
25326
26775
  id: id("restore"),
25327
26776
  operation: "restore",
@@ -25335,6 +26784,7 @@ async function restoreCommand(directory, flags, positionals) {
25335
26784
  targetImage: manifest2.image,
25336
26785
  targetImageDigest: manifest2.imageDigest,
25337
26786
  recreateDataVolume,
26787
+ remoteReconcile,
25338
26788
  phase: "prepared"
25339
26789
  };
25340
26790
  await markPending(loaded.directory, loaded.state, pending);
@@ -25386,6 +26836,8 @@ async function restoreCommand(directory, flags, positionals) {
25386
26836
  throw new Error(recoveredMessage);
25387
26837
  }
25388
26838
  try {
26839
+ if (managedVaultEnabled(loaded.directory, loaded.env))
26840
+ await assertVaultVolumesExist(loaded.directory, loaded.state);
25389
26841
  await startProject(loaded.directory, loaded.state.mode, loaded.env);
25390
26842
  loaded.state.lastError = `Restore of ${manifest2.backupId} stopped before data replacement: ${original}`;
25391
26843
  await clearPending(loaded.directory, loaded.state);
@@ -25400,7 +26852,7 @@ async function restoreCommand(directory, flags, positionals) {
25400
26852
  }
25401
26853
 
25402
26854
  // src/commands/rollback.ts
25403
- import { join as join27 } from "node:path";
26855
+ import { join as join28 } from "node:path";
25404
26856
  async function rollbackCommand(directory, flags) {
25405
26857
  if (!boolFlag(flags, "yes"))
25406
26858
  throw new Error("Rollback restores the previous image and data snapshot. Re-run with --yes after confirming post-update data loss.");
@@ -25416,16 +26868,23 @@ async function rollbackCommand(directory, flags) {
25416
26868
  if (manifest2.appVersion !== point.appVersion || manifest2.image !== point.image || manifest2.imageDigest !== point.imageDigest) {
25417
26869
  throw new Error(`Rollback point ${point.backupId} no longer matches its recorded image/version. Refusing to mutate the deployment.`);
25418
26870
  }
25419
- const backupEnv = parseEnv(await readText(join27(backupPath, "config.env")));
26871
+ const backupEnv = parseEnv(await readText(join28(backupPath, "config.env")));
25420
26872
  if (backupEnv.OR3_VERSION !== manifest2.appVersion || backupEnv.OR3_IMAGE !== manifest2.image) {
25421
26873
  throw new Error(`Backup ${manifest2.backupId} configuration does not match its manifest.`);
25422
26874
  }
25423
26875
  assertBackupMatchesDeployment(manifest2, backupEnv, loaded.state, loaded.env);
26876
+ assertSupportedBackupProfile(loaded.env);
26877
+ await assertBackupSourceProfile(loaded.directory, loaded.state, loaded.env);
25424
26878
  const recreateDataVolume = restoreRequiresVolumeRecreation(loaded.env, backupEnv);
25425
26879
  await pullAndRequireImage(point.image, point.imageDigest, "Rollback");
25426
26880
  await assertSupportedHostArchitecture(point.image);
26881
+ await assertExistingVaultCustody(loaded.directory, loaded.state, manifest2, backupPath, backupEnv);
25427
26882
  await assertRestoreFreeSpace(loaded.directory, loaded.state, loaded.env, manifest2, backupPath);
25428
26883
  await validateVolumeArchive(loaded.directory, loaded.state.mode, loaded.env, backupPath, backupEnv.OR3_STORAGE_FS_ROOT);
26884
+ const remotePreflight = manifest2.schemaVersion === 2 ? await preflightRemoteRestore(loaded.directory, manifest2, loaded.env, backupPath) : undefined;
26885
+ const remoteReconcile = remotePreflight && "uploaded" in remotePreflight ? remotePreflight : undefined;
26886
+ if (remoteReconcile)
26887
+ console.log(`S3 reconciliation added ${remoteReconcile.uploaded} objects and ${remoteReconcile.markersCreated} markers; added objects remain if rollback fails.`);
25429
26888
  const pending = {
25430
26889
  id: id("rollback"),
25431
26890
  operation: "rollback",
@@ -25439,6 +26898,7 @@ async function rollbackCommand(directory, flags) {
25439
26898
  targetImage: point.image,
25440
26899
  targetImageDigest: point.imageDigest,
25441
26900
  recreateDataVolume,
26901
+ remoteReconcile,
25442
26902
  phase: "prepared"
25443
26903
  };
25444
26904
  await markPending(loaded.directory, loaded.state, pending);
@@ -25490,6 +26950,8 @@ async function rollbackCommand(directory, flags) {
25490
26950
  throw new Error(recoveredMessage);
25491
26951
  }
25492
26952
  try {
26953
+ if (managedVaultEnabled(loaded.directory, loaded.env))
26954
+ await assertVaultVolumesExist(loaded.directory, loaded.state);
25493
26955
  await startProject(loaded.directory, loaded.state.mode, loaded.env);
25494
26956
  loaded.state.lastError = `Rollback to ${point.appVersion} stopped before data replacement: ${original}`;
25495
26957
  await clearPending(loaded.directory, loaded.state);
@@ -25527,6 +26989,7 @@ async function statusCommand(directory, flags = {}) {
25527
26989
  identityMatches: observation.identityMatches,
25528
26990
  lastReceipt: state?.lastReceipt ?? null,
25529
26991
  backups: observation.backups,
26992
+ recoveryPoint: latestFullSnapshot(observation.backups, observation.observedAt),
25530
26993
  findings
25531
26994
  }, null, 2));
25532
26995
  return;
@@ -25626,8 +27089,8 @@ ${result.stderr}`);
25626
27089
  }
25627
27090
 
25628
27091
  // src/commands/update.ts
25629
- import { readFile as readFile4, statfs as statfs2 } from "node:fs/promises";
25630
- import { join as join28, resolve as resolve14 } from "node:path";
27092
+ import { readFile as readFile6, statfs as statfs2 } from "node:fs/promises";
27093
+ import { join as join29, resolve as resolve15 } from "node:path";
25631
27094
  async function assessUpdate(directory, targetVersion, options = {}) {
25632
27095
  const checkDocker = options.checkDocker ?? false;
25633
27096
  const observation = await observeDeployment(directory, { checkDocker, checkImage: false });
@@ -25692,7 +27155,7 @@ async function assessUpdate(directory, targetVersion, options = {}) {
25692
27155
  for (const name of managedAssetNames(state?.mode ?? "local")) {
25693
27156
  let desired;
25694
27157
  try {
25695
- desired = await readFile4(join28(ASSET_ROOT, name));
27158
+ desired = await readFile6(join29(ASSET_ROOT, name));
25696
27159
  } catch (error62) {
25697
27160
  findings.push({
25698
27161
  code: "managed-asset-unreadable",
@@ -25703,12 +27166,12 @@ async function assessUpdate(directory, targetVersion, options = {}) {
25703
27166
  continue;
25704
27167
  }
25705
27168
  try {
25706
- const installedPath = join28(directory, name);
27169
+ const installedPath = join29(directory, name);
25707
27170
  if (!await fileExists(installedPath)) {
25708
27171
  changed.push(`${name} (add)`);
25709
27172
  continue;
25710
27173
  }
25711
- const installed = await readFile4(installedPath);
27174
+ const installed = await readFile6(installedPath);
25712
27175
  if (!installed.equals(desired))
25713
27176
  changed.push(`${name} (update)`);
25714
27177
  } catch (error62) {
@@ -25815,6 +27278,8 @@ async function runUpdateCommand(directory, flags, asJson) {
25815
27278
  progress(`OR3 ${targetVersion} is already installed. Nothing to do.`);
25816
27279
  return;
25817
27280
  }
27281
+ assertSupportedBackupProfile(env);
27282
+ await assertBackupSourceProfile(loaded.directory, state, env);
25818
27283
  const preflight = await assessUpdate(loaded.directory, targetVersion, { underLease: true, checkDocker: true });
25819
27284
  const preflightBlockers = preflight.findings.filter((finding) => finding.severity === "blocker");
25820
27285
  if (preflightBlockers.length > 0) {
@@ -25902,6 +27367,7 @@ async function runUpdateCommand(directory, flags, asJson) {
25902
27367
  await copyAssets(loaded.directory, state.mode);
25903
27368
  if (migrateLegacyVolume || recreateDataVolume) {
25904
27369
  await restoreVolumeArchive(loaded.directory, state.mode, nextEnv, backup.backupDir);
27370
+ await restoreGenerationIdentities(loaded.directory, state, nextEnv, backup.backupDir);
25905
27371
  }
25906
27372
  } catch (error62) {
25907
27373
  await updatePending(loaded.directory, state, { phase: "restoring-previous" });
@@ -25915,7 +27381,8 @@ async function runUpdateCommand(directory, flags, asJson) {
25915
27381
  }
25916
27382
  await restoreBackupData(loaded.directory, state, oldEnv, backup.backupDir, {
25917
27383
  recreateDataVolume,
25918
- previousSafetySnapshot: true
27384
+ previousSafetySnapshot: true,
27385
+ remoteReferencePolicy: "warn"
25919
27386
  });
25920
27387
  } catch (restoreError) {
25921
27388
  throw new Error(`Update to ${targetVersion} failed, and automatic backup restoration also failed: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}. Original update error: ${error62 instanceof Error ? error62.message : String(error62)}`);
@@ -25938,7 +27405,7 @@ async function runUpdateCommand(directory, flags, asJson) {
25938
27405
  const targetReadyEvidence = {
25939
27406
  checkedAt: now(),
25940
27407
  deploymentId: targetDeploymentId,
25941
- deploymentRoot: resolve14(loaded.directory),
27408
+ deploymentRoot: resolve15(loaded.directory),
25942
27409
  containerId: targetChecks.containerId,
25943
27410
  imageDigest: digest,
25944
27411
  configurationSha256: await sha256File(deploymentPaths(loaded.directory).env),
@@ -26012,7 +27479,7 @@ async function runUpdateCommand(directory, flags, asJson) {
26012
27479
  state.image = targetImage;
26013
27480
  state.imageDigest = digest;
26014
27481
  state.deploymentId = targetDeploymentId;
26015
- state.deploymentRoot = resolve14(loaded.directory);
27482
+ state.deploymentRoot = resolve15(loaded.directory);
26016
27483
  state.lastSuccessfulOperation = "update";
26017
27484
  state.lastError = undefined;
26018
27485
  let commit;
@@ -26058,7 +27525,7 @@ async function runUpdateCommand(directory, flags, asJson) {
26058
27525
  }
26059
27526
 
26060
27527
  // src/commands/verify.ts
26061
- import { join as join29, resolve as resolve15 } from "node:path";
27528
+ import { join as join30, resolve as resolve16 } from "node:path";
26062
27529
  async function verificationCredentials(directory, flags, env) {
26063
27530
  const passwordFile = stringFlag(flags, "verification-password-file");
26064
27531
  if (flags["verification-email"] !== undefined && !stringFlag(flags, "verification-email")) {
@@ -26070,7 +27537,7 @@ async function verificationCredentials(directory, flags, env) {
26070
27537
  const configuredEmail = stringFlag(flags, "verification-email")?.trim() || env.OR3_MANAGED_OWNER_EMAIL || env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
26071
27538
  let initialCredentials = {};
26072
27539
  if (!configuredEmail || !passwordFile && !env.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD) {
26073
- const initialCredentialsPath = join29(directory, ".or3-initial-credentials");
27540
+ const initialCredentialsPath = join30(directory, ".or3-initial-credentials");
26074
27541
  if (await fileExists(initialCredentialsPath)) {
26075
27542
  initialCredentials = parseEnv(await readOwnerOnlyText(initialCredentialsPath, "The initial credentials file"));
26076
27543
  }
@@ -26079,7 +27546,7 @@ async function verificationCredentials(directory, flags, env) {
26079
27546
  if (!email3)
26080
27547
  throw new Error("A current owner email is required for verification.");
26081
27548
  validateEmail(email3);
26082
- const password = passwordFile ? (await readOwnerOnlyText(resolve15(passwordFile), "The verification password file")).trim() : env.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD || initialCredentials.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD;
27549
+ const password = passwordFile ? (await readOwnerOnlyText(resolve16(passwordFile), "The verification password file")).trim() : env.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD || initialCredentials.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD;
26083
27550
  if (!password) {
26084
27551
  throw new Error("A current owner password is required. Pass --verification-password-file after changing the bootstrap password.");
26085
27552
  }
@@ -26190,11 +27657,134 @@ async function verifyReadOnlyCommand(directory, flags) {
26190
27657
  }
26191
27658
  }
26192
27659
 
27660
+ // src/commands/vault-key.ts
27661
+ import { spawn as spawn2 } from "node:child_process";
27662
+ import { createHash as createHash7, randomUUID as randomUUID6 } from "node:crypto";
27663
+ import { constants as constants2 } from "node:fs";
27664
+ import { open as open5, realpath as realpath3, stat as stat6 } from "node:fs/promises";
27665
+ import { basename as basename7, dirname as dirname8, isAbsolute as isAbsolute6, join as join31, sep as sep2 } from "node:path";
27666
+ async function vaultKeyCommand(directory, positionals, flags) {
27667
+ if (positionals.length !== 1 || positionals[0] !== "export")
27668
+ throw new Error("Use vault-key export --out <absolute-path>.");
27669
+ const destination = requireStringFlag(flags, "out");
27670
+ if (!isAbsolute6(destination))
27671
+ throw new Error("Vault key destination must be an absolute path.");
27672
+ const leaf = destination.split(sep2).at(-1);
27673
+ if (!leaf || leaf === "." || leaf === "..")
27674
+ throw new Error("Vault key destination must name a new file.");
27675
+ const parent = await realpath3(dirname8(destination));
27676
+ const parentInfo = await stat6(parent);
27677
+ if (!parentInfo.isDirectory() || (parentInfo.mode & 511) !== 448 || typeof process.getuid === "function" && parentInfo.uid !== process.getuid()) {
27678
+ throw new Error("Vault key destination parent must be an owner-only directory (0700).");
27679
+ }
27680
+ const target = join31(parent, basename7(destination));
27681
+ const deployment = await realpath3(directory);
27682
+ if (target === deployment || target.startsWith(`${deployment}${sep2}`))
27683
+ throw new Error("Vault key destination must be outside the managed deployment directory.");
27684
+ const file2 = await open5(target, constants2.O_WRONLY | constants2.O_CREAT | constants2.O_EXCL | constants2.O_NOFOLLOW, 384);
27685
+ let written = false;
27686
+ const parts = [];
27687
+ try {
27688
+ await ensureDocker();
27689
+ const loaded = await loadManaged(directory);
27690
+ assertNoPending(loaded.state);
27691
+ await requireImageDigest(loaded.state.image, loaded.state.imageDigest, "Current deployment");
27692
+ const volume = `${loaded.state.volumeName}-vault-key`;
27693
+ if (!(await run("docker", ["volume", "inspect", volume], directory)).ok)
27694
+ throw new Error("Vault key custody is missing or cannot be inspected.");
27695
+ const script = `const fs=require('node:fs');const p='/vault-key/master.key';const s=fs.lstatSync(p);if(!s.isFile()||s.isSymbolicLink()||s.uid!==1000||(s.mode&0o777)!==0o600||s.nlink!==1||s.size!==32)process.exit(1);process.stdout.write(fs.readFileSync(p));`;
27696
+ const helperName = `or3-vault-key-export-${randomUUID6()}`;
27697
+ const child = spawn2("docker", [
27698
+ "run",
27699
+ "--rm",
27700
+ "--name",
27701
+ helperName,
27702
+ "--log-driver",
27703
+ "none",
27704
+ "--pull",
27705
+ "never",
27706
+ "--network",
27707
+ "none",
27708
+ "--read-only",
27709
+ "--user",
27710
+ "1000:65532",
27711
+ "--security-opt",
27712
+ "no-new-privileges:true",
27713
+ "--cap-drop",
27714
+ "ALL",
27715
+ "--mount",
27716
+ `type=volume,src=${volume},dst=/vault-key,readonly`,
27717
+ "--entrypoint",
27718
+ "/nodejs/bin/node",
27719
+ imageAtDigest(loaded.state.image, loaded.state.imageDigest),
27720
+ "-e",
27721
+ script
27722
+ ], { cwd: directory, stdio: ["ignore", "pipe", "ignore"] });
27723
+ const timeout = setTimeout(() => child.kill("SIGKILL"), 120000);
27724
+ const exited = new Promise((resolve17, reject) => {
27725
+ child.once("error", reject);
27726
+ child.once("close", resolve17);
27727
+ });
27728
+ let bytes = 0;
27729
+ let helperFailure;
27730
+ try {
27731
+ for await (const chunk of child.stdout) {
27732
+ const buffer = Buffer.from(chunk);
27733
+ parts.push(buffer);
27734
+ bytes += buffer.length;
27735
+ if (bytes > 32) {
27736
+ child.kill("SIGKILL");
27737
+ throw new Error("Vault key export returned an invalid key.");
27738
+ }
27739
+ }
27740
+ if (await exited !== 0 || bytes !== 32)
27741
+ throw new Error("Vault key export could not read valid custody.");
27742
+ } catch (error62) {
27743
+ helperFailure = error62;
27744
+ } finally {
27745
+ clearTimeout(timeout);
27746
+ if (child.exitCode === null && child.signalCode === null)
27747
+ child.kill("SIGKILL");
27748
+ await exited.catch(() => null);
27749
+ const cleanup = await run("docker", ["rm", "--force", helperName], directory);
27750
+ if (!cleanup.ok && !/No such container/i.test(cleanup.stderr)) {
27751
+ throw new AggregateError(helperFailure ? [helperFailure] : [], "Vault key helper cleanup failed; inspect the named helper before retrying.");
27752
+ }
27753
+ }
27754
+ if (helperFailure)
27755
+ throw helperFailure;
27756
+ const key = Buffer.concat(parts, bytes);
27757
+ try {
27758
+ await file2.writeFile(key);
27759
+ await file2.chmod(384);
27760
+ await file2.sync();
27761
+ const parentHandle = await open5(parent, "r");
27762
+ try {
27763
+ await parentHandle.sync();
27764
+ } finally {
27765
+ await parentHandle.close();
27766
+ }
27767
+ written = true;
27768
+ console.log(`Exported vault master key to ${target} (SHA-256 ${createHash7("sha256").update(key).digest("hex")}). Protect it separately from the backup.`);
27769
+ } finally {
27770
+ key.fill(0);
27771
+ }
27772
+ } finally {
27773
+ for (const part of parts)
27774
+ part.fill(0);
27775
+ await file2.close();
27776
+ if (!written) {
27777
+ console.error(`Vault key export did not complete; inspect the owner-only destination ${target} before retrying.`);
27778
+ }
27779
+ }
27780
+ }
27781
+
26193
27782
  // src/cli/dispatch.ts
26194
27783
  var MUTATING_COMMANDS = new Set([
26195
27784
  "init",
26196
27785
  "update",
26197
27786
  "backup",
27787
+ "vault-key",
26198
27788
  "verify",
26199
27789
  "restore",
26200
27790
  "rollback",
@@ -26208,10 +27798,10 @@ var MUTATING_COMMANDS = new Set([
26208
27798
  ]);
26209
27799
  function mutationDirectory(command, positionals, flags) {
26210
27800
  if (command === "init")
26211
- return resolve16(process.cwd(), positionals[0] ?? "or3-cloud");
27801
+ return resolve17(process.cwd(), positionals[0] ?? "or3-cloud");
26212
27802
  if (command === "adopt") {
26213
27803
  const sourceDirectory = requireStringFlag(flags, "from");
26214
- return resolve16(process.cwd(), positionals[0] ?? `${basename7(sourceDirectory)}-managed`);
27804
+ return resolve17(process.cwd(), positionals[0] ?? `${basename8(sourceDirectory)}-managed`);
26215
27805
  }
26216
27806
  return process.cwd();
26217
27807
  }
@@ -26229,6 +27819,8 @@ async function dispatchCommand(command, parsed) {
26229
27819
  }
26230
27820
  if (command === "backup")
26231
27821
  return await backupCommand(process.cwd(), parsed.positionals, parsed.flags);
27822
+ if (command === "vault-key")
27823
+ return await vaultKeyCommand(process.cwd(), parsed.positionals, parsed.flags);
26232
27824
  if (command === "restore")
26233
27825
  return await restoreCommand(process.cwd(), parsed.flags, parsed.positionals);
26234
27826
  if (command === "rollback")
@@ -26270,6 +27862,7 @@ Usage:
26270
27862
  npx @or3/cloud init [directory] --public --domain <hostname>
26271
27863
  npx @or3/cloud update [--to <exact-version>] [--dry-run] [--json]
26272
27864
  npx @or3/cloud backup [list [--json]|prune [--keep <n>]|export <backup-id> <destination-dir>]
27865
+ npx @or3/cloud vault-key export --out <absolute-path>
26273
27866
  npx @or3/cloud restore <backup-id-or-path> --yes
26274
27867
  npx @or3/cloud rollback --yes
26275
27868
  npx @or3/cloud credentials reset --yes [--owner-password-file <path> --admin-password-file <path>]
@@ -26292,6 +27885,7 @@ Options:
26292
27885
  --verification-password-file <path> Read the current owner password for this verification
26293
27886
  --port <port> Local OR3 port (default: 3000)
26294
27887
  --keep <n> Backups to retain when pruning (default: 5)
27888
+ --out <absolute-path> New owner-only vault-key export destination
26295
27889
  --force Bypass suspect-entry deferral; protected recovery backups remain protected
26296
27890
  --tail <n> Log lines to show (default: 200)
26297
27891
  --public Require verification through the public HTTPS origin
@@ -26300,7 +27894,7 @@ Options:
26300
27894
  --finish Commit a proven completed replacement without restoring data
26301
27895
  --restore Explicitly restore the recorded snapshot (requires --yes)
26302
27896
  --json Emit one machine-readable result object on stdout
26303
- --purge-data Remove data volumes and managed files (with remove)
27897
+ --purge-data Currently disabled pending export reconstruction support
26304
27898
  --yes Confirm a destructive restore, rollback, credentials reset, or purge
26305
27899
  --help Show this help
26306
27900
  --version Show the Cloud package version
@@ -26311,11 +27905,11 @@ The installer never changes firewall, DNS, Cloudflare, or Tailscale settings.`);
26311
27905
 
26312
27906
  // src/commands/recover-restart.ts
26313
27907
  import { request } from "node:http";
26314
- import { join as join30 } from "node:path";
27908
+ import { join as join32 } from "node:path";
26315
27909
  async function recoverRestart(directory, flags) {
26316
27910
  let journal;
26317
27911
  try {
26318
- journal = await readRestartJournal(join30(directory, ".or3-cloud", "restarts", "restarts.json"));
27912
+ journal = await readRestartJournal(join32(directory, ".or3-cloud", "restarts", "restarts.json"));
26319
27913
  } catch (error62) {
26320
27914
  if (error62.code === "ENOENT")
26321
27915
  return false;
@@ -26338,9 +27932,9 @@ async function recoverRestart(directory, flags) {
26338
27932
  if (!boolFlag(flags, "finish") || boolFlag(flags, "restore"))
26339
27933
  throw new Error("The restart is unresolved. Inspect recover --dry-run, then use recover --finish to reconcile it. Data restoration is not a restart recovery action.");
26340
27934
  const value = JSON.stringify({ requestId: operation.requestId });
26341
- await new Promise((resolve17, reject) => {
27935
+ await new Promise((resolve18, reject) => {
26342
27936
  const req = request({
26343
- socketPath: join30(deploymentPaths(directory).operatorIpc, "operator.sock"),
27937
+ socketPath: join32(deploymentPaths(directory).operatorIpc, "operator.sock"),
26344
27938
  path: "/lifecycle/v1/reconcile",
26345
27939
  method: "POST",
26346
27940
  headers: { "content-type": "application/json", "content-length": Buffer.byteLength(value) }
@@ -26355,7 +27949,7 @@ async function recoverRestart(directory, flags) {
26355
27949
  response.on("end", () => {
26356
27950
  clearTimeout(timer);
26357
27951
  if (response.statusCode === 200)
26358
- resolve17();
27952
+ resolve18();
26359
27953
  else
26360
27954
  reject(new Error("Restart recovery is unverified. Keep the receipt and inspect app/operator diagnostics; no additional restart was executed."));
26361
27955
  });
@@ -26406,7 +28000,7 @@ async function assertDeploymentFolder(command, directory = process.cwd()) {
26406
28000
  return;
26407
28001
  throw new Error("No OR3 Cloud deployment here. Run this from the folder that init created (usually ./or3-cloud).");
26408
28002
  }
26409
- var invokedAsCli = process.argv[1] ? ["or3", "cli.mjs"].includes(basename8(process.argv[1])) : false;
26410
- if (invokedAsCli || process.argv[1] && resolve17(process.argv[1]) === resolve17(fileURLToPath2(import.meta.url))) {
28003
+ var invokedAsCli = process.argv[1] ? ["or3", "cli.mjs"].includes(basename9(process.argv[1])) : false;
28004
+ if (invokedAsCli || process.argv[1] && resolve18(process.argv[1]) === resolve18(fileURLToPath2(import.meta.url))) {
26411
28005
  await main();
26412
28006
  }