@camstack/server 1.2.480 → 1.2.482

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.
@@ -70,6 +70,7 @@ exports.buildNodesProvider = buildNodesProvider;
70
70
  exports.buildIntegrationsProvider = buildIntegrationsProvider;
71
71
  exports.dispatchCustomAction = dispatchCustomAction;
72
72
  exports.buildAddonsProvider = buildAddonsProvider;
73
+ const integration_secrets_1 = require("./integration-secrets");
73
74
  const node_crypto_1 = require("node:crypto");
74
75
  const os = __importStar(require("node:os"));
75
76
  const system_1 = require("@camstack/system");
@@ -685,6 +686,14 @@ function buildNodesProvider(agentRegistry, moleculer, addonRegistry, getNodeRoot
685
686
  },
686
687
  };
687
688
  }
689
+ /**
690
+ * The keychain handle a hub door uses for an integration's addon (D787), or
691
+ * `null` when this registry predates the keychain (a test double, an older
692
+ * kernel) — the door then stores the settings as it always did.
693
+ */
694
+ function ownerSecrets(ar, owner) {
695
+ return typeof ar.secretsForOwner === 'function' ? ar.secretsForOwner(owner) : null;
696
+ }
688
697
  function requireIntegrationRegistry(ar) {
689
698
  const reg = ar.getIntegrationRegistry();
690
699
  if (!reg)
@@ -845,7 +854,10 @@ function buildIntegrationsProvider(ar, eb, loggingService, capabilityRegistry) {
845
854
  },
846
855
  getByAddonId: async (input) => requireIntegrationRegistry(ar).getIntegrationByAddonId(input.addonId),
847
856
  create: async (input) => {
848
- const { skipRestart, ...payload } = input;
857
+ const { skipRestart, secretKeys, settings, ...rest } = input;
858
+ // Settings are written AFTER the row exists: a secret's ref is scoped
859
+ // to the integration id (D787).
860
+ const payload = rest;
849
861
  const reg = requireIntegrationRegistry(ar);
850
862
  logger.info('request', {
851
863
  tags: { addonId: input.addonId },
@@ -872,7 +884,7 @@ function buildIntegrationsProvider(ar, eb, loggingService, capabilityRegistry) {
872
884
  // those would make every integration lacking the hook uncreatable, and
873
885
  // would fail a good create on a flaky network. They pass, and they SAY SO
874
886
  // in the log: silence here would read as "validated".
875
- const verdict = await runConnectionTest(ar, input.addonId, input.settings ?? {}, logger);
887
+ const verdict = await runConnectionTest(ar, input.addonId, settings ?? {}, logger);
876
888
  if (verdict.status === 'rejected') {
877
889
  logger.warn('rejected — connection test refused the settings', {
878
890
  tags: { addonId: input.addonId },
@@ -896,11 +908,25 @@ function buildIntegrationsProvider(ar, eb, loggingService, capabilityRegistry) {
896
908
  });
897
909
  }
898
910
  const integration = await reg.createIntegration(payload);
911
+ if (settings !== undefined && Object.keys(settings).length > 0) {
912
+ await (0, integration_secrets_1.writeIntegrationSettings)({
913
+ registry: reg,
914
+ secrets: ownerSecrets(ar, integration.addonId),
915
+ onOrphaned: (refs) => logger.warn('dropped keychain refs could not be deleted', {
916
+ meta: { refs: [...refs] },
917
+ }),
918
+ }, {
919
+ integrationId: integration.id,
920
+ settings,
921
+ secretKeys: secretKeys ?? [],
922
+ operatorWrite: true,
923
+ });
924
+ }
899
925
  logger.info('persisted', {
900
926
  tags: { integrationId: integration.id, addonId: integration.addonId },
901
927
  meta: { phase: 'create' },
902
928
  });
903
- const hasSettings = input.settings != null && Object.keys(input.settings).length > 0;
929
+ const hasSettings = settings != null && Object.keys(settings).length > 0;
904
930
  if (!skipRestart && hasSettings) {
905
931
  logger.info('settings present — restarting addon', {
906
932
  tags: { addonId: input.addonId },
@@ -1086,7 +1112,13 @@ function buildIntegrationsProvider(ar, eb, loggingService, capabilityRegistry) {
1086
1112
  const integration = await reg.getIntegration(input.id);
1087
1113
  if (!integration)
1088
1114
  throw new Error(`Integration "${input.id}" not found`);
1089
- return reg.getIntegrationSettings(input.id);
1115
+ // The integration's own addon (the caller the transport stamped) reads
1116
+ // its secrets resolved; anyone else — the admin form — reads "set"
1117
+ // views, never a value (D787, D789).
1118
+ const caller = Reflect.get(input, 'callerAddonId');
1119
+ const forOwner = typeof caller === 'string' &&
1120
+ (caller === integration.addonId || ar.runnerIdFor(integration.addonId) === caller);
1121
+ return (0, integration_secrets_1.readIntegrationSettings)({ registry: reg, secrets: forOwner ? ownerSecrets(ar, integration.addonId) : null }, { integrationId: input.id, forOwner });
1090
1122
  },
1091
1123
  setSettings: async (input) => {
1092
1124
  const reg = requireIntegrationRegistry(ar);
@@ -1103,7 +1135,19 @@ function buildIntegrationsProvider(ar, eb, loggingService, capabilityRegistry) {
1103
1135
  tags: { integrationId: input.id, addonId: integration.addonId },
1104
1136
  meta: { phase: 'setSettings', keys: settingsKeys },
1105
1137
  });
1106
- await reg.setIntegrationSettings(input.id, input.settings);
1138
+ await (0, integration_secrets_1.writeIntegrationSettings)({
1139
+ registry: reg,
1140
+ secrets: ownerSecrets(ar, integration.addonId),
1141
+ onOrphaned: (refs) => logger.warn('dropped keychain refs could not be deleted', {
1142
+ meta: { refs: [...refs] },
1143
+ }),
1144
+ }, {
1145
+ integrationId: input.id,
1146
+ settings: input.settings,
1147
+ secretKeys: input.secretKeys ?? [],
1148
+ // The integration's own addon writing back is not the operator.
1149
+ operatorWrite: typeof Reflect.get(input, 'callerAddonId') !== 'string',
1150
+ });
1107
1151
  logger.info('persisted — restarting addon', {
1108
1152
  tags: { addonId: integration.addonId },
1109
1153
  meta: { phase: 'setSettings' },
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.writeIntegrationSettings = writeIntegrationSettings;
4
+ exports.readIntegrationSettings = readIntegrationSettings;
5
+ /**
6
+ * The integration-settings door of the keychain (D787). An integration's
7
+ * settings (a Wyze password, a PetKit account password) live in the
8
+ * `integration_settings` table, written by `integrations.create` /
9
+ * `setSettings` and read back by the integration's own addon through
10
+ * `integrations.getSettings` — and by the admin form through the same method.
11
+ *
12
+ * - WRITE: the keys the form's schema declares secret (`secretKeys`, by type)
13
+ * and every key that already holds a marker go through the keychain save
14
+ * path, OWNED by the integration's addon (`integration/<id>` scope). A value
15
+ * equal to an inline pre-keychain one stays inline; `''` keeps.
16
+ * - READ, by the owning addon (the caller the transport stamped): markers
17
+ * resolve to values. READ, by anyone else (the admin form): markers become
18
+ * their "set" views — the value never leaves the hub.
19
+ */
20
+ const types_1 = require("@camstack/types");
21
+ async function writeIntegrationSettings(deps, input) {
22
+ if (deps.secrets === null) {
23
+ return deps.registry.setIntegrationSettings(input.integrationId, input.settings);
24
+ }
25
+ const stored = (await deps.registry.getIntegrationSettings(input.integrationId)) ?? {};
26
+ const markerKeys = Object.entries(stored)
27
+ .filter(([, v]) => (0, types_1.isSecretMarker)(v))
28
+ .map(([k]) => k);
29
+ const tree = (0, types_1.secretTreeFromPaths)([...new Set([...input.secretKeys, ...markerKeys])]);
30
+ if (tree.size === 0) {
31
+ return deps.registry.setIntegrationSettings(input.integrationId, input.settings);
32
+ }
33
+ const result = await (0, types_1.transformSecretWrite)({
34
+ tree,
35
+ secrets: deps.secrets,
36
+ patch: input.settings,
37
+ stored,
38
+ scope: { kind: 'integration', integrationId: input.integrationId },
39
+ operatorWrite: input.operatorWrite === true,
40
+ });
41
+ if (Object.keys(result.patch).length > 0) {
42
+ await deps.registry.setIntegrationSettings(input.integrationId, result.patch);
43
+ }
44
+ const { orphaned } = await result.afterWrite();
45
+ if (orphaned.length > 0)
46
+ deps.onOrphaned?.(orphaned);
47
+ }
48
+ async function readIntegrationSettings(deps, input) {
49
+ const stored = await deps.registry.getIntegrationSettings(input.integrationId);
50
+ if (input.forOwner) {
51
+ if (deps.secrets === null)
52
+ return stored;
53
+ return (await (0, types_1.resolveSecretMarkers)(stored, deps.secrets)).value;
54
+ }
55
+ const out = {};
56
+ for (const [key, value] of Object.entries(stored)) {
57
+ out[key] = (0, types_1.isSecretMarker)(value) ? (0, types_1.toSecretFieldView)(value) : value;
58
+ }
59
+ return out;
60
+ }
@@ -53,6 +53,7 @@ exports.redactStatusAggregateSetup = redactStatusAggregateSetup;
53
53
  exports.redactStatusAggregateBatchSetup = redactStatusAggregateBatchSetup;
54
54
  exports.redactConfigEntries = redactConfigEntries;
55
55
  exports.redactStreamUrlSecrets = redactStreamUrlSecrets;
56
+ exports.maskUrlCredentialsDeep = maskUrlCredentialsDeep;
56
57
  /** What a non-admin sees instead of the value. Matches the `***` convention
57
58
  * `maskUrlCredentials` already uses for credentials in log output, so one
58
59
  * redaction marker means one thing across the system. */
@@ -86,24 +87,88 @@ function isSecretConfigKey(key) {
86
87
  function isRecord(x) {
87
88
  return x !== null && typeof x === 'object' && !Array.isArray(x);
88
89
  }
90
+ function parsePath(path) {
91
+ return path
92
+ .split('.')
93
+ .filter((part) => part.length > 0)
94
+ .map((part) => part.endsWith('[]') ? { key: part.slice(0, -2), each: true } : { key: part, each: false });
95
+ }
96
+ /** The declared secret paths a device row advertises, parsed. Absent ⇒ none. */
97
+ function declaredPathsOf(row) {
98
+ const raw = row['secretConfigPaths'];
99
+ if (!Array.isArray(raw))
100
+ return [];
101
+ return raw.filter((p) => typeof p === 'string').map(parsePath);
102
+ }
103
+ /** Withhold every non-empty string at or below `value` (a declared secret node). */
104
+ function withholdAll(value) {
105
+ if (typeof value === 'string')
106
+ return value.length > 0 ? exports.REDACTED_SECRET : value;
107
+ if (Array.isArray(value))
108
+ return value.map(withholdAll);
109
+ if (!isRecord(value))
110
+ return value;
111
+ const out = {};
112
+ for (const [key, child] of Object.entries(value))
113
+ out[key] = withholdAll(child);
114
+ return out;
115
+ }
116
+ /** Apply ONE declared path to `value`, returning a copy; a path that does not resolve is a no-op. */
117
+ function withholdPath(value, segments) {
118
+ const [head, ...rest] = segments;
119
+ if (head === undefined)
120
+ return withholdAll(value);
121
+ if (!isRecord(value) || !(head.key in value))
122
+ return value;
123
+ const child = value[head.key];
124
+ const next = head.each
125
+ ? Array.isArray(child)
126
+ ? child.map((el) => withholdPath(el, rest))
127
+ : child
128
+ : withholdPath(child, rest);
129
+ return { ...value, [head.key]: next };
130
+ }
89
131
  /**
90
- * Replace the value of every credential-named key with {@link REDACTED_SECRET}.
132
+ * The keyword BACKSTOP (D634), now at any depth: a credential-NAMED key's
133
+ * string value is withheld, and a non-secret string that is a URL loses its
134
+ * userinfo / credential params. Only strings are touched — a numeric
135
+ * `maxTokens` or an array called `eventTypeTokens` is not a credential.
91
136
  *
92
- * An EMPTY value stays empty: "not configured" and "configured, withheld" are
93
- * different facts, and flattening them would make a broken camera look set up.
94
- * A non-string value is left alone — a numeric `maxTokens` is not a secret, and
95
- * the only credentials this repo persists are strings.
137
+ * This is no longer the declaration (D783): it catches what a driver has not
138
+ * yet declared, and `scripts/check-secret-fields-declared.ts` is what keeps
139
+ * that set from growing.
96
140
  */
97
- function redactRecord(config) {
141
+ function backstop(value) {
142
+ if (typeof value === 'string') {
143
+ return value.includes('://') ? (0, types_1.maskUrlCredentials)(value) : value;
144
+ }
145
+ if (Array.isArray(value))
146
+ return value.map(backstop);
147
+ if (!isRecord(value))
148
+ return value;
98
149
  const out = {};
99
- for (const [key, value] of Object.entries(config)) {
150
+ for (const [key, child] of Object.entries(value)) {
100
151
  out[key] =
101
- isSecretConfigKey(key) && typeof value === 'string' && value.length > 0
152
+ isSecretConfigKey(key) && typeof child === 'string' && child.length > 0
102
153
  ? exports.REDACTED_SECRET
103
- : value;
154
+ : backstop(child);
104
155
  }
105
156
  return out;
106
157
  }
158
+ /**
159
+ * Withhold a device `config` blob's secrets: first every DECLARED path
160
+ * (`secretConfigPaths`, at any depth), then the keyword backstop over what is
161
+ * left.
162
+ *
163
+ * An EMPTY value stays empty: "not configured" and "configured, withheld" are
164
+ * different facts, and flattening them would make a broken camera look set up.
165
+ */
166
+ function redactConfigBlob(config, declared) {
167
+ let out = config;
168
+ for (const segments of declared)
169
+ out = withholdPath(out, segments);
170
+ return backstop(out);
171
+ }
107
172
  /**
108
173
  * Project a `DeviceInfo` (or an array of them) with its `config` redacted.
109
174
  * Anything that is not a device row passes through untouched — a route that
@@ -117,7 +182,7 @@ function redactDeviceInfoSecrets(data) {
117
182
  const config = data['config'];
118
183
  if (!isRecord(config))
119
184
  return data;
120
- return { ...data, config: redactRecord(config) };
185
+ return { ...data, config: redactConfigBlob(config, declaredPathsOf(data)) };
121
186
  }
122
187
  /**
123
188
  * Project a `Record<parentId, DeviceInfo[]>` — the shape `getChildrenBatch`
@@ -349,7 +414,7 @@ function redactConfigEntries(data) {
349
414
  * `streamBroker.getRtspEntry`, `deviceOps.getStreamSources`, …) hand a
350
415
  * non-admin caller `rtsp://admin:hunter2@192.168.10.139/…` verbatim: a
351
416
  * read-only token can open every camera by copy-pasting the string. These
352
- * are NOT config blobs, so {@link redactRecord} never sees them — the leak
417
+ * are NOT config blobs, so {@link redactDeviceInfoSecrets} never sees them — the leak
353
418
  * lives in a plain `url` / `mutedUrl` string field.
354
419
  */
355
420
  const URL_FIELD_KEYS = ['url', 'mutedUrl'];
@@ -377,6 +442,27 @@ function redactStreamUrlSecrets(data) {
377
442
  }
378
443
  return out;
379
444
  }
445
+ /**
446
+ * Mask URL credentials in EVERY string of an arbitrary payload — the runtime
447
+ * state reads (`deviceState.getSnapshot` / `getCapSlice` / `getAllSnapshots`),
448
+ * the status aggregates and `streamCatalog.getCatalog`. Reolink persists its
449
+ * stream catalog (`rtsp://user:pass@…`, FLV `…&password=…`) in the
450
+ * `stream-catalog` slice, so a view-level read handed the camera password to
451
+ * any non-admin (D783). Only strings holding `://` are touched; no key is
452
+ * judged by its name here, so `eventTypeTokens` & co. keep their values.
453
+ */
454
+ function maskUrlCredentialsDeep(data) {
455
+ if (typeof data === 'string')
456
+ return data.includes('://') ? (0, types_1.maskUrlCredentials)(data) : data;
457
+ if (Array.isArray(data))
458
+ return data.map(maskUrlCredentialsDeep);
459
+ if (!isRecord(data))
460
+ return data;
461
+ const out = {};
462
+ for (const [key, value] of Object.entries(data))
463
+ out[key] = maskUrlCredentialsDeep(value);
464
+ return out;
465
+ }
380
466
  /**
381
467
  * Every device-manager read whose response carries a device `config` blob, and
382
468
  * the projection that cuts it.
@@ -408,8 +494,14 @@ exports.NON_ADMIN_CONFIG_REDACTED_METHODS = new Map([
408
494
  ['integrations.list', redactDeepSecrets],
409
495
  ['integrations.get', redactDeepSecrets],
410
496
  ['integrations.getByAddonId', redactDeepSecrets],
411
- ['deviceManager.getDeviceStatusAggregate', redactStatusAggregateSetup],
412
- ['deviceManager.getDeviceStatusAggregateBatch', redactStatusAggregateBatchSetup],
497
+ [
498
+ 'deviceManager.getDeviceStatusAggregate',
499
+ (d) => maskUrlCredentialsDeep(redactStatusAggregateSetup(d)),
500
+ ],
501
+ [
502
+ 'deviceManager.getDeviceStatusAggregateBatch',
503
+ (d) => maskUrlCredentialsDeep(redactStatusAggregateBatchSetup(d)),
504
+ ],
413
505
  ['deviceManager.getDevice', redactDeviceInfoSecrets],
414
506
  ['deviceManager.listAll', redactDeviceInfoSecrets],
415
507
  ['deviceManager.getChildren', redactDeviceInfoSecrets],
@@ -428,4 +520,10 @@ exports.NON_ADMIN_CONFIG_REDACTED_METHODS = new Map([
428
520
  ['deviceManager.getStreamSources', redactStreamUrlSecrets],
429
521
  ['streamBroker.getRtspEntry', redactStreamUrlSecrets],
430
522
  ['streamBroker.getAllRtspEntries', redactStreamUrlSecrets],
523
+ // ── Stream catalog + runtime state (D783) — Reolink persists its catalog,
524
+ // credentials inline, in the `stream-catalog` slice. Every URL is masked.
525
+ ['streamCatalog.getCatalog', maskUrlCredentialsDeep],
526
+ ['deviceState.getSnapshot', maskUrlCredentialsDeep],
527
+ ['deviceState.getCapSlice', maskUrlCredentialsDeep],
528
+ ['deviceState.getAllSnapshots', maskUrlCredentialsDeep],
431
529
  ]);
@@ -2,7 +2,7 @@
2
2
  // AUTO-GENERATED by scripts/generate-cap-mounts.ts — DO NOT EDIT
3
3
  // Re-run: npx tsx scripts/generate-cap-mounts.ts
4
4
  //
5
- // Mounted: 162 Skipped (legacy): 3
5
+ // Mounted: 164 Skipped (legacy): 3
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  exports.LEGACY_SHAPE_SKIP = void 0;
8
8
  exports.mountAllCaps = mountAllCaps;
@@ -269,6 +269,8 @@ function mountAllCaps(services) {
269
269
  imageSettings: (0, generated_cap_routers_js_1.createCapRouter_imageSettings)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'image-settings'), remoteCapProxy),
270
270
  integrations: (0, generated_cap_routers_js_1.createCapRouter_integrations)((_ctx) => reg?.getSingleton('integrations') ?? null, remoteCapProxy),
271
271
  intercom: (0, generated_cap_routers_js_1.createCapRouter_intercom)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'intercom'), remoteCapProxy),
272
+ keychain: (0, generated_cap_routers_js_1.createCapRouter_keychain)((_ctx) => reg?.getSingleton('keychain') ?? null, remoteCapProxy),
273
+ keychainAdmin: (0, generated_cap_routers_js_1.createCapRouter_keychainAdmin)((_ctx) => reg?.getSingleton('keychain-admin') ?? null, remoteCapProxy),
272
274
  lawnMowerControl: (0, generated_cap_routers_js_1.createCapRouter_lawnMowerControl)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'lawn-mower-control'), remoteCapProxy),
273
275
  llm: (0, generated_cap_routers_js_1.createCapRouter_llm)((_ctx, addonId) => {
274
276
  if (!reg)