@camstack/system 1.2.156 → 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 (67) 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 +491 -9
  48. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +491 -9
  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 +204 -151
  57. package/dist/index.mjs +67 -15
  58. package/dist/kernel/config-manager.d.ts +31 -0
  59. package/dist/kernel/deps/manifest-system-deps.d.ts +15 -0
  60. package/dist/kernel/index.d.ts +2 -0
  61. package/dist/kernel/settings-door-view.d.ts +2 -2
  62. package/dist/kernel/storage-location-registry.d.ts +16 -1
  63. package/dist/{manifest-python-deps-DVODn-qc.js → manifest-system-deps-8boi90D9.js} +202 -7
  64. package/dist/{manifest-python-deps-CD14Pv0-.mjs → manifest-system-deps-tZZYG3RR.mjs} +197 -8
  65. package/dist/{retired-settings-keys-CV4QVdXj.mjs → retired-settings-keys-Bi0ZKBjb.mjs} +1 -1
  66. package/dist/{retired-settings-keys-BfAzWvPC.js → retired-settings-keys-Dp_CyuCW.js} +1 -1
  67. 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-DLh4_rn3.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",
@@ -309,7 +311,8 @@ var StorageMigrationCoordinator = class {
309
311
  toLocationId: target.id,
310
312
  moverJobId: null,
311
313
  state: null,
312
- error: null
314
+ error: null,
315
+ progress: null
313
316
  });
314
317
  if (BLOCKING_ONLY_CLASSES.includes(storageClass)) findings.push({
315
318
  code: "blockingOnly",
@@ -407,7 +410,8 @@ var StorageMigrationCoordinator = class {
407
410
  ...move,
408
411
  moverJobId: null,
409
412
  state: null,
410
- error: null
413
+ error: null,
414
+ progress: null
411
415
  })),
412
416
  pauseLeaseId: null,
413
417
  pausedParticipants: [],
@@ -418,8 +422,8 @@ var StorageMigrationCoordinator = class {
418
422
  finishedAt: null,
419
423
  error: null
420
424
  };
421
- this.active = job;
422
425
  await this.persist(job);
426
+ this.active = job;
423
427
  this.runPromise = this.run(job);
424
428
  this.runPromise;
425
429
  return job.jobId;
@@ -441,6 +445,177 @@ var StorageMigrationCoordinator = class {
441
445
  await Promise.all(job.moves.filter((move) => move.moverJobId !== null).map((move) => this.cancelMove(move)));
442
446
  return true;
443
447
  }
448
+ /**
449
+ * Every mover running right now, in both lanes — including the ones no
450
+ * migration armed.
451
+ *
452
+ * `status` already carries a migration's own progress (the coordinator folds
453
+ * it onto each move from the poll it is already doing). This exists for the
454
+ * other half: `recording.relocateFootage` and `pipelineAnalytics.relocateMedia`
455
+ * are operator-callable, and until {@link drain} existed that was the only way
456
+ * to run a drain at all. Such a mover has no job to fold into, so without this
457
+ * read a five-hour operation is invisible in the UI.
458
+ *
459
+ * `migrationJobId` is best-effort by construction: the coordinator keeps ONE
460
+ * durable job, so a mover armed by an older, since-overwritten migration
461
+ * reports `null`. That is the honest answer — nothing here can still claim it.
462
+ */
463
+ async movers() {
464
+ const job = await this.status();
465
+ const owned = /* @__PURE__ */ new Map();
466
+ for (const move of job?.moves ?? []) if (move.moverJobId !== null) owned.set(move.moverJobId, job?.jobId ?? "");
467
+ const [footage, media] = await Promise.all([this.deps.participants.recorder.listMovers(), this.deps.participants.analytics.listMovers()]);
468
+ const observedAt = this.deps.now();
469
+ const label = (lane, jobs) => jobs.map((mover) => ({
470
+ lane,
471
+ job: mover,
472
+ migrationJobId: owned.get(mover.jobId) ?? null,
473
+ observedAt
474
+ }));
475
+ return [...label("footage", footage), ...label("media", media)];
476
+ }
477
+ /**
478
+ * What every class's source STILL holds — the census behind a "drain
479
+ * remaining" action.
480
+ *
481
+ * A class appears here only when something is (or might be) left on a
482
+ * location that is not its default. An empty result therefore means exactly
483
+ * "there is nothing to drain", which is what lets the UI offer the action
484
+ * only when it is true, and what lets {@link drain} refuse rather than start
485
+ * a job that would move nothing and report `done` — the failure mode D295
486
+ * exists to end.
487
+ *
488
+ * `items: null` is "the archive could not be asked" and is still listed. A
489
+ * residue nobody could measure is the case an operator most needs to see;
490
+ * dropping it because the read failed would be the quiet success again.
491
+ */
492
+ async residue() {
493
+ const out = [];
494
+ for (const storageClass of STORAGE_CLASSES) {
495
+ if (!MOVER_CLASSES.includes(storageClass)) continue;
496
+ const target = this.deps.locations.getDefaultLocation(storageClass);
497
+ if (!target) continue;
498
+ if (laneOf(storageClass) === "media") {
499
+ const count = await unanswerable(this.deps.participants.analytics.residue({
500
+ toLocationId: target.id,
501
+ mode: storageClass === "galleryMedia" ? "gallery" : "move"
502
+ }));
503
+ if (count !== null && count.rows === 0) continue;
504
+ out.push({
505
+ storageClass,
506
+ fromLocationId: "*",
507
+ toLocationId: target.id,
508
+ items: count?.rows ?? null,
509
+ bytes: null
510
+ });
511
+ continue;
512
+ }
513
+ for (const source of this.deps.locations.listLocations({ type: storageClass })) {
514
+ if (source.id === target.id) continue;
515
+ const census = await unanswerable(this.deps.participants.recorder.residue({
516
+ fromLocationId: source.id,
517
+ footageClass: storageClass === "recordingsLow" ? "recordingsLow" : "recordings"
518
+ }));
519
+ if (census !== null && census.segments === 0) continue;
520
+ out.push({
521
+ storageClass,
522
+ fromLocationId: source.id,
523
+ toLocationId: target.id,
524
+ items: census?.segments ?? null,
525
+ bytes: census?.bytes ?? null
526
+ });
527
+ }
528
+ }
529
+ return out;
530
+ }
531
+ /**
532
+ * Run the DRAIN half alone, against classes whose default has already moved.
533
+ *
534
+ * ## Why this is a second verb rather than a looser `start`
535
+ *
536
+ * `start` refuses a destination that is already the class's default
537
+ * (`"recordingsLow:ssd" is already the "recordingsLow" default`). That refusal
538
+ * is correct and it is load-bearing: there is genuinely nothing left to
539
+ * repoint, and an operator must never be able to re-repoint a migrated class
540
+ * by accident. Making `start` idempotent — "an already-repointed class
541
+ * proceeds straight to draining" — would delete that protection AND make the
542
+ * verb mean two different things depending on state, so the confirmation an
543
+ * operator reads ("pauses the writers…") would be a lie half the time.
544
+ *
545
+ * `drain` instead cannot repoint AT ALL: it never touches
546
+ * `setDefaultLocations`, and its job starts in `draining` with `repointed`
547
+ * already true, so the `repointing` / `refreshing` / `resuming` blocks of
548
+ * {@link run} are behind it and unreachable. The two verbs are disjoint, and
549
+ * `start`'s refusal keeps meaning exactly what it meant.
550
+ *
551
+ * ## Why it re-derives the work instead of resuming the old job
552
+ *
553
+ * The finished job is the audit of what happened; re-opening it destroys
554
+ * that. And a drain is needed in cases where no migration job ever existed
555
+ * (a mover armed by hand, footage stranded on a location an operator added
556
+ * and then un-defaulted). One job = one operation, and the work list comes
557
+ * from {@link residue} — the archive — not from what a previous job believed.
558
+ */
559
+ async drain(input) {
560
+ if (this.startReserved) throw new Error("storage migration is already active");
561
+ this.startReserved = true;
562
+ try {
563
+ const existing = await this.status();
564
+ if (existing && isTerminal(existing) && existing.pausedParticipants.length > 0) {
565
+ await this.releaseAfterTerminal(existing);
566
+ await this.persist(existing);
567
+ if (existing.pausedParticipants.length > 0) throw new Error(`storage migration ${existing.jobId} still holds maintenance leases`);
568
+ }
569
+ if (existing && !isTerminal(existing)) throw new Error(`storage migration is already active (${existing.jobId})`);
570
+ for (const storageClass of input.classes) {
571
+ if (MOVER_CLASSES.includes(storageClass)) continue;
572
+ throw new Error(`No mover owns "${storageClass}" — there is nothing that can drain it. Move it by hand.`);
573
+ }
574
+ const residue = await this.residue();
575
+ const moves = [];
576
+ const destinations = {};
577
+ for (const storageClass of input.classes) {
578
+ const remaining = residue.filter((entry) => entry.storageClass === storageClass);
579
+ 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.`);
580
+ for (const entry of remaining) {
581
+ moves.push({
582
+ storageClass,
583
+ fromLocationId: entry.fromLocationId,
584
+ toLocationId: entry.toLocationId,
585
+ moverJobId: null,
586
+ state: null,
587
+ error: null,
588
+ progress: null
589
+ });
590
+ destinations[storageClass] = entry.toLocationId;
591
+ }
592
+ }
593
+ const now = this.deps.now();
594
+ const job = {
595
+ jobId: this.deps.newId(),
596
+ phase: "draining",
597
+ mode: "nonBlocking",
598
+ destinations,
599
+ throttleMbps: input.throttleMbps ?? 40,
600
+ moves,
601
+ pauseLeaseId: null,
602
+ pausedParticipants: [],
603
+ repointed: true,
604
+ cancelRequested: false,
605
+ startedAt: now,
606
+ updatedAt: now,
607
+ finishedAt: null,
608
+ error: null
609
+ };
610
+ await this.persist(job);
611
+ this.active = job;
612
+ this.runPromise = this.run(job);
613
+ this.runPromise;
614
+ return job.jobId;
615
+ } finally {
616
+ this.startReserved = false;
617
+ }
618
+ }
444
619
  /** Boot recovery resumes a durable unfinished state. A missing in-memory
445
620
  * child mover is recreated from the same copy-if-absent input. */
446
621
  async recover() {
@@ -649,6 +824,13 @@ var StorageMigrationCoordinator = class {
649
824
  }
650
825
  move.state = status.state;
651
826
  move.error = status.error;
827
+ move.progress = {
828
+ filesMoved: status.filesMoved,
829
+ filesTotal: status.filesTotal,
830
+ bytesMoved: status.bytesMoved,
831
+ startedAt: status.startedAt,
832
+ observedAt: this.deps.now()
833
+ };
652
834
  if (status.state === "failed") throw new Error(move.error ?? `${move.storageClass} move failed`);
653
835
  if (status.state === "cancelled") {
654
836
  job.cancelRequested = true;
@@ -768,6 +950,25 @@ var StorageMigrationCoordinator = class {
768
950
  await this.deps.state.set(job);
769
951
  }
770
952
  };
953
+ /**
954
+ * A residue read that FAILED is unanswerable, not zero — and it must not take
955
+ * the other lane down with it.
956
+ *
957
+ * `residue` asks two addons. One of them being mid-restart is an ordinary
958
+ * event, and letting that throw out of the whole call would blank the panel:
959
+ * an operator with 80 GB stranded on a recordings disk would be told nothing,
960
+ * because post-analysis was rebooting. `null` is the same "unknown" the
961
+ * participants themselves return for a read they could not take, so the entry
962
+ * is still LISTED and still labelled unknown — a drain attempted against it
963
+ * then fails loudly with the participant's own message, which is the point.
964
+ */
965
+ async function unanswerable(read) {
966
+ try {
967
+ return await read;
968
+ } catch {
969
+ return null;
970
+ }
971
+ }
771
972
  function requireLease(job) {
772
973
  if (job.pauseLeaseId === null) throw new Error("storage migration has no maintenance lease");
773
974
  return job.pauseLeaseId;
@@ -810,6 +1011,12 @@ function runsUnleased(job) {
810
1011
  * - **Cardinality conflict throws**: if two addons declare the same `id` with
811
1012
  * different `cardinality` values an `Error` is thrown immediately (message
812
1013
  * contains the words "cardinality" and the conflicting `id`).
1014
+ * - **Access conflict throws**, for the same reason and more sharply: the
1015
+ * EFFECTIVE access (declared, else `STORAGE_ACCESS_FALLBACK`) must agree.
1016
+ * "First declarer wins" is the right rule for a display name and a
1017
+ * catastrophic one for a safety constraint — it would let a second addon
1018
+ * silently widen a kind that the first one restricted, which is exactly the
1019
+ * accident this constraint exists to make impossible.
813
1020
  *
814
1021
  * @param perAddon - One entry per addon; each entry is that addon's
815
1022
  * `storageLocations` declaration array.
@@ -820,6 +1027,9 @@ function buildStorageLocationRegistry(perAddon) {
820
1027
  const existing = map.get(declaration.id);
821
1028
  if (existing !== void 0) {
822
1029
  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.`);
1030
+ const existingAccess = existing.access ?? "local-path";
1031
+ const declaredAccess = declaration.access ?? "local-path";
1032
+ 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
1033
  continue;
824
1034
  }
825
1035
  map.set(declaration.id, declaration);
@@ -835,12 +1045,53 @@ function buildStorageLocationRegistry(perAddon) {
835
1045
  cardinalityOf(id) {
836
1046
  return map.get(id)?.cardinality ?? null;
837
1047
  },
1048
+ accessOf(id) {
1049
+ const declaration = map.get(id);
1050
+ if (declaration === void 0) return null;
1051
+ return declaration.access ?? "local-path";
1052
+ },
838
1053
  list() {
839
1054
  return frozen;
840
1055
  }
841
1056
  };
842
1057
  }
843
1058
  //#endregion
1059
+ //#region src/builtins/storage-orchestrator/access-guards.ts
1060
+ /**
1061
+ * May `storage.resolve` return a path for this location?
1062
+ *
1063
+ * `resolve` is the only method on the cap whose result LEAVES the storage
1064
+ * abstraction — every caller of it opens the string with `node:fs`. A remote
1065
+ * provider answers with a path on the remote host, which on this node is
1066
+ * either missing or an unrelated local directory of the same name. For the
1067
+ * recorder, writing to the wrong place is not an error the operator sees; it
1068
+ * is a black window in the footage.
1069
+ *
1070
+ * Only a POSITIVE `false` refuses. `undefined` is "not yet classified", not
1071
+ * "remote" — and a conclusion drawn from a read that was never made is how a
1072
+ * doorbell rang 0/3 (D49).
1073
+ */
1074
+ function resolveRefusalFor(location, locality) {
1075
+ if (locality !== false) return null;
1076
+ 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.`;
1077
+ }
1078
+ /**
1079
+ * Can this node's filesystem answer "does this location still hold anything?"
1080
+ *
1081
+ * Only for a provider it KNOWS to be node-local. Everything else is `unknown`,
1082
+ * which refuses a delete.
1083
+ *
1084
+ * The direction that matters is the one this closes. A remote location carries
1085
+ * a plain-looking `basePath` (`/backups/camstack`); `fs.readdir` of it on the
1086
+ * hub answers `ENOENT`, and `ENOENT` is deliberately read as `empty` — because
1087
+ * for a LOCAL location an absent root means there is nothing the delete can
1088
+ * strand. Applied to a remote row, that same reasoning turns a full share into
1089
+ * a vacant one and walks D293's delete guard straight past it.
1090
+ */
1091
+ function canProbeOccupancyLocally(locality) {
1092
+ return locality === true;
1093
+ }
1094
+ //#endregion
844
1095
  //#region src/builtins/storage-orchestrator/data-store-dispatch.ts
845
1096
  /**
846
1097
  * Pick the engine to serve a call.
@@ -892,6 +1143,106 @@ function createDataStoreDispatch(getEngines) {
892
1143
  };
893
1144
  }
894
1145
  //#endregion
1146
+ //#region src/builtins/storage-orchestrator/location-secrets.ts
1147
+ /**
1148
+ * Credential redaction for `StorageLocation.config`.
1149
+ *
1150
+ * ── What was wrong ─────────────────────────────────────────────────────────
1151
+ * `storage.listLocations` carries no `auth` override, so it is `protected` —
1152
+ * every authenticated session reaches it, admin or not. The orchestrator
1153
+ * spread each stored row verbatim. `StorageLocation.config` is
1154
+ * `z.record(z.string(), z.unknown())`, and the SFTP / S3 / WebDAV providers
1155
+ * already declare `password`, `privateKey`, `privateKeyPassphrase`,
1156
+ * `secretAccessKey` and `token` inside it. Nothing was exposed only because
1157
+ * every location on the live hub is `filesystem-storage`, whose config is
1158
+ * `basePath` + `maxUsedGb`. The exposure arrives with the first remote
1159
+ * location, and three providers that collect one already ship.
1160
+ *
1161
+ * ── The mechanism ──────────────────────────────────────────────────────────
1162
+ * Schema-driven, never a hardcoded key list: each provider's
1163
+ * `getProviderInfo().configSchema` already says which of its fields hold a
1164
+ * credential, so it is the one thing that cannot drift from the provider.
1165
+ *
1166
+ * Two directions, and both are needed:
1167
+ * - **out** — {@link redactLocationConfig} replaces every secret value with
1168
+ * `REDACTED_SECRET` on the way to a caller.
1169
+ * - **in** — {@link restoreRedactedSecrets} treats that same sentinel, sent
1170
+ * back on an upsert, as "keep what is stored". Without it the wizard's
1171
+ * edit-and-save round trip would write the sentinel into the config and
1172
+ * lock the operator out of their own share on the first rename.
1173
+ *
1174
+ * ── The trap this deliberately avoids ──────────────────────────────────────
1175
+ * Keying redaction off `type: 'password'` alone MISSES a secret parked in a
1176
+ * `textarea`, and the SFTP provider parks an entire PEM private key there.
1177
+ * Discovery goes through `collectSecretConfigKeys`, whose discriminator is
1178
+ * `type === 'password' || secret === true`. A redaction that quietly misses a
1179
+ * credential is worse than none: it is a leak plus an assurance there is not
1180
+ * one.
1181
+ */
1182
+ /** Shared empty set for providers that declare no secrets (or are unknown). */
1183
+ var EMPTY_SECRET_KEYS = /* @__PURE__ */ new Set();
1184
+ /** Secret config keys a provider declares, derived from its own form schema. */
1185
+ function secretKeysOfProviderInfo(info) {
1186
+ return collectSecretConfigKeys(info.configSchema);
1187
+ }
1188
+ /**
1189
+ * A copy of `config` with every declared secret replaced by the sentinel.
1190
+ * Returns the SAME object when there is nothing to redact, so the common case
1191
+ * (a filesystem location) allocates nothing.
1192
+ *
1193
+ * A key the provider declares secret but that is absent from the stored config
1194
+ * is NOT invented — an absent password must keep reading as absent, or the
1195
+ * wizard would show a credential where none is set and the write-back would
1196
+ * then "keep" a value that never existed.
1197
+ */
1198
+ function redactLocationConfig(config, secretKeys) {
1199
+ if (secretKeys.size === 0) return config;
1200
+ let touched = false;
1201
+ const out = {};
1202
+ for (const [key, value] of Object.entries(config)) {
1203
+ if (secretKeys.has(key) && value !== void 0 && value !== null && value !== "") {
1204
+ out[key] = REDACTED_SECRET;
1205
+ touched = true;
1206
+ continue;
1207
+ }
1208
+ out[key] = value;
1209
+ }
1210
+ return touched ? out : config;
1211
+ }
1212
+ /** The same, applied to a whole location record. */
1213
+ function redactLocation(location, secretKeys) {
1214
+ const config = redactLocationConfig(location.config, secretKeys);
1215
+ if (config === location.config) return location;
1216
+ return {
1217
+ ...location,
1218
+ config
1219
+ };
1220
+ }
1221
+ /**
1222
+ * Undo the redaction on the way IN: any secret key whose incoming value is the
1223
+ * sentinel is restored from what is already stored.
1224
+ *
1225
+ * A sentinel with NOTHING stored behind it is dropped rather than persisted —
1226
+ * writing the literal `__camstack_redacted__` as a password is a
1227
+ * lock-yourself-out bug that would only surface at the next connection
1228
+ * attempt, hours later, as an authentication failure with no explanation.
1229
+ */
1230
+ function restoreRedactedSecrets(incoming, stored, secretKeys) {
1231
+ if (secretKeys.size === 0) return incoming;
1232
+ let touched = false;
1233
+ const out = {};
1234
+ for (const [key, value] of Object.entries(incoming)) {
1235
+ if (value !== "__camstack_redacted__" || !secretKeys.has(key)) {
1236
+ out[key] = value;
1237
+ continue;
1238
+ }
1239
+ touched = true;
1240
+ const previous = stored?.[key];
1241
+ if (previous !== void 0) out[key] = previous;
1242
+ }
1243
+ return touched ? out : incoming;
1244
+ }
1245
+ //#endregion
895
1246
  //#region src/builtins/storage-orchestrator/provider-discovery.ts
896
1247
  async function collectProviderInfos(providers, onError) {
897
1248
  const out = [];
@@ -1248,6 +1599,66 @@ var StorageOrchestratorService = class {
1248
1599
  * implicitly-demoted siblings are persisted before the in-memory map
1249
1600
  * mutation returns. Persistence errors propagate to the caller.
1250
1601
  */
1602
+ /**
1603
+ * Refuse a `(location kind, provider)` pair the kind cannot use.
1604
+ *
1605
+ * The rule, in one line: a `'local-path'` kind requires a provider whose
1606
+ * `getProviderInfo().nodeLocal` is `true`.
1607
+ *
1608
+ * `nodeLocal` is the honest name for "this provider's `resolve` returns a
1609
+ * path on the filesystem of the node that resolved it". The
1610
+ * `storage-provider` cap's own discriminated union already forces every
1611
+ * provider to declare it, and all four remote providers declare `false`, so
1612
+ * no new cap surface is needed to ask the question.
1613
+ *
1614
+ * Three-valued on purpose. Only a POSITIVE `false` refuses:
1615
+ * - `true` → node-local, always fine.
1616
+ * - `false` → the provider is known and known to be remote. REFUSE.
1617
+ * - `undefined` → the provider has not registered yet (early boot, an addon
1618
+ * still loading). That is "unknown", not "remote", and a read that could
1619
+ * not be made must never destroy work (D49) — the system seed runs
1620
+ * through this path before any provider registers. Allowed, and logged,
1621
+ * so the allowance is never silent.
1622
+ *
1623
+ * The DECLARATION side is read the same way. A kind the registry does not
1624
+ * know is `local-path` (fail-closed: an unknown kind is not a permissive
1625
+ * one), but NO REGISTRY AT ALL is a different statement — nothing has been
1626
+ * loaded, so nothing can be concluded about any kind. The addon injects the
1627
+ * registry inside `onInitialize`, before the `storage` cap is mounted, so
1628
+ * there is no window in which an operator upsert sees this branch; a service
1629
+ * constructed without one is the in-memory/early-boot path.
1630
+ */
1631
+ refuseIncompatibleProvider(input) {
1632
+ const registry = this.registry;
1633
+ if (registry === null) {
1634
+ this.logger.debug("storage-orchestrator: no location declarations loaded — access constraint not evaluated", { meta: {
1635
+ id: input.id,
1636
+ type: input.type,
1637
+ providerId: input.providerId
1638
+ } });
1639
+ return;
1640
+ }
1641
+ const access = registry.accessOf(input.type) ?? "local-path";
1642
+ if (access !== "local-path") return;
1643
+ const nodeLocal = this.nodeLocalResolver?.(input.providerId);
1644
+ if (nodeLocal === true) return;
1645
+ if (nodeLocal === void 0) {
1646
+ this.logger.debug("storage-orchestrator: provider not yet classified — allowing a local-path upsert", { meta: {
1647
+ id: input.id,
1648
+ type: input.type,
1649
+ providerId: input.providerId,
1650
+ access
1651
+ } });
1652
+ return;
1653
+ }
1654
+ this.logger.warn("storage-orchestrator: REFUSED a remote provider for a local-path kind", { meta: {
1655
+ id: input.id,
1656
+ type: input.type,
1657
+ providerId: input.providerId,
1658
+ access
1659
+ } });
1660
+ 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}".`);
1661
+ }
1251
1662
  upsertLocation(input) {
1252
1663
  const now = Date.now();
1253
1664
  const existing = this.locations.get(input.id);
@@ -1257,6 +1668,7 @@ var StorageOrchestratorService = class {
1257
1668
  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
1669
  }
1259
1670
  }
1671
+ this.refuseIncompatibleProvider(input);
1260
1672
  if (this.nodeLocalResolver?.(input.providerId) === true && !input.nodeId) input = {
1261
1673
  ...input,
1262
1674
  nodeId: "hub"
@@ -1768,6 +2180,19 @@ var StorageOrchestratorAddon = class extends BaseAddon {
1768
2180
  */
1769
2181
  nodeLocalByProvider = /* @__PURE__ */ new Map();
1770
2182
  /**
2183
+ * Cached `providerId → secret config keys`, derived from each provider's own
2184
+ * `configSchema` in the SAME refresh as `nodeLocalByProvider` — one
2185
+ * `getProviderInfo()` round trip answers both questions.
2186
+ *
2187
+ * An absent providerId yields an EMPTY set, which redacts nothing. That is
2188
+ * the one direction this cache can be wrong in, so it is worth saying why it
2189
+ * is acceptable: the entry is populated at the same moment the provider
2190
+ * becomes resolvable at all, so a location whose provider is unknown here
2191
+ * cannot be dispatched to either — there is no window in which a credential
2192
+ * is readable through a provider the orchestrator can otherwise use.
2193
+ */
2194
+ secretKeysByProvider = /* @__PURE__ */ new Map();
2195
+ /**
1771
2196
  * Disposers run on `onShutdown` — currently the eventBus subscription
1772
2197
  * for `capability:provider-registered` events used by the lazy seed
1773
2198
  * fallback. Stored separately from the `BaseAddon` disposer chain so
@@ -1802,13 +2227,24 @@ var StorageOrchestratorAddon = class extends BaseAddon {
1802
2227
  listLocations: async ({ type }) => {
1803
2228
  const rows = type !== void 0 ? service.listLocations({ type }) : service.listLocations();
1804
2229
  return Promise.all(rows.map(async (loc) => ({
1805
- ...loc,
2230
+ ...this.redacted(loc),
1806
2231
  capacity: await this.localCapacityOf(loc)
1807
2232
  })));
1808
2233
  },
1809
- getDefaultLocation: async ({ type }) => service.getDefaultLocation(type),
2234
+ getDefaultLocation: async ({ type }) => {
2235
+ const loc = service.getDefaultLocation(type);
2236
+ return loc === null ? null : this.redacted(loc);
2237
+ },
1810
2238
  listLocationDeclarations: async () => service.listDeclarations(),
1811
- upsertLocation: async (input) => service.upsertLocation(input),
2239
+ upsertLocation: async (input) => {
2240
+ const stored = service.getLocationById(input.id);
2241
+ const config = restoreRedactedSecrets(input.config, stored?.config, this.secretKeysFor(input.providerId));
2242
+ const saved = service.upsertLocation(config === input.config ? input : {
2243
+ ...input,
2244
+ config
2245
+ });
2246
+ return this.redacted(saved);
2247
+ },
1812
2248
  deleteLocation: async ({ id, force }) => {
1813
2249
  await service.deleteLocation(id, { force: force === true });
1814
2250
  },
@@ -1853,6 +2289,7 @@ var StorageOrchestratorAddon = class extends BaseAddon {
1853
2289
  },
1854
2290
  resolve: async ({ location, relativePath }) => {
1855
2291
  const loc = service.resolveRef(location);
2292
+ this.refuseRemoteResolve(loc);
1856
2293
  return (await service.getProviderFor(loc)).resolve({
1857
2294
  location: loc,
1858
2295
  relativePath
@@ -1975,6 +2412,8 @@ var StorageOrchestratorAddon = class extends BaseAddon {
1975
2412
  startMove: (input) => this.ctx.api.recording.startStorageMigrationMove.mutate(input),
1976
2413
  startDrain: (input) => this.ctx.api.recording.relocateFootage.mutate(input),
1977
2414
  getMove: (jobId) => this.ctx.api.recording.getStorageMigrationMoveStatus.query({ jobId }),
2415
+ listMovers: () => this.ctx.api.recording.listRelocateJobs.query({}),
2416
+ residue: (input) => this.ctx.api.recording.getRelocateResidue.query(input),
1978
2417
  cancelMove: async (jobId) => (await this.ctx.api.recording.cancelStorageMigrationMove.mutate({ jobId })).cancelled,
1979
2418
  refresh: async (leaseId) => {
1980
2419
  await this.ctx.api.recording.refreshStorageLocationsForMigration.mutate({ leaseId });
@@ -1991,6 +2430,8 @@ var StorageOrchestratorAddon = class extends BaseAddon {
1991
2430
  startDrain: (input) => this.ctx.api.pipelineAnalytics.relocateMedia.mutate(input),
1992
2431
  countUnstamped: () => this.ctx.api.pipelineAnalytics.countUnstampedEventMedia.query({}),
1993
2432
  getMove: (jobId) => this.ctx.api.pipelineAnalytics.getStorageMigrationMoveStatus.query({ jobId }),
2433
+ listMovers: () => this.ctx.api.pipelineAnalytics.listRelocateMediaJobs.query({}),
2434
+ residue: (input) => this.ctx.api.pipelineAnalytics.countRelocatableMedia.query(input),
1994
2435
  cancelMove: async (jobId) => (await this.ctx.api.pipelineAnalytics.cancelStorageMigrationMove.mutate({ jobId })).cancelled,
1995
2436
  refresh: async (leaseId) => {
1996
2437
  await this.ctx.api.pipelineAnalytics.refreshStorageLocationsForMigration.mutate({ leaseId });
@@ -2006,7 +2447,10 @@ var StorageOrchestratorAddon = class extends BaseAddon {
2006
2447
  plan: (input) => migration.plan(input),
2007
2448
  start: async (input) => ({ jobId: await migration.start(input) }),
2008
2449
  status: ({ jobId }) => migration.status(jobId),
2009
- cancel: async ({ jobId }) => ({ cancelled: await migration.cancel(jobId) })
2450
+ cancel: async ({ jobId }) => ({ cancelled: await migration.cancel(jobId) }),
2451
+ movers: () => migration.movers(),
2452
+ residue: () => migration.residue(),
2453
+ drain: async (input) => ({ jobId: await migration.drain(input) })
2010
2454
  };
2011
2455
  await this.seedFromDeclarations();
2012
2456
  const eventBus = this.ctx.eventBus;
@@ -2149,6 +2593,34 @@ var StorageOrchestratorAddon = class extends BaseAddon {
2149
2593
  }
2150
2594
  return out;
2151
2595
  }
2596
+ /** Declared secret config keys for a provider; empty when unknown. */
2597
+ secretKeysFor(providerId) {
2598
+ return this.secretKeysByProvider.get(providerId) ?? EMPTY_SECRET_KEYS;
2599
+ }
2600
+ /** A location with its provider's declared secrets replaced by the sentinel. */
2601
+ redacted(location) {
2602
+ return redactLocation(location, this.secretKeysFor(location.providerId));
2603
+ }
2604
+ /**
2605
+ * Is this location backed by a provider that serves a genuine local
2606
+ * filesystem? `true` / `false` / `undefined` — see
2607
+ * `StorageOrchestratorService.refuseIncompatibleProvider` for why the
2608
+ * unknown case is kept distinct rather than folded into either answer.
2609
+ */
2610
+ providerIsNodeLocal(location) {
2611
+ return this.nodeLocalByProvider.get(location.providerId);
2612
+ }
2613
+ /** See the call site in the `resolve` dispatch, and `access-guards.ts`. */
2614
+ refuseRemoteResolve(location) {
2615
+ const refusal = resolveRefusalFor(location, this.providerIsNodeLocal(location));
2616
+ if (refusal === null) return;
2617
+ this.ctx.logger.warn("storage-orchestrator: REFUSED resolve() on a remote-backed location", { meta: {
2618
+ id: location.id,
2619
+ type: location.type,
2620
+ providerId: location.providerId
2621
+ } });
2622
+ throw new Error(refusal);
2623
+ }
2152
2624
  /** statfs capacity of a location's basePath, walking up to the nearest
2153
2625
  * existing ancestor. Null for remote-node locations or unstattable paths. */
2154
2626
  async localCapacityOf(loc) {
@@ -2236,6 +2708,13 @@ var StorageOrchestratorAddon = class extends BaseAddon {
2236
2708
  * errno — `EACCES`, `EIO`, a stale NFS handle — is `unknown` and refuses.
2237
2709
  */
2238
2710
  async locationOccupancy(location) {
2711
+ if (!canProbeOccupancyLocally(this.providerIsNodeLocal(location))) {
2712
+ this.ctx.logger.debug("storage-orchestrator: occupancy unknown — location is not backed by a local filesystem", { meta: {
2713
+ id: location.id,
2714
+ providerId: location.providerId
2715
+ } });
2716
+ return "unknown";
2717
+ }
2239
2718
  if ((location.nodeId === void 0 || location.nodeId === "" ? HUB_NODE_ID : location.nodeId) !== (this.service?.getLocalNodeId() ?? HUB_NODE_ID)) return "unknown";
2240
2719
  const basePath = this.locationBasePath(location.id);
2241
2720
  if (basePath === null) return "unknown";
@@ -2318,7 +2797,10 @@ var StorageOrchestratorAddon = class extends BaseAddon {
2318
2797
  error: err instanceof Error ? err.message : String(err)
2319
2798
  } });
2320
2799
  });
2321
- for (const info of infos) this.nodeLocalByProvider.set(info.providerId, info.nodeLocal);
2800
+ for (const info of infos) {
2801
+ this.nodeLocalByProvider.set(info.providerId, info.nodeLocal);
2802
+ this.secretKeysByProvider.set(info.providerId, secretKeysOfProviderInfo(info));
2803
+ }
2322
2804
  }
2323
2805
  };
2324
2806
  //#endregion