@or3/cloud 0.1.100 → 0.1.107

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/assets/Caddyfile CHANGED
@@ -5,13 +5,34 @@
5
5
  header {
6
6
  Strict-Transport-Security "max-age=31536000"
7
7
  X-Content-Type-Options "nosniff"
8
- X-Frame-Options "DENY"
9
8
  Referrer-Policy "strict-origin-when-cross-origin"
10
9
  Permissions-Policy "camera=(), microphone=(), geolocation=()"
10
+ }
11
+
12
+ # Only the two host-owned bridge documents can be embedded. Ordinary app
13
+ # pages still deny every ancestor, including same-origin frames.
14
+ @hostDocument {
15
+ not path /or3/portable-frame /streamsaver/mitm.html
16
+ }
17
+ header @hostDocument {
18
+ X-Frame-Options "DENY"
11
19
  # Nuxt needs same-origin scripts plus inline hydration styles/scripts. The
12
20
  # browser exchanges OpenRouter PKCE authorization codes directly with the
13
21
  # OpenRouter API, so that origin must remain available to connect-src.
14
- Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://openrouter.ai; media-src 'self' blob:; worker-src 'self' blob:; frame-src 'none'; upgrade-insecure-requests"
22
+ Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://openrouter.ai; media-src 'self' blob:; worker-src 'self' blob:; frame-src 'self'; upgrade-insecure-requests"
23
+ }
24
+
25
+ # Preserve the portable route's hash-authorized, opaque-origin sandbox CSP.
26
+ # Applying the app policy here would block its worker or weaken containment.
27
+ @portableFrame path /or3/portable-frame
28
+ header @portableFrame X-Frame-Options "SAMEORIGIN"
29
+
30
+ # The fallback streaming exporter uses this bundled same-origin bridge to
31
+ # register its download service worker; it cannot use an opaque origin.
32
+ @downloadBridge path /streamsaver/mitm.html
33
+ header @downloadBridge {
34
+ X-Frame-Options "SAMEORIGIN"
35
+ Content-Security-Policy "default-src 'none'; script-src 'unsafe-inline'; connect-src 'self'; worker-src 'self'; frame-ancestors 'self'; base-uri 'none'; form-action 'none'"
15
36
  }
16
37
 
17
38
  encode zstd gzip
@@ -20354,6 +20354,10 @@ async function writeEnvFileDetailed(updates, options = {}) {
20354
20354
 
20355
20355
  // ../../shared/admin/configuration.ts
20356
20356
  var siteSettingPaths = {
20357
+ OR3_STORAGE_CAPACITY_WARNING_PERCENT: ["storageCapacityWarningPercent"],
20358
+ OR3_STORAGE_CAPACITY_CRITICAL_PERCENT: ["storageCapacityCriticalPercent"],
20359
+ OR3_CHAT_TOOL_ITERATIONS: ["chatToolIterations"],
20360
+ OR3_CHAT_GENERATED_IMAGES: ["chatGeneratedImages"],
20357
20361
  OR3_SITE_NAME: ["or3", "site", "name"],
20358
20362
  OR3_SITE_DESCRIPTION: ["or3", "site", "description"],
20359
20363
  OR3_LOGO_URL: ["or3", "site", "logoUrl"],
@@ -20851,6 +20855,8 @@ var ALLOWED_ENV_KEYS = new Set([
20851
20855
  "OR3_CLOUD_STORAGE_ENABLED",
20852
20856
  "NUXT_PUBLIC_STORAGE_PROVIDER",
20853
20857
  "OR3_STORAGE_FS_ROOT",
20858
+ "OR3_STORAGE_FS_CLEANUP_NAMESPACE_ID",
20859
+ "OR3_STORAGE_RETENTION_WORKSPACES",
20854
20860
  "OR3_STORAGE_FS_TOKEN_SECRET",
20855
20861
  "OR3_STORAGE_FS_URL_TTL_SECONDS",
20856
20862
  "OR3_ADMIN_USERNAME",
@@ -317,6 +317,22 @@ async function readLastReceipt() {
317
317
  }
318
318
  }
319
319
 
320
+ /** Read-only allowlist: a recorded snapshot is not current checksum evidence. */
321
+ export function projectRecoveryState(state) {
322
+ const safeId = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,100}$/.test(value) ? value : null;
323
+ const safeTime = value => typeof value === 'string' && value.length <= 64 && Number.isFinite(Date.parse(value)) ? value : null;
324
+ const snapshotId = safeId(state?.rollback?.backupId);
325
+ const pending = state?.incompleteOperation;
326
+ return {
327
+ observedAt: new Date().toISOString(), source: 'managed_operator',
328
+ snapshot: snapshotId ? { id: snapshotId, capturedAt: safeTime(state.rollback.createdAt), verification: 'operator_reported' } : null,
329
+ coverage: ['canonical', 'auth', 'files', 'configuration', 'billing_trials_support', 'credential_escrow', 'independent_copy'].map(component => ({ component, status: 'unknown', verifiedAt: null })),
330
+ lastRecoveryTestAt: null,
331
+ incompleteOperation: pending && safeId(pending.id) && ['init', 'update', 'restore', 'adopt', 'credentials-reset', 'backup', 'rollback'].includes(pending.operation)
332
+ ? { id: pending.id, operation: pending.operation, startedAt: safeTime(pending.startedAt) } : null,
333
+ };
334
+ }
335
+
320
336
  async function status(includeReceipt = false) {
321
337
  const version = await currentVersion();
322
338
  const [job, checked] = await Promise.all([readJob(), readReleaseCheck()]);
@@ -992,6 +1008,7 @@ async function main() {
992
1008
  if (await service.handle(service.controller, request, response)) return;
993
1009
  } catch { return send(response, 503, { code: 'controller_unavailable' }); }
994
1010
  }
1011
+ if (request.method === 'GET' && request.url === '/v2/recovery') return send(response, 200, { protocolVersion: 2, recovery: projectRecoveryState(await managedState()) });
995
1012
  if (request.method === 'GET' && route === '/status') return reply(200, await status(isV2));
996
1013
  if (request.method === 'POST' && route === '/check') {
997
1014
  if (!consumeRateLimit(checkAttempts, Date.now(), 30, 60_000)) {
package/dist/cli.mjs CHANGED
@@ -179,7 +179,7 @@ import { readFileSync } from "node:fs";
179
179
  import { join, resolve } from "node:path";
180
180
  import { fileURLToPath } from "node:url";
181
181
  var PACKAGE_ROOT = resolve(fileURLToPath(new URL("../", import.meta.url)));
182
- var PACKAGE_VERSION = "0.1.100";
182
+ var PACKAGE_VERSION = "0.1.107";
183
183
  var IMAGE_REPOSITORY = "ghcr.io/saluana/or3-chat";
184
184
  var ASSET_ROOT = resolve(fileURLToPath(new URL("../assets/", import.meta.url)));
185
185
  var SHA256_DIGEST = /^sha256:[0-9a-f]{64}$/;
@@ -402,6 +402,8 @@ var ALLOWED_ENV_KEYS = new Set([
402
402
  "OR3_CLOUD_STORAGE_ENABLED",
403
403
  "NUXT_PUBLIC_STORAGE_PROVIDER",
404
404
  "OR3_STORAGE_FS_ROOT",
405
+ "OR3_STORAGE_FS_CLEANUP_NAMESPACE_ID",
406
+ "OR3_STORAGE_RETENTION_WORKSPACES",
405
407
  "OR3_STORAGE_FS_TOKEN_SECRET",
406
408
  "OR3_STORAGE_FS_URL_TTL_SECONDS",
407
409
  "OR3_ADMIN_USERNAME",
@@ -4518,6 +4520,201 @@ import { join as join24 } from "node:path";
4518
4520
 
4519
4521
  // src/backup/restore.ts
4520
4522
  import { join as join21 } from "node:path";
4523
+
4524
+ // src/backup/generation-restore.ts
4525
+ function rebindRestoredGenerations(fs, path, crypto, db, root, options = {}) {
4526
+ const preserveMissing = options.previousSafetySnapshot === true;
4527
+ const fail = () => {
4528
+ throw new Error("Restored generation identity or payload is invalid");
4529
+ };
4530
+ const info = (name) => {
4531
+ try {
4532
+ return fs.lstatSync(name, { bigint: true });
4533
+ } catch (error) {
4534
+ if (error.code === "ENOENT")
4535
+ return null;
4536
+ throw error;
4537
+ }
4538
+ };
4539
+ const dir = (name) => {
4540
+ const s = info(name);
4541
+ if (!s?.isDirectory())
4542
+ fail();
4543
+ return s;
4544
+ };
4545
+ const json = (name) => {
4546
+ const s = info(name);
4547
+ if (!s?.isFile() || s.nlink !== 1n || s.size > 4096n)
4548
+ fail();
4549
+ return JSON.parse(fs.readFileSync(name, "utf8"));
4550
+ };
4551
+ const base = path.join(root, "generations-v1");
4552
+ const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((row) => row.name));
4553
+ const required = new Set;
4554
+ if (tables.has("storage_object_generations")) {
4555
+ for (const row of db.prepare("SELECT generation_id FROM storage_object_generations WHERE storage_provider_id = 'fs'").all())
4556
+ required.add(String(row.generation_id));
4557
+ }
4558
+ if (tables.has("storage_generation_uploads")) {
4559
+ for (const row of db.prepare("SELECT generation_id FROM storage_generation_uploads WHERE storage_provider_id = 'fs' AND ready_receipt_id IS NOT NULL").all())
4560
+ required.add(String(row.generation_id));
4561
+ }
4562
+ if (!info(base)) {
4563
+ if (required.size && !preserveMissing)
4564
+ fail();
4565
+ return;
4566
+ }
4567
+ dir(root);
4568
+ dir(base);
4569
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
4570
+ const namespaceFile = path.join(base, "namespace.json");
4571
+ if (!info(namespaceFile) && preserveMissing)
4572
+ return;
4573
+ const ns = json(namespaceFile);
4574
+ if (ns.version !== 1 || !uuid.test(ns.namespaceId))
4575
+ fail();
4576
+ if (!tables.has("storage_object_generations"))
4577
+ fail();
4578
+ const workspaceRoot = path.join(base, "workspaces");
4579
+ if (!info(workspaceRoot) && preserveMissing)
4580
+ return;
4581
+ dir(workspaceRoot);
4582
+ const changes = [];
4583
+ for (const workspace of fs.readdirSync(workspaceRoot)) {
4584
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(workspace))
4585
+ fail();
4586
+ const work = path.join(workspaceRoot, workspace);
4587
+ dir(work);
4588
+ for (const id2 of fs.readdirSync(work)) {
4589
+ if (!uuid.test(id2))
4590
+ fail();
4591
+ const slot = path.join(work, id2);
4592
+ dir(slot);
4593
+ const file = path.join(slot, "ready.json");
4594
+ if (!info(file)) {
4595
+ if (required.has(id2) && !preserveMissing)
4596
+ fail();
4597
+ continue;
4598
+ }
4599
+ const ready = json(file);
4600
+ if (ready.version !== 1 || ready.namespaceId !== ns.namespaceId || ready.workspaceId !== workspace || ready.generationId !== id2 || !/^sha256:[a-f0-9]{64}$/.test(ready.hash) || !Number.isSafeInteger(ready.sizeBytes) || ready.sizeBytes < 0 || !uuid.test(ready.readyReceiptId) || ready.storageId !== `fs-generation-v1:${ns.namespaceId}:${workspace}:${id2}:${ready.hash.slice(7)}`)
4601
+ fail();
4602
+ const generation = db.prepare("SELECT * FROM storage_object_generations WHERE generation_id = ?").get(id2);
4603
+ const upload = tables.has("storage_generation_uploads") ? db.prepare("SELECT * FROM storage_generation_uploads WHERE generation_id = ?").get(id2) : null;
4604
+ const record = generation ?? upload;
4605
+ if (!record || record.workspace_id !== workspace || record.storage_provider_id !== "fs" || record.storage_id !== ready.storageId || record.hash.replace(/^sha256:/i, "").toLowerCase() !== ready.hash.slice(7) || record.size_bytes !== ready.sizeBytes)
4606
+ fail();
4607
+ if (upload && (upload.namespace_id !== ns.namespaceId || upload.ready_receipt_id && upload.ready_receipt_id !== ready.readyReceiptId))
4608
+ fail();
4609
+ required.delete(id2);
4610
+ const payload = path.join(slot, "payload");
4611
+ const payloadInfo = info(payload);
4612
+ const needsBlob = generation?.state === "verified";
4613
+ if (!payloadInfo) {
4614
+ if (needsBlob && !preserveMissing)
4615
+ fail();
4616
+ continue;
4617
+ }
4618
+ dir(payload);
4619
+ if (generation?.state === "deleted" || !generation && upload?.state === "abandoned")
4620
+ fail();
4621
+ const entries = fs.readdirSync(payload);
4622
+ const links = new Map;
4623
+ for (const entry of entries) {
4624
+ if (entry !== "blob" && !/^\.upload-[0-9a-f-]{36}$/.test(entry))
4625
+ fail();
4626
+ const s = info(path.join(payload, entry));
4627
+ if (!s?.isFile())
4628
+ fail();
4629
+ const key = `${s.dev}:${s.ino}`;
4630
+ const previous = links.get(key);
4631
+ links.set(key, { count: (previous?.count ?? 0n) + 1n, expected: s.nlink });
4632
+ }
4633
+ if ([...links.values()].some((value) => value.count !== value.expected))
4634
+ fail();
4635
+ const blob = path.join(payload, "blob");
4636
+ if (info(blob)) {
4637
+ const fd = fs.openSync(blob, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
4638
+ try {
4639
+ const hash = crypto.createHash("sha256"), buffer = Buffer.alloc(65536);
4640
+ let size = 0, count = 0;
4641
+ while ((count = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) {
4642
+ hash.update(buffer.subarray(0, count));
4643
+ size += count;
4644
+ }
4645
+ if (size !== ready.sizeBytes || hash.digest("hex") !== ready.hash.slice(7))
4646
+ fail();
4647
+ } finally {
4648
+ fs.closeSync(fd);
4649
+ }
4650
+ } else if (needsBlob) {
4651
+ if (!preserveMissing)
4652
+ fail();
4653
+ continue;
4654
+ }
4655
+ changes.push({ file, ready: { ...ready, payloadDevice: String(payloadInfo.dev), payloadInode: String(payloadInfo.ino) } });
4656
+ }
4657
+ }
4658
+ if (required.size && !preserveMissing)
4659
+ fail();
4660
+ for (const { file, ready } of changes) {
4661
+ const temp = file + ".restore-" + crypto.randomUUID();
4662
+ const fd = fs.openSync(temp, "wx", 384);
4663
+ try {
4664
+ fs.writeFileSync(fd, JSON.stringify(ready));
4665
+ fs.fsyncSync(fd);
4666
+ } finally {
4667
+ fs.closeSync(fd);
4668
+ }
4669
+ fs.renameSync(temp, file);
4670
+ const directory = fs.openSync(path.dirname(file), "r");
4671
+ try {
4672
+ fs.fsyncSync(directory);
4673
+ } finally {
4674
+ fs.closeSync(directory);
4675
+ }
4676
+ }
4677
+ }
4678
+
4679
+ // src/backup/restore.ts
4680
+ async function restoreGenerationIdentities(directory, state, env, backupPath, options = {}) {
4681
+ await readManifest(backupPath, directory);
4682
+ const script = `
4683
+ const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto');
4684
+ const root = path.resolve(process.env.OR3_STORAGE_FS_ROOT || '/data/storage');
4685
+ if (root !== '/data' && !root.startsWith('/data/')) throw new Error('Generation restore requires managed storage');
4686
+ {
4687
+ const Database = require('/app/.output/server/node_modules/better-sqlite3');
4688
+ const db = new Database(process.env.OR3_SQLITE_DB_PATH, { readonly: true, fileMustExist: true });
4689
+ try {
4690
+ if (db.pragma('quick_check', { simple: true }) !== 'ok') throw new Error('Restored SQLite integrity check failed');
4691
+ (${rebindRestoredGenerations.toString()})(fs, path, crypto, db, root, ${JSON.stringify({ previousSafetySnapshot: options.previousSafetySnapshot === true })});
4692
+ } finally { db.close(); }
4693
+ }`;
4694
+ const result = await run("docker", [
4695
+ "run",
4696
+ "--rm",
4697
+ "--network",
4698
+ "none",
4699
+ "--read-only",
4700
+ "--security-opt",
4701
+ "no-new-privileges:true",
4702
+ "--cap-drop",
4703
+ "ALL",
4704
+ "-v",
4705
+ `${state.volumeName}:/data`,
4706
+ "--env",
4707
+ `OR3_SQLITE_DB_PATH=${env.OR3_SQLITE_DB_PATH ?? ""}`,
4708
+ "--env",
4709
+ `OR3_STORAGE_FS_ROOT=${env.OR3_STORAGE_FS_ROOT ?? "/data/storage"}`,
4710
+ "--entrypoint",
4711
+ "sh",
4712
+ env.OR3_IMAGE,
4713
+ ...containerNodeCommand(script).slice(1)
4714
+ ], directory);
4715
+ if (!result.ok)
4716
+ throw new Error(`Restored generation verification failed. ${redact(result.stderr, secretValues(env))}`);
4717
+ }
4521
4718
  function assertFilesystemPayloadHash(key, readChunk, createHash3) {
4522
4719
  const match = /^(sha256|md5)_([a-fA-F0-9]+)$/.exec(key);
4523
4720
  const algorithm = match?.[1];
@@ -4647,6 +4844,7 @@ async function restoreBackupData(directory, state, env, backupPath, options = {}
4647
4844
  if (!snapshotCheck.ok) {
4648
4845
  throw new Error(`Restored snapshot integrity verification failed. ${redact(snapshotCheck.stderr, secretValues(restoredEnv))}`);
4649
4846
  }
4847
+ await restoreGenerationIdentities(directory, state, restoredEnv, backupPath, { previousSafetySnapshot: options.previousSafetySnapshot });
4650
4848
  await options.beforeStart?.();
4651
4849
  await startProject(directory, state.mode, restoredEnv);
4652
4850
  return manifest2;
@@ -25902,6 +26100,7 @@ async function runUpdateCommand(directory, flags, asJson) {
25902
26100
  await copyAssets(loaded.directory, state.mode);
25903
26101
  if (migrateLegacyVolume || recreateDataVolume) {
25904
26102
  await restoreVolumeArchive(loaded.directory, state.mode, nextEnv, backup.backupDir);
26103
+ await restoreGenerationIdentities(loaded.directory, state, nextEnv, backup.backupDir);
25905
26104
  }
25906
26105
  } catch (error62) {
25907
26106
  await updatePending(loaded.directory, state, { phase: "restoring-previous" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@or3/cloud",
3
- "version": "0.1.100",
3
+ "version": "0.1.107",
4
4
  "description": "Install and operate the OR3 Cloud container distribution",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0",
@@ -19,9 +19,9 @@
19
19
  "dashboardUpdateProtocol": 1,
20
20
  "dashboardUpdateMinimumSourceVersion": "0.1.40",
21
21
  "stateSchema": 1,
22
- "imageDigest": "sha256:674186a2f176c5b400ab9379b82d48242ff9c64bd4ebf0af4eaee07a4307929f",
23
- "operatorImageDigest": "sha256:c2106191c2053df14d1212a8462f7dc125121f10e0ae2437e14752707e6cbf3f",
24
- "sourceRevision": "a79b927b87de07c407472b741ebbe8af7064d237"
22
+ "imageDigest": "sha256:345b221ce8716552bccbc0bebfbc9fe5d698cd4b486ab18b3a398c924eb48b7d",
23
+ "operatorImageDigest": "sha256:82df52a0b952907d7a1dbcdb2eebe411e654b3f6ad47439d1a89901399f18061",
24
+ "sourceRevision": "68b263584dc64d0e3a7b0035a90845d438678cd4"
25
25
  },
26
26
  "files": [
27
27
  "dist",