@camstack/server 1.1.61 → 1.1.63
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.
- package/dist/api/core/auth.router.js +15 -2
- package/dist/api/trpc/client-ip.js +132 -29
- package/dist/api/trpc/generated-cap-mounts.js +26 -1
- package/dist/api/trpc/generated-cap-routers.js +1179 -750
- package/dist/api/trpc/trpc.router.js +81 -17
- package/dist/core/addon/addon-registry.service.js +183 -59
- package/dist/core/addon/runner-convergence.js +62 -0
- package/dist/main.js +8 -1
- package/dist/server-root/index.js +89 -59
- package/package.json +12 -12
|
@@ -176,12 +176,14 @@ async function passkeySecondFactorOptedIn(userId, provider) {
|
|
|
176
176
|
//
|
|
177
177
|
// The PUBLIC `listLoginMethods` procedure walks the `login-method`
|
|
178
178
|
// collection and returns the union that the login page renders directly:
|
|
179
|
-
// `redirect` buttons (OIDC / magic-link)
|
|
179
|
+
// `redirect` buttons (OIDC / magic-link), native `passkey` ceremonies, and
|
|
180
|
+
// (generic mechanism, unused by shipped addons) pre-auth `widget`s.
|
|
180
181
|
// The widget arm is enriched server-side with a public `bundleUrl` from
|
|
181
182
|
// `addonId` + `bundle`, so the addon never encodes the static-route
|
|
182
183
|
// scheme. The version is a PATH prefix (`v<…>/`), not a `?v=` query, so the
|
|
183
184
|
// bundle's relative sibling imports inherit it and a CDN busts the WHOLE
|
|
184
|
-
// bundle atomically on deploy (see the stamping site below).
|
|
185
|
+
// bundle atomically on deploy (see the stamping site below). `redirect` and
|
|
186
|
+
// `passkey` arms pass through untouched — no server-side enrichment needed.
|
|
185
187
|
const PublicWidgetLoginMethodSchema = types_1.WidgetLoginMethodSchema.extend({
|
|
186
188
|
bundleUrl: zod_1.z.string(),
|
|
187
189
|
});
|
|
@@ -191,6 +193,7 @@ const AUTH_BUNDLE_VERSION = String(Date.now());
|
|
|
191
193
|
const PublicLoginMethodSchema = zod_1.z.discriminatedUnion('kind', [
|
|
192
194
|
types_1.RedirectLoginMethodSchema,
|
|
193
195
|
PublicWidgetLoginMethodSchema,
|
|
196
|
+
types_1.PasskeyLoginMethodSchema,
|
|
194
197
|
]);
|
|
195
198
|
// ── Share tokens (grid-view standalone share links) ──────────────────
|
|
196
199
|
//
|
|
@@ -965,6 +968,16 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
965
968
|
}
|
|
966
969
|
continue;
|
|
967
970
|
}
|
|
971
|
+
if (raw.kind === 'passkey') {
|
|
972
|
+
// Native ceremony — passes through untouched, no server-side
|
|
973
|
+
// enrichment (rpId/origin are already stamped by the addon).
|
|
974
|
+
const parsed = types_1.PasskeyLoginMethodSchema.safeParse(raw);
|
|
975
|
+
if (parsed.success) {
|
|
976
|
+
out.push(parsed.data);
|
|
977
|
+
seenIds.add(parsed.data.id);
|
|
978
|
+
}
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
968
981
|
// Widget arm — stamp a public bundleUrl with the version in the
|
|
969
982
|
// PATH, not a `?v=` query. Module-Federation bundles import their
|
|
970
983
|
// fixed-name siblings (`./_stub.js`) by RELATIVE url, which never
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.extractClientIp = extractClientIp;
|
|
4
|
+
exports.extractCfConnectingIp = extractCfConnectingIp;
|
|
5
|
+
exports.extractPeerIp = extractPeerIp;
|
|
4
6
|
exports.extractUserAgent = extractUserAgent;
|
|
7
|
+
exports.ipClass = ipClass;
|
|
8
|
+
exports.classifyClient = classifyClient;
|
|
9
|
+
exports.classifyClientRequest = classifyClientRequest;
|
|
10
|
+
exports.deriveRelayOnly = deriveRelayOnly;
|
|
5
11
|
exports.isRemoteClientIp = isRemoteClientIp;
|
|
6
12
|
/**
|
|
7
13
|
* Best-effort extraction of the originating client IP from a tRPC
|
|
@@ -38,6 +44,44 @@ function extractClientIp(req) {
|
|
|
38
44
|
}
|
|
39
45
|
return null;
|
|
40
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Best-effort read of Cloudflare's `CF-Connecting-IP` request header. When
|
|
49
|
+
* the hub is reached through a Cloudflare Tunnel, the local `cloudflared`
|
|
50
|
+
* connector originates the request from LOOPBACK, so the socket peer (and
|
|
51
|
+
* usually `req.ip`) is `127.0.0.1` — a genuinely-remote viewer would
|
|
52
|
+
* otherwise be misclassified LAN. Cloudflare stamps the real client address
|
|
53
|
+
* in `CF-Connecting-IP` on every tunneled request.
|
|
54
|
+
*
|
|
55
|
+
* SECURITY: this header is trivially spoofable by any client that can reach
|
|
56
|
+
* the hub directly (it's just an HTTP header). It is therefore trusted ONLY
|
|
57
|
+
* when the immediate socket peer is loopback/LAN — see `classifyClient`. A
|
|
58
|
+
* direct internet client that forges the header still classifies by its own
|
|
59
|
+
* (remote) peer address. Returns `null` when absent.
|
|
60
|
+
*/
|
|
61
|
+
function extractCfConnectingIp(req) {
|
|
62
|
+
if (!req)
|
|
63
|
+
return null;
|
|
64
|
+
const raw = req.headers['cf-connecting-ip'];
|
|
65
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
66
|
+
if (typeof value === 'string' && value.length > 0)
|
|
67
|
+
return normalizeIp(value.trim());
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Immediate socket peer address (never the `X-Forwarded-For` / `req.ip`
|
|
72
|
+
* proxy-resolved value). Used only to gate `CF-Connecting-IP` trust: the
|
|
73
|
+
* header is honoured solely when the request physically arrived from a
|
|
74
|
+
* loopback/LAN peer (the local `cloudflared` connector). Returns `null` when
|
|
75
|
+
* no socket is present (mesh-originated calls).
|
|
76
|
+
*/
|
|
77
|
+
function extractPeerIp(req) {
|
|
78
|
+
if (!req)
|
|
79
|
+
return null;
|
|
80
|
+
const remote = req.socket?.remoteAddress;
|
|
81
|
+
if (typeof remote === 'string' && remote.length > 0)
|
|
82
|
+
return normalizeIp(remote);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
41
85
|
/**
|
|
42
86
|
* Best-effort read of the originating client's `User-Agent` header. Both
|
|
43
87
|
* request shapes in the union (`FastifyRequest` and the raw WS-transport
|
|
@@ -71,33 +115,81 @@ function normalizeIp(ip) {
|
|
|
71
115
|
return out;
|
|
72
116
|
}
|
|
73
117
|
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* (10/8, 172.16/12, 192.168/16, 127/8, 100.64/10 Tailscale CGNAT) and
|
|
77
|
-
* IPv6 (::1, fc00::/7 unique-local, fe80::/10 link-local — fd7a::/16
|
|
78
|
-
* Tailscale ULA is a subset of fc00::/7 and is therefore already
|
|
79
|
-
* covered).
|
|
80
|
-
*
|
|
81
|
-
* Tailscale clients (the 100.64.0.0/10 CGNAT overlay or the fd7a::/16
|
|
82
|
-
* ULA overlay) are deliberately classified LOCAL: over the Tailscale
|
|
83
|
-
* mesh BOTH peers sit on the 100.x / fd7a:: overlay and are mutually
|
|
84
|
-
* reachable, so the broker offers ALL candidates (host/srflx/relay)
|
|
85
|
-
* — including the hub's Tailscale host candidate — and a direct
|
|
86
|
-
* host↔host pair wins ICE with native (non-re-encoded) media. Forcing
|
|
87
|
-
* relay-only for Tailscale would push them onto the broken relay path.
|
|
88
|
-
*
|
|
89
|
-
* Unparseable or null addresses return `false` (treated as LAN — the
|
|
90
|
-
* safe default that preserves the existing direct path).
|
|
118
|
+
* Classify a single already-normalised IP literal. Null / unparseable →
|
|
119
|
+
* `'lan'` (the safe default that keeps the low-latency direct path).
|
|
91
120
|
*/
|
|
92
|
-
function
|
|
121
|
+
function ipClass(ip) {
|
|
93
122
|
if (!ip)
|
|
94
|
-
return
|
|
95
|
-
if (ip.includes(':'))
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
123
|
+
return 'lan';
|
|
124
|
+
if (ip.includes(':')) {
|
|
125
|
+
if (isTailscaleIpv6(ip))
|
|
126
|
+
return 'vpn';
|
|
127
|
+
if (isPrivateIpv6(ip))
|
|
128
|
+
return 'lan';
|
|
129
|
+
return 'remote';
|
|
130
|
+
}
|
|
131
|
+
if (isIpv4(ip)) {
|
|
132
|
+
if (isTailscaleIpv4(ip))
|
|
133
|
+
return 'vpn';
|
|
134
|
+
if (isPrivateIpv4(ip))
|
|
135
|
+
return 'lan';
|
|
136
|
+
return 'remote';
|
|
137
|
+
}
|
|
99
138
|
// Not a recognisable IP literal — be conservative, treat as LAN.
|
|
100
|
-
return
|
|
139
|
+
return 'lan';
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Classify a viewer from the resolved request signals.
|
|
143
|
+
*
|
|
144
|
+
* `cfConnectingIp` (Cloudflare Tunnel's real-client header) is honoured ONLY
|
|
145
|
+
* when `peerIsLocal` — i.e. the request physically arrived from the local
|
|
146
|
+
* `cloudflared` connector on loopback/LAN. On that path the socket peer is
|
|
147
|
+
* loopback and cannot itself reveal the viewer, so the header is the only
|
|
148
|
+
* trustworthy signal; classify by ITS value. When the peer is NOT local the
|
|
149
|
+
* header is ignored (a direct internet client could forge it) and we classify
|
|
150
|
+
* by the proxy-resolved `clientIp`.
|
|
151
|
+
*/
|
|
152
|
+
function classifyClient(args) {
|
|
153
|
+
if (args.peerIsLocal && args.cfConnectingIp)
|
|
154
|
+
return ipClass(args.cfConnectingIp);
|
|
155
|
+
return ipClass(args.clientIp);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Classify a live tRPC request. Returns `null` for a mesh-originated call
|
|
159
|
+
* (no HTTP request) so the caller leaves the session input untouched rather
|
|
160
|
+
* than forcing a policy. Composes `extractClientIp` (proxy-aware), the
|
|
161
|
+
* `CF-Connecting-IP` header, and the immediate socket peer (CF trust gate).
|
|
162
|
+
*/
|
|
163
|
+
function classifyClientRequest(req) {
|
|
164
|
+
if (!req)
|
|
165
|
+
return null;
|
|
166
|
+
const clientIp = extractClientIp(req);
|
|
167
|
+
const cfConnectingIp = extractCfConnectingIp(req);
|
|
168
|
+
const peerIp = extractPeerIp(req);
|
|
169
|
+
const peerIsLocal = peerIp !== null && ipClass(peerIp) !== 'remote';
|
|
170
|
+
return classifyClient({ clientIp, cfConnectingIp, peerIsLocal });
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Derive the broker's `relayOnly` ICE override from the client class:
|
|
174
|
+
* - `remote` → true (CGNAT/4G/internet peer only offers a relay candidate;
|
|
175
|
+
* a forced relay↔relay path avoids werift nominating a
|
|
176
|
+
* dead host/hairpin-srflx pair).
|
|
177
|
+
* - `vpn` → false (the Tailscale tunnel already carries native media on a
|
|
178
|
+
* direct overlay host pair; forcing TURN would be worse).
|
|
179
|
+
* - `lan` → false (low-latency direct host/srflx path).
|
|
180
|
+
*/
|
|
181
|
+
function deriveRelayOnly(clientClass) {
|
|
182
|
+
return clientClass === 'remote';
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* True when the address is a genuinely-remote (internet) viewer — i.e. NOT
|
|
186
|
+
* lan and NOT vpn. Retained for callers that want the boolean; defined in
|
|
187
|
+
* terms of `ipClass` so the classification stays single-sourced. Tailscale
|
|
188
|
+
* (`'vpn'`) is NOT remote (kept on the direct overlay path). Unparseable /
|
|
189
|
+
* null → false (safe LAN default).
|
|
190
|
+
*/
|
|
191
|
+
function isRemoteClientIp(ip) {
|
|
192
|
+
return ipClass(ip) === 'remote';
|
|
101
193
|
}
|
|
102
194
|
function isIpv4(ip) {
|
|
103
195
|
const parts = ip.split('.');
|
|
@@ -110,6 +202,14 @@ function isIpv4(ip) {
|
|
|
110
202
|
return n >= 0 && n <= 255;
|
|
111
203
|
});
|
|
112
204
|
}
|
|
205
|
+
/** 100.64.0.0/10 — Tailscale CGNAT overlay (100.64.0.0 – 100.127.255.255). */
|
|
206
|
+
function isTailscaleIpv4(ip) {
|
|
207
|
+
const parts = ip.split('.').map((p) => Number.parseInt(p, 10));
|
|
208
|
+
const [a, b] = parts;
|
|
209
|
+
if (a === undefined || b === undefined)
|
|
210
|
+
return false;
|
|
211
|
+
return a === 100 && b >= 64 && b <= 127;
|
|
212
|
+
}
|
|
113
213
|
function isPrivateIpv4(ip) {
|
|
114
214
|
const parts = ip.split('.').map((p) => Number.parseInt(p, 10));
|
|
115
215
|
const [a, b] = parts;
|
|
@@ -125,13 +225,16 @@ function isPrivateIpv4(ip) {
|
|
|
125
225
|
return true; // 192.168.0.0/16
|
|
126
226
|
if (a === 169 && b === 254)
|
|
127
227
|
return true; // 169.254.0.0/16 link-local
|
|
128
|
-
// 100.64.0.0/10 — Tailscale CGNAT overlay (100.64.0.0 – 100.127.255.255).
|
|
129
|
-
// Both Tailscale peers are mutually reachable on this overlay, so treat
|
|
130
|
-
// it as local (direct host↔host pair, no relay).
|
|
131
|
-
if (a === 100 && b >= 64 && b <= 127)
|
|
132
|
-
return true;
|
|
133
228
|
return false;
|
|
134
229
|
}
|
|
230
|
+
/**
|
|
231
|
+
* fd7a:115c:a1e0::/48 — Tailscale IPv6 ULA overlay (source: Tailscale docs).
|
|
232
|
+
* A strict subset of fc00::/7; checked BEFORE the generic-ULA test so a
|
|
233
|
+
* tailnet client on the hub's own v6 address classifies `'vpn'`, not `'lan'`.
|
|
234
|
+
*/
|
|
235
|
+
function isTailscaleIpv6(ip) {
|
|
236
|
+
return ip.toLowerCase().startsWith('fd7a:115c:a1e0:');
|
|
237
|
+
}
|
|
135
238
|
function isPrivateIpv6(ip) {
|
|
136
239
|
const lower = ip.toLowerCase();
|
|
137
240
|
if (lower === '::1')
|
|
@@ -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: 138 Skipped (legacy): 3
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
7
|
exports.LEGACY_SHAPE_SKIP = void 0;
|
|
8
8
|
exports.mountAllCaps = mountAllCaps;
|
|
@@ -205,6 +205,30 @@ function mountAllCaps(services) {
|
|
|
205
205
|
integrations: (0, generated_cap_routers_js_1.createCapRouter_integrations)((_ctx) => reg?.getSingleton('integrations') ?? null, remoteCapProxy),
|
|
206
206
|
intercom: (0, generated_cap_routers_js_1.createCapRouter_intercom)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'intercom'), remoteCapProxy),
|
|
207
207
|
lawnMowerControl: (0, generated_cap_routers_js_1.createCapRouter_lawnMowerControl)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'lawn-mower-control'), remoteCapProxy),
|
|
208
|
+
llm: (0, generated_cap_routers_js_1.createCapRouter_llm)((_ctx, addonId) => {
|
|
209
|
+
if (!reg)
|
|
210
|
+
return null;
|
|
211
|
+
if (addonId !== undefined) {
|
|
212
|
+
return reg.getProviderByAddonId('llm', addonId);
|
|
213
|
+
}
|
|
214
|
+
const entries = reg.getCollectionEntries('llm');
|
|
215
|
+
if (entries.length === 0)
|
|
216
|
+
return null;
|
|
217
|
+
const providers = entries.map(([, p]) => p);
|
|
218
|
+
const first = providers[0];
|
|
219
|
+
return {
|
|
220
|
+
...first,
|
|
221
|
+
listProfileKinds: (0, cap_mount_helpers_js_1.concatCollection)(providers, 'listProfileKinds'),
|
|
222
|
+
listProfiles: (0, cap_mount_helpers_js_1.concatCollection)(providers, 'listProfiles'),
|
|
223
|
+
listModels: (0, cap_mount_helpers_js_1.concatCollection)(providers, 'listModels'),
|
|
224
|
+
getDefaults: (0, cap_mount_helpers_js_1.concatCollection)(providers, 'getDefaults'),
|
|
225
|
+
getUsage: (0, cap_mount_helpers_js_1.concatCollection)(providers, 'getUsage'),
|
|
226
|
+
listModelCatalog: (0, cap_mount_helpers_js_1.concatCollection)(providers, 'listModelCatalog'),
|
|
227
|
+
listRuntimeNodes: (0, cap_mount_helpers_js_1.concatCollection)(providers, 'listRuntimeNodes'),
|
|
228
|
+
listNodeModels: (0, cap_mount_helpers_js_1.concatCollection)(providers, 'listNodeModels'),
|
|
229
|
+
};
|
|
230
|
+
}, remoteCapProxy),
|
|
231
|
+
llmRuntime: (0, generated_cap_routers_js_1.createCapRouter_llmRuntime)((_ctx) => reg?.getSingleton('llm-runtime') ?? null, remoteCapProxy),
|
|
208
232
|
localNetwork: (0, generated_cap_routers_js_1.createCapRouter_localNetwork)((_ctx) => reg?.getSingleton('local-network') ?? null, remoteCapProxy),
|
|
209
233
|
lockControl: (0, generated_cap_routers_js_1.createCapRouter_lockControl)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'lock-control'), remoteCapProxy),
|
|
210
234
|
loginMethod: (0, generated_cap_routers_js_1.createCapRouter_loginMethod)((_ctx, addonId) => {
|
|
@@ -326,6 +350,7 @@ function mountAllCaps(services) {
|
|
|
326
350
|
ptzAutotrack: (0, generated_cap_routers_js_1.createCapRouter_ptzAutotrack)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'ptz-autotrack'), remoteCapProxy),
|
|
327
351
|
reboot: (0, generated_cap_routers_js_1.createCapRouter_reboot)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'reboot'), remoteCapProxy),
|
|
328
352
|
recording: (0, generated_cap_routers_js_1.createCapRouter_recording)((_ctx) => reg?.getSingleton('recording') ?? null, remoteCapProxy),
|
|
353
|
+
sceneMonitor: (0, generated_cap_routers_js_1.createCapRouter_sceneMonitor)((_ctx) => reg?.getSingleton('scene-monitor') ?? null, remoteCapProxy),
|
|
329
354
|
scriptRunner: (0, generated_cap_routers_js_1.createCapRouter_scriptRunner)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'script-runner'), remoteCapProxy),
|
|
330
355
|
serverManagement: (0, generated_cap_routers_js_1.createCapRouter_serverManagement)((_ctx) => reg?.getSingleton('server-management') ??
|
|
331
356
|
null, remoteCapProxy),
|