@camstack/system 1.2.157 → 1.2.160

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 (77) 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.d.ts +10 -7
  10. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +50 -12
  11. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +50 -12
  12. package/dist/builtins/backup-orchestrator/write-targets.d.ts +63 -0
  13. package/dist/builtins/console-logging/index.js +1 -1
  14. package/dist/builtins/console-logging/index.mjs +1 -1
  15. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  16. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  17. package/dist/builtins/device-manager/device-bindings-store.d.ts +56 -10
  18. package/dist/builtins/device-manager/device-manager.addon.d.ts +11 -0
  19. package/dist/builtins/device-manager/device-manager.addon.js +216 -81
  20. package/dist/builtins/device-manager/device-manager.addon.mjs +216 -81
  21. package/dist/builtins/device-manager/device-meta-actions.d.ts +9 -3
  22. package/dist/builtins/device-manager/device-meta-store.d.ts +32 -5
  23. package/dist/builtins/device-manager/device-queries.d.ts +7 -3
  24. package/dist/builtins/device-manager/write-lock.d.ts +17 -0
  25. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  26. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  27. package/dist/builtins/hub-forwarder/index.js +1 -1
  28. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  29. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  30. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  31. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  32. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  33. package/dist/builtins/local-network/local-network.addon.js +1 -1
  34. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  35. package/dist/builtins/loki-logging/index.js +1 -1
  36. package/dist/builtins/loki-logging/index.mjs +1 -1
  37. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  38. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  39. package/dist/builtins/platform-probe/index.js +1 -1
  40. package/dist/builtins/platform-probe/index.mjs +1 -1
  41. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  42. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  43. package/dist/builtins/snapshot/index.js +1 -1
  44. package/dist/builtins/snapshot/index.mjs +1 -1
  45. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  46. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  47. package/dist/builtins/sqlite-storage/filter-compiler.d.ts +41 -2
  48. package/dist/builtins/sqlite-storage/sqlite-settings-backend.d.ts +15 -7
  49. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  50. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  51. package/dist/builtins/sqlite-storage/vector-index-shared.d.ts +30 -5
  52. package/dist/builtins/sqlite-storage/vector-index-vec.d.ts +28 -6
  53. package/dist/builtins/storage-orchestrator/access-guards.d.ts +32 -0
  54. package/dist/builtins/storage-orchestrator/location-secrets.d.ts +33 -0
  55. package/dist/builtins/storage-orchestrator/storage-migration-coordinator.d.ts +74 -3
  56. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.d.ts +26 -0
  57. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +552 -15
  58. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +552 -15
  59. package/dist/builtins/storage-orchestrator/storage-orchestrator.service.d.ts +56 -1
  60. package/dist/builtins/system-config/system-config.addon.js +1 -1
  61. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  62. package/dist/builtins/winston-logging/index.js +1 -1
  63. package/dist/builtins/winston-logging/index.mjs +1 -1
  64. package/dist/{dist-CDgIzo82.js → dist-CcvXUhHK.js} +537 -17
  65. package/dist/{dist-LvKus-mT.mjs → dist-DKs---Vx.mjs} +520 -18
  66. package/dist/index.js +141 -139
  67. package/dist/index.mjs +3 -3
  68. package/dist/kernel/deps/manifest-system-deps.d.ts +15 -0
  69. package/dist/kernel/index.d.ts +4 -2
  70. package/dist/kernel/moleculer/cap-usage-registry.d.ts +132 -7
  71. package/dist/kernel/storage-location-registry.d.ts +16 -1
  72. package/dist/kernel/transport/local-child-registry.d.ts +57 -0
  73. package/dist/{manifest-python-deps-CD14Pv0-.mjs → manifest-system-deps-DnoPpLGp.mjs} +466 -83
  74. package/dist/{manifest-python-deps-DVODn-qc.js → manifest-system-deps-oJMkWSX-.js} +477 -82
  75. package/dist/{retired-settings-keys-CV4QVdXj.mjs → retired-settings-keys-C3eONTvV.mjs} +1 -1
  76. package/dist/{retired-settings-keys-BfAzWvPC.js → retired-settings-keys-DXZ2xe7C.js} +1 -1
  77. package/package.json +2 -1
@@ -1,4 +1,4 @@
1
- import { St as storageMigrationCapability, Yt as parseJsonObject, b as StorageMigrationJobSchema, kt as BaseAddon, xt as storageCapability, y as StorageLocationTypeSchema, yt as settingsStoreCapability } from "../../dist-LvKus-mT.mjs";
1
+ import { Mt as BaseAddon, N as collectSecretConfigKeys, Qt as parseJsonObject, S as StorageMigrationJobSchema, St as settingsStoreCapability, Tt as storageMigrationCapability, m as REDACTED_SECRET, wt as storageCapability, x as StorageLocationTypeSchema } from "../../dist-DKs---Vx.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as path$1 from "node:path";
@@ -263,6 +263,8 @@ var BLOCKING_ONLY_CLASSES = ["galleryMedia"];
263
263
  * link back to this decision.
264
264
  */
265
265
  var GALLERY_FORBIDDEN_NEIGHBOUR_TYPES = ["recordings", "recordingsLow"];
266
+ /** The two single-flight mover engines. Lanes run in parallel; within a lane,
267
+ * classes run FIFO because the engine refuses a second concurrent job. */
266
268
  var MOVE_LANES = ["media", "footage"];
267
269
  var PARTICIPANTS = [
268
270
  "pipeline",
@@ -275,6 +277,18 @@ var REVERSE_PARTICIPANTS = [
275
277
  "pipeline"
276
278
  ];
277
279
  /**
280
+ * One lane of the seal gate, as an operator reads it.
281
+ *
282
+ * "0" and "unknown" must not print the same, and neither may be silently
283
+ * turned into the other: `{ present: true, rows: null }` is "there are some
284
+ * and the count did not land", which is a refusal with a missing number, not a
285
+ * clean lane.
286
+ */
287
+ function describeUnstamped(lane) {
288
+ if (!lane.present) return "0";
289
+ return lane.rows === null ? "an unknown number of" : lane.rows.toString();
290
+ }
291
+ /**
278
292
  * Durable orchestration layer over the independent recorder and event-media
279
293
  * movers. It owns the only public migration state machine; participants expose
280
294
  * intentionally narrow lease/move primitives and never alter camera settings.
@@ -309,7 +323,8 @@ var StorageMigrationCoordinator = class {
309
323
  toLocationId: target.id,
310
324
  moverJobId: null,
311
325
  state: null,
312
- error: null
326
+ error: null,
327
+ progress: null
313
328
  });
314
329
  if (BLOCKING_ONLY_CLASSES.includes(storageClass)) findings.push({
315
330
  code: "blockingOnly",
@@ -321,12 +336,21 @@ var StorageMigrationCoordinator = class {
321
336
  if (moves.length === 0) throw new Error("select at least one storage class");
322
337
  if (moves.some((move) => move.storageClass === "eventMedia")) {
323
338
  const unstamped = await this.deps.participants.analytics.countUnstamped();
324
- if (unstamped.total > 0) {
325
- if (mode === "nonBlocking") throw new Error(`${unstamped.total.toString()} event-media row(s) still carry no locationId (${unstamped.media.toString()} media, ${unstamped.retrainFrames.toString()} retrain frames). A non-blocking cutover would silently orphan them. Run the seal first: pipelineAnalytics.relocateMedia({ mode: "seal", toLocationId: "${this.deps.locations.getDefaultLocation("eventMedia")?.id ?? "eventMedia"}" }).`);
339
+ if (unstamped === null) {
340
+ if (mode === "nonBlocking") throw new Error("could not determine whether any event-media row still carries no locationId. A non-blocking cutover repoints the default and would silently orphan every such row, so an unmeasured collection is refused exactly like a non-empty one. Retry, or run the blocking mode, which stamps rows as it moves them.");
326
341
  findings.push({
327
342
  code: "unstampedEventMediaRows",
328
343
  storageClass: "eventMedia",
329
- message: `${unstamped.total.toString()} event-media row(s) carry no locationId. This blocking migration stamps them as it moves them, but a non-blocking one would be refused until a seal pass drives the count to zero.`
344
+ message: "the count of event-media rows carrying no locationId could not be taken — treat it as unknown, not as zero. This blocking migration stamps them as it moves them, so it is safe regardless; a non-blocking one would be refused."
345
+ });
346
+ } else if (unstamped.anyPresent) {
347
+ const scale = unstamped.total === null ? "an unknown number of" : unstamped.total.toString();
348
+ const perLane = `${describeUnstamped(unstamped.media)} media, ${describeUnstamped(unstamped.retrainFrames)} retrain frames`;
349
+ if (mode === "nonBlocking") throw new Error(`${scale} event-media row(s) still carry no locationId (${perLane}). A non-blocking cutover would silently orphan them. Run the seal first: pipelineAnalytics.relocateMedia({ mode: "seal", toLocationId: "${this.deps.locations.getDefaultLocation("eventMedia")?.id ?? "eventMedia"}" }).`);
350
+ findings.push({
351
+ code: "unstampedEventMediaRows",
352
+ storageClass: "eventMedia",
353
+ message: `${scale} event-media row(s) carry no locationId (${perLane}). This blocking migration stamps them as it moves them, but a non-blocking one would be refused until a seal pass drives the count to zero.`
330
354
  });
331
355
  }
332
356
  }
@@ -407,7 +431,8 @@ var StorageMigrationCoordinator = class {
407
431
  ...move,
408
432
  moverJobId: null,
409
433
  state: null,
410
- error: null
434
+ error: null,
435
+ progress: null
411
436
  })),
412
437
  pauseLeaseId: null,
413
438
  pausedParticipants: [],
@@ -441,6 +466,178 @@ var StorageMigrationCoordinator = class {
441
466
  await Promise.all(job.moves.filter((move) => move.moverJobId !== null).map((move) => this.cancelMove(move)));
442
467
  return true;
443
468
  }
469
+ /**
470
+ * Every mover running right now, in both lanes — including the ones no
471
+ * migration armed.
472
+ *
473
+ * `status` already carries a migration's own progress (the coordinator folds
474
+ * it onto each move from the poll it is already doing). This exists for the
475
+ * other half: `recording.relocateFootage` and `pipelineAnalytics.relocateMedia`
476
+ * are operator-callable, and until {@link drain} existed that was the only way
477
+ * to run a drain at all. Such a mover has no job to fold into, so without this
478
+ * read a five-hour operation is invisible in the UI.
479
+ *
480
+ * `migrationJobId` is best-effort by construction: the coordinator keeps ONE
481
+ * durable job, so a mover armed by an older, since-overwritten migration
482
+ * reports `null`. That is the honest answer — nothing here can still claim it.
483
+ */
484
+ async movers() {
485
+ const job = await this.status();
486
+ const owned = /* @__PURE__ */ new Map();
487
+ for (const move of job?.moves ?? []) if (move.moverJobId !== null) owned.set(move.moverJobId, job?.jobId ?? "");
488
+ const [footage, media] = await Promise.all([this.deps.participants.recorder.listMovers(), this.deps.participants.analytics.listMovers()]);
489
+ const observedAt = this.deps.now();
490
+ const label = (lane, jobs) => jobs.map((mover) => ({
491
+ lane,
492
+ job: mover,
493
+ migrationJobId: owned.get(mover.jobId) ?? null,
494
+ observedAt
495
+ }));
496
+ return [...label("footage", footage), ...label("media", media)];
497
+ }
498
+ /**
499
+ * What every class's source STILL holds — the census behind a "drain
500
+ * remaining" action.
501
+ *
502
+ * A class appears here only when something is (or might be) left on a
503
+ * location that is not its default. An empty result therefore means exactly
504
+ * "there is nothing to drain", which is what lets the UI offer the action
505
+ * only when it is true, and what lets {@link drain} refuse rather than start
506
+ * a job that would move nothing and report `done` — the failure mode D295
507
+ * exists to end.
508
+ *
509
+ * `items: null` is "the archive could not be asked" and is still listed. A
510
+ * residue nobody could measure is the case an operator most needs to see;
511
+ * dropping it because the read failed would be the quiet success again.
512
+ */
513
+ async residue() {
514
+ const out = [];
515
+ for (const storageClass of STORAGE_CLASSES) {
516
+ if (!MOVER_CLASSES.includes(storageClass)) continue;
517
+ const target = this.deps.locations.getDefaultLocation(storageClass);
518
+ if (!target) continue;
519
+ if (laneOf(storageClass) === "media") {
520
+ const count = await unanswerable(this.deps.participants.analytics.residue({
521
+ toLocationId: target.id,
522
+ mode: storageClass === "galleryMedia" ? "gallery" : "move"
523
+ }));
524
+ if (count !== null && count.rows === 0) continue;
525
+ out.push({
526
+ storageClass,
527
+ fromLocationId: "*",
528
+ toLocationId: target.id,
529
+ items: count?.rows ?? null,
530
+ bytes: null
531
+ });
532
+ continue;
533
+ }
534
+ for (const source of this.deps.locations.listLocations({ type: storageClass })) {
535
+ if (source.id === target.id) continue;
536
+ const census = await unanswerable(this.deps.participants.recorder.residue({
537
+ fromLocationId: source.id,
538
+ footageClass: storageClass === "recordingsLow" ? "recordingsLow" : "recordings"
539
+ }));
540
+ if (census !== null && census.segments === 0) continue;
541
+ out.push({
542
+ storageClass,
543
+ fromLocationId: source.id,
544
+ toLocationId: target.id,
545
+ items: census?.segments ?? null,
546
+ bytes: census?.bytes ?? null
547
+ });
548
+ }
549
+ }
550
+ return out;
551
+ }
552
+ /**
553
+ * Run the DRAIN half alone, against classes whose default has already moved.
554
+ *
555
+ * ## Why this is a second verb rather than a looser `start`
556
+ *
557
+ * `start` refuses a destination that is already the class's default
558
+ * (`"recordingsLow:ssd" is already the "recordingsLow" default`). That refusal
559
+ * is correct and it is load-bearing: there is genuinely nothing left to
560
+ * repoint, and an operator must never be able to re-repoint a migrated class
561
+ * by accident. Making `start` idempotent — "an already-repointed class
562
+ * proceeds straight to draining" — would delete that protection AND make the
563
+ * verb mean two different things depending on state, so the confirmation an
564
+ * operator reads ("pauses the writers…") would be a lie half the time.
565
+ *
566
+ * `drain` instead cannot repoint AT ALL: it never touches
567
+ * `setDefaultLocations`, and its job starts in `draining` with `repointed`
568
+ * already true, so the `repointing` / `refreshing` / `resuming` blocks of
569
+ * {@link run} are behind it and unreachable. The two verbs are disjoint, and
570
+ * `start`'s refusal keeps meaning exactly what it meant.
571
+ *
572
+ * ## Why it re-derives the work instead of resuming the old job
573
+ *
574
+ * The finished job is the audit of what happened; re-opening it destroys
575
+ * that. And a drain is needed in cases where no migration job ever existed
576
+ * (a mover armed by hand, footage stranded on a location an operator added
577
+ * and then un-defaulted). One job = one operation, and the work list comes
578
+ * from {@link residue} — the archive — not from what a previous job believed.
579
+ */
580
+ async drain(input) {
581
+ if (this.startReserved) throw new Error("storage migration is already active");
582
+ this.startReserved = true;
583
+ try {
584
+ const existing = await this.status();
585
+ if (existing && isTerminal(existing) && existing.pausedParticipants.length > 0) {
586
+ await this.releaseAfterTerminal(existing);
587
+ await this.persist(existing);
588
+ if (existing.pausedParticipants.length > 0) throw new Error(`storage migration ${existing.jobId} still holds maintenance leases`);
589
+ }
590
+ if (existing && !isTerminal(existing)) throw new Error(`storage migration is already active (${existing.jobId})`);
591
+ for (const storageClass of input.classes) {
592
+ if (MOVER_CLASSES.includes(storageClass)) continue;
593
+ throw new Error(`No mover owns "${storageClass}" — there is nothing that can drain it. Move it by hand.`);
594
+ }
595
+ const residue = await this.residue();
596
+ const moves = [];
597
+ const destinations = {};
598
+ for (const storageClass of input.classes) {
599
+ const remaining = residue.filter((entry) => entry.storageClass === storageClass);
600
+ 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.`);
601
+ for (const entry of remaining) {
602
+ if (this.deps.locations.getLocationById(entry.toLocationId)?.enabled === false) throw new Error(`The "${storageClass}" drain destination "${entry.toLocationId}" is DISABLED — a disabled location takes no writes, and a drain is the largest write there is. Enable it on the Storage screen, or make an enabled location the "${storageClass}" default first.`);
603
+ moves.push({
604
+ storageClass,
605
+ fromLocationId: entry.fromLocationId,
606
+ toLocationId: entry.toLocationId,
607
+ moverJobId: null,
608
+ state: null,
609
+ error: null,
610
+ progress: null
611
+ });
612
+ destinations[storageClass] = entry.toLocationId;
613
+ }
614
+ }
615
+ const now = this.deps.now();
616
+ const job = {
617
+ jobId: this.deps.newId(),
618
+ phase: "draining",
619
+ mode: "nonBlocking",
620
+ destinations,
621
+ throttleMbps: input.throttleMbps ?? 40,
622
+ moves,
623
+ pauseLeaseId: null,
624
+ pausedParticipants: [],
625
+ repointed: true,
626
+ cancelRequested: false,
627
+ startedAt: now,
628
+ updatedAt: now,
629
+ finishedAt: null,
630
+ error: null
631
+ };
632
+ await this.persist(job);
633
+ this.active = job;
634
+ this.runPromise = this.run(job);
635
+ this.runPromise;
636
+ return job.jobId;
637
+ } finally {
638
+ this.startReserved = false;
639
+ }
640
+ }
444
641
  /** Boot recovery resumes a durable unfinished state. A missing in-memory
445
642
  * child mover is recreated from the same copy-if-absent input. */
446
643
  async recover() {
@@ -649,6 +846,14 @@ var StorageMigrationCoordinator = class {
649
846
  }
650
847
  move.state = status.state;
651
848
  move.error = status.error;
849
+ move.progress = {
850
+ filesMoved: status.filesMoved,
851
+ filesTotal: status.filesTotal,
852
+ bytesMoved: status.bytesMoved,
853
+ ...status.rowsReconciled === void 0 ? {} : { rowsReconciled: status.rowsReconciled },
854
+ startedAt: status.startedAt,
855
+ observedAt: this.deps.now()
856
+ };
652
857
  if (status.state === "failed") throw new Error(move.error ?? `${move.storageClass} move failed`);
653
858
  if (status.state === "cancelled") {
654
859
  job.cancelRequested = true;
@@ -668,9 +873,10 @@ var StorageMigrationCoordinator = class {
668
873
  *
669
874
  * The gate is the RE-COUNT, not the seal job's terminal state: a seal that
670
875
  * failed some rows still finishes, and "finished" is not "there are none
671
- * left". A non-zero count here fails the job while nothing has been paused
876
+ * left". A remaining row here fails the job while nothing has been paused
672
877
  * and nothing has been repointed, which is the cheapest possible place to
673
- * discover it.
878
+ * discover it. So does a re-count that could not be TAKEN: the gate opens on
879
+ * a measured absence, and only on that.
674
880
  *
675
881
  * The seal's own mover job id is deliberately NOT durable. It is idempotent
676
882
  * and cheap, so a coordinator restart mid-seal simply re-runs the whole
@@ -680,7 +886,8 @@ var StorageMigrationCoordinator = class {
680
886
  async sealEventMedia(job) {
681
887
  const move = job.moves.find((candidate) => candidate.storageClass === "eventMedia");
682
888
  if (move === void 0) return;
683
- if ((await this.deps.participants.analytics.countUnstamped()).total > 0) {
889
+ const before = await this.deps.participants.analytics.countUnstamped();
890
+ if (before === null || before.anyPresent) {
684
891
  const started = await this.deps.participants.analytics.startDrain({
685
892
  toLocationId: move.fromLocationId,
686
893
  mode: "seal"
@@ -689,7 +896,8 @@ var StorageMigrationCoordinator = class {
689
896
  if (job.cancelRequested) return;
690
897
  }
691
898
  const after = await this.deps.participants.analytics.countUnstamped();
692
- if (after.total > 0) throw new Error(`event-media seal left ${after.total.toString()} row(s) without a locationId (${after.media.toString()} media, ${after.retrainFrames.toString()} retrain frames). A non-blocking cutover would silently orphan them; nothing has been paused or repointed.`);
899
+ if (after === null) throw new Error("event-media seal ran, but whether any row still carries no locationId could not be measured afterwards. Unknown is not zero; nothing has been paused or repointed.");
900
+ if (after.anyPresent) throw new Error(`event-media seal left ${after.total === null ? "an unknown number of" : after.total.toString()} row(s) without a locationId (${describeUnstamped(after.media)} media, ${describeUnstamped(after.retrainFrames)} retrain frames). A non-blocking cutover would silently orphan them; nothing has been paused or repointed.`);
693
901
  }
694
902
  async waitForSeal(job, moverJobId) {
695
903
  const sleep = this.deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
@@ -768,6 +976,25 @@ var StorageMigrationCoordinator = class {
768
976
  await this.deps.state.set(job);
769
977
  }
770
978
  };
979
+ /**
980
+ * A residue read that FAILED is unanswerable, not zero — and it must not take
981
+ * the other lane down with it.
982
+ *
983
+ * `residue` asks two addons. One of them being mid-restart is an ordinary
984
+ * event, and letting that throw out of the whole call would blank the panel:
985
+ * an operator with 80 GB stranded on a recordings disk would be told nothing,
986
+ * because post-analysis was rebooting. `null` is the same "unknown" the
987
+ * participants themselves return for a read they could not take, so the entry
988
+ * is still LISTED and still labelled unknown — a drain attempted against it
989
+ * then fails loudly with the participant's own message, which is the point.
990
+ */
991
+ async function unanswerable(read) {
992
+ try {
993
+ return await read;
994
+ } catch {
995
+ return null;
996
+ }
997
+ }
771
998
  function requireLease(job) {
772
999
  if (job.pauseLeaseId === null) throw new Error("storage migration has no maintenance lease");
773
1000
  return job.pauseLeaseId;
@@ -810,6 +1037,12 @@ function runsUnleased(job) {
810
1037
  * - **Cardinality conflict throws**: if two addons declare the same `id` with
811
1038
  * different `cardinality` values an `Error` is thrown immediately (message
812
1039
  * contains the words "cardinality" and the conflicting `id`).
1040
+ * - **Access conflict throws**, for the same reason and more sharply: the
1041
+ * EFFECTIVE access (declared, else `STORAGE_ACCESS_FALLBACK`) must agree.
1042
+ * "First declarer wins" is the right rule for a display name and a
1043
+ * catastrophic one for a safety constraint — it would let a second addon
1044
+ * silently widen a kind that the first one restricted, which is exactly the
1045
+ * accident this constraint exists to make impossible.
813
1046
  *
814
1047
  * @param perAddon - One entry per addon; each entry is that addon's
815
1048
  * `storageLocations` declaration array.
@@ -820,6 +1053,9 @@ function buildStorageLocationRegistry(perAddon) {
820
1053
  const existing = map.get(declaration.id);
821
1054
  if (existing !== void 0) {
822
1055
  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.`);
1056
+ const existingAccess = existing.access ?? "local-path";
1057
+ const declaredAccess = declaration.access ?? "local-path";
1058
+ 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.`);
823
1059
  continue;
824
1060
  }
825
1061
  map.set(declaration.id, declaration);
@@ -835,12 +1071,53 @@ function buildStorageLocationRegistry(perAddon) {
835
1071
  cardinalityOf(id) {
836
1072
  return map.get(id)?.cardinality ?? null;
837
1073
  },
1074
+ accessOf(id) {
1075
+ const declaration = map.get(id);
1076
+ if (declaration === void 0) return null;
1077
+ return declaration.access ?? "local-path";
1078
+ },
838
1079
  list() {
839
1080
  return frozen;
840
1081
  }
841
1082
  };
842
1083
  }
843
1084
  //#endregion
1085
+ //#region src/builtins/storage-orchestrator/access-guards.ts
1086
+ /**
1087
+ * May `storage.resolve` return a path for this location?
1088
+ *
1089
+ * `resolve` is the only method on the cap whose result LEAVES the storage
1090
+ * abstraction — every caller of it opens the string with `node:fs`. A remote
1091
+ * provider answers with a path on the remote host, which on this node is
1092
+ * either missing or an unrelated local directory of the same name. For the
1093
+ * recorder, writing to the wrong place is not an error the operator sees; it
1094
+ * is a black window in the footage.
1095
+ *
1096
+ * Only a POSITIVE `false` refuses. `undefined` is "not yet classified", not
1097
+ * "remote" — and a conclusion drawn from a read that was never made is how a
1098
+ * doorbell rang 0/3 (D49).
1099
+ */
1100
+ function resolveRefusalFor(location, locality) {
1101
+ if (locality !== false) return null;
1102
+ 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.`;
1103
+ }
1104
+ /**
1105
+ * Can this node's filesystem answer "does this location still hold anything?"
1106
+ *
1107
+ * Only for a provider it KNOWS to be node-local. Everything else is `unknown`,
1108
+ * which refuses a delete.
1109
+ *
1110
+ * The direction that matters is the one this closes. A remote location carries
1111
+ * a plain-looking `basePath` (`/backups/camstack`); `fs.readdir` of it on the
1112
+ * hub answers `ENOENT`, and `ENOENT` is deliberately read as `empty` — because
1113
+ * for a LOCAL location an absent root means there is nothing the delete can
1114
+ * strand. Applied to a remote row, that same reasoning turns a full share into
1115
+ * a vacant one and walks D293's delete guard straight past it.
1116
+ */
1117
+ function canProbeOccupancyLocally(locality) {
1118
+ return locality === true;
1119
+ }
1120
+ //#endregion
844
1121
  //#region src/builtins/storage-orchestrator/data-store-dispatch.ts
845
1122
  /**
846
1123
  * Pick the engine to serve a call.
@@ -892,6 +1169,106 @@ function createDataStoreDispatch(getEngines) {
892
1169
  };
893
1170
  }
894
1171
  //#endregion
1172
+ //#region src/builtins/storage-orchestrator/location-secrets.ts
1173
+ /**
1174
+ * Credential redaction for `StorageLocation.config`.
1175
+ *
1176
+ * ── What was wrong ─────────────────────────────────────────────────────────
1177
+ * `storage.listLocations` carries no `auth` override, so it is `protected` —
1178
+ * every authenticated session reaches it, admin or not. The orchestrator
1179
+ * spread each stored row verbatim. `StorageLocation.config` is
1180
+ * `z.record(z.string(), z.unknown())`, and the SFTP / S3 / WebDAV providers
1181
+ * already declare `password`, `privateKey`, `privateKeyPassphrase`,
1182
+ * `secretAccessKey` and `token` inside it. Nothing was exposed only because
1183
+ * every location on the live hub is `filesystem-storage`, whose config is
1184
+ * `basePath` + `maxUsedGb`. The exposure arrives with the first remote
1185
+ * location, and three providers that collect one already ship.
1186
+ *
1187
+ * ── The mechanism ──────────────────────────────────────────────────────────
1188
+ * Schema-driven, never a hardcoded key list: each provider's
1189
+ * `getProviderInfo().configSchema` already says which of its fields hold a
1190
+ * credential, so it is the one thing that cannot drift from the provider.
1191
+ *
1192
+ * Two directions, and both are needed:
1193
+ * - **out** — {@link redactLocationConfig} replaces every secret value with
1194
+ * `REDACTED_SECRET` on the way to a caller.
1195
+ * - **in** — {@link restoreRedactedSecrets} treats that same sentinel, sent
1196
+ * back on an upsert, as "keep what is stored". Without it the wizard's
1197
+ * edit-and-save round trip would write the sentinel into the config and
1198
+ * lock the operator out of their own share on the first rename.
1199
+ *
1200
+ * ── The trap this deliberately avoids ──────────────────────────────────────
1201
+ * Keying redaction off `type: 'password'` alone MISSES a secret parked in a
1202
+ * `textarea`, and the SFTP provider parks an entire PEM private key there.
1203
+ * Discovery goes through `collectSecretConfigKeys`, whose discriminator is
1204
+ * `type === 'password' || secret === true`. A redaction that quietly misses a
1205
+ * credential is worse than none: it is a leak plus an assurance there is not
1206
+ * one.
1207
+ */
1208
+ /** Shared empty set for providers that declare no secrets (or are unknown). */
1209
+ var EMPTY_SECRET_KEYS = /* @__PURE__ */ new Set();
1210
+ /** Secret config keys a provider declares, derived from its own form schema. */
1211
+ function secretKeysOfProviderInfo(info) {
1212
+ return collectSecretConfigKeys(info.configSchema);
1213
+ }
1214
+ /**
1215
+ * A copy of `config` with every declared secret replaced by the sentinel.
1216
+ * Returns the SAME object when there is nothing to redact, so the common case
1217
+ * (a filesystem location) allocates nothing.
1218
+ *
1219
+ * A key the provider declares secret but that is absent from the stored config
1220
+ * is NOT invented — an absent password must keep reading as absent, or the
1221
+ * wizard would show a credential where none is set and the write-back would
1222
+ * then "keep" a value that never existed.
1223
+ */
1224
+ function redactLocationConfig(config, secretKeys) {
1225
+ if (secretKeys.size === 0) return config;
1226
+ let touched = false;
1227
+ const out = {};
1228
+ for (const [key, value] of Object.entries(config)) {
1229
+ if (secretKeys.has(key) && value !== void 0 && value !== null && value !== "") {
1230
+ out[key] = REDACTED_SECRET;
1231
+ touched = true;
1232
+ continue;
1233
+ }
1234
+ out[key] = value;
1235
+ }
1236
+ return touched ? out : config;
1237
+ }
1238
+ /** The same, applied to a whole location record. */
1239
+ function redactLocation(location, secretKeys) {
1240
+ const config = redactLocationConfig(location.config, secretKeys);
1241
+ if (config === location.config) return location;
1242
+ return {
1243
+ ...location,
1244
+ config
1245
+ };
1246
+ }
1247
+ /**
1248
+ * Undo the redaction on the way IN: any secret key whose incoming value is the
1249
+ * sentinel is restored from what is already stored.
1250
+ *
1251
+ * A sentinel with NOTHING stored behind it is dropped rather than persisted —
1252
+ * writing the literal `__camstack_redacted__` as a password is a
1253
+ * lock-yourself-out bug that would only surface at the next connection
1254
+ * attempt, hours later, as an authentication failure with no explanation.
1255
+ */
1256
+ function restoreRedactedSecrets(incoming, stored, secretKeys) {
1257
+ if (secretKeys.size === 0) return incoming;
1258
+ let touched = false;
1259
+ const out = {};
1260
+ for (const [key, value] of Object.entries(incoming)) {
1261
+ if (value !== "__camstack_redacted__" || !secretKeys.has(key)) {
1262
+ out[key] = value;
1263
+ continue;
1264
+ }
1265
+ touched = true;
1266
+ const previous = stored?.[key];
1267
+ if (previous !== void 0) out[key] = previous;
1268
+ }
1269
+ return touched ? out : incoming;
1270
+ }
1271
+ //#endregion
895
1272
  //#region src/builtins/storage-orchestrator/provider-discovery.ts
896
1273
  async function collectProviderInfos(providers, onError) {
897
1274
  const out = [];
@@ -1068,6 +1445,34 @@ var StorageOrchestratorService = class {
1068
1445
  backfilled: backfill.length,
1069
1446
  total: this.locations.size
1070
1447
  } });
1448
+ this.reportDisabledDefaults();
1449
+ }
1450
+ /**
1451
+ * Shout about a persisted row that is BOTH the type default and disabled.
1452
+ *
1453
+ * `upsertLocation` cannot produce this (`resolveEnabled` force-enables a
1454
+ * default) and neither can `setDefaultLocations` — but hydrate writes rows
1455
+ * straight into the map without either, so a hand-edited store, a partial
1456
+ * migration or a row written by another build can. It matters because every
1457
+ * bare-type ref resolves through {@link getDefaultLocation}: on such a row
1458
+ * `storage.write({ location: 'eventMedia' })` and the migration coordinator's
1459
+ * drain destination both point at a disk the operator turned off. That is the
1460
+ * one thing this model must never do silently.
1461
+ *
1462
+ * Reported, never repaired: flipping an operator's flag back on at boot would
1463
+ * be the system overruling the switch instead of obeying it. The coordinator
1464
+ * refuses the drain (`storage-migration-coordinator.drain`); the bare-ref read
1465
+ * path is deliberately left working, because narrowing a READ is a worse bug
1466
+ * than the one this line reports.
1467
+ */
1468
+ reportDisabledDefaults() {
1469
+ for (const loc of this.locations.values()) {
1470
+ if (!loc.isDefault || loc.enabled !== false) continue;
1471
+ this.logger.error("storage-orchestrator: the type DEFAULT is disabled — every bare-type ref for this type resolves to a location the operator turned off. Enable it, or make another location the default.", { meta: {
1472
+ id: loc.id,
1473
+ type: loc.type
1474
+ } });
1475
+ }
1071
1476
  }
1072
1477
  /**
1073
1478
  * Inject the declaration-driven cardinality source. Called once by the
@@ -1139,6 +1544,7 @@ var StorageOrchestratorService = class {
1139
1544
  loaded: this.locations.size,
1140
1545
  isSystemUpgraded: upgraded
1141
1546
  } });
1547
+ this.reportDisabledDefaults();
1142
1548
  }
1143
1549
  /**
1144
1550
  * Boot backfill (SP1): stamp `nodeId` on every persisted node-local
@@ -1248,6 +1654,66 @@ var StorageOrchestratorService = class {
1248
1654
  * implicitly-demoted siblings are persisted before the in-memory map
1249
1655
  * mutation returns. Persistence errors propagate to the caller.
1250
1656
  */
1657
+ /**
1658
+ * Refuse a `(location kind, provider)` pair the kind cannot use.
1659
+ *
1660
+ * The rule, in one line: a `'local-path'` kind requires a provider whose
1661
+ * `getProviderInfo().nodeLocal` is `true`.
1662
+ *
1663
+ * `nodeLocal` is the honest name for "this provider's `resolve` returns a
1664
+ * path on the filesystem of the node that resolved it". The
1665
+ * `storage-provider` cap's own discriminated union already forces every
1666
+ * provider to declare it, and all four remote providers declare `false`, so
1667
+ * no new cap surface is needed to ask the question.
1668
+ *
1669
+ * Three-valued on purpose. Only a POSITIVE `false` refuses:
1670
+ * - `true` → node-local, always fine.
1671
+ * - `false` → the provider is known and known to be remote. REFUSE.
1672
+ * - `undefined` → the provider has not registered yet (early boot, an addon
1673
+ * still loading). That is "unknown", not "remote", and a read that could
1674
+ * not be made must never destroy work (D49) — the system seed runs
1675
+ * through this path before any provider registers. Allowed, and logged,
1676
+ * so the allowance is never silent.
1677
+ *
1678
+ * The DECLARATION side is read the same way. A kind the registry does not
1679
+ * know is `local-path` (fail-closed: an unknown kind is not a permissive
1680
+ * one), but NO REGISTRY AT ALL is a different statement — nothing has been
1681
+ * loaded, so nothing can be concluded about any kind. The addon injects the
1682
+ * registry inside `onInitialize`, before the `storage` cap is mounted, so
1683
+ * there is no window in which an operator upsert sees this branch; a service
1684
+ * constructed without one is the in-memory/early-boot path.
1685
+ */
1686
+ refuseIncompatibleProvider(input) {
1687
+ const registry = this.registry;
1688
+ if (registry === null) {
1689
+ this.logger.debug("storage-orchestrator: no location declarations loaded — access constraint not evaluated", { meta: {
1690
+ id: input.id,
1691
+ type: input.type,
1692
+ providerId: input.providerId
1693
+ } });
1694
+ return;
1695
+ }
1696
+ const access = registry.accessOf(input.type) ?? "local-path";
1697
+ if (access !== "local-path") return;
1698
+ const nodeLocal = this.nodeLocalResolver?.(input.providerId);
1699
+ if (nodeLocal === true) return;
1700
+ if (nodeLocal === void 0) {
1701
+ this.logger.debug("storage-orchestrator: provider not yet classified — allowing a local-path upsert", { meta: {
1702
+ id: input.id,
1703
+ type: input.type,
1704
+ providerId: input.providerId,
1705
+ access
1706
+ } });
1707
+ return;
1708
+ }
1709
+ this.logger.warn("storage-orchestrator: REFUSED a remote provider for a local-path kind", { meta: {
1710
+ id: input.id,
1711
+ type: input.type,
1712
+ providerId: input.providerId,
1713
+ access
1714
+ } });
1715
+ 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}".`);
1716
+ }
1251
1717
  upsertLocation(input) {
1252
1718
  const now = Date.now();
1253
1719
  const existing = this.locations.get(input.id);
@@ -1257,6 +1723,7 @@ var StorageOrchestratorService = class {
1257
1723
  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.`);
1258
1724
  }
1259
1725
  }
1726
+ this.refuseIncompatibleProvider(input);
1260
1727
  if (this.nodeLocalResolver?.(input.providerId) === true && !input.nodeId) input = {
1261
1728
  ...input,
1262
1729
  nodeId: "hub"
@@ -1768,6 +2235,19 @@ var StorageOrchestratorAddon = class extends BaseAddon {
1768
2235
  */
1769
2236
  nodeLocalByProvider = /* @__PURE__ */ new Map();
1770
2237
  /**
2238
+ * Cached `providerId → secret config keys`, derived from each provider's own
2239
+ * `configSchema` in the SAME refresh as `nodeLocalByProvider` — one
2240
+ * `getProviderInfo()` round trip answers both questions.
2241
+ *
2242
+ * An absent providerId yields an EMPTY set, which redacts nothing. That is
2243
+ * the one direction this cache can be wrong in, so it is worth saying why it
2244
+ * is acceptable: the entry is populated at the same moment the provider
2245
+ * becomes resolvable at all, so a location whose provider is unknown here
2246
+ * cannot be dispatched to either — there is no window in which a credential
2247
+ * is readable through a provider the orchestrator can otherwise use.
2248
+ */
2249
+ secretKeysByProvider = /* @__PURE__ */ new Map();
2250
+ /**
1771
2251
  * Disposers run on `onShutdown` — currently the eventBus subscription
1772
2252
  * for `capability:provider-registered` events used by the lazy seed
1773
2253
  * fallback. Stored separately from the `BaseAddon` disposer chain so
@@ -1802,13 +2282,24 @@ var StorageOrchestratorAddon = class extends BaseAddon {
1802
2282
  listLocations: async ({ type }) => {
1803
2283
  const rows = type !== void 0 ? service.listLocations({ type }) : service.listLocations();
1804
2284
  return Promise.all(rows.map(async (loc) => ({
1805
- ...loc,
2285
+ ...this.redacted(loc),
1806
2286
  capacity: await this.localCapacityOf(loc)
1807
2287
  })));
1808
2288
  },
1809
- getDefaultLocation: async ({ type }) => service.getDefaultLocation(type),
2289
+ getDefaultLocation: async ({ type }) => {
2290
+ const loc = service.getDefaultLocation(type);
2291
+ return loc === null ? null : this.redacted(loc);
2292
+ },
1810
2293
  listLocationDeclarations: async () => service.listDeclarations(),
1811
- upsertLocation: async (input) => service.upsertLocation(input),
2294
+ upsertLocation: async (input) => {
2295
+ const stored = service.getLocationById(input.id);
2296
+ const config = restoreRedactedSecrets(input.config, stored?.config, this.secretKeysFor(input.providerId));
2297
+ const saved = service.upsertLocation(config === input.config ? input : {
2298
+ ...input,
2299
+ config
2300
+ });
2301
+ return this.redacted(saved);
2302
+ },
1812
2303
  deleteLocation: async ({ id, force }) => {
1813
2304
  await service.deleteLocation(id, { force: force === true });
1814
2305
  },
@@ -1853,6 +2344,7 @@ var StorageOrchestratorAddon = class extends BaseAddon {
1853
2344
  },
1854
2345
  resolve: async ({ location, relativePath }) => {
1855
2346
  const loc = service.resolveRef(location);
2347
+ this.refuseRemoteResolve(loc);
1856
2348
  return (await service.getProviderFor(loc)).resolve({
1857
2349
  location: loc,
1858
2350
  relativePath
@@ -1975,6 +2467,8 @@ var StorageOrchestratorAddon = class extends BaseAddon {
1975
2467
  startMove: (input) => this.ctx.api.recording.startStorageMigrationMove.mutate(input),
1976
2468
  startDrain: (input) => this.ctx.api.recording.relocateFootage.mutate(input),
1977
2469
  getMove: (jobId) => this.ctx.api.recording.getStorageMigrationMoveStatus.query({ jobId }),
2470
+ listMovers: () => this.ctx.api.recording.listRelocateJobs.query({}),
2471
+ residue: (input) => this.ctx.api.recording.getRelocateResidue.query(input),
1978
2472
  cancelMove: async (jobId) => (await this.ctx.api.recording.cancelStorageMigrationMove.mutate({ jobId })).cancelled,
1979
2473
  refresh: async (leaseId) => {
1980
2474
  await this.ctx.api.recording.refreshStorageLocationsForMigration.mutate({ leaseId });
@@ -1991,6 +2485,8 @@ var StorageOrchestratorAddon = class extends BaseAddon {
1991
2485
  startDrain: (input) => this.ctx.api.pipelineAnalytics.relocateMedia.mutate(input),
1992
2486
  countUnstamped: () => this.ctx.api.pipelineAnalytics.countUnstampedEventMedia.query({}),
1993
2487
  getMove: (jobId) => this.ctx.api.pipelineAnalytics.getStorageMigrationMoveStatus.query({ jobId }),
2488
+ listMovers: () => this.ctx.api.pipelineAnalytics.listRelocateMediaJobs.query({}),
2489
+ residue: (input) => this.ctx.api.pipelineAnalytics.countRelocatableMedia.query(input),
1994
2490
  cancelMove: async (jobId) => (await this.ctx.api.pipelineAnalytics.cancelStorageMigrationMove.mutate({ jobId })).cancelled,
1995
2491
  refresh: async (leaseId) => {
1996
2492
  await this.ctx.api.pipelineAnalytics.refreshStorageLocationsForMigration.mutate({ leaseId });
@@ -2006,7 +2502,10 @@ var StorageOrchestratorAddon = class extends BaseAddon {
2006
2502
  plan: (input) => migration.plan(input),
2007
2503
  start: async (input) => ({ jobId: await migration.start(input) }),
2008
2504
  status: ({ jobId }) => migration.status(jobId),
2009
- cancel: async ({ jobId }) => ({ cancelled: await migration.cancel(jobId) })
2505
+ cancel: async ({ jobId }) => ({ cancelled: await migration.cancel(jobId) }),
2506
+ movers: () => migration.movers(),
2507
+ residue: () => migration.residue(),
2508
+ drain: async (input) => ({ jobId: await migration.drain(input) })
2010
2509
  };
2011
2510
  await this.seedFromDeclarations();
2012
2511
  const eventBus = this.ctx.eventBus;
@@ -2149,6 +2648,34 @@ var StorageOrchestratorAddon = class extends BaseAddon {
2149
2648
  }
2150
2649
  return out;
2151
2650
  }
2651
+ /** Declared secret config keys for a provider; empty when unknown. */
2652
+ secretKeysFor(providerId) {
2653
+ return this.secretKeysByProvider.get(providerId) ?? EMPTY_SECRET_KEYS;
2654
+ }
2655
+ /** A location with its provider's declared secrets replaced by the sentinel. */
2656
+ redacted(location) {
2657
+ return redactLocation(location, this.secretKeysFor(location.providerId));
2658
+ }
2659
+ /**
2660
+ * Is this location backed by a provider that serves a genuine local
2661
+ * filesystem? `true` / `false` / `undefined` — see
2662
+ * `StorageOrchestratorService.refuseIncompatibleProvider` for why the
2663
+ * unknown case is kept distinct rather than folded into either answer.
2664
+ */
2665
+ providerIsNodeLocal(location) {
2666
+ return this.nodeLocalByProvider.get(location.providerId);
2667
+ }
2668
+ /** See the call site in the `resolve` dispatch, and `access-guards.ts`. */
2669
+ refuseRemoteResolve(location) {
2670
+ const refusal = resolveRefusalFor(location, this.providerIsNodeLocal(location));
2671
+ if (refusal === null) return;
2672
+ this.ctx.logger.warn("storage-orchestrator: REFUSED resolve() on a remote-backed location", { meta: {
2673
+ id: location.id,
2674
+ type: location.type,
2675
+ providerId: location.providerId
2676
+ } });
2677
+ throw new Error(refusal);
2678
+ }
2152
2679
  /** statfs capacity of a location's basePath, walking up to the nearest
2153
2680
  * existing ancestor. Null for remote-node locations or unstattable paths. */
2154
2681
  async localCapacityOf(loc) {
@@ -2236,6 +2763,13 @@ var StorageOrchestratorAddon = class extends BaseAddon {
2236
2763
  * errno — `EACCES`, `EIO`, a stale NFS handle — is `unknown` and refuses.
2237
2764
  */
2238
2765
  async locationOccupancy(location) {
2766
+ if (!canProbeOccupancyLocally(this.providerIsNodeLocal(location))) {
2767
+ this.ctx.logger.debug("storage-orchestrator: occupancy unknown — location is not backed by a local filesystem", { meta: {
2768
+ id: location.id,
2769
+ providerId: location.providerId
2770
+ } });
2771
+ return "unknown";
2772
+ }
2239
2773
  if ((location.nodeId === void 0 || location.nodeId === "" ? HUB_NODE_ID : location.nodeId) !== (this.service?.getLocalNodeId() ?? HUB_NODE_ID)) return "unknown";
2240
2774
  const basePath = this.locationBasePath(location.id);
2241
2775
  if (basePath === null) return "unknown";
@@ -2318,7 +2852,10 @@ var StorageOrchestratorAddon = class extends BaseAddon {
2318
2852
  error: err instanceof Error ? err.message : String(err)
2319
2853
  } });
2320
2854
  });
2321
- for (const info of infos) this.nodeLocalByProvider.set(info.providerId, info.nodeLocal);
2855
+ for (const info of infos) {
2856
+ this.nodeLocalByProvider.set(info.providerId, info.nodeLocal);
2857
+ this.secretKeysByProvider.set(info.providerId, secretKeysOfProviderInfo(info));
2858
+ }
2322
2859
  }
2323
2860
  };
2324
2861
  //#endregion