@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.
@@ -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) + pre-auth `widget`s (passkey).
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
- * True when the address is NOT in a private / loopback / link-local /
75
- * Tailscale range i.e. an internet (remote) viewer. Covers IPv4
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 isRemoteClientIp(ip) {
121
+ function ipClass(ip) {
93
122
  if (!ip)
94
- return false;
95
- if (ip.includes(':'))
96
- return !isPrivateIpv6(ip);
97
- if (isIpv4(ip))
98
- return !isPrivateIpv4(ip);
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 false;
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: 135 Skipped (legacy): 3
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),