@camstack/server 0.1.6 → 0.1.7

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.
Files changed (42) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/addon-upload.spec.ts +58 -0
  3. package/src/__tests__/bulk-update-coordinator.spec.ts +286 -0
  4. package/src/__tests__/cap-ownership-authority.spec.ts +400 -0
  5. package/src/__tests__/cap-providers-bulk-update.spec.ts +388 -0
  6. package/src/__tests__/cap-route-adapter.spec.ts +289 -0
  7. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +123 -0
  8. package/src/__tests__/cap-routers/capabilities-node.spec.ts +55 -0
  9. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +30 -0
  10. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +249 -0
  11. package/src/__tests__/framework-installer-defer-restart.spec.ts +165 -0
  12. package/src/__tests__/moleculer/uds-readiness.spec.ts +143 -0
  13. package/src/__tests__/moleculer/uds-topology.spec.ts +390 -0
  14. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +123 -0
  15. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +39 -4
  16. package/src/__tests__/native-cap-route.spec.ts +404 -0
  17. package/src/__tests__/oauth2-account-linking.spec.ts +85 -0
  18. package/src/__tests__/uds-addon-call-wiring.spec.ts +237 -0
  19. package/src/__tests__/uds-log-ingest.spec.ts +183 -0
  20. package/src/api/addon-upload.ts +27 -1
  21. package/src/api/capabilities.router.ts +1 -1
  22. package/src/api/core/bulk-update-coordinator.ts +302 -0
  23. package/src/api/core/cap-providers.ts +59 -6
  24. package/src/api/core/capabilities.router.ts +26 -3
  25. package/src/api/oauth2/oauth2-routes.ts +5 -1
  26. package/src/api/trpc/__tests__/client-ip.spec.ts +120 -0
  27. package/src/api/trpc/cap-route-error-formatter.ts +163 -0
  28. package/src/api/trpc/client-ip.ts +130 -0
  29. package/src/api/trpc/generated-cap-mounts.ts +19 -1
  30. package/src/api/trpc/generated-cap-routers.ts +180 -1
  31. package/src/api/trpc/trpc.middleware.ts +5 -1
  32. package/src/api/trpc/trpc.router.ts +45 -0
  33. package/src/core/addon/addon-call-gateway.ts +157 -0
  34. package/src/core/addon/addon-package.service.ts +9 -0
  35. package/src/core/addon/addon-registry.service.ts +364 -105
  36. package/src/core/addon/addon-settings-provider.ts +40 -116
  37. package/src/core/capability/capability.service.ts +9 -0
  38. package/src/core/moleculer/cap-call-fn.spec.ts +166 -0
  39. package/src/core/moleculer/cap-call-fn.ts +103 -0
  40. package/src/core/moleculer/cap-route-authority.ts +182 -0
  41. package/src/core/moleculer/moleculer.service.ts +380 -36
  42. package/src/main.ts +45 -12
@@ -0,0 +1,302 @@
1
+ /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument -- The server installs @camstack/types@0.1.38 (last published) in server/backend/node_modules, while the workspace has 0.1.39 with the new BulkUpdate* types. ESLint's type-checker resolves against 0.1.38 and treats the new imports as `any`. Runtime is correct because Node module resolution walks up to root node_modules → workspace symlink. This disable mirrors the pattern in cap-providers.ts (same root cause). Will resolve when 0.1.39 is published and the local dist is synced. */
2
+ import { randomUUID } from 'node:crypto'
3
+ import {
4
+ EventCategory,
5
+ type BulkUpdateItem,
6
+ type BulkUpdateState,
7
+ type BulkUpdatePhase,
8
+ type BulkUpdateItemStatus,
9
+ } from '@camstack/types'
10
+
11
+ /**
12
+ * Narrow event-bus interface required by BulkUpdateCoordinator.
13
+ * Intentionally narrower than IEventBus: the coordinator only emits a single
14
+ * topic, so we avoid importing the full IEventBus (which lives in @camstack/types
15
+ * and would pull in all event-catalog types). The full IEventBus satisfies this
16
+ * interface structurally, so wiring in cap-providers.ts is cast-free.
17
+ */
18
+ export interface IBulkUpdateEventBus {
19
+ emit(category: EventCategory.AddonsBulkUpdateProgress, payload: BulkUpdateState): void
20
+ }
21
+
22
+ export interface IBulkUpdateLogger {
23
+ info(message: string, ...args: unknown[]): void
24
+ warn(message: string, ...args: unknown[]): void
25
+ error(message: string, ...args: unknown[]): void
26
+ debug(message: string, ...args: unknown[]): void
27
+ }
28
+
29
+ export interface BulkUpdateCoordinatorDeps {
30
+ readonly eventBus: IBulkUpdateEventBus
31
+ readonly updateAddon: (input: { name: string; version: string }) => Promise<void>
32
+ readonly updateFrameworkPackage: (input: { packageName: string; version: string; deferRestart: boolean }) => Promise<void>
33
+ readonly restartServer: (input: { confirm: true }) => Promise<void>
34
+ readonly logger: IBulkUpdateLogger
35
+ /** Injectable clock for tests. Default: `() => Date.now()`. */
36
+ readonly clock?: () => number
37
+ /** How long to keep completed state before purging. Default: 5 minutes. */
38
+ readonly cleanupAfterMs?: number
39
+ }
40
+
41
+ export interface StartBulkUpdateInput {
42
+ readonly nodeId: string
43
+ readonly items: readonly { name: string; version: string; isSystem: boolean }[]
44
+ }
45
+
46
+ const DEFAULT_CLEANUP_AFTER_MS = 5 * 60 * 1_000
47
+
48
+ export class BulkUpdateCoordinator {
49
+ private readonly states = new Map<string, BulkUpdateState>()
50
+ private readonly cancelFlags = new Map<string, { cancelled: boolean }>()
51
+ /**
52
+ * Tracks wall-clock time (ms) when each bulk completed. Used for lazy
53
+ * cleanup in `get()` — avoids scheduling a fake-timer `setTimeout` that
54
+ * would be eagerly fired by `vi.runAllTimersAsync()` in tests.
55
+ */
56
+ private readonly completedWallMs = new Map<string, number>()
57
+ /** Tracks which nodeIds currently have an active (non-completed) bulk update. */
58
+ private readonly activeNodeIds = new Set<string>()
59
+
60
+ private readonly now: () => number
61
+ private readonly cleanupAfterMs: number
62
+ /** Wall-clock source. Fake timers intercept `Date.now`, so tests can advance via `advanceTimersByTimeAsync`. */
63
+ private readonly wallNow: () => number
64
+
65
+ constructor(private readonly deps: BulkUpdateCoordinatorDeps) {
66
+ this.now = deps.clock ?? (() => Date.now())
67
+ this.cleanupAfterMs = deps.cleanupAfterMs ?? DEFAULT_CLEANUP_AFTER_MS
68
+ this.wallNow = () => Date.now()
69
+ }
70
+
71
+ // ── Public API ────────────────────────────────────────────────────
72
+
73
+ start(input: StartBulkUpdateInput): { id: string } {
74
+ if (this.activeNodeIds.has(input.nodeId)) {
75
+ throw new Error(`Bulk update already in progress for node ${input.nodeId}`)
76
+ }
77
+
78
+ const id = randomUUID()
79
+
80
+ const items: BulkUpdateItem[] = input.items.map(i => ({
81
+ name: i.name,
82
+ isSystem: i.isSystem,
83
+ // fromVersion: the cap interface receives name+version+isSystem only;
84
+ // the caller (cap-providers.ts) may enrich this with the current version
85
+ // if available. Empty string is acceptable per plan spec.
86
+ fromVersion: '',
87
+ toVersion: i.version,
88
+ status: 'queued' as BulkUpdateItemStatus,
89
+ }))
90
+
91
+ const state: BulkUpdateState = {
92
+ id,
93
+ nodeId: input.nodeId,
94
+ startedAtMs: this.now(),
95
+ total: items.length,
96
+ completed: 0,
97
+ failed: 0,
98
+ current: null,
99
+ phase: 'regular',
100
+ cancelled: false,
101
+ items,
102
+ }
103
+
104
+ this.states.set(id, state)
105
+ this.activeNodeIds.add(input.nodeId)
106
+
107
+ const cancelFlag = { cancelled: false }
108
+ this.cancelFlags.set(id, cancelFlag)
109
+
110
+ // Emit initial state so clients see the bulk as started immediately
111
+ this.emit(state)
112
+
113
+ void this.runLoop(id, cancelFlag).catch(err => {
114
+ this.deps.logger.error('BulkUpdateCoordinator: loop crashed unexpectedly', err)
115
+ })
116
+
117
+ return { id }
118
+ }
119
+
120
+ get(id: string): BulkUpdateState | null {
121
+ const state = this.states.get(id)
122
+ if (state === undefined) return null
123
+ // Lazy cleanup: purge if the wall-clock elapsed since completion exceeds threshold.
124
+ // This avoids scheduling a long-lived setTimeout that would be eagerly fired
125
+ // by vi.runAllTimersAsync() in tests.
126
+ const completedWall = this.completedWallMs.get(id)
127
+ if (completedWall !== undefined && this.wallNow() - completedWall >= this.cleanupAfterMs) {
128
+ this.purge(id)
129
+ return null
130
+ }
131
+ return state
132
+ }
133
+
134
+ list(nodeId?: string): readonly BulkUpdateState[] {
135
+ const all = [...this.states.keys()]
136
+ .map(id => this.get(id)) // get() applies lazy-cleanup
137
+ .filter((s): s is BulkUpdateState => s !== null)
138
+ return nodeId === undefined ? all : all.filter(s => s.nodeId === nodeId)
139
+ }
140
+
141
+ cancel(id: string): { cancelled: boolean } {
142
+ const state = this.states.get(id)
143
+ const flag = this.cancelFlags.get(id)
144
+ if (state === undefined || flag === undefined) return { cancelled: false }
145
+ // Once restarting, the hub restart is committed — cancel has no effect.
146
+ if (state.phase === 'restarting') return { cancelled: false }
147
+ // Already completed.
148
+ if (state.completedAtMs !== undefined) return { cancelled: false }
149
+
150
+ flag.cancelled = true
151
+ this.mutate(id, s => ({ ...s, cancelled: true }))
152
+ return { cancelled: true }
153
+ }
154
+
155
+ // ── Internal loop ─────────────────────────────────────────────────
156
+
157
+ private async runLoop(id: string, cancelFlag: { cancelled: boolean }): Promise<void> {
158
+ const initial = this.states.get(id)!
159
+
160
+ // ── Phase 1: regular addons ──────────────────────────────────────
161
+ this.transitionPhase(id, 'regular')
162
+
163
+ for (const item of initial.items.filter(i => !i.isSystem)) {
164
+ if (cancelFlag.cancelled) break
165
+ await this.processItem(id, item, false)
166
+ }
167
+
168
+ // ── Phase 2: system packages (deferRestart: true) ────────────────
169
+ if (!cancelFlag.cancelled && initial.items.some(i => i.isSystem)) {
170
+ this.transitionPhase(id, 'system')
171
+
172
+ for (const item of initial.items.filter(i => i.isSystem)) {
173
+ if (cancelFlag.cancelled) break
174
+ await this.processItem(id, item, true)
175
+ }
176
+
177
+ // ── Phase 3: single restart ──────────────────────────────────
178
+ const anySystemPendingRestart = this.states.get(id)!.items.some(
179
+ i => i.isSystem && i.status === 'done-pending-restart',
180
+ )
181
+
182
+ if (anySystemPendingRestart && !cancelFlag.cancelled) {
183
+ this.transitionPhase(id, 'restarting')
184
+ try {
185
+ await this.deps.restartServer({ confirm: true })
186
+ // NOTE: In production, restartServer kills+respawns the hub process.
187
+ // Code below this point will not execute in that scenario.
188
+ // If the mock/stub returns (e.g. in tests), we fall through to finalizing.
189
+ } catch (err) {
190
+ // Restart failed but the npm installs already completed. Promote all
191
+ // done-pending-restart items to done with a caveat error so the UI
192
+ // can inform the user that a manual restart is needed.
193
+ this.deps.logger.error('BulkUpdateCoordinator: restart failed', err)
194
+ const errMsg = err instanceof Error ? err.message : String(err)
195
+ for (const it of this.states.get(id)!.items) {
196
+ if (it.status === 'done-pending-restart') {
197
+ this.setItemStatus(id, it.name, 'done', {
198
+ error: `Restart failed; manual restart required (${errMsg})`,
199
+ })
200
+ }
201
+ }
202
+ }
203
+ }
204
+ }
205
+
206
+ // ── Phase 4: finalize ────────────────────────────────────────────
207
+ // Reached when:
208
+ // a) no system packages at all, OR
209
+ // b) restart failed (process continued), OR
210
+ // c) cancelled before the restart phase.
211
+ this.transitionPhase(id, 'finalizing')
212
+ this.completeBulk(id)
213
+ }
214
+
215
+ private async processItem(id: string, item: BulkUpdateItem, isSystem: boolean): Promise<void> {
216
+ this.setItemStatus(id, item.name, 'updating', { startedAtMs: this.now() })
217
+ this.mutate(id, s => ({ ...s, current: item.name }))
218
+ this.emit(this.states.get(id)!)
219
+
220
+ try {
221
+ if (isSystem) {
222
+ await this.deps.updateFrameworkPackage({
223
+ packageName: item.name,
224
+ version: item.toVersion,
225
+ deferRestart: true,
226
+ })
227
+ this.setItemStatus(id, item.name, 'done-pending-restart', { completedAtMs: this.now() })
228
+ } else {
229
+ await this.deps.updateAddon({ name: item.name, version: item.toVersion })
230
+ this.setItemStatus(id, item.name, 'done', { completedAtMs: this.now() })
231
+ }
232
+ } catch (err) {
233
+ const msg = err instanceof Error ? err.message : String(err)
234
+ this.setItemStatus(id, item.name, 'failed', { error: msg, completedAtMs: this.now() })
235
+ }
236
+
237
+ this.mutate(id, s => ({ ...s, current: null }))
238
+ this.emit(this.states.get(id)!)
239
+ }
240
+
241
+ // ── State mutation helpers ────────────────────────────────────────
242
+
243
+ private setItemStatus(
244
+ id: string,
245
+ name: string,
246
+ status: BulkUpdateItemStatus,
247
+ fields: { error?: string; startedAtMs?: number; completedAtMs?: number } = {},
248
+ ): void {
249
+ this.mutate(id, s => {
250
+ const items = s.items.map(it =>
251
+ it.name === name ? { ...it, status, ...fields } : it,
252
+ )
253
+ // completed = all terminal states: done | done-pending-restart | failed
254
+ const completed = items.filter(
255
+ it => it.status === 'done' || it.status === 'done-pending-restart' || it.status === 'failed',
256
+ ).length
257
+ const failed = items.filter(it => it.status === 'failed').length
258
+ return { ...s, items, completed, failed }
259
+ })
260
+ }
261
+
262
+ private transitionPhase(id: string, phase: BulkUpdatePhase): void {
263
+ this.mutate(id, s => ({ ...s, phase }))
264
+ this.emit(this.states.get(id)!)
265
+ }
266
+
267
+ private completeBulk(id: string): void {
268
+ this.mutate(id, s => ({ ...s, completedAtMs: this.now(), current: null }))
269
+ this.emit(this.states.get(id)!)
270
+
271
+ // Free the nodeId slot so a new bulk for the same node can be started
272
+ const nodeId = this.states.get(id)!.nodeId
273
+ this.activeNodeIds.delete(nodeId)
274
+
275
+ // Record wall-clock completion time for lazy cleanup in `get()`.
276
+ // We intentionally avoid scheduling a setTimeout here: a long-lived
277
+ // setTimeout (5 min) would be eagerly fired by vi.runAllTimersAsync()
278
+ // in tests, causing `get()` to return null immediately after the run.
279
+ // Instead, `get()` lazily checks whether the cleanup threshold has
280
+ // elapsed using Date.now() — which fake timers DO advance via
281
+ // advanceTimersByTimeAsync(), making the cleanup testable without
282
+ // a long-running timer.
283
+ this.completedWallMs.set(id, this.wallNow())
284
+ }
285
+
286
+ private purge(id: string): void {
287
+ this.states.delete(id)
288
+ this.cancelFlags.delete(id)
289
+ this.completedWallMs.delete(id)
290
+ }
291
+
292
+ /** Immutably update the state for the given id. No-op if id is unknown. */
293
+ private mutate(id: string, update: (s: BulkUpdateState) => BulkUpdateState): void {
294
+ const current = this.states.get(id)
295
+ if (current === undefined) return
296
+ this.states.set(id, update(current))
297
+ }
298
+
299
+ private emit(state: BulkUpdateState): void {
300
+ this.deps.eventBus.emit(EventCategory.AddonsBulkUpdateProgress, state)
301
+ }
302
+ }
@@ -58,6 +58,8 @@ import type { AddonPackageService } from '../../core/addon/addon-package.service
58
58
  import type { NetworkQualityService } from '../../core/network/network-quality.service'
59
59
  import type { ConfigService } from '../../core/config/config.service'
60
60
  import { persistCollectionDisabled } from './collection-preference.js'
61
+ import { BulkUpdateCoordinator } from './bulk-update-coordinator.js'
62
+ import { FRAMEWORK_PACKAGE_ALLOWLIST } from '../../core/addon/addon-package.service.js'
61
63
 
62
64
  const execFileAsync = promisify(execFile)
63
65
 
@@ -477,9 +479,17 @@ export function buildNodesProvider(
477
479
  return reg.getGraph({ windowSeconds: input.windowSeconds, nowMs: Date.now() })
478
480
  },
479
481
  setProcessLogLevel: async (input) => {
480
- await broker.call('$node-mgmt.setLogLevel', {
481
- level: input.level,
482
- }, { nodeID: input.nodeId, timeout: 5_000 })
482
+ // E2: for hub-local children, try the UDS path first (fire-and-forget);
483
+ // fall back to the Moleculer $node-mgmt.setLogLevel action for remote
484
+ // nodes (agents) that still run a per-node broker. The UDS path is always
485
+ // attempted for hub/<runner> nodeIds even when the Moleculer path would
486
+ // also work — both are safe to run in parallel during Phase E.
487
+ const reachedViaUds = moleculer.setChildLogLevelByNodeId(input.nodeId, input.level)
488
+ if (!reachedViaUds) {
489
+ await broker.call('$node-mgmt.setLogLevel', {
490
+ level: input.level,
491
+ }, { nodeID: input.nodeId, timeout: 5_000 })
492
+ }
483
493
  return { success: true }
484
494
  },
485
495
  executeQuery: async (input) => {
@@ -806,8 +816,45 @@ export function buildAddonsProvider(
806
816
  moleculer: MoleculerService,
807
817
  configService: ConfigService,
808
818
  ctx: TrpcContext,
819
+ eb: EventBusService,
809
820
  ): IAddonsProvider {
810
821
  const broker = moleculer.broker as unknown as BrokerLike
822
+
823
+ // Adapt the hub EventBusService (which takes a full SystemEvent object) to
824
+ // the IBulkUpdateEventBus interface (which takes (category, payload) pairs).
825
+ // Using `import { EventCategory }` from @camstack/types avoids a new import
826
+ // — it is already resolved in the generated-cap-routers layer above. The
827
+ // `eb.emit` overload that takes a TypedSystemEvent is the type-safe path.
828
+ const bulkEventBus = {
829
+ emit: (category: Parameters<typeof eb.emit>[0]['category'], payload: unknown) => {
830
+ eb.emit({
831
+ id: randomUUID(),
832
+ timestamp: new Date(),
833
+ source: { type: 'core', id: 'bulk-update-coordinator' },
834
+ category,
835
+ data: payload as Record<string, unknown>,
836
+ })
837
+ },
838
+ }
839
+
840
+ const bulkCoordinator = new BulkUpdateCoordinator({
841
+ eventBus: bulkEventBus,
842
+ updateAddon: async (i) => { await ps.updatePackage(i.name, i.version) },
843
+ updateFrameworkPackage: async (i) => {
844
+ await ps.updateFrameworkPackage({
845
+ packageName: i.packageName,
846
+ version: i.version,
847
+ deferRestart: i.deferRestart,
848
+ })
849
+ },
850
+ restartServer: async () => {
851
+ await ps.restartServer(ctx.user?.username ?? ctx.user?.id)
852
+ },
853
+ logger: ls.createLogger('bulk-update'),
854
+ })
855
+
856
+ const frameworkAllowSet = new Set<string>(FRAMEWORK_PACKAGE_ALLOWLIST)
857
+
811
858
  return {
812
859
  list: async () => {
813
860
  const rollbackable = ps.getRollbackablePackages()
@@ -843,9 +890,10 @@ export function buildAddonsProvider(
843
890
  },
844
891
  listUpdates: async (input) => {
845
892
  const nodeId = input.nodeId
846
- if (nodeId === undefined || isHubNode(nodeId)) return ps.checkUpdates()
847
- const installed = await fetchAgentInstalledPackages(broker, nodeId)
848
- return ps.checkUpdatesForInstalled(installed)
893
+ const updates = nodeId === undefined || isHubNode(nodeId)
894
+ ? await ps.checkUpdates()
895
+ : await ps.checkUpdatesForInstalled(await fetchAgentInstalledPackages(broker, nodeId))
896
+ return updates.map(u => ({ ...u, isSystem: frameworkAllowSet.has(u.name) }))
849
897
  },
850
898
  updatePackage: async (input) => {
851
899
  const nodeId = input.nodeId
@@ -937,6 +985,7 @@ export function buildAddonsProvider(
937
985
  : ctx.user?.id !== undefined
938
986
  ? { requestedBy: ctx.user.id }
939
987
  : {}),
988
+ ...(input.deferRestart !== undefined ? { deferRestart: input.deferRestart } : {}),
940
989
  }),
941
990
  getVersions: async (input) => ps.getPackageVersions(input.name),
942
991
  restartAddon: async (input) => ar.restartAddon(input.addonId),
@@ -955,6 +1004,10 @@ export function buildAddonsProvider(
955
1004
  }
956
1005
  return { success: true as const }
957
1006
  },
1007
+ startBulkUpdate: async (input) => bulkCoordinator.start(input),
1008
+ getBulkUpdateState: async ({ id }) => bulkCoordinator.get(id),
1009
+ cancelBulkUpdate: async ({ id }) => bulkCoordinator.cancel(id),
1010
+ listActiveBulkUpdates: async ({ nodeId }) => bulkCoordinator.list(nodeId),
958
1011
  custom: async (input) => {
959
1012
  const registry = ar.getCustomActionRegistry()
960
1013
  const entry = registry.resolve(input.addonId, input.action)
@@ -28,12 +28,35 @@ export function createCapabilitiesRouter(
28
28
  }),
29
29
 
30
30
  setActiveSingleton: adminProcedure
31
- .input(z.object({ capability: z.string(), addonId: z.string() }))
31
+ .input(z.object({
32
+ capability: z.string(),
33
+ addonId: z.string(),
34
+ nodeId: z.string().optional(),
35
+ }))
32
36
  .output(z.void())
33
37
  .mutation(async ({ input }) => {
34
38
  if (!registry) throw new Error('Capability registry unavailable')
35
- await registry.setActiveSingleton(input.capability, input.addonId)
36
- config.update('capabilities', { [input.capability]: input.addonId })
39
+ await registry.setActiveSingleton(input.capability, input.addonId, input.nodeId)
40
+ if (input.nodeId !== undefined) {
41
+ // Per-node override: persist under the node-qualified key so the boot
42
+ // restorer (`setNodeConfigReader`) replays it on restart.
43
+ config.set(`capabilities.singletonNode.${input.capability}.${input.nodeId}`, input.addonId)
44
+ } else {
45
+ // Cluster-global default: persist to the SAME key the boot restorer reads
46
+ // (addon-registry `setConfigReader` → `capabilities.singleton.<cap>`).
47
+ config.set(`capabilities.singleton.${input.capability}`, input.addonId)
48
+ }
49
+ }),
50
+
51
+ clearSingletonNodeOverride: adminProcedure
52
+ .input(z.object({ capability: z.string(), nodeId: z.string() }))
53
+ .output(z.void())
54
+ .mutation(({ input }) => {
55
+ if (!registry) throw new Error('Capability registry unavailable')
56
+ registry.clearSingletonNodeOverride(input.capability, input.nodeId)
57
+ // Persist the cleared state as null so the boot restorer doesn't re-apply
58
+ // a stale override on restart.
59
+ config.set(`capabilities.singletonNode.${input.capability}.${input.nodeId}`, null)
37
60
  }),
38
61
 
39
62
  /**
@@ -196,7 +196,11 @@ export function registerOauth2Routes(fastify: FastifyInstance, deps: Oauth2Deps)
196
196
  username: tokenInfo.username ?? '',
197
197
  scopes: descriptor.requestedScopes,
198
198
  redirectUri: v.redirectUri,
199
- hubUrl: deps.publicHubUrl(),
199
+ // Prefer the integration's own public origin (e.g. the operator-selected
200
+ // external-access endpoint surfaced by a forked exporter addon) so the
201
+ // claim the cloud Lambda routes back on is the reachable public URL, not
202
+ // the hub-global fallback (which defaults to localhost in dev).
203
+ hubUrl: descriptor.hubUrl ?? deps.publicHubUrl(),
200
204
  })
201
205
 
202
206
  return reply.redirect(`${v.redirectUri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(v.state)}`)
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Unit tests for the client-IP extraction + LAN/remote classification
3
+ * used by the `webrtcSession.createSession` relay-only override.
4
+ *
5
+ * The classification is the load-bearing decision: a `true` from
6
+ * `isRemoteClientIp` forces TURN-relay-only ICE for that live-view
7
+ * session. A false positive would needlessly relay a LAN viewer
8
+ * (latency); a false negative would leave a remote viewer on the dead
9
+ * direct path (the bug being fixed). The private-range edges are
10
+ * therefore exercised explicitly.
11
+ */
12
+ import { describe, it, expect } from 'vitest'
13
+ import type { IncomingMessage } from 'node:http'
14
+ import { extractClientIp, isRemoteClientIp } from '../client-ip.js'
15
+
16
+ function reqWith(opts: {
17
+ xff?: string | string[]
18
+ ip?: string
19
+ remoteAddress?: string
20
+ }): IncomingMessage {
21
+ const headers: Record<string, string | string[]> = {}
22
+ if (opts.xff !== undefined) headers['x-forwarded-for'] = opts.xff
23
+ const req = {
24
+ headers,
25
+ socket: { remoteAddress: opts.remoteAddress },
26
+ } as unknown as IncomingMessage
27
+ if (opts.ip !== undefined) {
28
+ Object.defineProperty(req, 'ip', { value: opts.ip, enumerable: true })
29
+ }
30
+ return req
31
+ }
32
+
33
+ describe('extractClientIp', () => {
34
+ it('returns null for an absent request (mesh-originated call)', () => {
35
+ expect(extractClientIp(undefined)).toBeNull()
36
+ })
37
+
38
+ it('prefers the first X-Forwarded-For hop over socket/ip', () => {
39
+ const req = reqWith({ xff: '203.0.113.7, 10.0.0.1', ip: '10.0.0.1', remoteAddress: '10.0.0.1' })
40
+ expect(extractClientIp(req)).toBe('203.0.113.7')
41
+ })
42
+
43
+ it('handles X-Forwarded-For as an array', () => {
44
+ const req = reqWith({ xff: ['198.51.100.4, 10.0.0.1'] })
45
+ expect(extractClientIp(req)).toBe('198.51.100.4')
46
+ })
47
+
48
+ it('falls back to req.ip when no XFF', () => {
49
+ const req = reqWith({ ip: '192.168.1.50', remoteAddress: '192.168.1.50' })
50
+ expect(extractClientIp(req)).toBe('192.168.1.50')
51
+ })
52
+
53
+ it('falls back to socket.remoteAddress when no XFF or ip', () => {
54
+ const req = reqWith({ remoteAddress: '127.0.0.1' })
55
+ expect(extractClientIp(req)).toBe('127.0.0.1')
56
+ })
57
+
58
+ it('strips IPv4-mapped IPv6 prefix', () => {
59
+ const req = reqWith({ remoteAddress: '::ffff:192.168.1.5' })
60
+ expect(extractClientIp(req)).toBe('192.168.1.5')
61
+ })
62
+
63
+ it('strips IPv6 zone id', () => {
64
+ const req = reqWith({ remoteAddress: 'fe80::1%en0' })
65
+ expect(extractClientIp(req)).toBe('fe80::1')
66
+ })
67
+ })
68
+
69
+ describe('isRemoteClientIp', () => {
70
+ it('null → false (treated as LAN — safe default)', () => {
71
+ expect(isRemoteClientIp(null)).toBe(false)
72
+ })
73
+
74
+ // Private / loopback / link-local / Tailscale ranges → LAN (direct path kept)
75
+ const lan = [
76
+ '10.0.0.1',
77
+ '10.255.255.255',
78
+ '172.16.0.1',
79
+ '172.31.255.255',
80
+ '192.168.1.1',
81
+ '127.0.0.1',
82
+ '169.254.1.1',
83
+ // Tailscale CGNAT overlay (100.64.0.0/10) — direct host↔host over the mesh
84
+ '100.64.0.1', // bottom of 100.64/10
85
+ '100.104.179.3', // the hub's own Tailscale IP (confirmed live)
86
+ '100.127.255.255', // top of 100.64/10
87
+ '::1',
88
+ 'fe80::1',
89
+ 'fc00::1',
90
+ 'fd12:3456::1',
91
+ // Tailscale ULA overlay (fd7a::/16) — subset of fc00::/7
92
+ 'fd7a:115c:a1e0::1',
93
+ ]
94
+ for (const ip of lan) {
95
+ it(`LAN: ${ip} → not remote`, () => {
96
+ expect(isRemoteClientIp(ip)).toBe(false)
97
+ })
98
+ }
99
+
100
+ // Public ranges → remote (relay-only forced)
101
+ const remote = [
102
+ '203.0.113.7', // TEST-NET-3 (public)
103
+ '8.8.8.8',
104
+ '172.15.0.1', // just below the 172.16/12 private block
105
+ '172.32.0.1', // just above the 172.16/12 private block
106
+ '11.0.0.1', // just above 10/8
107
+ '100.63.255.255', // just below the 100.64/10 Tailscale block (public)
108
+ '100.128.0.1', // just above the 100.64/10 Tailscale block (public)
109
+ '2001:4860:4860::8888', // public IPv6
110
+ ]
111
+ for (const ip of remote) {
112
+ it(`remote: ${ip} → remote`, () => {
113
+ expect(isRemoteClientIp(ip)).toBe(true)
114
+ })
115
+ }
116
+
117
+ it('unparseable literal → false (conservative LAN default)', () => {
118
+ expect(isRemoteClientIp('not-an-ip')).toBe(false)
119
+ })
120
+ })