@camstack/system 1.2.157 → 1.2.159

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.
Files changed (65) hide show
  1. package/dist/addon-runner.js +17 -16
  2. package/dist/addon-runner.mjs +2 -1
  3. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  6. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  7. package/dist/builtins/alerts/alerts.addon.js +1 -1
  8. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  9. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  10. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  11. package/dist/builtins/console-logging/index.js +1 -1
  12. package/dist/builtins/console-logging/index.mjs +1 -1
  13. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  14. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  15. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  16. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  17. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  18. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  19. package/dist/builtins/hub-forwarder/index.js +1 -1
  20. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  21. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  22. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  23. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  24. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  25. package/dist/builtins/local-network/local-network.addon.js +1 -1
  26. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  27. package/dist/builtins/loki-logging/index.js +1 -1
  28. package/dist/builtins/loki-logging/index.mjs +1 -1
  29. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  30. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  31. package/dist/builtins/platform-probe/index.js +1 -1
  32. package/dist/builtins/platform-probe/index.mjs +1 -1
  33. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  34. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  35. package/dist/builtins/snapshot/index.js +1 -1
  36. package/dist/builtins/snapshot/index.mjs +1 -1
  37. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  38. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  39. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  40. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  41. package/dist/builtins/sqlite-storage/vector-index-shared.d.ts +30 -5
  42. package/dist/builtins/sqlite-storage/vector-index-vec.d.ts +28 -6
  43. package/dist/builtins/storage-orchestrator/access-guards.d.ts +32 -0
  44. package/dist/builtins/storage-orchestrator/location-secrets.d.ts +33 -0
  45. package/dist/builtins/storage-orchestrator/storage-migration-coordinator.d.ts +71 -1
  46. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.d.ts +26 -0
  47. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +490 -8
  48. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +490 -8
  49. package/dist/builtins/storage-orchestrator/storage-orchestrator.service.d.ts +37 -1
  50. package/dist/builtins/system-config/system-config.addon.js +1 -1
  51. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  52. package/dist/builtins/winston-logging/index.js +1 -1
  53. package/dist/builtins/winston-logging/index.mjs +1 -1
  54. package/dist/{dist-CDgIzo82.js → dist-Ck2jkBZk.js} +463 -10
  55. package/dist/{dist-LvKus-mT.mjs → dist-DLh4_rn3.mjs} +446 -11
  56. package/dist/index.js +140 -139
  57. package/dist/index.mjs +3 -3
  58. package/dist/kernel/deps/manifest-system-deps.d.ts +15 -0
  59. package/dist/kernel/index.d.ts +2 -0
  60. package/dist/kernel/storage-location-registry.d.ts +16 -1
  61. package/dist/{manifest-python-deps-DVODn-qc.js → manifest-system-deps-8boi90D9.js} +202 -7
  62. package/dist/{manifest-python-deps-CD14Pv0-.mjs → manifest-system-deps-tZZYG3RR.mjs} +197 -8
  63. package/dist/{retired-settings-keys-CV4QVdXj.mjs → retired-settings-keys-Bi0ZKBjb.mjs} +1 -1
  64. package/dist/{retired-settings-keys-BfAzWvPC.js → retired-settings-keys-Dp_CyuCW.js} +1 -1
  65. package/package.json +2 -1
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-CDgIzo82.js");
6
+ const require_dist = require("../../dist-Ck2jkBZk.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  let node_fs_promises = require("node:fs/promises");
9
9
  node_fs_promises = require_chunk.__toESM(node_fs_promises);
@@ -270,6 +270,8 @@ var BLOCKING_ONLY_CLASSES = ["galleryMedia"];
270
270
  * link back to this decision.
271
271
  */
272
272
  var GALLERY_FORBIDDEN_NEIGHBOUR_TYPES = ["recordings", "recordingsLow"];
273
+ /** The two single-flight mover engines. Lanes run in parallel; within a lane,
274
+ * classes run FIFO because the engine refuses a second concurrent job. */
273
275
  var MOVE_LANES = ["media", "footage"];
274
276
  var PARTICIPANTS = [
275
277
  "pipeline",
@@ -316,7 +318,8 @@ var StorageMigrationCoordinator = class {
316
318
  toLocationId: target.id,
317
319
  moverJobId: null,
318
320
  state: null,
319
- error: null
321
+ error: null,
322
+ progress: null
320
323
  });
321
324
  if (BLOCKING_ONLY_CLASSES.includes(storageClass)) findings.push({
322
325
  code: "blockingOnly",
@@ -414,7 +417,8 @@ var StorageMigrationCoordinator = class {
414
417
  ...move,
415
418
  moverJobId: null,
416
419
  state: null,
417
- error: null
420
+ error: null,
421
+ progress: null
418
422
  })),
419
423
  pauseLeaseId: null,
420
424
  pausedParticipants: [],
@@ -448,6 +452,177 @@ var StorageMigrationCoordinator = class {
448
452
  await Promise.all(job.moves.filter((move) => move.moverJobId !== null).map((move) => this.cancelMove(move)));
449
453
  return true;
450
454
  }
455
+ /**
456
+ * Every mover running right now, in both lanes — including the ones no
457
+ * migration armed.
458
+ *
459
+ * `status` already carries a migration's own progress (the coordinator folds
460
+ * it onto each move from the poll it is already doing). This exists for the
461
+ * other half: `recording.relocateFootage` and `pipelineAnalytics.relocateMedia`
462
+ * are operator-callable, and until {@link drain} existed that was the only way
463
+ * to run a drain at all. Such a mover has no job to fold into, so without this
464
+ * read a five-hour operation is invisible in the UI.
465
+ *
466
+ * `migrationJobId` is best-effort by construction: the coordinator keeps ONE
467
+ * durable job, so a mover armed by an older, since-overwritten migration
468
+ * reports `null`. That is the honest answer — nothing here can still claim it.
469
+ */
470
+ async movers() {
471
+ const job = await this.status();
472
+ const owned = /* @__PURE__ */ new Map();
473
+ for (const move of job?.moves ?? []) if (move.moverJobId !== null) owned.set(move.moverJobId, job?.jobId ?? "");
474
+ const [footage, media] = await Promise.all([this.deps.participants.recorder.listMovers(), this.deps.participants.analytics.listMovers()]);
475
+ const observedAt = this.deps.now();
476
+ const label = (lane, jobs) => jobs.map((mover) => ({
477
+ lane,
478
+ job: mover,
479
+ migrationJobId: owned.get(mover.jobId) ?? null,
480
+ observedAt
481
+ }));
482
+ return [...label("footage", footage), ...label("media", media)];
483
+ }
484
+ /**
485
+ * What every class's source STILL holds — the census behind a "drain
486
+ * remaining" action.
487
+ *
488
+ * A class appears here only when something is (or might be) left on a
489
+ * location that is not its default. An empty result therefore means exactly
490
+ * "there is nothing to drain", which is what lets the UI offer the action
491
+ * only when it is true, and what lets {@link drain} refuse rather than start
492
+ * a job that would move nothing and report `done` — the failure mode D295
493
+ * exists to end.
494
+ *
495
+ * `items: null` is "the archive could not be asked" and is still listed. A
496
+ * residue nobody could measure is the case an operator most needs to see;
497
+ * dropping it because the read failed would be the quiet success again.
498
+ */
499
+ async residue() {
500
+ const out = [];
501
+ for (const storageClass of STORAGE_CLASSES) {
502
+ if (!MOVER_CLASSES.includes(storageClass)) continue;
503
+ const target = this.deps.locations.getDefaultLocation(storageClass);
504
+ if (!target) continue;
505
+ if (laneOf(storageClass) === "media") {
506
+ const count = await unanswerable(this.deps.participants.analytics.residue({
507
+ toLocationId: target.id,
508
+ mode: storageClass === "galleryMedia" ? "gallery" : "move"
509
+ }));
510
+ if (count !== null && count.rows === 0) continue;
511
+ out.push({
512
+ storageClass,
513
+ fromLocationId: "*",
514
+ toLocationId: target.id,
515
+ items: count?.rows ?? null,
516
+ bytes: null
517
+ });
518
+ continue;
519
+ }
520
+ for (const source of this.deps.locations.listLocations({ type: storageClass })) {
521
+ if (source.id === target.id) continue;
522
+ const census = await unanswerable(this.deps.participants.recorder.residue({
523
+ fromLocationId: source.id,
524
+ footageClass: storageClass === "recordingsLow" ? "recordingsLow" : "recordings"
525
+ }));
526
+ if (census !== null && census.segments === 0) continue;
527
+ out.push({
528
+ storageClass,
529
+ fromLocationId: source.id,
530
+ toLocationId: target.id,
531
+ items: census?.segments ?? null,
532
+ bytes: census?.bytes ?? null
533
+ });
534
+ }
535
+ }
536
+ return out;
537
+ }
538
+ /**
539
+ * Run the DRAIN half alone, against classes whose default has already moved.
540
+ *
541
+ * ## Why this is a second verb rather than a looser `start`
542
+ *
543
+ * `start` refuses a destination that is already the class's default
544
+ * (`"recordingsLow:ssd" is already the "recordingsLow" default`). That refusal
545
+ * is correct and it is load-bearing: there is genuinely nothing left to
546
+ * repoint, and an operator must never be able to re-repoint a migrated class
547
+ * by accident. Making `start` idempotent — "an already-repointed class
548
+ * proceeds straight to draining" — would delete that protection AND make the
549
+ * verb mean two different things depending on state, so the confirmation an
550
+ * operator reads ("pauses the writers…") would be a lie half the time.
551
+ *
552
+ * `drain` instead cannot repoint AT ALL: it never touches
553
+ * `setDefaultLocations`, and its job starts in `draining` with `repointed`
554
+ * already true, so the `repointing` / `refreshing` / `resuming` blocks of
555
+ * {@link run} are behind it and unreachable. The two verbs are disjoint, and
556
+ * `start`'s refusal keeps meaning exactly what it meant.
557
+ *
558
+ * ## Why it re-derives the work instead of resuming the old job
559
+ *
560
+ * The finished job is the audit of what happened; re-opening it destroys
561
+ * that. And a drain is needed in cases where no migration job ever existed
562
+ * (a mover armed by hand, footage stranded on a location an operator added
563
+ * and then un-defaulted). One job = one operation, and the work list comes
564
+ * from {@link residue} — the archive — not from what a previous job believed.
565
+ */
566
+ async drain(input) {
567
+ if (this.startReserved) throw new Error("storage migration is already active");
568
+ this.startReserved = true;
569
+ try {
570
+ const existing = await this.status();
571
+ if (existing && isTerminal(existing) && existing.pausedParticipants.length > 0) {
572
+ await this.releaseAfterTerminal(existing);
573
+ await this.persist(existing);
574
+ if (existing.pausedParticipants.length > 0) throw new Error(`storage migration ${existing.jobId} still holds maintenance leases`);
575
+ }
576
+ if (existing && !isTerminal(existing)) throw new Error(`storage migration is already active (${existing.jobId})`);
577
+ for (const storageClass of input.classes) {
578
+ if (MOVER_CLASSES.includes(storageClass)) continue;
579
+ throw new Error(`No mover owns "${storageClass}" — there is nothing that can drain it. Move it by hand.`);
580
+ }
581
+ const residue = await this.residue();
582
+ const moves = [];
583
+ const destinations = {};
584
+ for (const storageClass of input.classes) {
585
+ const remaining = residue.filter((entry) => entry.storageClass === storageClass);
586
+ if (remaining.length === 0) throw new Error(`"${storageClass}" has nothing left outside its default location — there is nothing to drain. To CHANGE where it writes, use storageMigration.start with a new destination.`);
587
+ for (const entry of remaining) {
588
+ moves.push({
589
+ storageClass,
590
+ fromLocationId: entry.fromLocationId,
591
+ toLocationId: entry.toLocationId,
592
+ moverJobId: null,
593
+ state: null,
594
+ error: null,
595
+ progress: null
596
+ });
597
+ destinations[storageClass] = entry.toLocationId;
598
+ }
599
+ }
600
+ const now = this.deps.now();
601
+ const job = {
602
+ jobId: this.deps.newId(),
603
+ phase: "draining",
604
+ mode: "nonBlocking",
605
+ destinations,
606
+ throttleMbps: input.throttleMbps ?? 40,
607
+ moves,
608
+ pauseLeaseId: null,
609
+ pausedParticipants: [],
610
+ repointed: true,
611
+ cancelRequested: false,
612
+ startedAt: now,
613
+ updatedAt: now,
614
+ finishedAt: null,
615
+ error: null
616
+ };
617
+ await this.persist(job);
618
+ this.active = job;
619
+ this.runPromise = this.run(job);
620
+ this.runPromise;
621
+ return job.jobId;
622
+ } finally {
623
+ this.startReserved = false;
624
+ }
625
+ }
451
626
  /** Boot recovery resumes a durable unfinished state. A missing in-memory
452
627
  * child mover is recreated from the same copy-if-absent input. */
453
628
  async recover() {
@@ -656,6 +831,13 @@ var StorageMigrationCoordinator = class {
656
831
  }
657
832
  move.state = status.state;
658
833
  move.error = status.error;
834
+ move.progress = {
835
+ filesMoved: status.filesMoved,
836
+ filesTotal: status.filesTotal,
837
+ bytesMoved: status.bytesMoved,
838
+ startedAt: status.startedAt,
839
+ observedAt: this.deps.now()
840
+ };
659
841
  if (status.state === "failed") throw new Error(move.error ?? `${move.storageClass} move failed`);
660
842
  if (status.state === "cancelled") {
661
843
  job.cancelRequested = true;
@@ -775,6 +957,25 @@ var StorageMigrationCoordinator = class {
775
957
  await this.deps.state.set(job);
776
958
  }
777
959
  };
960
+ /**
961
+ * A residue read that FAILED is unanswerable, not zero — and it must not take
962
+ * the other lane down with it.
963
+ *
964
+ * `residue` asks two addons. One of them being mid-restart is an ordinary
965
+ * event, and letting that throw out of the whole call would blank the panel:
966
+ * an operator with 80 GB stranded on a recordings disk would be told nothing,
967
+ * because post-analysis was rebooting. `null` is the same "unknown" the
968
+ * participants themselves return for a read they could not take, so the entry
969
+ * is still LISTED and still labelled unknown — a drain attempted against it
970
+ * then fails loudly with the participant's own message, which is the point.
971
+ */
972
+ async function unanswerable(read) {
973
+ try {
974
+ return await read;
975
+ } catch {
976
+ return null;
977
+ }
978
+ }
778
979
  function requireLease(job) {
779
980
  if (job.pauseLeaseId === null) throw new Error("storage migration has no maintenance lease");
780
981
  return job.pauseLeaseId;
@@ -817,6 +1018,12 @@ function runsUnleased(job) {
817
1018
  * - **Cardinality conflict throws**: if two addons declare the same `id` with
818
1019
  * different `cardinality` values an `Error` is thrown immediately (message
819
1020
  * contains the words "cardinality" and the conflicting `id`).
1021
+ * - **Access conflict throws**, for the same reason and more sharply: the
1022
+ * EFFECTIVE access (declared, else `STORAGE_ACCESS_FALLBACK`) must agree.
1023
+ * "First declarer wins" is the right rule for a display name and a
1024
+ * catastrophic one for a safety constraint — it would let a second addon
1025
+ * silently widen a kind that the first one restricted, which is exactly the
1026
+ * accident this constraint exists to make impossible.
820
1027
  *
821
1028
  * @param perAddon - One entry per addon; each entry is that addon's
822
1029
  * `storageLocations` declaration array.
@@ -827,6 +1034,9 @@ function buildStorageLocationRegistry(perAddon) {
827
1034
  const existing = map.get(declaration.id);
828
1035
  if (existing !== void 0) {
829
1036
  if (existing.cardinality !== declaration.cardinality) throw new Error(`Storage location cardinality conflict for id "${declaration.id}": "${existing.cardinality}" (first declarer) vs "${declaration.cardinality}" (later declarer). All addons declaring the same storage location id must agree on cardinality.`);
1037
+ const existingAccess = existing.access ?? "local-path";
1038
+ const declaredAccess = declaration.access ?? "local-path";
1039
+ if (existingAccess !== declaredAccess) throw new Error(`Storage location access conflict for id "${declaration.id}": "${existingAccess}" (first declarer) vs "${declaredAccess}" (later declarer). All addons declaring the same storage location id must agree on access — a later declaration must never widen what an earlier one restricted.`);
830
1040
  continue;
831
1041
  }
832
1042
  map.set(declaration.id, declaration);
@@ -842,12 +1052,53 @@ function buildStorageLocationRegistry(perAddon) {
842
1052
  cardinalityOf(id) {
843
1053
  return map.get(id)?.cardinality ?? null;
844
1054
  },
1055
+ accessOf(id) {
1056
+ const declaration = map.get(id);
1057
+ if (declaration === void 0) return null;
1058
+ return declaration.access ?? "local-path";
1059
+ },
845
1060
  list() {
846
1061
  return frozen;
847
1062
  }
848
1063
  };
849
1064
  }
850
1065
  //#endregion
1066
+ //#region src/builtins/storage-orchestrator/access-guards.ts
1067
+ /**
1068
+ * May `storage.resolve` return a path for this location?
1069
+ *
1070
+ * `resolve` is the only method on the cap whose result LEAVES the storage
1071
+ * abstraction — every caller of it opens the string with `node:fs`. A remote
1072
+ * provider answers with a path on the remote host, which on this node is
1073
+ * either missing or an unrelated local directory of the same name. For the
1074
+ * recorder, writing to the wrong place is not an error the operator sees; it
1075
+ * is a black window in the footage.
1076
+ *
1077
+ * Only a POSITIVE `false` refuses. `undefined` is "not yet classified", not
1078
+ * "remote" — and a conclusion drawn from a read that was never made is how a
1079
+ * doorbell rang 0/3 (D49).
1080
+ */
1081
+ function resolveRefusalFor(location, locality) {
1082
+ if (locality !== false) return null;
1083
+ return `storage.resolve is a request for a path this node can open with node:fs, and location "${location.id}" is served by the remote provider "${location.providerId}" (nodeLocal: false) — the path it would return exists on the remote host, not here. Read and write this location through the storage cap (read/write, or beginUpload/writeChunk/finalizeUpload) instead.`;
1084
+ }
1085
+ /**
1086
+ * Can this node's filesystem answer "does this location still hold anything?"
1087
+ *
1088
+ * Only for a provider it KNOWS to be node-local. Everything else is `unknown`,
1089
+ * which refuses a delete.
1090
+ *
1091
+ * The direction that matters is the one this closes. A remote location carries
1092
+ * a plain-looking `basePath` (`/backups/camstack`); `fs.readdir` of it on the
1093
+ * hub answers `ENOENT`, and `ENOENT` is deliberately read as `empty` — because
1094
+ * for a LOCAL location an absent root means there is nothing the delete can
1095
+ * strand. Applied to a remote row, that same reasoning turns a full share into
1096
+ * a vacant one and walks D293's delete guard straight past it.
1097
+ */
1098
+ function canProbeOccupancyLocally(locality) {
1099
+ return locality === true;
1100
+ }
1101
+ //#endregion
851
1102
  //#region src/builtins/storage-orchestrator/data-store-dispatch.ts
852
1103
  /**
853
1104
  * Pick the engine to serve a call.
@@ -899,6 +1150,106 @@ function createDataStoreDispatch(getEngines) {
899
1150
  };
900
1151
  }
901
1152
  //#endregion
1153
+ //#region src/builtins/storage-orchestrator/location-secrets.ts
1154
+ /**
1155
+ * Credential redaction for `StorageLocation.config`.
1156
+ *
1157
+ * ── What was wrong ─────────────────────────────────────────────────────────
1158
+ * `storage.listLocations` carries no `auth` override, so it is `protected` —
1159
+ * every authenticated session reaches it, admin or not. The orchestrator
1160
+ * spread each stored row verbatim. `StorageLocation.config` is
1161
+ * `z.record(z.string(), z.unknown())`, and the SFTP / S3 / WebDAV providers
1162
+ * already declare `password`, `privateKey`, `privateKeyPassphrase`,
1163
+ * `secretAccessKey` and `token` inside it. Nothing was exposed only because
1164
+ * every location on the live hub is `filesystem-storage`, whose config is
1165
+ * `basePath` + `maxUsedGb`. The exposure arrives with the first remote
1166
+ * location, and three providers that collect one already ship.
1167
+ *
1168
+ * ── The mechanism ──────────────────────────────────────────────────────────
1169
+ * Schema-driven, never a hardcoded key list: each provider's
1170
+ * `getProviderInfo().configSchema` already says which of its fields hold a
1171
+ * credential, so it is the one thing that cannot drift from the provider.
1172
+ *
1173
+ * Two directions, and both are needed:
1174
+ * - **out** — {@link redactLocationConfig} replaces every secret value with
1175
+ * `REDACTED_SECRET` on the way to a caller.
1176
+ * - **in** — {@link restoreRedactedSecrets} treats that same sentinel, sent
1177
+ * back on an upsert, as "keep what is stored". Without it the wizard's
1178
+ * edit-and-save round trip would write the sentinel into the config and
1179
+ * lock the operator out of their own share on the first rename.
1180
+ *
1181
+ * ── The trap this deliberately avoids ──────────────────────────────────────
1182
+ * Keying redaction off `type: 'password'` alone MISSES a secret parked in a
1183
+ * `textarea`, and the SFTP provider parks an entire PEM private key there.
1184
+ * Discovery goes through `collectSecretConfigKeys`, whose discriminator is
1185
+ * `type === 'password' || secret === true`. A redaction that quietly misses a
1186
+ * credential is worse than none: it is a leak plus an assurance there is not
1187
+ * one.
1188
+ */
1189
+ /** Shared empty set for providers that declare no secrets (or are unknown). */
1190
+ var EMPTY_SECRET_KEYS = /* @__PURE__ */ new Set();
1191
+ /** Secret config keys a provider declares, derived from its own form schema. */
1192
+ function secretKeysOfProviderInfo(info) {
1193
+ return require_dist.collectSecretConfigKeys(info.configSchema);
1194
+ }
1195
+ /**
1196
+ * A copy of `config` with every declared secret replaced by the sentinel.
1197
+ * Returns the SAME object when there is nothing to redact, so the common case
1198
+ * (a filesystem location) allocates nothing.
1199
+ *
1200
+ * A key the provider declares secret but that is absent from the stored config
1201
+ * is NOT invented — an absent password must keep reading as absent, or the
1202
+ * wizard would show a credential where none is set and the write-back would
1203
+ * then "keep" a value that never existed.
1204
+ */
1205
+ function redactLocationConfig(config, secretKeys) {
1206
+ if (secretKeys.size === 0) return config;
1207
+ let touched = false;
1208
+ const out = {};
1209
+ for (const [key, value] of Object.entries(config)) {
1210
+ if (secretKeys.has(key) && value !== void 0 && value !== null && value !== "") {
1211
+ out[key] = require_dist.REDACTED_SECRET;
1212
+ touched = true;
1213
+ continue;
1214
+ }
1215
+ out[key] = value;
1216
+ }
1217
+ return touched ? out : config;
1218
+ }
1219
+ /** The same, applied to a whole location record. */
1220
+ function redactLocation(location, secretKeys) {
1221
+ const config = redactLocationConfig(location.config, secretKeys);
1222
+ if (config === location.config) return location;
1223
+ return {
1224
+ ...location,
1225
+ config
1226
+ };
1227
+ }
1228
+ /**
1229
+ * Undo the redaction on the way IN: any secret key whose incoming value is the
1230
+ * sentinel is restored from what is already stored.
1231
+ *
1232
+ * A sentinel with NOTHING stored behind it is dropped rather than persisted —
1233
+ * writing the literal `__camstack_redacted__` as a password is a
1234
+ * lock-yourself-out bug that would only surface at the next connection
1235
+ * attempt, hours later, as an authentication failure with no explanation.
1236
+ */
1237
+ function restoreRedactedSecrets(incoming, stored, secretKeys) {
1238
+ if (secretKeys.size === 0) return incoming;
1239
+ let touched = false;
1240
+ const out = {};
1241
+ for (const [key, value] of Object.entries(incoming)) {
1242
+ if (value !== "__camstack_redacted__" || !secretKeys.has(key)) {
1243
+ out[key] = value;
1244
+ continue;
1245
+ }
1246
+ touched = true;
1247
+ const previous = stored?.[key];
1248
+ if (previous !== void 0) out[key] = previous;
1249
+ }
1250
+ return touched ? out : incoming;
1251
+ }
1252
+ //#endregion
902
1253
  //#region src/builtins/storage-orchestrator/provider-discovery.ts
903
1254
  async function collectProviderInfos(providers, onError) {
904
1255
  const out = [];
@@ -1255,6 +1606,66 @@ var StorageOrchestratorService = class {
1255
1606
  * implicitly-demoted siblings are persisted before the in-memory map
1256
1607
  * mutation returns. Persistence errors propagate to the caller.
1257
1608
  */
1609
+ /**
1610
+ * Refuse a `(location kind, provider)` pair the kind cannot use.
1611
+ *
1612
+ * The rule, in one line: a `'local-path'` kind requires a provider whose
1613
+ * `getProviderInfo().nodeLocal` is `true`.
1614
+ *
1615
+ * `nodeLocal` is the honest name for "this provider's `resolve` returns a
1616
+ * path on the filesystem of the node that resolved it". The
1617
+ * `storage-provider` cap's own discriminated union already forces every
1618
+ * provider to declare it, and all four remote providers declare `false`, so
1619
+ * no new cap surface is needed to ask the question.
1620
+ *
1621
+ * Three-valued on purpose. Only a POSITIVE `false` refuses:
1622
+ * - `true` → node-local, always fine.
1623
+ * - `false` → the provider is known and known to be remote. REFUSE.
1624
+ * - `undefined` → the provider has not registered yet (early boot, an addon
1625
+ * still loading). That is "unknown", not "remote", and a read that could
1626
+ * not be made must never destroy work (D49) — the system seed runs
1627
+ * through this path before any provider registers. Allowed, and logged,
1628
+ * so the allowance is never silent.
1629
+ *
1630
+ * The DECLARATION side is read the same way. A kind the registry does not
1631
+ * know is `local-path` (fail-closed: an unknown kind is not a permissive
1632
+ * one), but NO REGISTRY AT ALL is a different statement — nothing has been
1633
+ * loaded, so nothing can be concluded about any kind. The addon injects the
1634
+ * registry inside `onInitialize`, before the `storage` cap is mounted, so
1635
+ * there is no window in which an operator upsert sees this branch; a service
1636
+ * constructed without one is the in-memory/early-boot path.
1637
+ */
1638
+ refuseIncompatibleProvider(input) {
1639
+ const registry = this.registry;
1640
+ if (registry === null) {
1641
+ this.logger.debug("storage-orchestrator: no location declarations loaded — access constraint not evaluated", { meta: {
1642
+ id: input.id,
1643
+ type: input.type,
1644
+ providerId: input.providerId
1645
+ } });
1646
+ return;
1647
+ }
1648
+ const access = registry.accessOf(input.type) ?? "local-path";
1649
+ if (access !== "local-path") return;
1650
+ const nodeLocal = this.nodeLocalResolver?.(input.providerId);
1651
+ if (nodeLocal === true) return;
1652
+ if (nodeLocal === void 0) {
1653
+ this.logger.debug("storage-orchestrator: provider not yet classified — allowing a local-path upsert", { meta: {
1654
+ id: input.id,
1655
+ type: input.type,
1656
+ providerId: input.providerId,
1657
+ access
1658
+ } });
1659
+ return;
1660
+ }
1661
+ this.logger.warn("storage-orchestrator: REFUSED a remote provider for a local-path kind", { meta: {
1662
+ id: input.id,
1663
+ type: input.type,
1664
+ providerId: input.providerId,
1665
+ access
1666
+ } });
1667
+ throw new Error(`Storage kind "${input.type}" is declared access "local-path": the service that owns it reads and writes its bytes with node:fs on the path "storage.resolve" returns, so it can only be backed by a node-local provider. Provider "${input.providerId}" is remote (nodeLocal: false) and its resolved paths do not exist on this node. Refusing location "${input.id}".`);
1668
+ }
1258
1669
  upsertLocation(input) {
1259
1670
  const now = Date.now();
1260
1671
  const existing = this.locations.get(input.id);
@@ -1264,6 +1675,7 @@ var StorageOrchestratorService = class {
1264
1675
  if (already) throw new Error(`Storage type "${input.type}" is single — only one location allowed (existing: "${already.id}"). Edit it instead of adding a new one.`);
1265
1676
  }
1266
1677
  }
1678
+ this.refuseIncompatibleProvider(input);
1267
1679
  if (this.nodeLocalResolver?.(input.providerId) === true && !input.nodeId) input = {
1268
1680
  ...input,
1269
1681
  nodeId: "hub"
@@ -1775,6 +2187,19 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
1775
2187
  */
1776
2188
  nodeLocalByProvider = /* @__PURE__ */ new Map();
1777
2189
  /**
2190
+ * Cached `providerId → secret config keys`, derived from each provider's own
2191
+ * `configSchema` in the SAME refresh as `nodeLocalByProvider` — one
2192
+ * `getProviderInfo()` round trip answers both questions.
2193
+ *
2194
+ * An absent providerId yields an EMPTY set, which redacts nothing. That is
2195
+ * the one direction this cache can be wrong in, so it is worth saying why it
2196
+ * is acceptable: the entry is populated at the same moment the provider
2197
+ * becomes resolvable at all, so a location whose provider is unknown here
2198
+ * cannot be dispatched to either — there is no window in which a credential
2199
+ * is readable through a provider the orchestrator can otherwise use.
2200
+ */
2201
+ secretKeysByProvider = /* @__PURE__ */ new Map();
2202
+ /**
1778
2203
  * Disposers run on `onShutdown` — currently the eventBus subscription
1779
2204
  * for `capability:provider-registered` events used by the lazy seed
1780
2205
  * fallback. Stored separately from the `BaseAddon` disposer chain so
@@ -1809,13 +2234,24 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
1809
2234
  listLocations: async ({ type }) => {
1810
2235
  const rows = type !== void 0 ? service.listLocations({ type }) : service.listLocations();
1811
2236
  return Promise.all(rows.map(async (loc) => ({
1812
- ...loc,
2237
+ ...this.redacted(loc),
1813
2238
  capacity: await this.localCapacityOf(loc)
1814
2239
  })));
1815
2240
  },
1816
- getDefaultLocation: async ({ type }) => service.getDefaultLocation(type),
2241
+ getDefaultLocation: async ({ type }) => {
2242
+ const loc = service.getDefaultLocation(type);
2243
+ return loc === null ? null : this.redacted(loc);
2244
+ },
1817
2245
  listLocationDeclarations: async () => service.listDeclarations(),
1818
- upsertLocation: async (input) => service.upsertLocation(input),
2246
+ upsertLocation: async (input) => {
2247
+ const stored = service.getLocationById(input.id);
2248
+ const config = restoreRedactedSecrets(input.config, stored?.config, this.secretKeysFor(input.providerId));
2249
+ const saved = service.upsertLocation(config === input.config ? input : {
2250
+ ...input,
2251
+ config
2252
+ });
2253
+ return this.redacted(saved);
2254
+ },
1819
2255
  deleteLocation: async ({ id, force }) => {
1820
2256
  await service.deleteLocation(id, { force: force === true });
1821
2257
  },
@@ -1860,6 +2296,7 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
1860
2296
  },
1861
2297
  resolve: async ({ location, relativePath }) => {
1862
2298
  const loc = service.resolveRef(location);
2299
+ this.refuseRemoteResolve(loc);
1863
2300
  return (await service.getProviderFor(loc)).resolve({
1864
2301
  location: loc,
1865
2302
  relativePath
@@ -1982,6 +2419,8 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
1982
2419
  startMove: (input) => this.ctx.api.recording.startStorageMigrationMove.mutate(input),
1983
2420
  startDrain: (input) => this.ctx.api.recording.relocateFootage.mutate(input),
1984
2421
  getMove: (jobId) => this.ctx.api.recording.getStorageMigrationMoveStatus.query({ jobId }),
2422
+ listMovers: () => this.ctx.api.recording.listRelocateJobs.query({}),
2423
+ residue: (input) => this.ctx.api.recording.getRelocateResidue.query(input),
1985
2424
  cancelMove: async (jobId) => (await this.ctx.api.recording.cancelStorageMigrationMove.mutate({ jobId })).cancelled,
1986
2425
  refresh: async (leaseId) => {
1987
2426
  await this.ctx.api.recording.refreshStorageLocationsForMigration.mutate({ leaseId });
@@ -1998,6 +2437,8 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
1998
2437
  startDrain: (input) => this.ctx.api.pipelineAnalytics.relocateMedia.mutate(input),
1999
2438
  countUnstamped: () => this.ctx.api.pipelineAnalytics.countUnstampedEventMedia.query({}),
2000
2439
  getMove: (jobId) => this.ctx.api.pipelineAnalytics.getStorageMigrationMoveStatus.query({ jobId }),
2440
+ listMovers: () => this.ctx.api.pipelineAnalytics.listRelocateMediaJobs.query({}),
2441
+ residue: (input) => this.ctx.api.pipelineAnalytics.countRelocatableMedia.query(input),
2001
2442
  cancelMove: async (jobId) => (await this.ctx.api.pipelineAnalytics.cancelStorageMigrationMove.mutate({ jobId })).cancelled,
2002
2443
  refresh: async (leaseId) => {
2003
2444
  await this.ctx.api.pipelineAnalytics.refreshStorageLocationsForMigration.mutate({ leaseId });
@@ -2013,7 +2454,10 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
2013
2454
  plan: (input) => migration.plan(input),
2014
2455
  start: async (input) => ({ jobId: await migration.start(input) }),
2015
2456
  status: ({ jobId }) => migration.status(jobId),
2016
- cancel: async ({ jobId }) => ({ cancelled: await migration.cancel(jobId) })
2457
+ cancel: async ({ jobId }) => ({ cancelled: await migration.cancel(jobId) }),
2458
+ movers: () => migration.movers(),
2459
+ residue: () => migration.residue(),
2460
+ drain: async (input) => ({ jobId: await migration.drain(input) })
2017
2461
  };
2018
2462
  await this.seedFromDeclarations();
2019
2463
  const eventBus = this.ctx.eventBus;
@@ -2156,6 +2600,34 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
2156
2600
  }
2157
2601
  return out;
2158
2602
  }
2603
+ /** Declared secret config keys for a provider; empty when unknown. */
2604
+ secretKeysFor(providerId) {
2605
+ return this.secretKeysByProvider.get(providerId) ?? EMPTY_SECRET_KEYS;
2606
+ }
2607
+ /** A location with its provider's declared secrets replaced by the sentinel. */
2608
+ redacted(location) {
2609
+ return redactLocation(location, this.secretKeysFor(location.providerId));
2610
+ }
2611
+ /**
2612
+ * Is this location backed by a provider that serves a genuine local
2613
+ * filesystem? `true` / `false` / `undefined` — see
2614
+ * `StorageOrchestratorService.refuseIncompatibleProvider` for why the
2615
+ * unknown case is kept distinct rather than folded into either answer.
2616
+ */
2617
+ providerIsNodeLocal(location) {
2618
+ return this.nodeLocalByProvider.get(location.providerId);
2619
+ }
2620
+ /** See the call site in the `resolve` dispatch, and `access-guards.ts`. */
2621
+ refuseRemoteResolve(location) {
2622
+ const refusal = resolveRefusalFor(location, this.providerIsNodeLocal(location));
2623
+ if (refusal === null) return;
2624
+ this.ctx.logger.warn("storage-orchestrator: REFUSED resolve() on a remote-backed location", { meta: {
2625
+ id: location.id,
2626
+ type: location.type,
2627
+ providerId: location.providerId
2628
+ } });
2629
+ throw new Error(refusal);
2630
+ }
2159
2631
  /** statfs capacity of a location's basePath, walking up to the nearest
2160
2632
  * existing ancestor. Null for remote-node locations or unstattable paths. */
2161
2633
  async localCapacityOf(loc) {
@@ -2243,6 +2715,13 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
2243
2715
  * errno — `EACCES`, `EIO`, a stale NFS handle — is `unknown` and refuses.
2244
2716
  */
2245
2717
  async locationOccupancy(location) {
2718
+ if (!canProbeOccupancyLocally(this.providerIsNodeLocal(location))) {
2719
+ this.ctx.logger.debug("storage-orchestrator: occupancy unknown — location is not backed by a local filesystem", { meta: {
2720
+ id: location.id,
2721
+ providerId: location.providerId
2722
+ } });
2723
+ return "unknown";
2724
+ }
2246
2725
  if ((location.nodeId === void 0 || location.nodeId === "" ? HUB_NODE_ID : location.nodeId) !== (this.service?.getLocalNodeId() ?? HUB_NODE_ID)) return "unknown";
2247
2726
  const basePath = this.locationBasePath(location.id);
2248
2727
  if (basePath === null) return "unknown";
@@ -2325,7 +2804,10 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
2325
2804
  error: err instanceof Error ? err.message : String(err)
2326
2805
  } });
2327
2806
  });
2328
- for (const info of infos) this.nodeLocalByProvider.set(info.providerId, info.nodeLocal);
2807
+ for (const info of infos) {
2808
+ this.nodeLocalByProvider.set(info.providerId, info.nodeLocal);
2809
+ this.secretKeysByProvider.set(info.providerId, secretKeysOfProviderInfo(info));
2810
+ }
2329
2811
  }
2330
2812
  };
2331
2813
  //#endregion