@camstack/server 1.2.81 → 1.2.83
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
|
-
//
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
//
|
|
927
|
-
//
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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
|
}
|
|
@@ -9,6 +9,15 @@ const consent_page_js_1 = require("./consent-page.js");
|
|
|
9
9
|
const private_host_js_1 = require("./private-host.js");
|
|
10
10
|
/** Longest caller-supplied `integration` value echoed back in an error. */
|
|
11
11
|
const MAX_ECHOED_INTEGRATION_LEN = 100;
|
|
12
|
+
/**
|
|
13
|
+
* `Retry-After` on a refusal caused by an addon that has not registered yet.
|
|
14
|
+
*
|
|
15
|
+
* A forked addon's runner spawns, loads and initialises in seconds. This is
|
|
16
|
+
* advice to the CLIENT and nothing more — the hub never sleeps, never polls
|
|
17
|
+
* and never queues the request ([D3](../../../../../docs/decisions/adr-0003.md));
|
|
18
|
+
* it answers immediately with what is true right now.
|
|
19
|
+
*/
|
|
20
|
+
const INTEGRATION_PENDING_RETRY_AFTER_SEC = 5;
|
|
12
21
|
/** Render the received `integration` for a human, bounded. */
|
|
13
22
|
function describeReceivedIntegration(raw) {
|
|
14
23
|
if (raw === undefined)
|
|
@@ -48,15 +57,16 @@ function resolveIntegrationParam(raw) {
|
|
|
48
57
|
* NOT checked — that pair is verified only at the Lambda boundary, and a
|
|
49
58
|
* PUBLIC client (`requiresPkce`) has no secret to check it against at all;
|
|
50
59
|
* the S256 challenge is what binds the code to its requester instead. */
|
|
51
|
-
function validateAuthorizeQuery(q, knownIntegrations) {
|
|
60
|
+
function validateAuthorizeQuery(q, knownIntegrations, pendingAddons = []) {
|
|
52
61
|
if (q.response_type !== 'code')
|
|
53
62
|
return { ok: false, status: 400, error: 'unsupported_response_type' };
|
|
54
|
-
//
|
|
63
|
+
// Four distinct faults used to collapse into one opaque string.
|
|
55
64
|
// Alexa linking was down for a day because the message named neither what
|
|
56
65
|
// arrived nor what would have been accepted.
|
|
57
66
|
const detail = {
|
|
58
67
|
received: describeReceivedIntegration(q.integration),
|
|
59
68
|
known_integrations: [...knownIntegrations.keys()],
|
|
69
|
+
...(pendingAddons.length > 0 ? { pending_addons: pendingAddons } : {}),
|
|
60
70
|
};
|
|
61
71
|
const param = resolveIntegrationParam(q.integration);
|
|
62
72
|
if (!param.ok) {
|
|
@@ -67,6 +77,21 @@ function validateAuthorizeQuery(q, knownIntegrations) {
|
|
|
67
77
|
}
|
|
68
78
|
const policy = knownIntegrations.get(param.value);
|
|
69
79
|
if (!policy) {
|
|
80
|
+
// The fourth fault, and the one moving every descriptor into an addon made
|
|
81
|
+
// likelier: the id is absent because the addon that owns it has not
|
|
82
|
+
// registered yet. `invalid_request` means "your request is wrong, do not
|
|
83
|
+
// repeat it" — during boot that is a lie, and it is the lie that sends a
|
|
84
|
+
// client to its degraded fallback and leaves it there. `temporarily_
|
|
85
|
+
// unavailable` is RFC 6749 §4.1.2.1 and says the opposite.
|
|
86
|
+
if (pendingAddons.length > 0) {
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
status: 503,
|
|
90
|
+
error: 'temporarily_unavailable — integration not registered yet',
|
|
91
|
+
detail,
|
|
92
|
+
retryAfterSec: INTEGRATION_PENDING_RETRY_AFTER_SEC,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
70
95
|
return { ok: false, status: 400, error: 'invalid_request — unknown integration', detail };
|
|
71
96
|
}
|
|
72
97
|
if (!q.redirect_uri)
|
|
@@ -138,19 +163,29 @@ function refuseAuthorize(reply, logger, method, refusal) {
|
|
|
138
163
|
method,
|
|
139
164
|
received: refusal.detail?.received ?? null,
|
|
140
165
|
knownIntegrations: refusal.detail?.known_integrations ?? [],
|
|
166
|
+
// Without this, a refusal during the boot window is byte-identical in
|
|
167
|
+
// Loki to one against a hub that has been up for a week.
|
|
168
|
+
pendingAddons: refusal.detail?.pending_addons ?? [],
|
|
141
169
|
},
|
|
142
170
|
});
|
|
171
|
+
if (refusal.retryAfterSec !== undefined) {
|
|
172
|
+
void reply.header('Retry-After', String(refusal.retryAfterSec));
|
|
173
|
+
}
|
|
143
174
|
return reply.status(refusal.status).send({ error: refusal.error, ...refusal.detail });
|
|
144
175
|
}
|
|
145
|
-
/**
|
|
146
|
-
|
|
176
|
+
/** Read integrationId → descriptor from every registered `oauth-integration`
|
|
177
|
+
* provider, plus the addons that still owe one. */
|
|
178
|
+
async function readIntegrationRegistry(registry) {
|
|
147
179
|
const entries = registry.getCollectionEntries('oauth-integration');
|
|
148
|
-
const
|
|
180
|
+
const descriptors = new Map();
|
|
149
181
|
for (const [, provider] of entries) {
|
|
150
182
|
const descriptor = await provider.getDescriptor();
|
|
151
|
-
|
|
183
|
+
descriptors.set(descriptor.integrationId, descriptor);
|
|
152
184
|
}
|
|
153
|
-
|
|
185
|
+
const pendingAddons = registry
|
|
186
|
+
.getManifestDeclarers('oauth-integration')
|
|
187
|
+
.filter((addonId) => !registry.hasProvider('oauth-integration', addonId));
|
|
188
|
+
return { descriptors, pendingAddons };
|
|
154
189
|
}
|
|
155
190
|
/** Parse an application/x-www-form-urlencoded body string into a plain object. */
|
|
156
191
|
function parseFormBody(raw) {
|
|
@@ -199,13 +234,13 @@ function registerOauth2Routes(fastify, deps) {
|
|
|
199
234
|
if (!registry) {
|
|
200
235
|
return reply.status(503).send({ error: 'service_unavailable' });
|
|
201
236
|
}
|
|
202
|
-
const
|
|
237
|
+
const { descriptors, pendingAddons } = await readIntegrationRegistry(registry);
|
|
203
238
|
const query = request.query;
|
|
204
|
-
const v = validateAuthorizeQuery(query,
|
|
239
|
+
const v = validateAuthorizeQuery(query, descriptors, pendingAddons);
|
|
205
240
|
if (!v.ok) {
|
|
206
241
|
return refuseAuthorize(reply, deps.logger, 'GET', v);
|
|
207
242
|
}
|
|
208
|
-
const descriptor =
|
|
243
|
+
const descriptor = descriptors.get(v.integration);
|
|
209
244
|
if (!isRedirectUriAllowed(v.redirectUri, descriptor.allowedRedirectPrefixes, descriptor.allowedPrivateHostPaths ?? [])) {
|
|
210
245
|
deps.logger.warn('oauth2 authorize refused: redirect_uri not allowed', {
|
|
211
246
|
meta: {
|
|
@@ -247,18 +282,31 @@ function registerOauth2Routes(fastify, deps) {
|
|
|
247
282
|
// from one that is refusing it, or the client dead-ends on a 400 with no way
|
|
248
283
|
// to fall back. Unauthenticated (so is /token) and it discloses only which
|
|
249
284
|
// integrations are installed.
|
|
285
|
+
//
|
|
286
|
+
// `complete` exists because absence-means-old-hub is a STICKY verdict: a
|
|
287
|
+
// client that probes 200ms after a restart sees a short list, concludes the
|
|
288
|
+
// hub cannot do OAuth, and settles into its password fallback for good. The
|
|
289
|
+
// flag says whether the list is final; a client SHOULD re-probe while it is
|
|
290
|
+
// false rather than downgrade. The pending addon IDS are deliberately not
|
|
291
|
+
// here — this route needs no token, and "not final yet" is the entire signal
|
|
292
|
+
// a client can act on. They are on the session-gated /authorize refusal,
|
|
293
|
+
// where a human is reading them as a diagnosis.
|
|
250
294
|
fastify.get('/api/oauth2/integrations', async (_request, reply) => {
|
|
251
295
|
const registry = deps.getRegistry();
|
|
252
296
|
if (!registry) {
|
|
253
297
|
return reply.status(503).send({ error: 'service_unavailable' });
|
|
254
298
|
}
|
|
255
|
-
const
|
|
299
|
+
const { descriptors, pendingAddons } = await readIntegrationRegistry(registry);
|
|
300
|
+
if (pendingAddons.length > 0) {
|
|
301
|
+
void reply.header('Retry-After', String(INTEGRATION_PENDING_RETRY_AFTER_SEC));
|
|
302
|
+
}
|
|
256
303
|
return reply.send({
|
|
257
|
-
integrations: [...
|
|
304
|
+
integrations: [...descriptors.values()].map((d) => ({
|
|
258
305
|
integrationId: d.integrationId,
|
|
259
306
|
displayName: d.displayName,
|
|
260
307
|
requiresPkce: d.requiresPkce === true,
|
|
261
308
|
})),
|
|
309
|
+
complete: pendingAddons.length === 0,
|
|
262
310
|
});
|
|
263
311
|
});
|
|
264
312
|
// ─── POST /api/oauth2/authorize ───────────────────────────────────────────
|
|
@@ -284,7 +332,7 @@ function registerOauth2Routes(fastify, deps) {
|
|
|
284
332
|
if (!registry) {
|
|
285
333
|
return reply.status(503).send({ error: 'service_unavailable' });
|
|
286
334
|
}
|
|
287
|
-
const
|
|
335
|
+
const { descriptors, pendingAddons } = await readIntegrationRegistry(registry);
|
|
288
336
|
const body = request.body;
|
|
289
337
|
const formQuery = {
|
|
290
338
|
response_type: body.response_type,
|
|
@@ -296,11 +344,11 @@ function registerOauth2Routes(fastify, deps) {
|
|
|
296
344
|
};
|
|
297
345
|
// Re-validated, not trusted: the hidden fields came back from a browser
|
|
298
346
|
// and every one of them is attacker-editable.
|
|
299
|
-
const v = validateAuthorizeQuery(formQuery,
|
|
347
|
+
const v = validateAuthorizeQuery(formQuery, descriptors, pendingAddons);
|
|
300
348
|
if (!v.ok) {
|
|
301
349
|
return refuseAuthorize(reply, deps.logger, 'POST', v);
|
|
302
350
|
}
|
|
303
|
-
const descriptor =
|
|
351
|
+
const descriptor = descriptors.get(v.integration);
|
|
304
352
|
if (!isRedirectUriAllowed(v.redirectUri, descriptor.allowedRedirectPrefixes, descriptor.allowedPrivateHostPaths ?? [])) {
|
|
305
353
|
deps.logger.warn('oauth2 authorize refused: redirect_uri not allowed', {
|
|
306
354
|
meta: {
|
|
@@ -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:
|
|
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),
|