@camstack/server 1.2.80 → 1.2.82

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.
@@ -68,10 +68,8 @@ exports.buildNodesProvider = buildNodesProvider;
68
68
  exports.buildIntegrationsProvider = buildIntegrationsProvider;
69
69
  exports.dispatchCustomAction = dispatchCustomAction;
70
70
  exports.buildAddonsProvider = buildAddonsProvider;
71
- const node_child_process_1 = require("node:child_process");
72
71
  const node_crypto_1 = require("node:crypto");
73
72
  const os = __importStar(require("node:os"));
74
- const node_util_1 = require("node:util");
75
73
  const system_1 = require("@camstack/system");
76
74
  const types_1 = require("@camstack/types");
77
75
  const server_1 = require("@trpc/server");
@@ -79,7 +77,6 @@ const integration_id_backfill_1 = require("../../boot/integration-id-backfill");
79
77
  const addon_package_service_js_1 = require("../../core/addon/addon-package.service.js");
80
78
  const lifecycle_runner_singleton_js_1 = require("../../core/lifecycle/lifecycle-runner.singleton.js");
81
79
  const collection_preference_js_1 = require("./collection-preference.js");
82
- const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
83
80
  // ── system ──────────────────────────────────────────────────────────
84
81
  function getRetention(registry) {
85
82
  return (registry?.getSingleton('analysis-data-persistence')?.retention ?? null);
@@ -569,6 +566,117 @@ function getDeviceProvider(ar, addonId) {
569
566
  const provider = ar.getCapabilityRegistry().getProviderByAddon('device-provider', addonId);
570
567
  return isDeviceProvider(provider) ? provider : null;
571
568
  }
569
+ function isConnectionTestProvider(value) {
570
+ return (value !== null &&
571
+ typeof value === 'object' &&
572
+ typeof Reflect.get(value, 'testSettings') === 'function');
573
+ }
574
+ function getConnectionTestProvider(ar, addonId) {
575
+ const provider = ar.getCapabilityRegistry().getProviderByAddon('connection-test', addonId);
576
+ return isConnectionTestProvider(provider) ? provider : null;
577
+ }
578
+ function isBrokerProvider(value) {
579
+ return (value !== null &&
580
+ typeof value === 'object' &&
581
+ typeof Reflect.get(value, 'testConnection') === 'function');
582
+ }
583
+ /**
584
+ * Does this addon declare a pre-creation test at all? Drives
585
+ * `getAvailableTypes().canTest`, so the wizard can say "this one cannot be
586
+ * tested" instead of offering a button that always answers the same nonsense.
587
+ *
588
+ * Broker-mode addons count: their connection identity is the broker, and the
589
+ * broker cap owns a real `testConnection`.
590
+ */
591
+ function declaresConnectionTest(ar, addonId, mode) {
592
+ if (getConnectionTestProvider(ar, addonId) !== null)
593
+ return true;
594
+ if (mode !== 'broker')
595
+ return false;
596
+ return isBrokerProvider(ar.getCapabilityRegistry().getProviderByAddon('broker', addonId));
597
+ }
598
+ /**
599
+ * Run the DECLARED pre-creation test for `(addonId, settings)`.
600
+ *
601
+ * Never throws: a test that blows up is `inconclusive`, which is emphatically
602
+ * NOT `rejected`. A timeout must not read as a refusal — it would block a
603
+ * perfectly good create on a flaky network, and the operator would have no way
604
+ * to tell the two apart.
605
+ */
606
+ async function runConnectionTest(ar, addonId, settings, logger) {
607
+ // 1. The provider's own declared test — the primary path.
608
+ const tester = getConnectionTestProvider(ar, addonId);
609
+ if (tester) {
610
+ try {
611
+ const outcome = await tester.testSettings({ settings });
612
+ if (outcome.outcome === 'validated') {
613
+ return {
614
+ status: 'validated',
615
+ testedBy: addonId,
616
+ ...(outcome.latencyMs !== undefined ? { latencyMs: outcome.latencyMs } : {}),
617
+ ...(outcome.detail !== undefined ? { detail: outcome.detail } : {}),
618
+ };
619
+ }
620
+ if (outcome.outcome === 'rejected') {
621
+ return { status: 'rejected', error: outcome.error, testedBy: addonId };
622
+ }
623
+ return { status: 'inconclusive', error: outcome.error, testedBy: addonId };
624
+ }
625
+ catch (err) {
626
+ logger.warn('connection test could not run', {
627
+ tags: { addonId },
628
+ meta: { phase: 'test', error: (0, types_1.errMsg)(err) },
629
+ });
630
+ return {
631
+ status: 'inconclusive',
632
+ error: `Connection test could not run: ${(0, types_1.errMsg)(err)}`,
633
+ testedBy: addonId,
634
+ };
635
+ }
636
+ }
637
+ // 2. Broker-backed integrations carry their connection identity as a
638
+ // `brokerId`; the broker already owns the real semantic check (HA opens a
639
+ // temporary WS handshake, MQTT pings the bridge). Reached because the
640
+ // settings NAME a broker, not because nothing else matched.
641
+ const brokerId = settings['brokerId'];
642
+ if (typeof brokerId === 'string' && brokerId.length > 0) {
643
+ const brokerProvider = ar
644
+ .getCapabilityRegistry()
645
+ .getProviderByAddonId('broker', addonId);
646
+ if (!brokerProvider) {
647
+ return {
648
+ status: 'inconclusive',
649
+ error: `Broker provider for addon '${addonId}' is not available`,
650
+ testedBy: null,
651
+ };
652
+ }
653
+ try {
654
+ const result = await brokerProvider.testConnection({ id: brokerId });
655
+ return result.ok
656
+ ? { status: 'validated', latencyMs: result.latencyMs, testedBy: addonId }
657
+ : { status: 'rejected', error: result.error, testedBy: addonId };
658
+ }
659
+ catch (err) {
660
+ logger.warn('broker connection test could not run', {
661
+ tags: { addonId },
662
+ meta: { phase: 'test', brokerId, error: (0, types_1.errMsg)(err) },
663
+ });
664
+ return {
665
+ status: 'inconclusive',
666
+ error: `Broker test could not run: ${(0, types_1.errMsg)(err)}`,
667
+ testedBy: addonId,
668
+ };
669
+ }
670
+ }
671
+ // 3. Nothing declared. Say so plainly — this must never render as a pass,
672
+ // and must never render as a failure we did not observe.
673
+ return {
674
+ status: 'unsupported',
675
+ error: `Integration '${addonId}' declares no connection test, ` +
676
+ `so these settings cannot be validated before it is created.`,
677
+ testedBy: null,
678
+ };
679
+ }
572
680
  /**
573
681
  * Marker caps that flag an addon as a creatable integration type:
574
682
  * - `device-provider` — classic providers (Reolink/ONVIF/Frigate)
@@ -621,6 +729,38 @@ function buildIntegrationsProvider(ar, eb, loggingService, capabilityRegistry) {
621
729
  throw new Error(`Addon "${input.addonId}" is unique-instance and already has an integration (${existing[0].id})`);
622
730
  }
623
731
  }
732
+ // ── the gate ────────────────────────────────────────────────────
733
+ //
734
+ // "Next" must validate and create ONLY on success. `rejected` is the one
735
+ // status that blocks: it means the remote was asked and said no. An
736
+ // `unsupported` (no test declared) or `inconclusive` (declared test could
737
+ // not complete) observed NOTHING about the credentials — refusing on
738
+ // those would make every integration lacking the hook uncreatable, and
739
+ // would fail a good create on a flaky network. They pass, and they SAY SO
740
+ // in the log: silence here would read as "validated".
741
+ const verdict = await runConnectionTest(ar, input.addonId, input.settings ?? {}, logger);
742
+ if (verdict.status === 'rejected') {
743
+ logger.warn('rejected — connection test refused the settings', {
744
+ tags: { addonId: input.addonId },
745
+ meta: { phase: 'create', status: verdict.status, testedBy: verdict.testedBy },
746
+ });
747
+ throw new server_1.TRPCError({
748
+ code: 'BAD_REQUEST',
749
+ message: verdict.error ?? `Connection test failed for '${input.addonId}'`,
750
+ });
751
+ }
752
+ if (verdict.status === 'validated') {
753
+ logger.info('connection test validated — proceeding', {
754
+ tags: { addonId: input.addonId },
755
+ meta: { phase: 'create', latencyMs: verdict.latencyMs },
756
+ });
757
+ }
758
+ else {
759
+ logger.warn('creating without validation — the settings were not validated', {
760
+ tags: { addonId: input.addonId },
761
+ meta: { phase: 'create', status: verdict.status, reason: verdict.error },
762
+ });
763
+ }
624
764
  const integration = await reg.createIntegration(payload);
625
765
  logger.info('persisted', {
626
766
  tags: { integrationId: integration.id, addonId: integration.addonId },
@@ -911,6 +1051,7 @@ function buildIntegrationsProvider(ar, eb, loggingService, capabilityRegistry) {
911
1051
  kind,
912
1052
  brokerKind,
913
1053
  supportsLocationImport,
1054
+ canTest: declaresConnectionTest(ar, m.id, mode),
914
1055
  existingInstances: existing.map((i) => ({
915
1056
  id: i.id,
916
1057
  name: i.name,
@@ -920,63 +1061,24 @@ function buildIntegrationsProvider(ar, eb, loggingService, capabilityRegistry) {
920
1061
  });
921
1062
  },
922
1063
  testConnection: async (input) => {
923
- // Broker-backed integrations (Approach A) carry their connection
924
- // identity as a `brokerId` in settings testing is a broker
925
- // concern now, so delegate to the addon's `broker` cap. The broker
926
- // already owns the real semantic check (HA opens a temporary WS
927
- // handshake; MQTT pings the bridge). We translate the broker's
928
- // discriminated result (`{ok:true,latencyMs}|{ok:false,error}`) into
929
- // the integrations `{success, error?}` output shape. Falls back to
930
- // the default RTSP/ffprobe path below for legacy device-provider
931
- // addons (Reolink/Frigate/ONVIF) that probe a stream URL.
932
- const registry = ar.getCapabilityRegistry();
933
- const brokerId = input.settings['brokerId'];
934
- if (typeof brokerId === 'string' && brokerId.length > 0) {
935
- const brokerProvider = registry.getProviderByAddonId('broker', input.addonId);
936
- if (!brokerProvider) {
937
- return {
938
- success: false,
939
- error: `Broker provider for addon '${input.addonId}' is not available`,
940
- };
941
- }
942
- try {
943
- const result = await brokerProvider.testConnection({ id: brokerId });
944
- return result.ok ? { success: true } : { success: false, error: result.error };
945
- }
946
- catch (err) {
947
- return { success: false, error: (0, types_1.errMsg)(err) };
948
- }
949
- }
950
- // Default — RTSP/Frigate/ONVIF legacy path: probe the stream URL.
951
- const url = String(input.settings['main_stream_url'] ?? input.settings['url'] ?? '').trim();
952
- if (!url)
953
- return { success: false, error: 'No stream URL provided' };
954
- try {
955
- const { stdout } = await execFileAsync('ffprobe', [
956
- '-v',
957
- 'error',
958
- '-rtsp_transport',
959
- 'tcp',
960
- '-timeout',
961
- '3000000',
962
- '-show_entries',
963
- 'stream=codec_name,width,height',
964
- '-of',
965
- 'json',
966
- url,
967
- ], { timeout: 5000 });
968
- const parsed = (0, types_1.asJsonObject)(JSON.parse(stdout));
969
- const streams = (0, types_1.asJsonArray)(parsed?.streams);
970
- return streams.length > 0
971
- ? { success: true }
972
- : { success: false, error: 'No streams found at URL' };
973
- }
974
- catch (err) {
975
- return {
976
- success: false,
977
- error: `Connection failed: ${(0, types_1.errMsg)(err)}`,
978
- };
979
- }
1064
+ // The Test button. Routes to the DECLARED test (see `runConnectionTest`)
1065
+ // and reports the tri-state verbatim: `success` is true ONLY for
1066
+ // `validated`, so a test that never ran can never render as a tick — and
1067
+ // `status` lets the UI show "could not be validated" in its own colour
1068
+ // instead of a red failure nobody observed.
1069
+ const verdict = await runConnectionTest(ar, input.addonId, input.settings, logger);
1070
+ logger.info('connection test', {
1071
+ tags: { addonId: input.addonId },
1072
+ meta: { phase: 'test', status: verdict.status, testedBy: verdict.testedBy },
1073
+ });
1074
+ return {
1075
+ success: verdict.status === 'validated',
1076
+ status: verdict.status,
1077
+ testedBy: verdict.testedBy,
1078
+ ...(verdict.error !== undefined ? { error: verdict.error } : {}),
1079
+ ...(verdict.latencyMs !== undefined ? { latencyMs: verdict.latencyMs } : {}),
1080
+ ...(verdict.detail !== undefined ? { detail: verdict.detail } : {}),
1081
+ };
980
1082
  },
981
1083
  };
982
1084
  }
@@ -7,6 +7,43 @@ exports.registerOauth2Routes = registerOauth2Routes;
7
7
  const session_cookie_js_1 = require("../../auth/session-cookie.js");
8
8
  const consent_page_js_1 = require("./consent-page.js");
9
9
  const private_host_js_1 = require("./private-host.js");
10
+ /** Longest caller-supplied `integration` value echoed back in an error. */
11
+ const MAX_ECHOED_INTEGRATION_LEN = 100;
12
+ /** Render the received `integration` for a human, bounded. */
13
+ function describeReceivedIntegration(raw) {
14
+ if (raw === undefined)
15
+ return null;
16
+ const joined = typeof raw === 'string' ? raw : raw.join(',');
17
+ if (joined === '')
18
+ return null;
19
+ return joined.length > MAX_ECHOED_INTEGRATION_LEN
20
+ ? `${joined.slice(0, MAX_ECHOED_INTEGRATION_LEN)}…`
21
+ : joined;
22
+ }
23
+ /**
24
+ * Resolve the `integration` parameter to a single id.
25
+ *
26
+ * A repeat carrying ONE distinct value is collapsed rather than refused:
27
+ * every layer that parses the query — proxy, framework, app — necessarily
28
+ * agrees on the value, so there is no parameter-pollution ambiguity to
29
+ * protect against, and refusing would strand a client whose request is
30
+ * merely redundant. A repeat whose values DISAGREE is exactly that
31
+ * ambiguity, and is refused.
32
+ */
33
+ function resolveIntegrationParam(raw) {
34
+ if (raw === undefined)
35
+ return { ok: false, reason: 'missing' };
36
+ if (typeof raw === 'string') {
37
+ return raw === '' ? { ok: false, reason: 'missing' } : { ok: true, value: raw };
38
+ }
39
+ const distinct = [...new Set(raw.filter((v) => v !== ''))];
40
+ const [first] = distinct;
41
+ if (first === undefined)
42
+ return { ok: false, reason: 'missing' };
43
+ if (distinct.length > 1)
44
+ return { ok: false, reason: 'conflicting-repeat' };
45
+ return { ok: true, value: first };
46
+ }
10
47
  /** Validate the inbound authorize query. `client_id` is intentionally
11
48
  * NOT checked — that pair is verified only at the Lambda boundary, and a
12
49
  * PUBLIC client (`requiresPkce`) has no secret to check it against at all;
@@ -14,9 +51,24 @@ const private_host_js_1 = require("./private-host.js");
14
51
  function validateAuthorizeQuery(q, knownIntegrations) {
15
52
  if (q.response_type !== 'code')
16
53
  return { ok: false, status: 400, error: 'unsupported_response_type' };
17
- const policy = q.integration ? knownIntegrations.get(q.integration) : undefined;
18
- if (!q.integration || !policy)
19
- return { ok: false, status: 400, error: 'invalid_request — unknown integration' };
54
+ // Three distinct client faults used to collapse into one opaque string.
55
+ // Alexa linking was down for a day because the message named neither what
56
+ // arrived nor what would have been accepted.
57
+ const detail = {
58
+ received: describeReceivedIntegration(q.integration),
59
+ known_integrations: [...knownIntegrations.keys()],
60
+ };
61
+ const param = resolveIntegrationParam(q.integration);
62
+ if (!param.ok) {
63
+ const error = param.reason === 'missing'
64
+ ? 'invalid_request — integration parameter missing'
65
+ : 'invalid_request — integration parameter repeated with conflicting values';
66
+ return { ok: false, status: 400, error, detail };
67
+ }
68
+ const policy = knownIntegrations.get(param.value);
69
+ if (!policy) {
70
+ return { ok: false, status: 400, error: 'invalid_request — unknown integration', detail };
71
+ }
20
72
  if (!q.redirect_uri)
21
73
  return { ok: false, status: 400, error: 'invalid_request — redirect_uri required' };
22
74
  if (!q.state)
@@ -32,7 +84,7 @@ function validateAuthorizeQuery(q, knownIntegrations) {
32
84
  }
33
85
  return {
34
86
  ok: true,
35
- integration: q.integration,
87
+ integration: param.value,
36
88
  redirectUri: q.redirect_uri,
37
89
  state: q.state,
38
90
  codeChallenge: challenge,
@@ -76,6 +128,20 @@ function summariseScopes(scopes) {
76
128
  }
77
129
  return scopes.map((s) => s.type).join(', ') || 'no permissions';
78
130
  }
131
+ /** A refused `/authorize`. Logged at `warn` and echoed to the caller: both
132
+ * halves name the value that ARRIVED and the ids that would have worked, so
133
+ * neither the operator nor the log reader has to guess which of them is
134
+ * wrong. */
135
+ function refuseAuthorize(reply, logger, method, refusal) {
136
+ logger.warn(`oauth2 authorize refused: ${refusal.error}`, {
137
+ meta: {
138
+ method,
139
+ received: refusal.detail?.received ?? null,
140
+ knownIntegrations: refusal.detail?.known_integrations ?? [],
141
+ },
142
+ });
143
+ return reply.status(refusal.status).send({ error: refusal.error, ...refusal.detail });
144
+ }
79
145
  /** Build a map of integrationId → descriptor from all registered oauth-integration providers. */
80
146
  async function buildIntegrationMap(registry) {
81
147
  const entries = registry.getCollectionEntries('oauth-integration');
@@ -137,10 +203,19 @@ function registerOauth2Routes(fastify, deps) {
137
203
  const query = request.query;
138
204
  const v = validateAuthorizeQuery(query, descriptorMap);
139
205
  if (!v.ok) {
140
- return reply.status(v.status).send({ error: v.error });
206
+ return refuseAuthorize(reply, deps.logger, 'GET', v);
141
207
  }
142
208
  const descriptor = descriptorMap.get(v.integration);
143
209
  if (!isRedirectUriAllowed(v.redirectUri, descriptor.allowedRedirectPrefixes, descriptor.allowedPrivateHostPaths ?? [])) {
210
+ deps.logger.warn('oauth2 authorize refused: redirect_uri not allowed', {
211
+ meta: {
212
+ method: 'GET',
213
+ integration: v.integration,
214
+ redirectUri: v.redirectUri,
215
+ allowedPrefixes: descriptor.allowedRedirectPrefixes,
216
+ allowedPrivateHostPaths: descriptor.allowedPrivateHostPaths ?? [],
217
+ },
218
+ });
144
219
  return reply
145
220
  .status(400)
146
221
  .send({ error: 'invalid_request — redirect_uri not allowed for this integration' });
@@ -223,10 +298,19 @@ function registerOauth2Routes(fastify, deps) {
223
298
  // and every one of them is attacker-editable.
224
299
  const v = validateAuthorizeQuery(formQuery, descriptorMap);
225
300
  if (!v.ok) {
226
- return reply.status(v.status).send({ error: v.error });
301
+ return refuseAuthorize(reply, deps.logger, 'POST', v);
227
302
  }
228
303
  const descriptor = descriptorMap.get(v.integration);
229
304
  if (!isRedirectUriAllowed(v.redirectUri, descriptor.allowedRedirectPrefixes, descriptor.allowedPrivateHostPaths ?? [])) {
305
+ deps.logger.warn('oauth2 authorize refused: redirect_uri not allowed', {
306
+ meta: {
307
+ method: 'POST',
308
+ integration: v.integration,
309
+ redirectUri: v.redirectUri,
310
+ allowedPrefixes: descriptor.allowedRedirectPrefixes,
311
+ allowedPrivateHostPaths: descriptor.allowedPrivateHostPaths ?? [],
312
+ },
313
+ });
230
314
  return reply
231
315
  .status(400)
232
316
  .send({ error: 'invalid_request — redirect_uri not allowed for this integration' });
@@ -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: 144 Skipped (legacy): 3
5
+ // Mounted: 145 Skipped (legacy): 3
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  exports.LEGACY_SHAPE_SKIP = void 0;
8
8
  exports.mountAllCaps = mountAllCaps;
@@ -111,6 +111,15 @@ function mountAllCaps(services) {
111
111
  carbonMonoxide: (0, generated_cap_routers_js_1.createCapRouter_carbonMonoxide)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'carbon-monoxide'), remoteCapProxy),
112
112
  climateControl: (0, generated_cap_routers_js_1.createCapRouter_climateControl)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'climate-control'), remoteCapProxy),
113
113
  color: (0, generated_cap_routers_js_1.createCapRouter_color)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'color'), remoteCapProxy),
114
+ connectionTest: (0, generated_cap_routers_js_1.createCapRouter_connectionTest)((_ctx, addonId) => {
115
+ if (!reg)
116
+ return null;
117
+ if (addonId !== undefined) {
118
+ return reg.getProviderByAddonId('connection-test', addonId);
119
+ }
120
+ const entries = reg.getCollectionEntries('connection-test');
121
+ return entries[0]?.[1] ?? null;
122
+ }, remoteCapProxy),
114
123
  connectivity: (0, generated_cap_routers_js_1.createCapRouter_connectivity)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'connectivity'), remoteCapProxy),
115
124
  consumables: (0, generated_cap_routers_js_1.createCapRouter_consumables)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'consumables'), remoteCapProxy),
116
125
  contact: (0, generated_cap_routers_js_1.createCapRouter_contact)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'contact'), remoteCapProxy),