@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
@@ -3,8 +3,12 @@ import superjson from 'superjson'
3
3
  import { METHOD_ACCESS_MAP } from '@camstack/types'
4
4
  import type { TrpcContext } from './trpc.context'
5
5
  import { checkScopeAccess } from './scope-access.js'
6
+ import { formatTrpcError } from './cap-route-error-formatter.js'
6
7
 
7
- const t = initTRPC.context<TrpcContext>().create({ transformer: superjson })
8
+ const t = initTRPC.context<TrpcContext>().create({
9
+ transformer: superjson,
10
+ errorFormatter: formatTrpcError,
11
+ })
8
12
 
9
13
  // ---------------------------------------------------------------------------
10
14
  // Async-generator subscription helpers (tRPC v11 — replaces deprecated observable)
@@ -30,6 +30,7 @@ import {
30
30
  platformProbeCapability,
31
31
  decoderCapability,
32
32
  localNetworkCapability,
33
+ webrtcSessionCapability,
33
34
  } from '@camstack/types'
34
35
  import {
35
36
  // The auto-mount covers ~75 caps. The handful re-imported below back
@@ -51,6 +52,7 @@ import {
51
52
  createCapRouter_integrations,
52
53
  createCapRouter_nodes,
53
54
  createCapRouter_addons,
55
+ createCapRouter_webrtcSession,
54
56
  } from './generated-cap-routers'
55
57
  import { mountAllCaps } from './generated-cap-mounts.js'
56
58
  import {
@@ -74,6 +76,7 @@ import { createCapabilitiesRouter } from '../core/capabilities.router.js'
74
76
  import { createStreamProbeRouter } from '../core/stream-probe.router.js'
75
77
  import { createHwAccelRouter } from '../core/hwaccel.router.js'
76
78
  import { requireSingleton, firstSupported, anySupports } from './cap-mount-helpers.js'
79
+ import type { TrpcContext } from './trpc.context.js'
77
80
  import type { AuthService } from '../../core/auth/auth.service'
78
81
  import type { ConfigService } from '../../core/config/config.service'
79
82
  import type { FeatureService } from '../../core/feature/feature.service'
@@ -108,6 +111,28 @@ export interface RouterServices {
108
111
  streamProbe: StreamProbeService | null
109
112
  }
110
113
 
114
+ type WebrtcSessionProvider = InferProvider<typeof webrtcSessionCapability>
115
+
116
+ /**
117
+ * Relay-only forcing for remote viewers is DISABLED (2026-05-26).
118
+ *
119
+ * It was meant to give CGNAT/4G viewers a clean relay↔relay path, but werift's
120
+ * TURN media-forward is unreliable between two real TURN servers (relay↔relay
121
+ * connects yet media never arrives → connected-but-black), and forcing relay
122
+ * ALSO kills the direct LAN/Tailscale host pair — which carries full native
123
+ * quality with no relay. We now offer ALL candidates (host incl. the hub's
124
+ * advertised Tailscale address, srflx, relay) and let ICE nominate the best
125
+ * reachable pair: direct when possible, relay only as a fallback. The
126
+ * `relayOnly` cap field + broker support remain for when relay media-forward
127
+ * is fixed; this wrapper is a pass-through for now.
128
+ */
129
+ function wrapWebrtcSessionProviderWithRelay(
130
+ provider: WebrtcSessionProvider,
131
+ _ctx: TrpcContext,
132
+ ): WebrtcSessionProvider {
133
+ return provider
134
+ }
135
+
111
136
  /**
112
137
  * Build the AppRouter. Mounts every codegen'd cap router via the auto-
113
138
  * mount entrypoint and overrides the handful that need service-backed
@@ -183,6 +208,7 @@ function buildCapabilityRouters(services: RouterServices) {
183
208
  services.moleculer,
184
209
  services.configService,
185
210
  ctx,
211
+ services.eventBus,
186
212
  ),
187
213
  ),
188
214
 
@@ -253,6 +279,25 @@ function buildCapabilityRouters(services: RouterServices) {
253
279
  },
254
280
  ),
255
281
 
282
+ // ── Cap override: server-detected remote → relay-only ────────────
283
+ // The broker (a forked addon) can't see the HTTP request, so it
284
+ // can't tell a LAN viewer from a remote one. We override only the
285
+ // `getProvider` accessor to return a per-request provider whose
286
+ // `createSession` carries a server-computed `relayOnly` flag derived
287
+ // from the client IP in `ctx.req`. Remote (CGNAT/4G) viewers force
288
+ // TURN-relay-only ICE; LAN viewers keep the direct host/srflx path.
289
+ // All other methods delegate straight through, and the cross-node
290
+ // remote-proxy routing is preserved (forked/agent-hosted brokers).
291
+ webrtcSession: createCapRouter_webrtcSession(
292
+ (ctx) => {
293
+ const provider = services.capabilityRegistry
294
+ ?.getSingleton<WebrtcSessionProvider>('webrtc-session') ?? null
295
+ return provider ? wrapWebrtcSessionProviderWithRelay(provider, ctx) : null
296
+ },
297
+ (capName, nodeId) =>
298
+ services.moleculer.createCapabilityProxy(capName, nodeId) as WebrtcSessionProvider | null,
299
+ ),
300
+
256
301
  // NOT MOUNTED — legacy provider shapes (positional args / sync
257
302
  // returns) that don't match the codegen routers' {input}-object +
258
303
  // Promise<T> contract. Tracked by `LEGACY_SHAPE_SKIP` in
@@ -0,0 +1,157 @@
1
+ /**
2
+ * `AddonCallGateway` — the SINGLE hub-side router for addon-LEVEL calls (the
3
+ * surfaces the removed per-addon Moleculer broker used to carry: routes,
4
+ * custom-actions, settings — see `AddonCallTarget`).
5
+ *
6
+ * Why this exists: that routing decision (is the addon running in-process on
7
+ * the hub, as a forked hub-local CHILD reachable over UDS, or on a REMOTE
8
+ * agent over Moleculer?) used to be duplicated per surface — routes/custom in
9
+ * `addon-registry.service.ts`, settings in `addon-settings-provider.ts`. When
10
+ * the UDS migration ported routes+custom to `callAddonOnChild`, settings was
11
+ * left on the dead `<addonId>.settings.<method>` Moleculer path because nothing
12
+ * centralised "dispatch an addon-level call to wherever the addon runs". Every
13
+ * forked hub-local addon's settings panel silently went empty for months.
14
+ *
15
+ * Now every addon-level surface routes through `callForked` here. Combined with
16
+ * the exhaustive `AddonCallTarget` union (a `never`-checked dispatch in
17
+ * `createChildAddonCallDispatch`), a future surface can't be half-wired without
18
+ * a compile error — the class of "missed link" is closed.
19
+ */
20
+ import type { AddonCallInput, LocalChildRegistry } from '@camstack/kernel'
21
+
22
+ /** An addon-level call minus its `addonId` — the gateway merges that in. */
23
+ export type AddonCallSurface = Omit<AddonCallInput, 'addonId'>
24
+
25
+ /**
26
+ * Minimal structural view of the Moleculer broker (remote-agent leg) — typed
27
+ * locally because the lint type-checker can't resolve Moleculer's
28
+ * `ServiceBroker` (mirrors the pattern in `addon-settings-provider.ts`).
29
+ */
30
+ export interface AddonCallBroker {
31
+ readonly registry: unknown
32
+ call(
33
+ action: string,
34
+ params: Record<string, unknown>,
35
+ opts?: { readonly nodeID?: string; readonly timeout?: number },
36
+ ): Promise<unknown>
37
+ }
38
+
39
+ /** Where an addon physically runs — the routing decision, made in one place. */
40
+ export type AddonCallDestination =
41
+ | { readonly kind: 'in-process' }
42
+ | { readonly kind: 'hub-local-child' }
43
+ | { readonly kind: 'remote-agent'; readonly baseNodeId: string }
44
+
45
+ export interface AddonCallGatewayDeps {
46
+ /** This hub's node id (for the local short-circuit). */
47
+ readonly hubNodeId: string
48
+ /** Which node hosts a given addon ('hub' / hubNodeId = on this hub). */
49
+ readonly resolveNode: (addonId: string) => string
50
+ /** UDS registry of forked hub-local children (null before it's wired). */
51
+ readonly getChildRegistry: () => LocalChildRegistry | null
52
+ /** Moleculer broker for the remote-agent leg. */
53
+ readonly broker: AddonCallBroker
54
+ }
55
+
56
+ const REMOTE_TIMEOUT_MS = 10_000
57
+
58
+ export class AddonCallGateway {
59
+ constructor(private readonly deps: AddonCallGatewayDeps) {}
60
+
61
+ /**
62
+ * Classify where an addon runs. `nodeId: 'hub'` from a caller means "the hub
63
+ * cluster", NOT "force in-process" — a forked hub-local addon is still a
64
+ * `hub-local-child` (UDS), never the in-process path (which is only the
65
+ * `@camstack/core` builtins that have no forked runner).
66
+ */
67
+ classify(addonId: string, explicitNodeId?: string): AddonCallDestination {
68
+ const resolved = this.deps.resolveNode(addonId)
69
+ const onHub = resolved === 'hub' || resolved === this.deps.hubNodeId
70
+ if (onHub) {
71
+ const childRegistry = this.deps.getChildRegistry()
72
+ if (childRegistry !== null && childRegistry.isChildKnown(addonId)) {
73
+ return { kind: 'hub-local-child' }
74
+ }
75
+ return { kind: 'in-process' }
76
+ }
77
+ const onThisHub = explicitNodeId === 'hub' || explicitNodeId === this.deps.hubNodeId
78
+ const baseNodeId = explicitNodeId && !onThisHub ? explicitNodeId : resolved
79
+ return { kind: 'remote-agent', baseNodeId }
80
+ }
81
+
82
+ /** True when the addon is an in-process hub builtin (caller invokes directly). */
83
+ isInProcess(addonId: string, explicitNodeId?: string): boolean {
84
+ return this.classify(addonId, explicitNodeId).kind === 'in-process'
85
+ }
86
+
87
+ /**
88
+ * Dispatch a forked addon-level call to wherever the addon runs:
89
+ * - `hub-local-child` → UDS `LocalChildRegistry.callAddonOnChild`
90
+ * - `remote-agent` → Moleculer `broker.call`
91
+ * Throws for `in-process` — that addon has no forked surface, so the caller
92
+ * must invoke the in-process instance directly (the invocation is
93
+ * surface-specific; only the ROUTING is centralised here).
94
+ */
95
+ async callForked(addonId: string, input: AddonCallSurface, explicitNodeId?: string): Promise<unknown> {
96
+ const dest = this.classify(addonId, explicitNodeId)
97
+ const fullInput: AddonCallInput = { ...input, addonId }
98
+ switch (dest.kind) {
99
+ case 'hub-local-child': {
100
+ const childRegistry = this.deps.getChildRegistry()
101
+ if (childRegistry === null) {
102
+ throw new Error(`AddonCallGateway: child registry unavailable for "${addonId}"`)
103
+ }
104
+ return childRegistry.callAddonOnChild(addonId, fullInput)
105
+ }
106
+ case 'remote-agent':
107
+ return this.callRemoteAgent(addonId, dest.baseNodeId, fullInput)
108
+ case 'in-process':
109
+ throw new Error(`AddonCallGateway: addon "${addonId}" runs in-process — invoke it directly`)
110
+ default: {
111
+ const _exhaustive: never = dest
112
+ throw new Error(`AddonCallGateway: unhandled destination ${JSON.stringify(_exhaustive)}`)
113
+ }
114
+ }
115
+ }
116
+
117
+ /** Map an addon-level call to the remote agent's Moleculer action. */
118
+ private async callRemoteAgent(addonId: string, baseNodeId: string, input: AddonCallInput): Promise<unknown> {
119
+ const workerNodeId = this.resolveWorkerNodeId(addonId, baseNodeId)
120
+ const opts = workerNodeId
121
+ ? { nodeID: workerNodeId, timeout: REMOTE_TIMEOUT_MS }
122
+ : { timeout: REMOTE_TIMEOUT_MS }
123
+ if (input.target === 'settings') {
124
+ if (input.method == null) {
125
+ throw new Error(`AddonCallGateway: settings call to "${addonId}" missing method`)
126
+ }
127
+ return this.deps.broker.call(
128
+ `${addonId}.settings.${input.method}`,
129
+ (input.args ?? {}) as Record<string, unknown>,
130
+ opts,
131
+ )
132
+ }
133
+ // routes/custom are hub-local-child surfaces (mounted / invoked on the
134
+ // owning node); they are not proxied to a remote agent through this gateway.
135
+ throw new Error(`AddonCallGateway: target "${input.target}" not supported for remote agent "${baseNodeId}"`)
136
+ }
137
+
138
+ /**
139
+ * Resolve the Moleculer nodeID that actually hosts an addon's service.
140
+ * Forkable addons register under `${baseNodeId}/${addonId}`; in-process
141
+ * addons under the base nodeId. The registry is the ground truth — baseNodeId
142
+ * is a hint. (Moved verbatim from `addon-settings-provider.ts`.)
143
+ */
144
+ private resolveWorkerNodeId(addonId: string, baseNodeId: string): string | null {
145
+ const registry = this.deps.broker.registry
146
+ const services = (registry as unknown as {
147
+ getServiceList: (opts: { onlyAvailable: boolean }) => readonly { name: string; nodeID: string }[]
148
+ }).getServiceList({ onlyAvailable: true })
149
+ const exactNode = `${baseNodeId}/${addonId}`
150
+ const preferred = services.find((s) => s.name === addonId && s.nodeID === exactNode)
151
+ if (preferred) return preferred.nodeID
152
+ const anyForBase = services.find((s) => s.name === addonId && s.nodeID === baseNodeId)
153
+ if (anyForBase) return anyForBase.nodeID
154
+ const anyWithName = services.find((s) => s.name === addonId)
155
+ return anyWithName?.nodeID ?? null
156
+ }
157
+ }
@@ -1022,6 +1022,7 @@ export class AddonPackageService {
1022
1022
  readonly packageName: string
1023
1023
  readonly version?: string
1024
1024
  readonly requestedBy?: string
1025
+ readonly deferRestart?: boolean
1025
1026
  }): Promise<{
1026
1027
  packageName: string
1027
1028
  fromVersion: string
@@ -1052,6 +1053,14 @@ export class AddonPackageService {
1052
1053
  const args = ['install', '--prefix', appRoot, spec, '--no-save', ...buildNpmRegistryArgs(registry)]
1053
1054
  await execFileAsync('npm', args, { timeout: 180_000 })
1054
1055
 
1056
+ if (input.deferRestart === true) {
1057
+ this.logger.info(
1058
+ `updateFrameworkPackage(${packageName}@${toVersion}): install done, restart deferred`,
1059
+ )
1060
+ // Sentinel: 0 signals "no restart scheduled" to the caller
1061
+ return { packageName, fromVersion, toVersion, restartingAt: 0 }
1062
+ }
1063
+
1055
1064
  const restartingAt = Date.now()
1056
1065
  const markerPayload: PendingRestartMarkerPayload = {
1057
1066
  kind: 'framework-update',