@or3/cloud 0.1.107 → 0.1.113

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.107";
197
+ var PACKAGE_VERSION = "0.1.113";
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",
@@ -550,7 +708,9 @@ function buildEnv(input) {
550
708
  return values;
551
709
  }
552
710
  function secretValues(env) {
553
- 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);
554
714
  }
555
715
 
556
716
  // src/deployment/state-store.ts
@@ -844,6 +1004,12 @@ import { dirname as dirname2, join as join5 } from "node:path";
844
1004
  var execFile = promisify(execFileCallback);
845
1005
  var COMMAND_TIMEOUT_MS = 180000;
846
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
+ }
847
1013
  var PROCESS_ENV_PASSTHROUGH = [
848
1014
  "PATH",
849
1015
  "HOME",
@@ -940,7 +1106,8 @@ function terminateChildProcess(child) {
940
1106
  }
941
1107
  child.kill("SIGKILL");
942
1108
  }
943
- 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);
944
1111
  if (await fileExists(destination)) {
945
1112
  throw new Error(`Refusing to overwrite existing archive ${destination}.`);
946
1113
  }
@@ -948,7 +1115,7 @@ async function streamCommandToFile(command, args, destination, cwd, secrets = []
948
1115
  const temporary = `${destination}.${randomBytes3(8).toString("hex")}.partial`;
949
1116
  const child = spawn(command, args, {
950
1117
  cwd,
951
- env: commandEnvironment(command, args, cwd),
1118
+ env: commandEnvironment(command, args, cwd, environment),
952
1119
  detached: process.platform !== "win32",
953
1120
  stdio: ["ignore", "pipe", "pipe"]
954
1121
  });
@@ -958,7 +1125,7 @@ async function streamCommandToFile(command, args, destination, cwd, secrets = []
958
1125
  const timeout = setTimeout(() => {
959
1126
  timedOut = true;
960
1127
  terminateChildProcess(child);
961
- }, STREAM_COMMAND_TIMEOUT_MS);
1128
+ }, timeoutMs);
962
1129
  child.stderr.setEncoding("utf8");
963
1130
  child.stderr.on("data", (chunk) => {
964
1131
  stderr = `${stderr}${chunk}`.slice(-64 * 1024);
@@ -974,7 +1141,7 @@ async function streamCommandToFile(command, args, destination, cwd, secrets = []
974
1141
  transfer
975
1142
  ]);
976
1143
  if (hasTimedOut())
977
- 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.`);
978
1145
  if (exitCode !== 0) {
979
1146
  throw new Error(`${command} ${args.join(" ")} exited with ${exitCode}. ${redact(stderr, secrets)}`.trim());
980
1147
  }
@@ -998,10 +1165,11 @@ Temporary archive cleanup failed: ${redact(cleanupError instanceof Error ? clean
998
1165
  clearTimeout(timeout);
999
1166
  }
1000
1167
  }
1001
- 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);
1002
1170
  const child = spawn(command, args, {
1003
1171
  cwd,
1004
- env: commandEnvironment(command, args, cwd),
1172
+ env: commandEnvironment(command, args, cwd, environment),
1005
1173
  detached: process.platform !== "win32",
1006
1174
  stdio: ["pipe", "ignore", "pipe"]
1007
1175
  });
@@ -1011,7 +1179,7 @@ async function streamFileToCommand(command, args, source, cwd, secrets = []) {
1011
1179
  const timeout = setTimeout(() => {
1012
1180
  timedOut = true;
1013
1181
  terminateChildProcess(child);
1014
- }, STREAM_COMMAND_TIMEOUT_MS);
1182
+ }, timeoutMs);
1015
1183
  child.stderr.setEncoding("utf8");
1016
1184
  child.stderr.on("data", (chunk) => {
1017
1185
  stderr = `${stderr}${chunk}`.slice(-64 * 1024);
@@ -1026,13 +1194,13 @@ async function streamFileToCommand(command, args, source, cwd, secrets = []) {
1026
1194
  pipeline(createReadStream2(source), child.stdin)
1027
1195
  ]);
1028
1196
  if (hasTimedOut())
1029
- 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.`);
1030
1198
  if (exitCode !== 0) {
1031
1199
  throw new Error(`${command} ${args.join(" ")} exited with ${exitCode}. ${redact(stderr, secrets)}`.trim());
1032
1200
  }
1033
1201
  } catch (error) {
1034
1202
  terminateChildProcess(child);
1035
- const detail = error instanceof Error ? error.message : String(error);
1203
+ const detail = redact(error instanceof Error ? error.message : String(error), secrets);
1036
1204
  throw new Error(`${detail}${stderr && !detail.includes(stderr) ? `
1037
1205
  ${redact(stderr, secrets)}` : ""}`);
1038
1206
  } finally {
@@ -1121,6 +1289,31 @@ function parseComposeConfig(text) {
1121
1289
  throw new Error(`Docker Compose did not return a readable JSON configuration: ${error instanceof Error ? error.message : String(error)}`);
1122
1290
  }
1123
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
+ }
1124
1317
  function composeEnvironmentValue(service, key) {
1125
1318
  const environment = service.environment;
1126
1319
  if (Array.isArray(environment)) {
@@ -1171,6 +1364,41 @@ async function resolvedComposeConfig(directory, mode, deadline = performance.now
1171
1364
  ${result.stderr}`);
1172
1365
  return result.stdout;
1173
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
+ }
1174
1402
  async function assertSafeComposeBinding(directory, mode, env, deadline) {
1175
1403
  const config = await resolvedComposeConfig(directory, mode, deadline);
1176
1404
  const parsed = parseComposeConfig(config);
@@ -1570,8 +1798,14 @@ async function restoreVolumeArchive(directory, mode, env, backupPath) {
1570
1798
  ], join7(backupPath, "data.tgz"), directory, secretValues(env));
1571
1799
  }
1572
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";
1573
1807
  const storageRoot = posix.relative("/data", archivedStorageRoot ?? env.OR3_STORAGE_FS_ROOT ?? "/data/storage");
1574
- 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/`;
1575
1809
  await streamFileToCommand("docker", [
1576
1810
  "run",
1577
1811
  "--rm",
@@ -1587,7 +1821,7 @@ async function validateVolumeArchive(directory, _mode, env, backupPath, archived
1587
1821
  "sh",
1588
1822
  env.OR3_IMAGE,
1589
1823
  "-c",
1590
- `{ 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" '
1591
1825
  {
1592
1826
  entry = $0
1593
1827
  sub(/^\\.\\//, "", entry)
@@ -1612,8 +1846,8 @@ async function validateVolumeArchive(directory, _mode, env, backupPath, archived
1612
1846
  }
1613
1847
  END {
1614
1848
  if (!seen_status || tar_status != "0") exit 1
1615
- if (!auth || !sync) {
1616
- 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"
1617
1851
  exit 1
1618
1852
  }
1619
1853
  for (entry in metadata) {
@@ -1626,14 +1860,15 @@ END {
1626
1860
  }
1627
1861
  }'`,
1628
1862
  "or3-archive-validation",
1629
- storagePrefix
1863
+ storagePrefix,
1864
+ requireAuth ? "1" : "0"
1630
1865
  ], join7(backupPath, "data.tgz"), directory, secretValues(env));
1631
1866
  }
1632
1867
 
1633
1868
  // src/backup/manifests.ts
1634
- import { createHmac, randomBytes as randomBytes5, timingSafeEqual } from "node:crypto";
1635
- import { lstat, readdir as readdir2 } from "node:fs/promises";
1636
- 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";
1637
1872
 
1638
1873
  // src/deployment/assets.ts
1639
1874
  import { randomBytes as randomBytes4 } from "node:crypto";
@@ -1793,6 +2028,572 @@ function assertRestorableManagedAssets(manifest2) {
1793
2028
  }
1794
2029
  }
1795
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
+
1796
2597
  // src/backup/manifests.ts
1797
2598
  var BACKUP_ID_PATTERN = /^backup-[0-9A-Za-z-]+$/;
1798
2599
  async function backupAuthenticationKey(directory, create = false) {
@@ -1820,14 +2621,14 @@ function backupAuthenticationTag(key, manifestContents) {
1820
2621
  }
1821
2622
  async function writeBackupAuthentication(directory, backupPath, manifestContents) {
1822
2623
  const key = await backupAuthenticationKey(directory, true);
1823
- await writeSecure(join9(backupPath, "manifest.auth"), `${backupAuthenticationTag(key, manifestContents)}
2624
+ await writeSecure(join12(backupPath, "manifest.auth"), `${backupAuthenticationTag(key, manifestContents)}
1824
2625
  `);
1825
2626
  }
1826
2627
  async function assertBackupAuthentication(directory, backupPath, manifestContents) {
1827
2628
  const key = await backupAuthenticationKey(directory);
1828
2629
  let provided;
1829
2630
  try {
1830
- const value = (await readText(join9(backupPath, "manifest.auth"))).trim();
2631
+ const value = (await readText(join12(backupPath, "manifest.auth"))).trim();
1831
2632
  if (!/^[0-9a-f]{64}$/i.test(value))
1832
2633
  throw new Error("invalid authentication tag");
1833
2634
  provided = Buffer.from(value, "hex");
@@ -1856,10 +2657,10 @@ function classifyBackupError(error) {
1856
2657
  return { code: "backup-entry-invalid", message };
1857
2658
  }
1858
2659
  async function inspectBackupEntry(backupsRoot, entry, directory) {
1859
- const path = join9(backupsRoot, entry);
2660
+ const path = join12(backupsRoot, entry);
1860
2661
  let info;
1861
2662
  try {
1862
- info = await lstat(path);
2663
+ info = await lstat3(path);
1863
2664
  } catch (error) {
1864
2665
  return { kind: "unreadable", entryName: entry, ...classifyBackupError(error) };
1865
2666
  }
@@ -1888,7 +2689,7 @@ async function inspectBackupEntry(backupsRoot, entry, directory) {
1888
2689
  };
1889
2690
  }
1890
2691
  try {
1891
- await lstat(join9(path, "manifest.auth"));
2692
+ await lstat3(join12(path, "manifest.auth"));
1892
2693
  } catch (error) {
1893
2694
  if (error.code === "ENOENT") {
1894
2695
  return {
@@ -1901,8 +2702,8 @@ async function inspectBackupEntry(backupsRoot, entry, directory) {
1901
2702
  return { kind: "unreadable", entryName: entry, ...classifyBackupError(error) };
1902
2703
  }
1903
2704
  try {
1904
- const raw = JSON.parse(await readText(join9(path, "manifest.json")));
1905
- 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) {
1906
2707
  return {
1907
2708
  kind: "unsupported",
1908
2709
  entryName: entry,
@@ -1932,9 +2733,13 @@ async function inspectBackupEntry(backupsRoot, entry, directory) {
1932
2733
  };
1933
2734
  }
1934
2735
  let bytes = 0;
1935
- for (const file of ["data.tgz", "config.env", "manifest.json", "manifest.auth", ...manifest2.vaultSha256 ? ["vault.backup"] : []]) {
1936
- 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;
1937
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);
1938
2743
  return {
1939
2744
  kind: "verified",
1940
2745
  backup: {
@@ -1943,7 +2748,11 @@ async function inspectBackupEntry(backupsRoot, entry, directory) {
1943
2748
  appVersion: manifest2.appVersion,
1944
2749
  path,
1945
2750
  bytes,
1946
- 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 } : {}
1947
2756
  }
1948
2757
  };
1949
2758
  } catch (error) {
@@ -1989,15 +2798,15 @@ async function inventoryBackups(directory) {
1989
2798
  });
1990
2799
  return { entries, storeErrors: [] };
1991
2800
  }
1992
- async function enumerateBackups(directory) {
1993
- const inventory = await inventoryBackups(directory);
1994
- 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);
1995
- }
1996
2801
  function assertBackupMatchesDeployment(manifest2, backupEnv, state, env) {
1997
2802
  if (manifest2.mode !== state.mode || manifest2.appVersion === undefined || manifest2.image === undefined) {
1998
2803
  throw new Error(`Backup ${manifest2.backupId} does not match the managed deployment mode.`);
1999
2804
  }
2000
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);
2001
2810
  const expected = {
2002
2811
  OR3_COMPOSE_PROJECT: state.composeProject,
2003
2812
  OR3_VOLUME_NAME: state.volumeName,
@@ -2040,32 +2849,53 @@ function assertBackupMatchesDeployment(manifest2, backupEnv, state, env) {
2040
2849
  }
2041
2850
  async function readManifest(backupPath, authenticatedForDirectory) {
2042
2851
  await lifecycleFaults.beforeArchiveRead?.();
2043
- const manifestContents = await readText(join9(backupPath, "manifest.json"));
2852
+ const manifestContents = await readText(join12(backupPath, "manifest.json"));
2044
2853
  if (authenticatedForDirectory)
2045
2854
  await assertBackupAuthentication(authenticatedForDirectory, backupPath, manifestContents);
2046
2855
  const manifest2 = JSON.parse(manifestContents);
2047
- 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)) {
2048
2858
  throw new Error(`Invalid backup manifest at ${backupPath}.`);
2049
2859
  }
2050
- const actual = await sha256File(join9(backupPath, "data.tgz"));
2860
+ const actual = await sha256File(join12(backupPath, "data.tgz"));
2051
2861
  if (actual !== manifest2.dataSha256)
2052
2862
  throw new Error(`Backup checksum mismatch for ${manifest2.backupId}. Expected ${manifest2.dataSha256}, got ${actual}.`);
2053
2863
  if (manifest2.configSha256) {
2054
- const configActual = await sha256File(join9(backupPath, "config.env"));
2864
+ const configActual = await sha256File(join12(backupPath, "config.env"));
2055
2865
  if (configActual !== manifest2.configSha256)
2056
2866
  throw new Error(`Backup configuration checksum mismatch for ${manifest2.backupId}.`);
2057
2867
  }
2058
- 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)
2059
2889
  throw new Error(`Vault backup checksum mismatch for ${manifest2.backupId}.`);
2060
2890
  await verifiedManagedAssetContents(backupPath, manifest2);
2061
2891
  return manifest2;
2062
2892
  }
2063
2893
  async function resolveBackup(directory, value) {
2064
- if (!isAbsolute(value) && !BACKUP_ID_PATTERN.test(value)) {
2894
+ if (!isAbsolute3(value) && !BACKUP_ID_PATTERN.test(value)) {
2065
2895
  throw new Error(`Backup ID "${value}" is invalid. Use an OR3-generated backup ID or an absolute external backup path.`);
2066
2896
  }
2067
- const candidate = isAbsolute(value) ? value : backupDirectory(directory, value);
2068
- if (!await fileExists(join9(candidate, "manifest.json")))
2897
+ const candidate = isAbsolute3(value) ? value : backupDirectory(directory, value);
2898
+ if (!await fileExists(join12(candidate, "manifest.json")))
2069
2899
  throw new Error(`Backup ${value} was not found in ${deploymentPaths(directory).backups}.`);
2070
2900
  return resolve4(candidate);
2071
2901
  }
@@ -2074,7 +2904,7 @@ async function recordedBackupPath(directory, pending) {
2074
2904
  const path = previous ? pending.previousBackupPath : pending.backupPath;
2075
2905
  const backupId = previous ? pending.previousBackupId : pending.backupId;
2076
2906
  const resolved = path ? resolve4(path) : backupId ? await resolveBackup(directory, backupId) : undefined;
2077
- if (!resolved || !await fileExists(join9(resolved, "manifest.json"))) {
2907
+ if (!resolved || !await fileExists(join12(resolved, "manifest.json"))) {
2078
2908
  throw new Error(`The incomplete ${pending.operation} has no readable ${previous ? "pre-mutation" : "target"} backup source.`);
2079
2909
  }
2080
2910
  const manifest2 = await readManifest(resolved, directory);
@@ -2216,16 +3046,21 @@ async function maskedQuestion(question) {
2216
3046
 
2217
3047
  // src/deployment/adoption-source.ts
2218
3048
  import { existsSync as existsSync2 } from "node:fs";
2219
- import { basename as basename2, join as join10 } from "node:path";
3049
+ import { basename as basename2, join as join13 } from "node:path";
2220
3050
 
2221
3051
  // src/runtime/health.ts
2222
3052
  var DEEP_HEALTH_TIMEOUT_MS = 180000;
2223
- 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))";
2224
3054
  var VERIFY_DATABASES_SCRIPT = `
2225
3055
  const fs = require('node:fs');
2226
3056
  const Database = require('/app/.output/server/node_modules/better-sqlite3');
2227
3057
  const results = [];
2228
- 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) {
2229
3064
  const info = fs.statSync(path);
2230
3065
  if (info.uid !== 65532 || info.gid !== 65532) throw new Error(path + ' must be owned by 65532:65532');
2231
3066
  const db = new Database(path, { readonly: true, fileMustExist: true });
@@ -2298,15 +3133,112 @@ async function sourceServiceRunning(directory, composeFiles) {
2298
3133
  throw new Error(`Could not determine whether the V1 OR3 service is running. ${result.stderr.trim()}`);
2299
3134
  return Boolean(result.stdout.trim());
2300
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
+ }
2301
3233
  async function readSourceVolume(sourceDirectory) {
2302
3234
  const composeResult = await run("docker", [
2303
3235
  "compose",
2304
3236
  "--project-directory",
2305
3237
  sourceDirectory,
2306
3238
  "--env-file",
2307
- join10(sourceDirectory, ".env"),
3239
+ join13(sourceDirectory, ".env"),
2308
3240
  "-f",
2309
- join10(sourceDirectory, "compose.yaml"),
3241
+ join13(sourceDirectory, "compose.yaml"),
2310
3242
  "ps",
2311
3243
  "-aq",
2312
3244
  "or3"
@@ -2327,7 +3259,7 @@ async function readSourceVolume(sourceDirectory) {
2327
3259
  }
2328
3260
  function sourceMode(sourceDirectory, sourceEnv) {
2329
3261
  const publicDeployment = sourceEnv.OR3_PUBLIC_DOMAIN && sourceEnv.OR3_PUBLIC_DOMAIN !== "localhost" && sourceEnv.OR3_FORCE_HTTPS !== "false";
2330
- return publicDeployment && existsSync2(join10(sourceDirectory, "compose.public.yaml")) ? "public" : "local";
3262
+ return publicDeployment && existsSync2(join13(sourceDirectory, "compose.public.yaml")) ? "public" : "local";
2331
3263
  }
2332
3264
  function sourceComposeArgs(sourceDirectory, sourceComposeFiles) {
2333
3265
  return [
@@ -2335,7 +3267,7 @@ function sourceComposeArgs(sourceDirectory, sourceComposeFiles) {
2335
3267
  "--project-directory",
2336
3268
  sourceDirectory,
2337
3269
  "--env-file",
2338
- join10(sourceDirectory, ".env"),
3270
+ join13(sourceDirectory, ".env"),
2339
3271
  ...sourceComposeFiles
2340
3272
  ];
2341
3273
  }
@@ -2348,8 +3280,8 @@ async function restartSource(sourceDirectory, sourceComposeFiles, secrets) {
2348
3280
  }
2349
3281
  function assertSupportedSource(sourceDirectory, sourceEnv, providers) {
2350
3282
  const values = [sourceEnv.AUTH_PROVIDER, sourceEnv.OR3_AUTH_PROVIDER].filter(Boolean);
2351
- if (!values.includes("basic-auth"))
2352
- 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.`);
2353
3285
  if (sourceEnv.SSR_AUTH_ENABLED === "false" || sourceEnv.OR3_GUEST_ACCESS_ENABLED === "true") {
2354
3286
  throw new Error("V1 project does not require authenticated access; adoption expects the supported authenticated profile.");
2355
3287
  }
@@ -2374,21 +3306,94 @@ function assertSupportedSource(sourceDirectory, sourceEnv, providers) {
2374
3306
  throw new Error("V1 project is missing OR3 version metadata.");
2375
3307
  }
2376
3308
  }
2377
- function assertSupportedSourceCompose(configText, sourceVolume, expectedPort) {
3309
+ function assertSupportedSourceCompose(configText, sourceVolume, expectedPort, sourceEnv, allowedImages) {
2378
3310
  const config = parseComposeConfig(configText);
2379
3311
  const service = config.services?.or3;
2380
3312
  if (!service)
2381
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
+ }
2382
3383
  for (const [key, expected] of [
2383
3384
  ["OR3_BASIC_AUTH_DB_PATH", "/data/auth.sqlite"],
2384
3385
  ["OR3_SQLITE_DB_PATH", "/data/sync.sqlite"],
2385
- ["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"]
2386
3389
  ]) {
2387
3390
  if (composeEnvironmentValue(service, key) !== expected) {
2388
3391
  throw new Error(`V1 Compose configuration does not resolve ${key} to ${expected}. Adoption refuses custom data layouts.`);
2389
3392
  }
2390
3393
  }
2391
3394
  const mounts = service.volumes ?? [];
3395
+ if (mounts.length !== 1)
3396
+ throw new Error("V1 Compose has additional mounts that adoption cannot safely migrate.");
2392
3397
  const dataMount = mounts.find((mount) => mount.target === "/data");
2393
3398
  if (!dataMount || dataMount.type !== "volume") {
2394
3399
  throw new Error("V1 Compose configuration must mount one named volume at /data.");
@@ -2408,8 +3413,8 @@ function assertSupportedSourceCompose(configText, sourceVolume, expectedPort) {
2408
3413
 
2409
3414
  // src/runtime/dashboard-operator.ts
2410
3415
  import { randomBytes as randomBytes6 } from "node:crypto";
2411
- import { chmod as chmod4, mkdir as mkdir4, rm as rm4, stat as stat2 } from "node:fs/promises";
2412
- 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";
2413
3418
 
2414
3419
  // src/runtime/images.ts
2415
3420
  async function pullImage(image, expectedDigest2) {
@@ -2551,13 +3556,13 @@ async function dashboardOperatorEnv(directory, version) {
2551
3556
  if (configured && !configured.startsWith("unix://"))
2552
3557
  return;
2553
3558
  const socket = configured ? configured.slice("unix://".length) : "/var/run/docker.sock";
2554
- if (!isAbsolute2(socket))
3559
+ if (!isAbsolute4(socket))
2555
3560
  return;
2556
3561
  let socketStat;
2557
3562
  let uid;
2558
3563
  let gid;
2559
3564
  try {
2560
- socketStat = await stat2(socket);
3565
+ socketStat = await stat3(socket);
2561
3566
  if (!socketStat.isSocket())
2562
3567
  return;
2563
3568
  uid = process.getuid();
@@ -2635,7 +3640,7 @@ async function prepareVerifiedDashboardOperator(directory, version) {
2635
3640
  if (await verifyDashboardOperatorBridge(directory, candidate))
2636
3641
  return candidate;
2637
3642
  } catch {}
2638
- await rm4(deploymentPaths(directory).operatorIpc, { recursive: true, force: true }).catch(() => {
3643
+ await rm5(deploymentPaths(directory).operatorIpc, { recursive: true, force: true }).catch(() => {
2639
3644
  return;
2640
3645
  });
2641
3646
  console.warn("Dashboard updates are unavailable on this Docker setup; the deployment will remain host-CLI managed.");
@@ -2643,7 +3648,7 @@ async function prepareVerifiedDashboardOperator(directory, version) {
2643
3648
  }
2644
3649
  function dashboardOperatorHandoffArgs(directory, env, jobId) {
2645
3650
  const project = env.OR3_COMPOSE_PROJECT;
2646
- 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 ?? "")) {
2647
3652
  throw new Error("Refusing to schedule an invalid dashboard operator handoff.");
2648
3653
  }
2649
3654
  return [
@@ -2674,7 +3679,7 @@ function dashboardOperatorHandoffArgs(directory, env, jobId) {
2674
3679
  "--entrypoint",
2675
3680
  "/usr/local/bin/node",
2676
3681
  env.OR3_OPERATOR_IMAGE,
2677
- join11(directory, "dashboard-operator.mjs"),
3682
+ join14(directory, "dashboard-operator.mjs"),
2678
3683
  "--complete-handoff",
2679
3684
  jobId,
2680
3685
  project
@@ -2760,10 +3765,10 @@ async function checkPublicPrerequisites(domain) {
2760
3765
 
2761
3766
  // src/runtime/project.ts
2762
3767
  import { readFileSync as readFileSync5 } from "node:fs";
2763
- import { join as join12 } from "node:path";
3768
+ import { join as join15 } from "node:path";
2764
3769
  function managedVaultEnabled(directory, env) {
2765
- const selected = env ?? parseEnv(readFileSync5(join12(directory, ".env"), "utf8"));
2766
- 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(`
2767
3772
  or3-vault:
2768
3773
  `);
2769
3774
  }
@@ -2785,11 +3790,11 @@ async function startProject(directory, mode, env) {
2785
3790
  }
2786
3791
 
2787
3792
  // src/runtime/verification.ts
2788
- import { createHash as createHash2, randomBytes as randomBytes7, randomUUID } from "node:crypto";
2789
- 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") {
2790
3795
  const health = value;
2791
- if (!health || health.status !== "ok" || health.providers?.auth?.provider !== "basic-auth" || health.providers.sync?.provider !== "sqlite" || health.providers.storage?.provider !== "fs") {
2792
- 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.`);
2793
3798
  }
2794
3799
  return health;
2795
3800
  }
@@ -2884,7 +3889,7 @@ async function verifyPublicApplication(baseUrl, credentials) {
2884
3889
  Buffer.from("89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489", "hex"),
2885
3890
  randomBytes7(8)
2886
3891
  ]);
2887
- const hash = `sha256:${createHash2("sha256").update(probeBytes).digest("hex")}`;
3892
+ const hash = `sha256:${createHash4("sha256").update(probeBytes).digest("hex")}`;
2888
3893
  let storageId;
2889
3894
  let metadataAttempted = false;
2890
3895
  let physicalCleanupDeferred = false;
@@ -2926,7 +3931,7 @@ async function verifyPublicApplication(baseUrl, credentials) {
2926
3931
  }
2927
3932
  });
2928
3933
  const metadataCreatedAt = Date.now();
2929
- const metadataOpId = randomUUID();
3934
+ const metadataOpId = randomUUID4();
2930
3935
  const pushed = await verificationJson(baseUrl, "/api/sync/push", {
2931
3936
  cookie,
2932
3937
  body: {
@@ -2976,7 +3981,7 @@ async function verifyPublicApplication(baseUrl, credentials) {
2976
3981
  if (storageId) {
2977
3982
  if (metadataAttempted) {
2978
3983
  const metadataDeleteAt = Date.now();
2979
- const metadataDeleteOpId = randomUUID();
3984
+ const metadataDeleteOpId = randomUUID4();
2980
3985
  const deleted = await verificationJson(baseUrl, "/api/sync/push", {
2981
3986
  cookie,
2982
3987
  body: {
@@ -3076,31 +4081,31 @@ async function adoptCommand(positionals, flags) {
3076
4081
  if (targetDirectory === sourceDirectory)
3077
4082
  throw new Error("Choose a separate target directory for adoption.");
3078
4083
  await readDirectoryEmpty(targetDirectory);
3079
- if (!await fileExists(join13(sourceDirectory, "or3-release.json")))
4084
+ if (!await fileExists(join16(sourceDirectory, "or3-release.json")))
3080
4085
  throw new Error(`V1 release metadata is missing at ${sourceDirectory}/or3-release.json.`);
3081
- const release = JSON.parse(await readText(join13(sourceDirectory, "or3-release.json")));
4086
+ const release = JSON.parse(await readText(join16(sourceDirectory, "or3-release.json")));
3082
4087
  if (!release.or3Version || !isVersion(release.or3Version))
3083
4088
  throw new Error("V1 release metadata does not contain a usable OR3 version.");
3084
4089
  if (release.or3Version !== PACKAGE_VERSION) {
3085
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.`);
3086
4091
  }
3087
- const sourceEnv = parseEnv(await readText(join13(sourceDirectory, ".env")));
3088
- 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"));
3089
4094
  assertSupportedSource(sourceDirectory, sourceEnv, providers);
3090
4095
  const mode = sourceMode(sourceDirectory, sourceEnv);
3091
4096
  const domain = mode === "public" ? sourceEnv.OR3_PUBLIC_DOMAIN : undefined;
3092
4097
  if (domain)
3093
4098
  await checkPublicPrerequisites(domain);
3094
- const sourceComposeFiles = ["-f", join13(sourceDirectory, "compose.yaml")];
4099
+ const sourceComposeFiles = ["-f", join16(sourceDirectory, "compose.yaml")];
3095
4100
  if (mode === "public")
3096
- sourceComposeFiles.push("-f", join13(sourceDirectory, "compose.public.yaml"));
4101
+ sourceComposeFiles.push("-f", join16(sourceDirectory, "compose.public.yaml"));
3097
4102
  await ensureDocker();
3098
4103
  const sourceConfig = await run("docker", [
3099
4104
  "compose",
3100
4105
  "--project-directory",
3101
4106
  sourceDirectory,
3102
4107
  "--env-file",
3103
- join13(sourceDirectory, ".env"),
4108
+ join16(sourceDirectory, ".env"),
3104
4109
  ...sourceComposeFiles,
3105
4110
  "config",
3106
4111
  "--format",
@@ -3129,11 +4134,12 @@ async function adoptCommand(positionals, flags) {
3129
4134
  throw new Error("V1 project has an invalid OR3 port.");
3130
4135
  if (mode === "public" && [80, 443].includes(sourcePort))
3131
4136
  throw new Error("V1 public deployment uses a Caddy port for OR3; adoption requires a separate OR3 port.");
3132
- assertSupportedSourceCompose(sourceConfig.stdout, sourceVolume, sourcePort);
4137
+ assertSupportedSourceCompose(sourceConfig.stdout, sourceVolume, sourcePort, sourceEnv, [sourceImage, image]);
3133
4138
  const sourceInitiallyRunning = await sourceServiceRunning(sourceDirectory, sourceComposeFiles);
4139
+ await assertSourceContainer(sourceDirectory, sourceComposeFiles, image, sourceConfig.stdout, sourceVolume, sourcePort, sourceEnv);
3134
4140
  const targetNames = composeProjectNames(targetDirectory);
3135
4141
  await assertDockerProjectAbsent(targetNames.project);
3136
- 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] : []]) {
3137
4143
  const existing = await run("docker", ["volume", "inspect", volume]);
3138
4144
  if (existing.ok)
3139
4145
  throw new Error(`Docker volume ${volume} already exists. Choose a new adoption target directory.`);
@@ -3187,7 +4193,7 @@ ${existing.stderr}`)) {
3187
4193
  OR3_GUEST_ACCESS_ENABLED: "false"
3188
4194
  });
3189
4195
  await writeSecure(deploymentPaths(targetDirectory).env, serializeEnv(targetEnv));
3190
- await writeSecure(join13(targetDirectory, ".or3-initial-credentials"), serializeInitialCredentials({
4196
+ await writeSecure(join16(targetDirectory, ".or3-initial-credentials"), serializeInitialCredentials({
3191
4197
  bootstrapEmail: email,
3192
4198
  bootstrapPassword: password,
3193
4199
  adminUsername: targetEnv.OR3_ADMIN_USERNAME,
@@ -3208,6 +4214,7 @@ ${existing.stderr}`)) {
3208
4214
  phase: "prepared"
3209
4215
  });
3210
4216
  let sourceStopAttempted = false;
4217
+ let targetMayHaveStarted = false;
3211
4218
  try {
3212
4219
  sourceStopAttempted = true;
3213
4220
  const sourceStop = await run("docker", [...sourceComposeArgs(sourceDirectory, sourceComposeFiles), "stop"], sourceDirectory);
@@ -3223,7 +4230,7 @@ ${existing.stderr}`)) {
3223
4230
  }
3224
4231
  }
3225
4232
  await mkdir5(sourceBackupDir, { recursive: true, mode: 448 });
3226
- await copySecure(join13(sourceDirectory, ".env"), join13(sourceBackupDir, "config.env"));
4233
+ await copySecure(join16(sourceDirectory, ".env"), join16(sourceBackupDir, "config.env"));
3227
4234
  await archiveExternalVolume(sourceImage, sourceVolume, sourceBackupDir);
3228
4235
  const sourceManifest = {
3229
4236
  schemaVersion: 1,
@@ -3232,8 +4239,8 @@ ${existing.stderr}`)) {
3232
4239
  appVersion: release.or3Version,
3233
4240
  image,
3234
4241
  imageDigest: digest,
3235
- dataSha256: await sha256File(join13(sourceBackupDir, "data.tgz")),
3236
- configSha256: await sha256File(join13(sourceBackupDir, "config.env")),
4242
+ dataSha256: await sha256File(join16(sourceBackupDir, "data.tgz")),
4243
+ configSha256: await sha256File(join16(sourceBackupDir, "config.env")),
3237
4244
  mode,
3238
4245
  domain,
3239
4246
  composeProject: sourceEnv.OR3_COMPOSE_PROJECT ?? sanitizeName(basename3(sourceDirectory)),
@@ -3244,11 +4251,13 @@ ${existing.stderr}`)) {
3244
4251
  };
3245
4252
  const sourceManifestContents = `${JSON.stringify(sourceManifest, null, 2)}
3246
4253
  `;
3247
- await writeSecure(join13(sourceBackupDir, "manifest.json"), sourceManifestContents);
4254
+ await writeSecure(join16(sourceBackupDir, "manifest.json"), sourceManifestContents);
3248
4255
  await writeBackupAuthentication(targetDirectory, sourceBackupDir, sourceManifestContents);
3249
4256
  await readManifest(sourceBackupDir, targetDirectory);
3250
4257
  await updatePending(targetDirectory, state, { phase: "snapshot-created" });
4258
+ await updatePending(targetDirectory, state, { phase: "target-mutating" });
3251
4259
  await restoreVolumeArchive(targetDirectory, mode, targetEnv, sourceBackupDir);
4260
+ targetMayHaveStarted = true;
3252
4261
  await startProject(targetDirectory, mode, targetEnv);
3253
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);
3254
4263
  const runtimeEnv = withoutProvisioningCredentials(targetEnv);
@@ -3260,11 +4269,12 @@ ${existing.stderr}`)) {
3260
4269
  console.log(`Source backup: ${sourceBackupDir}`);
3261
4270
  console.log(`The original deployment is preserved and stopped. Verify sign-in, chat, and file access before removing anything.`);
3262
4271
  } catch (error) {
3263
- await compose(targetDirectory, mode, ["down"]).catch(() => {
3264
- return;
3265
- });
4272
+ if (!targetMayHaveStarted)
4273
+ await compose(targetDirectory, mode, ["down"]).catch(() => {
4274
+ return;
4275
+ });
3266
4276
  let sourceRecoveryError = "";
3267
- if (sourceStopAttempted && sourceInitiallyRunning) {
4277
+ if (!targetMayHaveStarted && sourceStopAttempted && sourceInitiallyRunning) {
3268
4278
  try {
3269
4279
  await restartSource(sourceDirectory, sourceComposeFiles, secretValues(sourceEnv));
3270
4280
  } catch (recovery) {
@@ -3273,88 +4283,297 @@ ${existing.stderr}`)) {
3273
4283
  }
3274
4284
  state.lastError = redact(`${error instanceof Error ? error.message : String(error)}${sourceRecoveryError}`, secretValues(targetEnv));
3275
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. ";
3276
4287
  throw new Error(`${state.lastError}
3277
- ${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}.` : ""}`);
3278
4289
  }
3279
4290
  }
3280
4291
 
3281
4292
  // src/backup/create.ts
3282
- import { chmod as chmod6, lstat as lstat2, mkdir as mkdir6 } from "node:fs/promises";
3283
- 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";
3284
4295
 
3285
4296
  // src/backup/references.ts
3286
- import { randomUUID as randomUUID2 } from "node:crypto";
3287
- import { join as join14 } from "node:path";
3288
- var INSPECTOR = "/app/scripts/docker/backup-inspect.mjs";
3289
- var NODE = 'if [ -x /nodejs/bin/node ]; then exec /nodejs/bin/node "$@"; else exec node "$@"; fi';
3290
- async function validateCanonicalBackupReferences(directory, env, backupPath) {
3291
- 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))) {
3292
4341
  throw new Error("Unsupported offline backup inspection provider profile; SQLite/FS is required.");
3293
4342
  }
3294
- const helper = `or3-backup-inspection-${randomUUID2()}`;
3295
- const checked = async (args) => {
3296
- const result = await run("docker", args, directory);
3297
- if (!result.ok)
3298
- throw new Error(`Offline backup reference inspection failed or is unsupported. ${redact(result.stderr, secretValues(env))}`);
3299
- return result.stdout.trim();
3300
- };
3301
- 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;
3302
4403
  try {
3303
- await checked([
3304
- "run",
3305
- "--pull",
3306
- "never",
3307
- "--rm",
3308
- "--detach",
3309
- "--name",
3310
- helper,
3311
- "--network",
3312
- "none",
3313
- "--read-only",
3314
- "--security-opt",
3315
- "no-new-privileges:true",
3316
- "--cap-drop",
3317
- "ALL",
3318
- "--memory",
3319
- "256m",
3320
- "--cpus",
3321
- "0.5",
3322
- "--pids-limit",
3323
- "32",
3324
- "--tmpfs",
3325
- "/scratch:rw,noexec,nosuid,nodev,size=512m,mode=700,uid=65532,gid=65532",
3326
- "--entrypoint",
3327
- "sh",
3328
- env.OR3_IMAGE,
3329
- "-c",
3330
- "sleep 240"
3331
- ]);
3332
- await streamFileToCommand("docker", ["exec", "-i", helper, "sh", "-c", "ulimit -f 16384; tar tvzf - > /scratch/inventory"], join14(backupPath, "data.tgz"), directory, secretValues(env));
3333
- const inventory = JSON.parse(await checked(["exec", helper, "sh", "-c", NODE, "or3-backup-inspection", INSPECTOR, "inventory"]));
3334
- 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)
3335
- throw new Error("Invalid offline backup inspection inventory response.");
3336
- const freeKiB = Number(await checked(["exec", helper, "sh", "-c", "df -Pk /scratch | awk 'NR == 2 { print $4 }'"]));
3337
- if (!Number.isFinite(freeKiB) || freeKiB < 0)
3338
- throw new Error("Could not verify offline backup scratch capacity.");
3339
- assertEnoughFreeSpace(freeKiB * 1024, inventory.bytes, "Offline backup inspection scratch");
3340
- await streamFileToCommand("docker", ["exec", "-i", helper, "tar", "-xz", "-f", "-", "-C", "/scratch", ...inventory.selected], join14(backupPath, "data.tgz"), directory, secretValues(env));
3341
- 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"]));
3342
4569
  if (result.contract !== 1 || result.complete !== true || !Number.isSafeInteger(result.liveAttachments) || result.liveAttachments < 0)
3343
4570
  throw new Error("Offline canonical backup reference inspection did not complete.");
3344
- } catch (error) {
3345
- primary = error;
3346
- }
3347
- const cleanup = await run("docker", ["rm", "--force", helper], directory);
3348
- if (!cleanup.ok && !/No such container/i.test(cleanup.stderr)) {
3349
- throw new AggregateError(primary ? [primary] : [], `Offline backup inspection helper cleanup failed. ${redact(cleanup.stderr, secretValues(env))}`);
3350
- }
3351
- if (primary)
3352
- throw primary;
4571
+ });
3353
4572
  }
3354
4573
 
3355
4574
  // src/backup/retention.ts
3356
- import { rm as rm5 } from "node:fs/promises";
3357
- 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";
3358
4577
  var BACKUP_RETENTION_KEEP = 5;
3359
4578
  function assertRemovableArtifactName(name) {
3360
4579
  if (!BACKUP_ID_PATTERN.test(name)) {
@@ -3369,18 +4588,18 @@ async function removeNamedBackupArtifact(directory, backupId) {
3369
4588
  if (dirname4(target) !== deploymentPaths(directory).backups) {
3370
4589
  throw new Error(`Refusing to remove a path outside the backups directory: ${target}.`);
3371
4590
  }
3372
- await rm5(target, { recursive: true, force: true });
3373
- 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 });
3374
4593
  }
3375
4594
  async function removeEnumeratedBackupArtifact(directory, backup) {
3376
4595
  await lifecycleFaults.beforeArtifactDelete?.();
3377
- const backupsRoot = resolve8(deploymentPaths(directory).backups);
3378
- const target = resolve8(backup.path);
4596
+ const backupsRoot = resolve9(deploymentPaths(directory).backups);
4597
+ const target = resolve9(backup.path);
3379
4598
  if (dirname4(target) !== backupsRoot || basename4(target) !== backup.backupId || !BACKUP_ID_PATTERN.test(backup.backupId)) {
3380
4599
  throw new Error(`Refusing to prune an untrusted backup path ${backup.path}.`);
3381
4600
  }
3382
- await rm5(target, { recursive: true, force: true });
3383
- 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 });
3384
4603
  }
3385
4604
  function planRetention(entries, keep, protectedIds, options = {}) {
3386
4605
  if (!Number.isInteger(keep) || keep < 1)
@@ -3404,9 +4623,13 @@ function planRetention(entries, keep, protectedIds, options = {}) {
3404
4623
  if (automatic)
3405
4624
  canPrune = false;
3406
4625
  }
3407
- 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);
3408
- const removable = verified.filter((backup) => !protectedIds.has(backup.backupId));
3409
- 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);
3410
4633
  const removeSet = new Set(remove);
3411
4634
  return {
3412
4635
  keep: verified.filter((backup) => !removeSet.has(backup.backupId)).map((backup) => backup.backupId),
@@ -3455,7 +4678,7 @@ async function pruneBackups(directory, state, keep, force, options = {}) {
3455
4678
  throw new Error(`Retention selected backup ${backupId}, but its verified path disappeared before deletion.`);
3456
4679
  const revalidated = await inspectBackupEntry(deploymentPaths(directory).backups, backupId, directory);
3457
4680
  const current = revalidated.kind === "verified" ? revalidated.backup : undefined;
3458
- 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) {
3459
4682
  throw new Error(`Backup ${backupId} changed while pruning was in progress. Aborting retention before deletion.`);
3460
4683
  }
3461
4684
  await removeEnumeratedBackupArtifact(directory, current);
@@ -3470,7 +4693,7 @@ var asError = (error) => error instanceof Error ? error : new Error(String(error
3470
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.';
3471
4694
  async function artifactStatus(path) {
3472
4695
  try {
3473
- await lstat2(path);
4696
+ await lstat4(path);
3474
4697
  return { absent: false, detail: `Still present: ${path}.` };
3475
4698
  } catch (error) {
3476
4699
  if (error.code === "ENOENT")
@@ -3487,7 +4710,7 @@ async function cleanupPartialBackup(directory, backupId) {
3487
4710
  }
3488
4711
  const statuses = await Promise.all([
3489
4712
  artifactStatus(backupDirectory(directory, backupId)),
3490
- artifactStatus(join16(deploymentPaths(directory).exports, `${backupId}.json`))
4713
+ artifactStatus(join19(deploymentPaths(directory).exports, `${backupId}.json`))
3491
4714
  ]);
3492
4715
  const removed = statuses.every((status) => status.absent);
3493
4716
  const detail = statuses.map((status) => status.detail).join(" ");
@@ -3496,7 +4719,17 @@ async function cleanupPartialBackup(directory, backupId) {
3496
4719
  return { error, removed, detail };
3497
4720
  }
3498
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);
3499
4730
  const restartAfter = options.restartAfter ?? true;
4731
+ if (managedVaultEnabled(directory, env))
4732
+ await assertVaultVolumesExist(directory, state);
3500
4733
  await requireImageDigest(state.image, state.imageDigest, "Current deployment");
3501
4734
  const backupId = options.backupId ?? id("backup");
3502
4735
  if (!BACKUP_ID_PATTERN.test(backupId))
@@ -3536,6 +4769,7 @@ async function createBackup(directory, state, env, options = {}) {
3536
4769
  await report("capturing", `Backup capture: OR3 is stopped; writing ${backupDir}.`);
3537
4770
  await volumeArchive(directory, state.mode, env, backupDir);
3538
4771
  if (managedVaultEnabled(directory, env)) {
4772
+ await assertVaultVolumesExist(directory, state);
3539
4773
  await streamCommandToFile("docker", composeArgs(directory, state.mode, [
3540
4774
  "run",
3541
4775
  "--rm",
@@ -3546,9 +4780,9 @@ async function createBackup(directory, state, env, options = {}) {
3546
4780
  "or3-vault",
3547
4781
  "/app/vault/entrypoint.mjs",
3548
4782
  "backup"
3549
- ]), join16(backupDir, "vault.backup"), directory, secretValues(env));
4783
+ ]), join19(backupDir, "vault.backup"), directory, secretValues(env));
3550
4784
  }
3551
- await copySecure(deploymentPaths(directory).env, join16(backupDir, "config.env"));
4785
+ await copySecure(deploymentPaths(directory).env, join19(backupDir, "config.env"));
3552
4786
  managedAssetSha256 = await snapshotManagedAssets(directory, state.mode, backupDir);
3553
4787
  artifact = "captured";
3554
4788
  } catch (error) {
@@ -3562,6 +4796,8 @@ async function createBackup(directory, state, env, options = {}) {
3562
4796
  progressFailures.push(asError(error));
3563
4797
  }
3564
4798
  try {
4799
+ if (managedVaultEnabled(directory, env))
4800
+ await assertVaultVolumesExist(directory, state);
3565
4801
  await startProject(directory, state.mode, env);
3566
4802
  service = "healthy";
3567
4803
  downtimeMs = Date.now() - stopStartedAt;
@@ -3571,20 +4807,30 @@ async function createBackup(directory, state, env, options = {}) {
3571
4807
  }
3572
4808
  }
3573
4809
  }
4810
+ if (!primaryFailure && restartFailure && remoteStorage)
4811
+ primaryFailure = new Error("S3 capture requires successful service recovery before remote transfer.");
3574
4812
  if (!primaryFailure) {
3575
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
+ }
3576
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);
3577
4822
  const manifest2 = {
3578
- schemaVersion: 1,
4823
+ ...remote ? { schemaVersion: 2, storage: "s3", remote } : { schemaVersion: 1 },
3579
4824
  backupId,
4825
+ snapshotKind: options.safetySnapshot ? "safety" : "full",
3580
4826
  createdAt: capturedAt,
3581
4827
  appVersion: state.appVersion,
3582
4828
  image: state.image,
3583
4829
  imageDigest: state.imageDigest,
3584
- dataSha256: await sha256File(join16(backupDir, "data.tgz")),
3585
- ...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")) } : {},
3586
4832
  dataBytes: volumeSize,
3587
- configSha256: await sha256File(join16(backupDir, "config.env")),
4833
+ configSha256: await sha256File(join19(backupDir, "config.env")),
3588
4834
  managedAssetSha256,
3589
4835
  managedAssetInventoryVersion: managedAssetSha256?.["dashboard-lifecycle.mjs"] ? MANAGED_ASSET_INVENTORY_VERSION : managedAssetSha256?.["compose.operator.yaml"] ? 3 : managedAssetSha256?.["dashboard-operator.mjs"] ? 2 : undefined,
3590
4836
  mode: state.mode,
@@ -3598,10 +4844,8 @@ async function createBackup(directory, state, env, options = {}) {
3598
4844
  };
3599
4845
  const manifestContents = `${JSON.stringify(manifest2, null, 2)}
3600
4846
  `;
3601
- await writeSecure(join16(backupDir, "manifest.json"), manifestContents);
4847
+ await writeSecure(join19(backupDir, "manifest.json"), manifestContents);
3602
4848
  await writeBackupAuthentication(directory, backupDir, manifestContents);
3603
- if (!options.safetySnapshot)
3604
- await validateCanonicalBackupReferences(directory, { ...env, OR3_IMAGE: imageAtDigest(state.image, state.imageDigest) }, backupDir);
3605
4849
  verifiedManifest = await readManifest(backupDir, directory);
3606
4850
  artifact = "verified";
3607
4851
  } catch (error) {
@@ -3650,12 +4894,12 @@ async function createBackup(directory, state, env, options = {}) {
3650
4894
  async function inspectUnclaimedBackupArtifacts(directory, backupId, backupPath) {
3651
4895
  if (!backupId || !backupPath || !BACKUP_ID_PATTERN.test(backupId))
3652
4896
  return;
3653
- const expectedPath = resolve9(backupDirectory(directory, backupId));
3654
- if (resolve9(backupPath) !== expectedPath)
4897
+ const expectedPath = resolve10(backupDirectory(directory, backupId));
4898
+ if (resolve10(backupPath) !== expectedPath)
3655
4899
  return;
3656
4900
  const statuses = await Promise.all([
3657
4901
  artifactStatus(expectedPath),
3658
- artifactStatus(join16(deploymentPaths(directory).exports, `${backupId}.json`))
4902
+ artifactStatus(join19(deploymentPaths(directory).exports, `${backupId}.json`))
3659
4903
  ]);
3660
4904
  const detail = statuses.map((status) => status.detail).join(" ");
3661
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".`;
@@ -3663,8 +4907,8 @@ async function inspectUnclaimedBackupArtifacts(directory, backupId, backupPath)
3663
4907
  async function cleanupJournaledPartialBackup(directory, backupId, backupPath) {
3664
4908
  if (!backupId || !backupPath || !BACKUP_ID_PATTERN.test(backupId))
3665
4909
  return;
3666
- const expectedPath = resolve9(backupDirectory(directory, backupId));
3667
- if (resolve9(backupPath) !== expectedPath)
4910
+ const expectedPath = resolve10(backupDirectory(directory, backupId));
4911
+ if (resolve10(backupPath) !== expectedPath)
3668
4912
  return;
3669
4913
  try {
3670
4914
  await readManifest(expectedPath, directory);
@@ -3681,51 +4925,53 @@ async function createPreMutationSnapshot(directory, state, env, backupId) {
3681
4925
  }
3682
4926
 
3683
4927
  // src/backup/export.ts
3684
- import { chmod as chmod7, mkdir as mkdir7, realpath, rm as rm6, stat as stat3 } from "node:fs/promises";
3685
- 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";
3686
4930
  var PURGE_REQUIRES_BACKUP_WITHIN_MS = 24 * 60 * 60 * 1000;
3687
4931
  async function exportBackup(directory, backupId, destination) {
3688
4932
  const backupPath = await resolveBackup(directory, backupId);
3689
4933
  const manifest2 = await readManifest(backupPath, directory);
3690
- const dest = resolve10(destination);
3691
- const source = resolve10(backupPath);
3692
- 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);
3693
4939
  if (source === dest || dest.startsWith(`${source}${sep}`)) {
3694
4940
  throw new Error("Choose a destination directory different from the backup itself.");
3695
4941
  }
3696
4942
  if (await fileExists(dest))
3697
4943
  throw new Error(`Destination ${dest} already exists. Choose a new empty destination so an export can never merge with unrelated files.`);
3698
4944
  await mkdir7(dirname5(dest), { recursive: true, mode: 448 });
3699
- const canonicalDestination = join17(await realpath(dirname5(dest)), basename5(dest));
4945
+ const canonicalDestination = join20(await realpath2(dirname5(dest)), basename5(dest));
3700
4946
  if (canonicalDestination === deploymentRoot || canonicalDestination.startsWith(`${deploymentRoot}${sep}`)) {
3701
4947
  throw new Error("Backup exports must live outside the managed deployment directory so `remove --purge-data` can never delete the remaining copy.");
3702
4948
  }
3703
4949
  await mkdir7(dest, { mode: 448 });
3704
4950
  try {
3705
- if (await realpath(dest) !== canonicalDestination) {
4951
+ if (await realpath2(dest) !== canonicalDestination) {
3706
4952
  throw new Error("Backup export destination changed while it was being created. Refusing to write an export through an unexpected path.");
3707
4953
  }
3708
4954
  await chmod7(dest, 448);
3709
- for (const file of ["data.tgz", "config.env", "manifest.json", "manifest.auth", ...manifest2.vaultSha256 ? ["vault.backup"] : []]) {
3710
- 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));
3711
4957
  }
3712
4958
  if (manifest2.managedAssetSha256) {
3713
4959
  for (const name of managedAssetNamesForInventory(manifest2.mode, manifest2.managedAssetInventoryVersion)) {
3714
- 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));
3715
4961
  }
3716
4962
  }
3717
- const dataSha = await sha256File(join17(dest, "data.tgz"));
4963
+ const dataSha = await sha256File(join20(dest, "data.tgz"));
3718
4964
  if (dataSha !== manifest2.dataSha256) {
3719
4965
  throw new Error(`Exported data.tgz checksum mismatch for ${manifest2.backupId}. Expected ${manifest2.dataSha256}, got ${dataSha}.`);
3720
4966
  }
3721
4967
  if (manifest2.configSha256) {
3722
- const configSha = await sha256File(join17(dest, "config.env"));
4968
+ const configSha = await sha256File(join20(dest, "config.env"));
3723
4969
  if (configSha !== manifest2.configSha256) {
3724
4970
  throw new Error(`Exported config.env checksum mismatch for ${manifest2.backupId}.`);
3725
4971
  }
3726
4972
  }
3727
4973
  await readManifest(dest, directory);
3728
- const destinationDevice = (await stat3(dest)).dev;
4974
+ const destinationDevice = (await stat4(dest)).dev;
3729
4975
  await mkdir7(deploymentPaths(directory).exports, { recursive: true, mode: 448 });
3730
4976
  const receipt = {
3731
4977
  schemaVersion: 1,
@@ -3736,65 +4982,28 @@ async function exportBackup(directory, backupId, destination) {
3736
4982
  dataSha256: manifest2.dataSha256,
3737
4983
  configSha256: manifest2.configSha256
3738
4984
  };
3739
- 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)}
3740
4986
  `);
3741
- 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;
3742
4990
  if (manifest2.managedAssetSha256) {
3743
4991
  for (const name of managedAssetNamesForInventory(manifest2.mode, manifest2.managedAssetInventoryVersion)) {
3744
- bytes += (await stat3(join17(dest, "managed-assets", name))).size;
4992
+ bytes += (await stat4(join20(dest, "managed-assets", name))).size;
3745
4993
  }
3746
4994
  }
3747
4995
  console.log(`Exported backup ${manifest2.backupId} (${bytes} bytes) to ${dest}`);
3748
4996
  console.log("The exported copy contains credentials and secrets. It is owner-only (0600); keep it off-host.");
3749
- if ((await stat3(backupPath)).dev === destinationDevice) {
3750
- 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.");
3751
4999
  } else {
3752
- 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.");
3753
5001
  }
3754
5002
  } catch (error) {
3755
- await rm6(dest, { recursive: true, force: true });
5003
+ await rm7(dest, { recursive: true, force: true });
3756
5004
  throw error;
3757
5005
  }
3758
5006
  }
3759
- function assertPurgeBackupFreshness(backups, nowMs) {
3760
- const cutoff = nowMs - PURGE_REQUIRES_BACKUP_WITHIN_MS;
3761
- if (!backups.some((backup) => new Date(backup.createdAt).getTime() >= cutoff)) {
3762
- 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.');
3763
- }
3764
- }
3765
- async function readBackupExportReceipt(directory, backupId) {
3766
- try {
3767
- const receipt = JSON.parse(await readText(join17(deploymentPaths(directory).exports, `${backupId}.json`)));
3768
- if (receipt.schemaVersion !== 1 || receipt.backupId !== backupId || typeof receipt.destination !== "string" || !isAbsolute3(receipt.destination) || typeof receipt.destinationDevice !== "number" || !receipt.dataSha256)
3769
- return;
3770
- return receipt;
3771
- } catch {
3772
- return;
3773
- }
3774
- }
3775
- async function assertPurgeHasVerifiedExport(directory, backups, nowMs) {
3776
- assertPurgeBackupFreshness(backups, nowMs);
3777
- const cutoff = nowMs - PURGE_REQUIRES_BACKUP_WITHIN_MS;
3778
- for (const backup of backups) {
3779
- if (new Date(backup.createdAt).getTime() < cutoff)
3780
- continue;
3781
- const receipt = await readBackupExportReceipt(directory, backup.backupId);
3782
- if (!receipt || !await fileExists(receipt.destination))
3783
- continue;
3784
- const deploymentRoot = await realpath(directory);
3785
- try {
3786
- const canonicalDestination = await realpath(receipt.destination);
3787
- if (canonicalDestination === deploymentRoot || canonicalDestination.startsWith(`${deploymentRoot}${sep}`))
3788
- continue;
3789
- const exported = await readManifest(receipt.destination, directory);
3790
- const sourceDevice = (await stat3(backup.path)).dev;
3791
- const destinationDevice = (await stat3(receipt.destination)).dev;
3792
- if (sourceDevice !== destinationDevice && receipt.destinationDevice === destinationDevice && exported.backupId === backup.backupId && exported.dataSha256 === receipt.dataSha256 && exported.dataSha256 === backup.dataSha256 && exported.configSha256 === receipt.configSha256)
3793
- return receipt;
3794
- } catch {}
3795
- }
3796
- 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.");
3797
- }
3798
5007
 
3799
5008
  // src/commands/backup.ts
3800
5009
  function parseKeep(flags) {
@@ -3808,6 +5017,10 @@ async function backupCreateCommand(directory) {
3808
5017
  await ensureDocker();
3809
5018
  const loaded = await loadManaged(directory);
3810
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);
3811
5024
  const backupId = id("backup");
3812
5025
  const initialAppRunning = await projectServiceRunning(loaded.directory, loaded.state.mode);
3813
5026
  const pending = {
@@ -3882,6 +5095,10 @@ async function backupListCommand(directory, flags = {}) {
3882
5095
  appVersion: backup.appVersion,
3883
5096
  bytes: backup.bytes,
3884
5097
  dataSha256: backup.dataSha256,
5098
+ manifestSchemaVersion: backup.schemaVersion,
5099
+ snapshotKind: backup.snapshotKind ?? null,
5100
+ recoveryArtifactsComplete: backup.recoveryArtifactsComplete === true,
5101
+ ...backup.remoteKind ? { remoteKind: backup.remoteKind } : {},
3885
5102
  trust: "verified"
3886
5103
  })),
3887
5104
  findings: findings.map((entry) => ({ entryName: entry.entryName, kind: entry.kind, code: entry.code, message: entry.message })),
@@ -3897,10 +5114,16 @@ async function backupListCommand(directory, flags = {}) {
3897
5114
  console.log(`No authenticated backups are available for ${loaded.directory}.`);
3898
5115
  } else {
3899
5116
  console.log(`OR3 Cloud backups for ${loaded.directory} (${verified.length} verified):`);
3900
- console.log("backupId createdAt version bytes trust checksum");
5117
+ console.log("backupId createdAt version bytes kind snapshot coverage trust checksum");
3901
5118
  for (const backup of verified) {
3902
- 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)}`);
3903
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.");
3904
5127
  }
3905
5128
  if (findings.length > 0) {
3906
5129
  console.log(`
@@ -3967,8 +5190,8 @@ async function backupCommand(directory, positionals, flags) {
3967
5190
  }
3968
5191
 
3969
5192
  // src/credentials/reset.ts
3970
- import { rm as rm7 } from "node:fs/promises";
3971
- import { join as join18 } from "node:path";
5193
+ import { rm as rm8 } from "node:fs/promises";
5194
+ import { join as join21 } from "node:path";
3972
5195
 
3973
5196
  // src/credentials/reset-script.ts
3974
5197
  function buildCredentialsResetScript(input2) {
@@ -4152,7 +5375,10 @@ async function verifyCredentialsInsideContainer(directory, mode, values) {
4152
5375
  throw new Error(`Credential verification inside the OR3 container failed. ${redact(result.stderr, [values.ownerPassword, values.adminPassword])}`);
4153
5376
  }
4154
5377
  async function applyCredentialReset(directory, state, nextEnv) {
5378
+ assertLegacyManagedProfile(nextEnv);
4155
5379
  const values = credentialResetValues(nextEnv);
5380
+ if (managedVaultEnabled(directory, nextEnv))
5381
+ await assertVaultVolumesExist(directory, state);
4156
5382
  const running = await run("docker", [...composeArgs(directory, state.mode, ["ps", "-q", "or3"])], directory);
4157
5383
  if (!running.ok || !running.stdout.trim()) {
4158
5384
  await writeSecure(deploymentPaths(directory).env, serializeEnv(nextEnv));
@@ -4161,9 +5387,11 @@ async function applyCredentialReset(directory, state, nextEnv) {
4161
5387
  await runCredentialsResetScript(directory, state.mode, values);
4162
5388
  const runtimeEnv = withoutProvisioningCredentials(nextEnv);
4163
5389
  await writeSecure(deploymentPaths(directory).env, serializeEnv(runtimeEnv));
4164
- const initialCredentials = join18(directory, ".or3-initial-credentials");
4165
- await rm7(initialCredentials, { force: true });
5390
+ const initialCredentials = join21(directory, ".or3-initial-credentials");
5391
+ await rm8(initialCredentials, { force: true });
4166
5392
  await stopProject(directory, state.mode);
5393
+ if (managedVaultEnabled(directory, runtimeEnv))
5394
+ await assertVaultVolumesExist(directory, state);
4167
5395
  await startProject(directory, state.mode, runtimeEnv);
4168
5396
  await verifyCredentialsInsideContainer(directory, state.mode, values);
4169
5397
  }
@@ -4176,6 +5404,10 @@ async function credentialsResetCommand(directory, flags) {
4176
5404
  const loaded = await loadManaged(directory);
4177
5405
  assertDeploymentDirectoryIdentity(directory, loaded.state);
4178
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);
4179
5411
  const ownerEmail = loaded.env.OR3_MANAGED_OWNER_EMAIL || loaded.env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
4180
5412
  const adminUsername = loaded.env.OR3_ADMIN_USERNAME;
4181
5413
  if (!ownerEmail)
@@ -4222,10 +5454,10 @@ async function credentialsCommand(directory, positionals, flags) {
4222
5454
 
4223
5455
  // src/commands/doctor.ts
4224
5456
  import { existsSync as existsSync3 } from "node:fs";
4225
- import { stat as stat4 } from "node:fs/promises";
4226
- 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";
4227
5459
  async function doctorCommand(directory) {
4228
- const resolved = resolve11(directory);
5460
+ const resolved = resolve12(directory);
4229
5461
  const paths = deploymentPaths(resolved);
4230
5462
  const failures = [];
4231
5463
  let state;
@@ -4248,10 +5480,10 @@ async function doctorCommand(directory) {
4248
5480
  failures.push(error instanceof Error ? error.message : String(error));
4249
5481
  console.log("✗ Managed state or .env is missing/invalid");
4250
5482
  }
4251
- 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")]) {
4252
5484
  if (!existsSync3(file))
4253
5485
  continue;
4254
- const mode = (await stat4(file)).mode & 511;
5486
+ const mode = (await stat5(file)).mode & 511;
4255
5487
  if (mode !== 384) {
4256
5488
  failures.push(`${file} must be mode 0600 (currently ${mode.toString(8)}).`);
4257
5489
  console.log(`✗ Permissions: ${file} is ${mode.toString(8)}`);
@@ -4319,8 +5551,8 @@ async function doctorCommand(directory) {
4319
5551
  throw new Error("Dashboard operator deployment identity does not match managed state.");
4320
5552
  }
4321
5553
  assertDashboardOperatorMounts(container.Mounts ?? [], resolved);
4322
- const ipcDirectory = await stat4(paths.operatorIpc);
4323
- 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"));
4324
5556
  const operatorUid = Number(env.OR3_OPERATOR_UID);
4325
5557
  const operatorGid = Number(env.OR3_OPERATOR_GID);
4326
5558
  if ((ipcDirectory.mode & 511) !== 456 || ipcDirectory.uid !== operatorUid || ipcDirectory.gid !== operatorGid || !operatorSocket.isSocket() || (operatorSocket.mode & 511) !== 432 || operatorSocket.uid !== operatorUid || operatorSocket.gid !== operatorGid) {
@@ -4379,15 +5611,15 @@ async function doctorCommand(directory) {
4379
5611
 
4380
5612
  // src/commands/init.ts
4381
5613
  import { chmod as chmod8, mkdir as mkdir8 } from "node:fs/promises";
4382
- import { join as join20, relative, resolve as resolve12 } from "node:path";
5614
+ import { join as join23, relative, resolve as resolve13 } from "node:path";
4383
5615
  async function initCommand(positionals, flags) {
4384
5616
  const hasLocal = boolFlag(flags, "local");
4385
5617
  const hasPublic = boolFlag(flags, "public");
4386
5618
  if (hasLocal === hasPublic)
4387
5619
  throw new Error("Choose exactly one of --local or --public.");
4388
5620
  const mode = hasPublic ? "public" : "local";
4389
- const directory = resolve12(process.cwd(), positionals[0] ?? "or3-cloud");
4390
- 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"))) {
4391
5623
  throw new Error(`${directory} is already a managed deployment. Use update, doctor, or adopt.`);
4392
5624
  }
4393
5625
  await readDirectoryEmpty(directory);
@@ -4413,7 +5645,7 @@ async function initCommand(positionals, flags) {
4413
5645
  await ensureDocker();
4414
5646
  const names = composeProjectNames(directory);
4415
5647
  await assertDockerProjectAbsent(names.project);
4416
- 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] : []]) {
4417
5649
  const existing = await run("docker", ["volume", "inspect", volume]);
4418
5650
  if (existing.ok)
4419
5651
  throw new Error(`Docker volume ${volume} already exists. Choose a new directory or inspect it before initializing.`);
@@ -4438,7 +5670,7 @@ ${existing.stderr}`)) {
4438
5670
  await copyAssets(directory, mode);
4439
5671
  const env = buildEnv({ mode, version, directory, image, email, password, domain, port, dashboardOperator: operator });
4440
5672
  await writeSecure(deploymentPaths(directory).env, serializeEnv(env));
4441
- await writeSecure(join20(directory, ".or3-initial-credentials"), serializeInitialCredentials({
5673
+ await writeSecure(join23(directory, ".or3-initial-credentials"), serializeInitialCredentials({
4442
5674
  bootstrapEmail: email,
4443
5675
  bootstrapPassword: password,
4444
5676
  adminUsername: email,
@@ -4463,7 +5695,7 @@ ${existing.stderr}`)) {
4463
5695
  const folder = relative(process.cwd(), directory) || ".";
4464
5696
  console.log(`
4465
5697
  OR3 Cloud ${version} is running at ${url}`);
4466
- 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.`);
4467
5699
  console.log("Save it in a password manager, then delete that file.");
4468
5700
  console.log(`
4469
5701
  Run later commands from the deployment folder: cd ${quote(folder)} && npx @or3/cloud doctor`);
@@ -4484,6 +5716,10 @@ async function startCommand(directory) {
4484
5716
  const loaded = await loadManaged(directory);
4485
5717
  assertDeploymentDirectoryIdentity(directory, loaded.state);
4486
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);
4487
5723
  await startProject(directory, loaded.state.mode, loaded.env);
4488
5724
  const url = loaded.state.mode === "public" ? `https://${loaded.state.domain}` : `http://127.0.0.1:${loaded.state.port}`;
4489
5725
  console.log(`OR3 started and is deeply healthy at ${url}.`);
@@ -4501,6 +5737,8 @@ async function restartCommand(directory, lifecycle) {
4501
5737
  const loaded = await loadManaged(directory);
4502
5738
  assertDeploymentDirectoryIdentity(directory, loaded.state);
4503
5739
  assertPhaseAllowsCommand("restart", loaded.state);
5740
+ assertLegacyManagedProfile(loaded.env);
5741
+ await assertLegacyComposeProfile(loaded.directory, loaded.state.mode, loaded.env, lifecycle?.deadline);
4504
5742
  if (lifecycle) {
4505
5743
  await assertSafeComposeBinding(directory, loaded.state.mode, loaded.env, lifecycle.deadline);
4506
5744
  await assertRunningAppImage(directory, loaded.state.mode, loaded.state.image, lifecycle.deadline);
@@ -4515,11 +5753,8 @@ async function restartCommand(directory, lifecycle) {
4515
5753
  console.log("OR3 restarted and is deeply healthy.");
4516
5754
  }
4517
5755
 
4518
- // src/commands/recover.ts
4519
- import { join as join24 } from "node:path";
4520
-
4521
5756
  // src/backup/restore.ts
4522
- import { join as join21 } from "node:path";
5757
+ import { join as join24 } from "node:path";
4523
5758
 
4524
5759
  // src/backup/generation-restore.ts
4525
5760
  function rebindRestoredGenerations(fs, path, crypto, db, root, options = {}) {
@@ -4715,14 +5950,14 @@ if (root !== '/data' && !root.startsWith('/data/')) throw new Error('Generation
4715
5950
  if (!result.ok)
4716
5951
  throw new Error(`Restored generation verification failed. ${redact(result.stderr, secretValues(env))}`);
4717
5952
  }
4718
- function assertFilesystemPayloadHash(key, readChunk, createHash3) {
5953
+ function assertFilesystemPayloadHash(key, readChunk, createHash5) {
4719
5954
  const match = /^(sha256|md5)_([a-fA-F0-9]+)$/.exec(key);
4720
5955
  const algorithm = match?.[1];
4721
5956
  const expected = match?.[2];
4722
5957
  if (algorithm !== "sha256" && algorithm !== "md5" || expected?.length !== (algorithm === "sha256" ? 64 : 32)) {
4723
5958
  throw new Error("Invalid canonical filesystem payload key");
4724
5959
  }
4725
- const hash = createHash3(algorithm);
5960
+ const hash = createHash5(algorithm);
4726
5961
  for (let chunk = readChunk();chunk !== undefined; chunk = readChunk())
4727
5962
  hash.update(chunk);
4728
5963
  if (hash.digest("hex") !== expected.toLowerCase())
@@ -4732,8 +5967,10 @@ var VERIFY_RESTORED_DATA_SCRIPT = `
4732
5967
  const fs = require('node:fs');
4733
5968
  const Database = require('/app/.output/server/node_modules/better-sqlite3');
4734
5969
  const validFilesystemPayload = ${assertFilesystemPayloadHash.toString()};
4735
- const paths = [process.env.OR3_BASIC_AUTH_DB_PATH, process.env.OR3_SQLITE_DB_PATH].filter(Boolean);
4736
- 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');
4737
5974
  for (const path of paths) {
4738
5975
  const databasePath = fs.realpathSync(path);
4739
5976
  if (!fs.statSync(databasePath).isFile()) throw new Error('Restored database payload must be a regular file');
@@ -4754,7 +5991,7 @@ const path = require('node:path');
4754
5991
  const crypto = require('node:crypto');
4755
5992
  const storageRoot = path.resolve(process.env.OR3_STORAGE_FS_ROOT || '/data/storage');
4756
5993
  const workspacesRoot = path.join(storageRoot, 'workspaces');
4757
- if ((storageRoot === '/data' || storageRoot.startsWith('/data/')) && fs.existsSync(workspacesRoot)) {
5994
+ if (storage === 'fs' && (storageRoot === '/data' || storageRoot.startsWith('/data/')) && fs.existsSync(workspacesRoot)) {
4758
5995
  const chunk = Buffer.alloc(65536);
4759
5996
  for (const workspace of fs.readdirSync(workspacesRoot, { withFileTypes: true }).filter(entry => /^[a-zA-Z0-9_-]+$/.test(entry.name))) {
4760
5997
  const directory = path.join(workspacesRoot, workspace.name);
@@ -4776,7 +6013,7 @@ if ((storageRoot === '/data' || storageRoot.startsWith('/data/')) && fs.existsSy
4776
6013
  }
4777
6014
  `;
4778
6015
  async function assertRestoreFreeSpace(directory, state, env, manifest2, backupPath) {
4779
- const archiveBytes = await gzipUncompressedBytes(join21(backupPath, "data.tgz"));
6016
+ const archiveBytes = await gzipUncompressedBytes(join24(backupPath, "data.tgz"));
4780
6017
  const requiredBytes = Math.max(manifest2.dataBytes ?? 0, archiveBytes);
4781
6018
  const freeBytes = await dataVolumeFreeBytes(directory, state.mode, env);
4782
6019
  const reclaimableBytes = await dataVolumeSize(directory, state.mode, env, "allocated");
@@ -4785,15 +6022,32 @@ async function assertRestoreFreeSpace(directory, state, env, manifest2, backupPa
4785
6022
  async function restoreBackupData(directory, state, env, backupPath, options = {}) {
4786
6023
  const manifest2 = await readManifest(backupPath, directory);
4787
6024
  assertRestorableManagedAssets(manifest2);
4788
- const backupEnv = parseEnv(await readText(join21(backupPath, "config.env")));
6025
+ const backupEnv = parseEnv(await readText(join24(backupPath, "config.env")));
4789
6026
  if (backupEnv.OR3_VERSION !== manifest2.appVersion || backupEnv.OR3_IMAGE !== manifest2.image) {
4790
6027
  throw new Error(`Backup ${manifest2.backupId} configuration does not match its manifest.`);
4791
6028
  }
4792
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
+ }
4793
6035
  if (manifest2.imageDigest)
4794
6036
  await requireImageDigest(manifest2.image, manifest2.imageDigest, `Backup ${manifest2.backupId}`);
6037
+ await assertExistingVaultCustody(directory, state, manifest2, backupPath, backupEnv);
4795
6038
  await validateVolumeArchive(directory, state.mode, env, backupPath, backupEnv.OR3_STORAGE_FS_ROOT);
4796
- 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) {
4797
6051
  await validateCanonicalBackupReferences(directory, { ...backupEnv, OR3_IMAGE: imageAtDigest(manifest2.image, manifest2.imageDigest) }, backupPath);
4798
6052
  } else {
4799
6053
  console.warn("Restoring the recorded byte-preservation safety snapshot; canonical attachment completeness is not certified. Verify previously uploaded files.");
@@ -4802,10 +6056,10 @@ async function restoreBackupData(directory, state, env, backupPath, options = {}
4802
6056
  await assertRestoreFreeSpace(directory, state, env, manifest2, backupPath);
4803
6057
  const restoresOperatorOverlay = [3, MANAGED_ASSET_INVENTORY_VERSION].includes(manifest2.managedAssetInventoryVersion ?? 1) && Boolean(manifest2.managedAssetSha256?.["compose.operator.yaml"]);
4804
6058
  const restoredEnv = restoresOperatorOverlay ? {
4805
- ...backupEnv,
6059
+ ...restoreStorageConfiguration(backupEnv, env),
4806
6060
  OR3_IMAGE: imageAtDigest(manifest2.image, manifest2.imageDigest)
4807
6061
  } : withoutDashboardOperator({
4808
- ...backupEnv,
6062
+ ...restoreStorageConfiguration(backupEnv, env),
4809
6063
  OR3_IMAGE: imageAtDigest(manifest2.image, manifest2.imageDigest)
4810
6064
  });
4811
6065
  if (options.recreateDataVolume)
@@ -4835,6 +6089,10 @@ async function restoreBackupData(directory, state, env, backupPath, options = {}
4835
6089
  "--env",
4836
6090
  `OR3_SQLITE_DB_PATH=${restoredEnv.OR3_SQLITE_DB_PATH ?? ""}`,
4837
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",
4838
6096
  `OR3_STORAGE_FS_ROOT=${restoredEnv.OR3_STORAGE_FS_ROOT ?? "/data/storage"}`,
4839
6097
  "--entrypoint",
4840
6098
  "sh",
@@ -4844,9 +6102,19 @@ async function restoreBackupData(directory, state, env, backupPath, options = {}
4844
6102
  if (!snapshotCheck.ok) {
4845
6103
  throw new Error(`Restored snapshot integrity verification failed. ${redact(snapshotCheck.stderr, secretValues(restoredEnv))}`);
4846
6104
  }
4847
- await restoreGenerationIdentities(directory, state, restoredEnv, backupPath, { previousSafetySnapshot: options.previousSafetySnapshot });
6105
+ if (manifest2.schemaVersion === 1)
6106
+ await restoreGenerationIdentities(directory, state, restoredEnv, backupPath, { previousSafetySnapshot: options.previousSafetySnapshot });
4848
6107
  await options.beforeStart?.();
4849
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
+ }
4850
6118
  return manifest2;
4851
6119
  }
4852
6120
  async function restorePreMutationSnapshot(directory, state, env) {
@@ -4856,30 +6124,30 @@ async function restorePreMutationSnapshot(directory, state, env) {
4856
6124
  const previous = await recordedBackupPath(directory, pending);
4857
6125
  await updatePending(directory, state, { phase: "restoring-previous" });
4858
6126
  if (pending.recreateDataVolume) {
4859
- 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" });
4860
6128
  return previous;
4861
6129
  }
4862
6130
  if (pending.previousRootOwnership) {
4863
6131
  await pullAndRequireImage(state.image, state.imageDigest, "Previous deployment");
4864
6132
  await setManagedVolumeRootOwnership(directory, state, env, state.image, pending.previousRootOwnership);
4865
6133
  }
4866
- await restoreBackupData(directory, state, env, previous.path, { previousSafetySnapshot: true });
6134
+ await restoreBackupData(directory, state, env, previous.path, { previousSafetySnapshot: true, remoteReferencePolicy: "warn" });
4867
6135
  return previous;
4868
6136
  }
4869
6137
 
4870
6138
  // src/deployment/observation.ts
4871
- import { createHash as createHash4 } from "node:crypto";
4872
- import { resolve as resolve13 } from "node:path";
6139
+ import { createHash as createHash6 } from "node:crypto";
6140
+ import { resolve as resolve14 } from "node:path";
4873
6141
 
4874
6142
  // src/deployment/lease.ts
4875
6143
  import { randomBytes as randomBytes8 } from "node:crypto";
4876
- import { mkdir as mkdir10, rename as rename3, rm as rm9 } from "node:fs/promises";
4877
- 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";
4878
6146
 
4879
6147
  // ../../shared/server/restarts/controller.ts
4880
- import { randomUUID as randomUUID3, createHash as createHash3 } from "node:crypto";
4881
- import { mkdir as mkdir9, open as open3, readFile as readFile3, rename as rename2, rm as rm8 } from "node:fs/promises";
4882
- 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";
4883
6151
 
4884
6152
  // ../../node_modules/zod/v4/classic/external.js
4885
6153
  var exports_external = {};
@@ -9928,7 +11196,7 @@ var recursive = /* @__PURE__ */ new WeakMap;
9928
11196
  var NONE = 0;
9929
11197
  var ASSUMED = 1;
9930
11198
  var PROVEN = 2;
9931
- function isRecursive(inst, stack, resolve13) {
11199
+ function isRecursive(inst, stack, resolve14) {
9932
11200
  const cached2 = recursive.get(inst);
9933
11201
  if (cached2 !== undefined)
9934
11202
  return cached2 ? PROVEN : NONE;
@@ -9938,7 +11206,7 @@ function isRecursive(inst, stack, resolve13) {
9938
11206
  let result = NONE;
9939
11207
  const check = (child) => {
9940
11208
  if (result !== PROVEN && child?._zod) {
9941
- const answer = isRecursive(child, stack, resolve13);
11209
+ const answer = isRecursive(child, stack, resolve14);
9942
11210
  if (answer > result)
9943
11211
  result = answer;
9944
11212
  }
@@ -9949,7 +11217,7 @@ function isRecursive(inst, stack, resolve13) {
9949
11217
  const desc = Object.getOwnPropertyDescriptor(sh, key);
9950
11218
  if (spread && !desc.enumerable)
9951
11219
  continue;
9952
- 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;
9953
11221
  if (child > answer)
9954
11222
  answer = child;
9955
11223
  }
@@ -10012,7 +11280,7 @@ function isRecursive(inst, stack, resolve13) {
10012
11280
  check(def.output);
10013
11281
  break;
10014
11282
  case "lazy": {
10015
- const inner = def._cachedInner ?? (resolve13 ? inst._zod.innerType : undefined);
11283
+ const inner = def._cachedInner ?? (resolve14 ? inst._zod.innerType : undefined);
10016
11284
  merge2(inner ? isRecursive(inner, stack, false) : ASSUMED);
10017
11285
  break;
10018
11286
  }
@@ -10073,7 +11341,7 @@ function bucketFor(state, inst) {
10073
11341
  return bucket;
10074
11342
  }
10075
11343
  var handoff;
10076
- var open2 = [];
11344
+ var open3 = [];
10077
11345
  var memo = {
10078
11346
  alloc(_inst, payload, empty) {
10079
11347
  const bucket = handoff;
@@ -10082,7 +11350,7 @@ var memo = {
10082
11350
  handoff = undefined;
10083
11351
  const entry = { value: empty, issues: null };
10084
11352
  bucket.set(payload.value, entry);
10085
- open2.push(entry);
11353
+ open3.push(entry);
10086
11354
  return empty;
10087
11355
  },
10088
11356
  guard(inst) {
@@ -10153,10 +11421,10 @@ var memo = {
10153
11421
  return payload;
10154
11422
  }
10155
11423
  handoff = bucket;
10156
- const depth = open2.length;
11424
+ const depth = open3.length;
10157
11425
  const result = base(payload, ctx);
10158
11426
  handoff = undefined;
10159
- const entry = open2.length > depth ? open2.pop() : undefined;
11427
+ const entry = open3.length > depth ? open3.pop() : undefined;
10160
11428
  if (result instanceof Promise) {
10161
11429
  return result.then((r) => {
10162
11430
  if (entry)
@@ -24328,7 +25596,7 @@ var recordSchema = exports_external.object({
24328
25596
  expected: exports_external.record(exports_external.string(), exports_external.union([exports_external.string().max(128), exports_external.boolean()])),
24329
25597
  executionIntent: exports_external.boolean(),
24330
25598
  runtimeIdentity: exports_external.string().max(512).optional()
24331
- }).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");
24332
25600
 
24333
25601
  class RestartError extends Error {
24334
25602
  code;
@@ -24342,15 +25610,15 @@ class RestartError extends Error {
24342
25610
  var resolved = new Set(["succeeded", "cancelled", "failed"]);
24343
25611
  var replayWindowMs = 24 * 60 * 60 * 1000;
24344
25612
  async function readRestartJournal(path) {
24345
- const text = await readFile3(path, "utf8");
25613
+ const text = await readFile5(path, "utf8");
24346
25614
  if (Buffer.byteLength(text) > 512 * 1024)
24347
25615
  throw new RestartError("journal_invalid");
24348
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));
24349
25617
  }
24350
25618
  async function writePrivateJson(path, value) {
24351
- const temporary = `${path}.${randomUUID3()}.tmp`;
25619
+ const temporary = `${path}.${randomUUID5()}.tmp`;
24352
25620
  try {
24353
- const file2 = await open3(temporary, "wx", 384);
25621
+ const file2 = await open4(temporary, "wx", 384);
24354
25622
  try {
24355
25623
  await file2.writeFile(JSON.stringify(value));
24356
25624
  await file2.sync();
@@ -24358,14 +25626,14 @@ async function writePrivateJson(path, value) {
24358
25626
  await file2.close();
24359
25627
  }
24360
25628
  await rename2(temporary, path);
24361
- const directory = await open3(dirname6(path), "r");
25629
+ const directory = await open4(dirname6(path), "r");
24362
25630
  try {
24363
25631
  await directory.sync();
24364
25632
  } finally {
24365
25633
  await directory.close();
24366
25634
  }
24367
25635
  } finally {
24368
- await rm8(temporary, { force: true });
25636
+ await rm9(temporary, { force: true });
24369
25637
  }
24370
25638
  }
24371
25639
  function publicOperation(o) {
@@ -24392,7 +25660,7 @@ class RestartController {
24392
25660
  constructor(directory, driver) {
24393
25661
  this.directory = directory;
24394
25662
  this.driver = driver;
24395
- this.path = join22(directory, "restarts.json");
25663
+ this.path = join25(directory, "restarts.json");
24396
25664
  }
24397
25665
  lock(action) {
24398
25666
  const next = this.serial.then(action, action);
@@ -24482,7 +25750,7 @@ class RestartController {
24482
25750
  if (!parsed.success)
24483
25751
  throw new RestartError("invalid_request", 400);
24484
25752
  const request = parsed.data;
24485
- const fingerprint = createHash3("sha256").update(JSON.stringify(request)).digest("hex");
25753
+ const fingerprint = createHash5("sha256").update(JSON.stringify(request)).digest("hex");
24486
25754
  return this.lock(async () => {
24487
25755
  const old = this.operations.find((o2) => o2.requestId === request.requestId);
24488
25756
  if (old) {
@@ -24608,7 +25876,7 @@ class RestartController {
24608
25876
  });
24609
25877
  return;
24610
25878
  }
24611
- await new Promise((resolve13) => setTimeout(resolve13, 250));
25879
+ await new Promise((resolve14) => setTimeout(resolve14, 250));
24612
25880
  }
24613
25881
  if (this.disposed)
24614
25882
  return;
@@ -24631,7 +25899,7 @@ class RestartController {
24631
25899
  throw new RestartError("configuration_unsupported", 501);
24632
25900
  if ((await this.driver.configuration()).revision !== expectedRevision)
24633
25901
  throw new RestartError("stale_revision");
24634
- const release = await this.driver.acquire(randomUUID3());
25902
+ const release = await this.driver.acquire(randomUUID5());
24635
25903
  try {
24636
25904
  await this.driver.save(updates, expectedRevision);
24637
25905
  } finally {
@@ -24690,7 +25958,7 @@ class RestartController {
24690
25958
  var DASHBOARD_LEASE_STALE_MS = 30000;
24691
25959
  async function readLeaseOwner(leasePath) {
24692
25960
  try {
24693
- const owner = JSON.parse(await readText(join23(leasePath, "owner.json")));
25961
+ const owner = JSON.parse(await readText(join26(leasePath, "owner.json")));
24694
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")
24695
25963
  return;
24696
25964
  return owner;
@@ -24738,7 +26006,7 @@ async function acquireDeploymentLease(directory, command, dashboardJobId) {
24738
26006
  const stalePath = `${leasePath}.stale-${randomBytes8(8).toString("hex")}`;
24739
26007
  try {
24740
26008
  await rename3(leasePath, stalePath);
24741
- await rm9(stalePath, { recursive: true, force: true });
26009
+ await rm10(stalePath, { recursive: true, force: true });
24742
26010
  await mkdir10(leasePath, { recursive: false, mode: 448 });
24743
26011
  } catch (reclaimError) {
24744
26012
  throw new Error(`Another OR3 Cloud operation owns ${directory}. Refusing to race its lifecycle lock. ${reclaimError instanceof Error ? reclaimError.message : String(reclaimError)}`);
@@ -24749,17 +26017,17 @@ async function acquireDeploymentLease(directory, command, dashboardJobId) {
24749
26017
  }
24750
26018
  }
24751
26019
  try {
24752
- await writeSecure(join23(leasePath, "owner.json"), `${JSON.stringify(owner, null, 2)}
26020
+ await writeSecure(join26(leasePath, "owner.json"), `${JSON.stringify(owner, null, 2)}
24753
26021
  `);
24754
26022
  } catch (error62) {
24755
- await rm9(leasePath, { recursive: true, force: true }).catch(() => {
26023
+ await rm10(leasePath, { recursive: true, force: true }).catch(() => {
24756
26024
  return;
24757
26025
  });
24758
26026
  throw error62;
24759
26027
  }
24760
26028
  const heartbeat = setInterval(() => {
24761
26029
  owner.heartbeatAt = now();
24762
- writeSecure(join23(leasePath, "owner.json"), `${JSON.stringify(owner, null, 2)}
26030
+ writeSecure(join26(leasePath, "owner.json"), `${JSON.stringify(owner, null, 2)}
24763
26031
  `).catch(() => {
24764
26032
  return;
24765
26033
  });
@@ -24769,7 +26037,7 @@ async function acquireDeploymentLease(directory, command, dashboardJobId) {
24769
26037
  clearInterval(heartbeat);
24770
26038
  const current = await readLeaseOwner(leasePath);
24771
26039
  if (current?.nonce === nonce)
24772
- await rm9(leasePath, { recursive: true, force: true });
26040
+ await rm10(leasePath, { recursive: true, force: true });
24773
26041
  };
24774
26042
  }
24775
26043
  async function withDeploymentLease(directory, command, action) {
@@ -24784,7 +26052,7 @@ async function withDeploymentLease(directory, command, action) {
24784
26052
  }
24785
26053
  async function assertRestartJournalSettled(directory) {
24786
26054
  try {
24787
- const journal = await readRestartJournal(join23(directory, ".or3-cloud", "restarts", "restarts.json"));
26055
+ const journal = await readRestartJournal(join26(directory, ".or3-cloud", "restarts", "restarts.json"));
24788
26056
  if (journal.operations.some((operation) => !["succeeded", "cancelled", "failed"].includes(operation.phase))) {
24789
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.");
24790
26058
  }
@@ -24798,8 +26066,19 @@ async function assertRestartJournalSettled(directory) {
24798
26066
  }
24799
26067
 
24800
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
+ }
24801
26080
  async function observeDeployment(directory, options = {}) {
24802
- const resolved2 = resolve13(directory);
26081
+ const resolved2 = resolve14(directory);
24803
26082
  const observedAt = now();
24804
26083
  let state = null;
24805
26084
  let stateError = null;
@@ -24926,6 +26205,7 @@ function publicStateProjection(state) {
24926
26205
  dashboardJobId: pending.dashboardJobId ?? null,
24927
26206
  targetVersion: pending.targetVersion ?? null,
24928
26207
  targetImageDigest: pending.targetImageDigest ?? null,
26208
+ remoteReconcile: pending.remoteReconcile ?? null,
24929
26209
  backupProgress: pending.backupProgress ? {
24930
26210
  stage: pending.backupProgress.stage,
24931
26211
  service: pending.backupProgress.service,
@@ -24955,7 +26235,7 @@ function observationFindings(observation) {
24955
26235
  return findings;
24956
26236
  }
24957
26237
  function stateFingerprint(state) {
24958
- return createHash4("sha256").update(JSON.stringify({
26238
+ return createHash6("sha256").update(JSON.stringify({
24959
26239
  schema: state.schemaVersion,
24960
26240
  appVersion: state.appVersion,
24961
26241
  image: state.image,
@@ -24987,6 +26267,9 @@ function decideRecoveryAction(state) {
24987
26267
  }
24988
26268
  return { action: "none", detail: "No incomplete operation is recorded." };
24989
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
+ }
24990
26273
  if (pending.operation === "update" && pending.phase === "target-ready" && pending.evidence) {
24991
26274
  return { action: "finish", detail: "A completed replacement carries durable target-ready evidence and can be committed without restoring data." };
24992
26275
  }
@@ -25028,6 +26311,7 @@ async function reconcileOperatorHandoff(directory, state, report = console.log)
25028
26311
  return detail;
25029
26312
  }
25030
26313
  async function collectTargetReadyChecks(directory, mode, env) {
26314
+ const profile = managedProviderProfile(env);
25031
26315
  const checks3 = [];
25032
26316
  let containerId;
25033
26317
  const container = await run("docker", composeArgs(directory, mode, ["ps", "-q", "or3"]), directory);
@@ -25040,16 +26324,16 @@ async function collectTargetReadyChecks(directory, mode, env) {
25040
26324
  if (databaseCheck.ok) {
25041
26325
  try {
25042
26326
  const databases = JSON.parse(databaseCheck.stdout.trim());
25043
- 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);
25044
26328
  } catch {
25045
26329
  databasesOk = false;
25046
26330
  }
25047
26331
  }
25048
- 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))}` });
25049
26333
  if (mode === "public") {
25050
26334
  const baseUrl = new URL(`https://${env.OR3_PUBLIC_DOMAIN}`);
25051
26335
  try {
25052
- validateVerificationHealth(await verificationJson(baseUrl, "/api/health?deep=true"));
26336
+ validateVerificationHealth(await verificationJson(baseUrl, "/api/health?deep=true"), profile);
25053
26337
  checks3.push({ code: "public-health", status: "passed", detail: `${baseUrl.origin} deep health reports the managed profile.` });
25054
26338
  } catch (error62) {
25055
26339
  checks3.push({ code: "public-health", status: "failed", detail: redact(error62 instanceof Error ? error62.message : String(error62)) });
@@ -25247,6 +26531,8 @@ async function runRecovery(directory, context) {
25247
26531
  const loaded = await loadManaged(directory);
25248
26532
  const pending = loaded.state.incompleteOperation;
25249
26533
  const decision = decideRecoveryAction(loaded.state);
26534
+ if (pending || decision.action === "reconcile-handoff")
26535
+ assertLegacyManagedProfile(loaded.env);
25250
26536
  if (decision.action === "reconcile-handoff") {
25251
26537
  if (explicitRestore)
25252
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.");
@@ -25273,7 +26559,18 @@ async function runRecovery(directory, context) {
25273
26559
  const detail = await finishTargetReadyUpdate(loaded.directory, loaded.state, loaded.env, report);
25274
26560
  return { kind: "recovered", operation: "update", detail };
25275
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);
25276
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
+ }
25277
26574
  try {
25278
26575
  if (pending.operation === "backup") {
25279
26576
  const failures = [];
@@ -25394,6 +26691,7 @@ async function runRecovery(directory, context) {
25394
26691
  if (sourceManifest.mode !== loaded.state.mode || sourceManifest.appVersion !== loaded.state.appVersion || sourceManifest.image !== loaded.state.image || sourceManifest.imageDigest !== loaded.state.imageDigest) {
25395
26692
  throw new Error(`Source backup ${sourceManifest.backupId} does not match the managed adoption target.`);
25396
26693
  }
26694
+ await updatePending(loaded.directory, loaded.state, { phase: "target-mutating" });
25397
26695
  await stopProject(loaded.directory, loaded.state.mode);
25398
26696
  await restoreVolumeArchive(loaded.directory, loaded.state.mode, loaded.env, sourceBackupPath);
25399
26697
  }
@@ -25418,84 +26716,29 @@ async function runRecovery(directory, context) {
25418
26716
  } catch (error62) {
25419
26717
  const recoverySecrets = pending.operation === "credentials-reset" ? secretValues(pending.credentialReset?.nextEnv ?? loaded.env) : secretValues(loaded.env);
25420
26718
  loaded.state.lastError = redact(error62 instanceof Error ? error62.message : String(error62), recoverySecrets);
25421
- if (pending.operation === "adopt" && pending.sourceDirectory) {
25422
- try {
25423
- await compose(loaded.directory, loaded.state.mode, ["down"]).catch(() => {
25424
- return;
25425
- });
25426
- const sourceEnv = parseEnv(await readText(join24(pending.sourceDirectory, ".env")));
25427
- const sourceFiles = ["-f", join24(pending.sourceDirectory, "compose.yaml")];
25428
- const sourceModeValue = sourceMode(pending.sourceDirectory, sourceEnv);
25429
- if (sourceModeValue === "public")
25430
- sourceFiles.push("-f", join24(pending.sourceDirectory, "compose.public.yaml"));
25431
- if (pending.sourceInitiallyRunning !== false) {
25432
- await restartSource(pending.sourceDirectory, sourceFiles, secretValues(sourceEnv));
25433
- }
25434
- } catch (recovery) {
25435
- loaded.state.lastError += ` Original deployment recovery failed: ${recovery instanceof Error ? recovery.message : String(recovery)}`;
25436
- }
25437
- }
25438
26719
  await writeState(loaded.directory, loaded.state);
25439
26720
  throw new Error(`Recovery could not safely complete ${pending.operation}. Keep the operation record and run doctor. ${loaded.state.lastError}`);
25440
26721
  }
25441
26722
  }
25442
26723
 
25443
26724
  // src/commands/remove.ts
25444
- import { rm as rm10 } from "node:fs/promises";
25445
- import { join as join25 } from "node:path";
25446
- function purgeVolumesFromState(state) {
25447
- const volumes = [state.volumeName];
25448
- if (state.mode === "public") {
25449
- if (state.caddyDataVolume)
25450
- volumes.push(state.caddyDataVolume);
25451
- if (state.caddyConfigVolume)
25452
- volumes.push(state.caddyConfigVolume);
25453
- }
25454
- return volumes;
25455
- }
25456
26725
  async function removeCommand(directory, flags) {
25457
26726
  if (boolFlag(flags, "purge-data") && !boolFlag(flags, "yes")) {
25458
- 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.");
25459
26728
  }
25460
26729
  await ensureDocker();
25461
26730
  const loaded = await loadManaged(directory);
25462
26731
  assertDeploymentDirectoryIdentity(directory, loaded.state);
25463
26732
  assertNoPending(loaded.state);
25464
- const { state } = loaded;
25465
- if (!boolFlag(flags, "purge-data")) {
25466
- await compose(directory, state.mode, ["down"]);
25467
- 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.`);
25468
- 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.");
25469
26735
  }
25470
- const exportReceipt = await assertPurgeHasVerifiedExport(directory, await enumerateBackups(directory), Date.now());
25471
- const volumes = purgeVolumesFromState(state);
25472
- const managedFiles = [
25473
- ".env",
25474
- ".or3-cloud",
25475
- ...managedAssetNames(state.mode),
25476
- ".or3-initial-credentials"
25477
- ];
25478
- console.log("Removing exactly these targets:");
25479
- for (const volume of volumes)
25480
- console.log(` docker volume ${volume}`);
25481
- for (const file2 of managedFiles)
25482
- console.log(` ${join25(directory, file2)}`);
25483
- await compose(directory, state.mode, ["down"]);
25484
- await assertPurgeHasVerifiedExport(directory, await enumerateBackups(directory), Date.now());
25485
- for (const volume of volumes) {
25486
- const result = await run("docker", ["volume", "rm", volume], directory);
25487
- if (!result.ok)
25488
- throw new Error(`${result.command}
25489
- ${result.stderr}`);
25490
- }
25491
- for (const file2 of managedFiles) {
25492
- await rm10(join25(directory, file2), { recursive: true, force: true });
25493
- }
25494
- 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.`);
25495
26738
  }
25496
26739
 
25497
26740
  // src/commands/restore.ts
25498
- import { join as join26 } from "node:path";
26741
+ import { join as join27 } from "node:path";
25499
26742
  async function restoreCommand(directory, flags, positionals) {
25500
26743
  if (!boolFlag(flags, "yes"))
25501
26744
  throw new Error("Restore replaces live data. Re-run with --yes after confirming the backup and data-loss boundary.");
@@ -25508,18 +26751,26 @@ async function restoreCommand(directory, flags, positionals) {
25508
26751
  const backupPath = await resolveBackup(loaded.directory, backupValue);
25509
26752
  const manifest2 = await readManifest(backupPath, loaded.directory);
25510
26753
  assertRestorableManagedAssets(manifest2);
25511
- const backupEnv = parseEnv(await readText(join26(backupPath, "config.env")));
26754
+ const backupEnv = parseEnv(await readText(join27(backupPath, "config.env")));
25512
26755
  if (backupEnv.OR3_VERSION !== manifest2.appVersion || backupEnv.OR3_IMAGE !== manifest2.image) {
25513
26756
  throw new Error(`Backup ${manifest2.backupId} configuration does not match its manifest.`);
25514
26757
  }
25515
26758
  assertBackupMatchesDeployment(manifest2, backupEnv, loaded.state, loaded.env);
26759
+ assertSupportedBackupProfile(loaded.env);
26760
+ await assertBackupSourceProfile(loaded.directory, loaded.state, loaded.env);
25516
26761
  const recreateDataVolume = restoreRequiresVolumeRecreation(loaded.env, backupEnv);
25517
26762
  if (manifest2.imageDigest)
25518
26763
  await pullAndRequireImage(manifest2.image, manifest2.imageDigest, `Backup ${manifest2.backupId}`);
25519
26764
  await assertSupportedHostArchitecture(manifest2.image);
26765
+ await assertExistingVaultCustody(loaded.directory, loaded.state, manifest2, backupPath, backupEnv);
25520
26766
  await assertRestoreFreeSpace(loaded.directory, loaded.state, loaded.env, manifest2, backupPath);
25521
26767
  await validateVolumeArchive(loaded.directory, loaded.state.mode, loaded.env, backupPath, backupEnv.OR3_STORAGE_FS_ROOT);
25522
- 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.`);
25523
26774
  const pending = {
25524
26775
  id: id("restore"),
25525
26776
  operation: "restore",
@@ -25533,6 +26784,7 @@ async function restoreCommand(directory, flags, positionals) {
25533
26784
  targetImage: manifest2.image,
25534
26785
  targetImageDigest: manifest2.imageDigest,
25535
26786
  recreateDataVolume,
26787
+ remoteReconcile,
25536
26788
  phase: "prepared"
25537
26789
  };
25538
26790
  await markPending(loaded.directory, loaded.state, pending);
@@ -25584,6 +26836,8 @@ async function restoreCommand(directory, flags, positionals) {
25584
26836
  throw new Error(recoveredMessage);
25585
26837
  }
25586
26838
  try {
26839
+ if (managedVaultEnabled(loaded.directory, loaded.env))
26840
+ await assertVaultVolumesExist(loaded.directory, loaded.state);
25587
26841
  await startProject(loaded.directory, loaded.state.mode, loaded.env);
25588
26842
  loaded.state.lastError = `Restore of ${manifest2.backupId} stopped before data replacement: ${original}`;
25589
26843
  await clearPending(loaded.directory, loaded.state);
@@ -25598,7 +26852,7 @@ async function restoreCommand(directory, flags, positionals) {
25598
26852
  }
25599
26853
 
25600
26854
  // src/commands/rollback.ts
25601
- import { join as join27 } from "node:path";
26855
+ import { join as join28 } from "node:path";
25602
26856
  async function rollbackCommand(directory, flags) {
25603
26857
  if (!boolFlag(flags, "yes"))
25604
26858
  throw new Error("Rollback restores the previous image and data snapshot. Re-run with --yes after confirming post-update data loss.");
@@ -25614,16 +26868,23 @@ async function rollbackCommand(directory, flags) {
25614
26868
  if (manifest2.appVersion !== point.appVersion || manifest2.image !== point.image || manifest2.imageDigest !== point.imageDigest) {
25615
26869
  throw new Error(`Rollback point ${point.backupId} no longer matches its recorded image/version. Refusing to mutate the deployment.`);
25616
26870
  }
25617
- const backupEnv = parseEnv(await readText(join27(backupPath, "config.env")));
26871
+ const backupEnv = parseEnv(await readText(join28(backupPath, "config.env")));
25618
26872
  if (backupEnv.OR3_VERSION !== manifest2.appVersion || backupEnv.OR3_IMAGE !== manifest2.image) {
25619
26873
  throw new Error(`Backup ${manifest2.backupId} configuration does not match its manifest.`);
25620
26874
  }
25621
26875
  assertBackupMatchesDeployment(manifest2, backupEnv, loaded.state, loaded.env);
26876
+ assertSupportedBackupProfile(loaded.env);
26877
+ await assertBackupSourceProfile(loaded.directory, loaded.state, loaded.env);
25622
26878
  const recreateDataVolume = restoreRequiresVolumeRecreation(loaded.env, backupEnv);
25623
26879
  await pullAndRequireImage(point.image, point.imageDigest, "Rollback");
25624
26880
  await assertSupportedHostArchitecture(point.image);
26881
+ await assertExistingVaultCustody(loaded.directory, loaded.state, manifest2, backupPath, backupEnv);
25625
26882
  await assertRestoreFreeSpace(loaded.directory, loaded.state, loaded.env, manifest2, backupPath);
25626
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.`);
25627
26888
  const pending = {
25628
26889
  id: id("rollback"),
25629
26890
  operation: "rollback",
@@ -25637,6 +26898,7 @@ async function rollbackCommand(directory, flags) {
25637
26898
  targetImage: point.image,
25638
26899
  targetImageDigest: point.imageDigest,
25639
26900
  recreateDataVolume,
26901
+ remoteReconcile,
25640
26902
  phase: "prepared"
25641
26903
  };
25642
26904
  await markPending(loaded.directory, loaded.state, pending);
@@ -25688,6 +26950,8 @@ async function rollbackCommand(directory, flags) {
25688
26950
  throw new Error(recoveredMessage);
25689
26951
  }
25690
26952
  try {
26953
+ if (managedVaultEnabled(loaded.directory, loaded.env))
26954
+ await assertVaultVolumesExist(loaded.directory, loaded.state);
25691
26955
  await startProject(loaded.directory, loaded.state.mode, loaded.env);
25692
26956
  loaded.state.lastError = `Rollback to ${point.appVersion} stopped before data replacement: ${original}`;
25693
26957
  await clearPending(loaded.directory, loaded.state);
@@ -25725,6 +26989,7 @@ async function statusCommand(directory, flags = {}) {
25725
26989
  identityMatches: observation.identityMatches,
25726
26990
  lastReceipt: state?.lastReceipt ?? null,
25727
26991
  backups: observation.backups,
26992
+ recoveryPoint: latestFullSnapshot(observation.backups, observation.observedAt),
25728
26993
  findings
25729
26994
  }, null, 2));
25730
26995
  return;
@@ -25824,8 +27089,8 @@ ${result.stderr}`);
25824
27089
  }
25825
27090
 
25826
27091
  // src/commands/update.ts
25827
- import { readFile as readFile4, statfs as statfs2 } from "node:fs/promises";
25828
- 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";
25829
27094
  async function assessUpdate(directory, targetVersion, options = {}) {
25830
27095
  const checkDocker = options.checkDocker ?? false;
25831
27096
  const observation = await observeDeployment(directory, { checkDocker, checkImage: false });
@@ -25890,7 +27155,7 @@ async function assessUpdate(directory, targetVersion, options = {}) {
25890
27155
  for (const name of managedAssetNames(state?.mode ?? "local")) {
25891
27156
  let desired;
25892
27157
  try {
25893
- desired = await readFile4(join28(ASSET_ROOT, name));
27158
+ desired = await readFile6(join29(ASSET_ROOT, name));
25894
27159
  } catch (error62) {
25895
27160
  findings.push({
25896
27161
  code: "managed-asset-unreadable",
@@ -25901,12 +27166,12 @@ async function assessUpdate(directory, targetVersion, options = {}) {
25901
27166
  continue;
25902
27167
  }
25903
27168
  try {
25904
- const installedPath = join28(directory, name);
27169
+ const installedPath = join29(directory, name);
25905
27170
  if (!await fileExists(installedPath)) {
25906
27171
  changed.push(`${name} (add)`);
25907
27172
  continue;
25908
27173
  }
25909
- const installed = await readFile4(installedPath);
27174
+ const installed = await readFile6(installedPath);
25910
27175
  if (!installed.equals(desired))
25911
27176
  changed.push(`${name} (update)`);
25912
27177
  } catch (error62) {
@@ -26013,6 +27278,8 @@ async function runUpdateCommand(directory, flags, asJson) {
26013
27278
  progress(`OR3 ${targetVersion} is already installed. Nothing to do.`);
26014
27279
  return;
26015
27280
  }
27281
+ assertSupportedBackupProfile(env);
27282
+ await assertBackupSourceProfile(loaded.directory, state, env);
26016
27283
  const preflight = await assessUpdate(loaded.directory, targetVersion, { underLease: true, checkDocker: true });
26017
27284
  const preflightBlockers = preflight.findings.filter((finding) => finding.severity === "blocker");
26018
27285
  if (preflightBlockers.length > 0) {
@@ -26114,7 +27381,8 @@ async function runUpdateCommand(directory, flags, asJson) {
26114
27381
  }
26115
27382
  await restoreBackupData(loaded.directory, state, oldEnv, backup.backupDir, {
26116
27383
  recreateDataVolume,
26117
- previousSafetySnapshot: true
27384
+ previousSafetySnapshot: true,
27385
+ remoteReferencePolicy: "warn"
26118
27386
  });
26119
27387
  } catch (restoreError) {
26120
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)}`);
@@ -26137,7 +27405,7 @@ async function runUpdateCommand(directory, flags, asJson) {
26137
27405
  const targetReadyEvidence = {
26138
27406
  checkedAt: now(),
26139
27407
  deploymentId: targetDeploymentId,
26140
- deploymentRoot: resolve14(loaded.directory),
27408
+ deploymentRoot: resolve15(loaded.directory),
26141
27409
  containerId: targetChecks.containerId,
26142
27410
  imageDigest: digest,
26143
27411
  configurationSha256: await sha256File(deploymentPaths(loaded.directory).env),
@@ -26211,7 +27479,7 @@ async function runUpdateCommand(directory, flags, asJson) {
26211
27479
  state.image = targetImage;
26212
27480
  state.imageDigest = digest;
26213
27481
  state.deploymentId = targetDeploymentId;
26214
- state.deploymentRoot = resolve14(loaded.directory);
27482
+ state.deploymentRoot = resolve15(loaded.directory);
26215
27483
  state.lastSuccessfulOperation = "update";
26216
27484
  state.lastError = undefined;
26217
27485
  let commit;
@@ -26257,7 +27525,7 @@ async function runUpdateCommand(directory, flags, asJson) {
26257
27525
  }
26258
27526
 
26259
27527
  // src/commands/verify.ts
26260
- import { join as join29, resolve as resolve15 } from "node:path";
27528
+ import { join as join30, resolve as resolve16 } from "node:path";
26261
27529
  async function verificationCredentials(directory, flags, env) {
26262
27530
  const passwordFile = stringFlag(flags, "verification-password-file");
26263
27531
  if (flags["verification-email"] !== undefined && !stringFlag(flags, "verification-email")) {
@@ -26269,7 +27537,7 @@ async function verificationCredentials(directory, flags, env) {
26269
27537
  const configuredEmail = stringFlag(flags, "verification-email")?.trim() || env.OR3_MANAGED_OWNER_EMAIL || env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
26270
27538
  let initialCredentials = {};
26271
27539
  if (!configuredEmail || !passwordFile && !env.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD) {
26272
- const initialCredentialsPath = join29(directory, ".or3-initial-credentials");
27540
+ const initialCredentialsPath = join30(directory, ".or3-initial-credentials");
26273
27541
  if (await fileExists(initialCredentialsPath)) {
26274
27542
  initialCredentials = parseEnv(await readOwnerOnlyText(initialCredentialsPath, "The initial credentials file"));
26275
27543
  }
@@ -26278,7 +27546,7 @@ async function verificationCredentials(directory, flags, env) {
26278
27546
  if (!email3)
26279
27547
  throw new Error("A current owner email is required for verification.");
26280
27548
  validateEmail(email3);
26281
- 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;
26282
27550
  if (!password) {
26283
27551
  throw new Error("A current owner password is required. Pass --verification-password-file after changing the bootstrap password.");
26284
27552
  }
@@ -26389,11 +27657,134 @@ async function verifyReadOnlyCommand(directory, flags) {
26389
27657
  }
26390
27658
  }
26391
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
+
26392
27782
  // src/cli/dispatch.ts
26393
27783
  var MUTATING_COMMANDS = new Set([
26394
27784
  "init",
26395
27785
  "update",
26396
27786
  "backup",
27787
+ "vault-key",
26397
27788
  "verify",
26398
27789
  "restore",
26399
27790
  "rollback",
@@ -26407,10 +27798,10 @@ var MUTATING_COMMANDS = new Set([
26407
27798
  ]);
26408
27799
  function mutationDirectory(command, positionals, flags) {
26409
27800
  if (command === "init")
26410
- return resolve16(process.cwd(), positionals[0] ?? "or3-cloud");
27801
+ return resolve17(process.cwd(), positionals[0] ?? "or3-cloud");
26411
27802
  if (command === "adopt") {
26412
27803
  const sourceDirectory = requireStringFlag(flags, "from");
26413
- return resolve16(process.cwd(), positionals[0] ?? `${basename7(sourceDirectory)}-managed`);
27804
+ return resolve17(process.cwd(), positionals[0] ?? `${basename8(sourceDirectory)}-managed`);
26414
27805
  }
26415
27806
  return process.cwd();
26416
27807
  }
@@ -26428,6 +27819,8 @@ async function dispatchCommand(command, parsed) {
26428
27819
  }
26429
27820
  if (command === "backup")
26430
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);
26431
27824
  if (command === "restore")
26432
27825
  return await restoreCommand(process.cwd(), parsed.flags, parsed.positionals);
26433
27826
  if (command === "rollback")
@@ -26469,6 +27862,7 @@ Usage:
26469
27862
  npx @or3/cloud init [directory] --public --domain <hostname>
26470
27863
  npx @or3/cloud update [--to <exact-version>] [--dry-run] [--json]
26471
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>
26472
27866
  npx @or3/cloud restore <backup-id-or-path> --yes
26473
27867
  npx @or3/cloud rollback --yes
26474
27868
  npx @or3/cloud credentials reset --yes [--owner-password-file <path> --admin-password-file <path>]
@@ -26491,6 +27885,7 @@ Options:
26491
27885
  --verification-password-file <path> Read the current owner password for this verification
26492
27886
  --port <port> Local OR3 port (default: 3000)
26493
27887
  --keep <n> Backups to retain when pruning (default: 5)
27888
+ --out <absolute-path> New owner-only vault-key export destination
26494
27889
  --force Bypass suspect-entry deferral; protected recovery backups remain protected
26495
27890
  --tail <n> Log lines to show (default: 200)
26496
27891
  --public Require verification through the public HTTPS origin
@@ -26499,7 +27894,7 @@ Options:
26499
27894
  --finish Commit a proven completed replacement without restoring data
26500
27895
  --restore Explicitly restore the recorded snapshot (requires --yes)
26501
27896
  --json Emit one machine-readable result object on stdout
26502
- --purge-data Remove data volumes and managed files (with remove)
27897
+ --purge-data Currently disabled pending export reconstruction support
26503
27898
  --yes Confirm a destructive restore, rollback, credentials reset, or purge
26504
27899
  --help Show this help
26505
27900
  --version Show the Cloud package version
@@ -26510,11 +27905,11 @@ The installer never changes firewall, DNS, Cloudflare, or Tailscale settings.`);
26510
27905
 
26511
27906
  // src/commands/recover-restart.ts
26512
27907
  import { request } from "node:http";
26513
- import { join as join30 } from "node:path";
27908
+ import { join as join32 } from "node:path";
26514
27909
  async function recoverRestart(directory, flags) {
26515
27910
  let journal;
26516
27911
  try {
26517
- journal = await readRestartJournal(join30(directory, ".or3-cloud", "restarts", "restarts.json"));
27912
+ journal = await readRestartJournal(join32(directory, ".or3-cloud", "restarts", "restarts.json"));
26518
27913
  } catch (error62) {
26519
27914
  if (error62.code === "ENOENT")
26520
27915
  return false;
@@ -26537,9 +27932,9 @@ async function recoverRestart(directory, flags) {
26537
27932
  if (!boolFlag(flags, "finish") || boolFlag(flags, "restore"))
26538
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.");
26539
27934
  const value = JSON.stringify({ requestId: operation.requestId });
26540
- await new Promise((resolve17, reject) => {
27935
+ await new Promise((resolve18, reject) => {
26541
27936
  const req = request({
26542
- socketPath: join30(deploymentPaths(directory).operatorIpc, "operator.sock"),
27937
+ socketPath: join32(deploymentPaths(directory).operatorIpc, "operator.sock"),
26543
27938
  path: "/lifecycle/v1/reconcile",
26544
27939
  method: "POST",
26545
27940
  headers: { "content-type": "application/json", "content-length": Buffer.byteLength(value) }
@@ -26554,7 +27949,7 @@ async function recoverRestart(directory, flags) {
26554
27949
  response.on("end", () => {
26555
27950
  clearTimeout(timer);
26556
27951
  if (response.statusCode === 200)
26557
- resolve17();
27952
+ resolve18();
26558
27953
  else
26559
27954
  reject(new Error("Restart recovery is unverified. Keep the receipt and inspect app/operator diagnostics; no additional restart was executed."));
26560
27955
  });
@@ -26605,7 +28000,7 @@ async function assertDeploymentFolder(command, directory = process.cwd()) {
26605
28000
  return;
26606
28001
  throw new Error("No OR3 Cloud deployment here. Run this from the folder that init created (usually ./or3-cloud).");
26607
28002
  }
26608
- var invokedAsCli = process.argv[1] ? ["or3", "cli.mjs"].includes(basename8(process.argv[1])) : false;
26609
- 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))) {
26610
28005
  await main();
26611
28006
  }