@camstack/server 0.1.3

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 (133) hide show
  1. package/.env.example +17 -0
  2. package/package.json +55 -0
  3. package/src/__tests__/addon-install-e2e.test.ts +75 -0
  4. package/src/__tests__/addon-pages-e2e.test.ts +178 -0
  5. package/src/__tests__/addon-route-session.test.ts +17 -0
  6. package/src/__tests__/addon-settings-router.spec.ts +62 -0
  7. package/src/__tests__/addon-upload.spec.ts +355 -0
  8. package/src/__tests__/agent-registry.spec.ts +162 -0
  9. package/src/__tests__/agent-status-page.spec.ts +84 -0
  10. package/src/__tests__/auth-session-cookie.test.ts +21 -0
  11. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
  12. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
  13. package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
  14. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
  15. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
  16. package/src/__tests__/cap-routers/harness.ts +159 -0
  17. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
  18. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
  19. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
  20. package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
  21. package/src/__tests__/capability-e2e.test.ts +386 -0
  22. package/src/__tests__/cli-e2e.test.ts +129 -0
  23. package/src/__tests__/core-cap-bridge.spec.ts +89 -0
  24. package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
  25. package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
  26. package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
  27. package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
  28. package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
  29. package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
  30. package/src/__tests__/framework-allowlist.spec.ts +95 -0
  31. package/src/__tests__/https-e2e.test.ts +118 -0
  32. package/src/__tests__/lifecycle-e2e.test.ts +140 -0
  33. package/src/__tests__/live-events-subscription.spec.ts +150 -0
  34. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
  35. package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
  36. package/src/__tests__/post-boot-restart.spec.ts +161 -0
  37. package/src/__tests__/singleton-contention.test.ts +487 -0
  38. package/src/__tests__/streaming-diagnostic.test.ts +512 -0
  39. package/src/__tests__/streaming-scale.test.ts +280 -0
  40. package/src/agent-status-page.ts +121 -0
  41. package/src/api/__tests__/addons-custom.spec.ts +134 -0
  42. package/src/api/__tests__/capabilities.router.test.ts +47 -0
  43. package/src/api/addon-upload.ts +472 -0
  44. package/src/api/addons-custom.router.ts +100 -0
  45. package/src/api/auth-whoami.ts +99 -0
  46. package/src/api/bridge-addons.router.ts +120 -0
  47. package/src/api/capabilities.router.ts +226 -0
  48. package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
  49. package/src/api/core/addon-settings.router.ts +124 -0
  50. package/src/api/core/agents.router.ts +87 -0
  51. package/src/api/core/auth.router.ts +303 -0
  52. package/src/api/core/cap-providers.ts +993 -0
  53. package/src/api/core/capabilities.router.ts +119 -0
  54. package/src/api/core/collection-preference.ts +40 -0
  55. package/src/api/core/event-bus-proxy.router.ts +45 -0
  56. package/src/api/core/hwaccel.router.ts +81 -0
  57. package/src/api/core/live-events.router.ts +60 -0
  58. package/src/api/core/logs.router.ts +162 -0
  59. package/src/api/core/notifications.router.ts +65 -0
  60. package/src/api/core/repl.router.ts +41 -0
  61. package/src/api/core/settings-backend.router.ts +142 -0
  62. package/src/api/core/stream-probe.router.ts +57 -0
  63. package/src/api/core/system-events.router.ts +116 -0
  64. package/src/api/health/health.routes.ts +123 -0
  65. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
  66. package/src/api/oauth2/consent-page.ts +42 -0
  67. package/src/api/oauth2/oauth2-routes.ts +248 -0
  68. package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
  69. package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
  70. package/src/api/trpc/cap-mount-helpers.ts +225 -0
  71. package/src/api/trpc/core-cap-bridge.ts +152 -0
  72. package/src/api/trpc/generated-cap-mounts.ts +707 -0
  73. package/src/api/trpc/generated-cap-routers.ts +6340 -0
  74. package/src/api/trpc/scope-access.ts +110 -0
  75. package/src/api/trpc/trpc.context.ts +255 -0
  76. package/src/api/trpc/trpc.middleware.ts +140 -0
  77. package/src/api/trpc/trpc.router.ts +275 -0
  78. package/src/auth/session-cookie.ts +44 -0
  79. package/src/boot/boot-config.ts +278 -0
  80. package/src/boot/post-boot.service.ts +103 -0
  81. package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
  82. package/src/core/addon/addon-package.service.ts +1684 -0
  83. package/src/core/addon/addon-registry.service.ts +2926 -0
  84. package/src/core/addon/addon-search.service.ts +90 -0
  85. package/src/core/addon/addon-settings-provider.ts +276 -0
  86. package/src/core/addon/addon.tokens.ts +2 -0
  87. package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
  88. package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
  89. package/src/core/addon-pages/addon-pages.service.ts +80 -0
  90. package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
  91. package/src/core/agent/agent-registry.service.ts +507 -0
  92. package/src/core/auth/auth.service.spec.ts +88 -0
  93. package/src/core/auth/auth.service.ts +8 -0
  94. package/src/core/capability/capability.service.ts +57 -0
  95. package/src/core/config/config.schema.ts +3 -0
  96. package/src/core/config/config.service.spec.ts +175 -0
  97. package/src/core/config/config.service.ts +7 -0
  98. package/src/core/events/event-bus.service.spec.ts +212 -0
  99. package/src/core/events/event-bus.service.ts +85 -0
  100. package/src/core/feature/feature.service.spec.ts +96 -0
  101. package/src/core/feature/feature.service.ts +8 -0
  102. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
  103. package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
  104. package/src/core/logging/log-ring-buffer.ts +3 -0
  105. package/src/core/logging/logging.service.spec.ts +247 -0
  106. package/src/core/logging/logging.service.ts +129 -0
  107. package/src/core/logging/scoped-logger.ts +3 -0
  108. package/src/core/moleculer/moleculer.service.ts +612 -0
  109. package/src/core/network/network-quality.service.spec.ts +47 -0
  110. package/src/core/network/network-quality.service.ts +5 -0
  111. package/src/core/notification/notification-wrapper.service.ts +36 -0
  112. package/src/core/notification/toast-wrapper.service.ts +31 -0
  113. package/src/core/provider/provider.tokens.ts +1 -0
  114. package/src/core/repl/repl-engine.service.spec.ts +417 -0
  115. package/src/core/repl/repl-engine.service.ts +156 -0
  116. package/src/core/storage/fs-storage-backend.spec.ts +70 -0
  117. package/src/core/storage/fs-storage-backend.ts +3 -0
  118. package/src/core/storage/settings-store.spec.ts +213 -0
  119. package/src/core/storage/settings-store.ts +2 -0
  120. package/src/core/storage/sql-schema.spec.ts +140 -0
  121. package/src/core/storage/sql-schema.ts +3 -0
  122. package/src/core/storage/storage-location-manager.spec.ts +121 -0
  123. package/src/core/storage/storage-location-manager.ts +3 -0
  124. package/src/core/storage/storage.service.spec.ts +73 -0
  125. package/src/core/storage/storage.service.ts +3 -0
  126. package/src/core/streaming/stream-probe.service.ts +212 -0
  127. package/src/core/topology/topology-emitter.service.ts +101 -0
  128. package/src/launcher.ts +309 -0
  129. package/src/main.ts +1049 -0
  130. package/src/manual-boot.ts +322 -0
  131. package/tsconfig.build.json +8 -0
  132. package/tsconfig.json +21 -0
  133. package/vitest.config.ts +26 -0
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Small helpers for wiring capability routers to the `CapabilityRegistry`
3
+ * in `trpc.router.ts`. These functions don't generate code — they just
4
+ * remove boilerplate from the mount lambdas we pass to `createCapRouter_X`.
5
+ *
6
+ * When to use what:
7
+ * - `requireSingleton(registry, name)` — singleton caps with no custom
8
+ * composition. Returns the active provider or null (the codegen'd
9
+ * router itself throws PRECONDITION_FAILED when null).
10
+ * - `concatCollection(providers, method)` — collection caps whose
11
+ * methods return arrays and where the desired behaviour is "union of
12
+ * every provider's contribution" (e.g. `turn-provider.getTurnServers`).
13
+ * - `firstSupported(providers, probe, action)` — collection caps where
14
+ * exactly one provider should handle each request, selected by a
15
+ * probe method (e.g. `snapshot-provider.supportsDevice`).
16
+ *
17
+ * Collections that route by an input key (e.g. `webrtc` picking the
18
+ * provider responsible for a given `streamId`) are NOT covered here —
19
+ * they have app-specific routing logic that belongs in the mount.
20
+ */
21
+ import { TRPCError } from '@trpc/server'
22
+ import type { CapabilityRegistry } from '@camstack/kernel'
23
+ import type { CapabilityProviderMap } from '@camstack/types'
24
+
25
+ /**
26
+ * Fetch the currently active singleton provider for a capability.
27
+ * Returns null if no provider is registered; the downstream codegen'd
28
+ * router surfaces this as `PRECONDITION_FAILED` to the caller.
29
+ */
30
+ export function requireSingleton<K extends keyof CapabilityProviderMap>(
31
+ registry: CapabilityRegistry | null,
32
+ capName: K,
33
+ ): CapabilityProviderMap[K] | null {
34
+ return registry?.getSingleton(capName) ?? null
35
+ }
36
+
37
+ /**
38
+ * Build a per-device dispatcher that satisfies the singleton-provider
39
+ * shape but resolves the actual implementation lazily via
40
+ * `registry.getNativeProvider(capName, deviceId)` on every method call.
41
+ *
42
+ * Use for device-scoped caps that have NO system-level wrapper (PTZ,
43
+ * reboot, doorbell, brightness, motion-trigger, switch, …) — drivers
44
+ * register per-device native providers via
45
+ * `DeviceContext.registerNativeCap`, and this helper bridges the cap-
46
+ * router's "fetch a singleton then call methods on it" flow into a
47
+ * "resolve native by deviceId per call" flow.
48
+ *
49
+ * Method input MUST carry `deviceId: number`. Methods without that
50
+ * field (auto-injected `getStatus({deviceId})` for caps with a status
51
+ * block, every business method that follows the cap-definition
52
+ * convention) work transparently.
53
+ *
54
+ * Throws PRECONDITION_FAILED with a device-specific message when no
55
+ * native provider exists for the requested deviceId — much friendlier
56
+ * than the singleton fallthrough's "no provider" generic error.
57
+ */
58
+ export function requireDeviceScoped<K extends keyof CapabilityProviderMap>(
59
+ registry: CapabilityRegistry | null,
60
+ capName: K,
61
+ ): CapabilityProviderMap[K] | null {
62
+ if (!registry) return null
63
+ // The Proxy is the singleton stand-in. Each property access returns
64
+ // a function that, on call, looks up the per-device native and
65
+ // forwards the call. No caching — the lookup is cheap (Map.get) and
66
+ // re-doing it per call lets devices come/go without stale refs.
67
+ const dispatcher = new Proxy({}, {
68
+ get(_target, prop: string | symbol) {
69
+ if (typeof prop !== 'string') return undefined
70
+ return async (input: { deviceId?: number } & Record<string, unknown>) => {
71
+ const deviceId = input?.deviceId
72
+ if (typeof deviceId !== 'number') {
73
+ throw new TRPCError({
74
+ code: 'BAD_REQUEST',
75
+ message: `${String(capName)}.${prop}: input must carry numeric "deviceId"`,
76
+ })
77
+ }
78
+ const native = registry.getNativeProvider<Record<string, (i: unknown) => unknown>>(
79
+ capName,
80
+ deviceId,
81
+ )
82
+ if (!native) {
83
+ throw new TRPCError({
84
+ code: 'PRECONDITION_FAILED',
85
+ message: `Capability "${String(capName)}" not registered for device ${deviceId}`,
86
+ })
87
+ }
88
+ const fn = native[prop]
89
+ if (typeof fn !== 'function') {
90
+ throw new TRPCError({
91
+ code: 'NOT_IMPLEMENTED',
92
+ message: `Capability "${String(capName)}" provider for device ${deviceId} does not implement "${prop}"`,
93
+ })
94
+ }
95
+ return fn.call(native, input)
96
+ }
97
+ },
98
+ })
99
+ return dispatcher as unknown as CapabilityProviderMap[K]
100
+ }
101
+
102
+ // ── Method-on-provider callable types ────────────────────────────────
103
+
104
+ /** A key on T whose value is a function with array / promise-array return. */
105
+ type ArrayReturningMethodKey<T> = {
106
+ [K in keyof T]: T[K] extends (...args: infer _A) => readonly unknown[] | Promise<readonly unknown[]>
107
+ ? K
108
+ : never
109
+ }[keyof T]
110
+
111
+ /** A key on T whose value is a function returning boolean / promise-boolean. */
112
+ type BoolReturningMethodKey<T> = {
113
+ [K in keyof T]: T[K] extends (...args: infer _A) => boolean | Promise<boolean>
114
+ ? K
115
+ : never
116
+ }[keyof T]
117
+
118
+ /**
119
+ * Build a method that fan-outs a call to every provider in a collection
120
+ * and concatenates their array results. Useful for contribution-style
121
+ * caps where each provider adds to a shared pool.
122
+ */
123
+ export function concatCollection<
124
+ T extends object,
125
+ K extends ArrayReturningMethodKey<T>,
126
+ >(
127
+ providers: readonly T[],
128
+ method: K,
129
+ ): T[K] extends (...args: infer A) => readonly (infer R)[] | Promise<readonly (infer R)[]>
130
+ ? (...args: A) => Promise<readonly R[]>
131
+ : never {
132
+ const wrapper = async (...args: unknown[]): Promise<readonly unknown[]> => {
133
+ const results = await Promise.all(
134
+ providers.map(async (p): Promise<readonly unknown[]> => {
135
+ const member = Reflect.get(p, method)
136
+ if (typeof member !== 'function') return []
137
+ // `Reflect.apply` returns `any`; funnel through unknown.
138
+ const out: unknown = await Reflect.apply(member, p, args)
139
+ if (!Array.isArray(out)) return []
140
+ const arr: readonly unknown[] = out
141
+ return arr
142
+ }),
143
+ )
144
+ return results.flat()
145
+ }
146
+ // Type-level bridge: the runtime wrapper signature (unknown → Promise<unknown[]>)
147
+ // matches the declared generic conditional return; TypeScript's
148
+ // conditional types can't be narrowed inside a function body, so this
149
+ // boundary assertion is required.
150
+ return wrapper as T[K] extends (...args: infer A) => readonly (infer R)[] | Promise<readonly (infer R)[]>
151
+ ? (...args: A) => Promise<readonly R[]>
152
+ : never
153
+ }
154
+
155
+ /**
156
+ * Iterate a collection asking each provider "do you handle this?" via a
157
+ * probe method, then call an action on the first one that answers yes.
158
+ * Each provider is tried in registration order; errors are swallowed and
159
+ * the next provider is attempted. Returns null if no provider matches or
160
+ * if every matching provider's action throws/returns null.
161
+ */
162
+ export function firstSupported<
163
+ T extends object,
164
+ Probe extends BoolReturningMethodKey<T>,
165
+ Action extends keyof T,
166
+ >(
167
+ providers: readonly T[],
168
+ probe: Probe,
169
+ action: Action,
170
+ ): T[Action] extends (...args: infer A) => infer R
171
+ ? (...args: A) => Promise<Awaited<R> | null>
172
+ : never {
173
+ const wrapper = async (...args: unknown[]): Promise<unknown> => {
174
+ const [first] = args
175
+ for (const p of providers) {
176
+ try {
177
+ const probeMember = Reflect.get(p, probe)
178
+ if (typeof probeMember !== 'function') continue
179
+ const supported: unknown = await Reflect.apply(probeMember, p, [first])
180
+ if (supported !== true) continue
181
+ const actionMember = Reflect.get(p, action)
182
+ if (typeof actionMember !== 'function') continue
183
+ const result: unknown = await Reflect.apply(actionMember, p, args)
184
+ if (result !== null && result !== undefined) return result
185
+ } catch {
186
+ // try next provider
187
+ }
188
+ }
189
+ return null
190
+ }
191
+ // Type-level bridge — see concatCollection for the same pattern.
192
+ return wrapper as T[Action] extends (...args: infer A) => infer R
193
+ ? (...args: A) => Promise<Awaited<R> | null>
194
+ : never
195
+ }
196
+
197
+ /**
198
+ * Convenience for collection caps that want a "logical OR of probes"
199
+ * (e.g. `supportsDevice` across every snapshot-provider).
200
+ */
201
+ export function anySupports<
202
+ T extends object,
203
+ K extends BoolReturningMethodKey<T>,
204
+ >(
205
+ providers: readonly T[],
206
+ probe: K,
207
+ ): T[K] extends (...args: infer A) => boolean | Promise<boolean>
208
+ ? (...args: A) => Promise<boolean>
209
+ : never {
210
+ const wrapper = async (...args: unknown[]): Promise<boolean> => {
211
+ for (const p of providers) {
212
+ const member = Reflect.get(p, probe)
213
+ if (typeof member !== 'function') continue
214
+ try {
215
+ const result: unknown = await Reflect.apply(member, p, args)
216
+ if (result === true) return true
217
+ } catch { /* next */ }
218
+ }
219
+ return false
220
+ }
221
+ // Type-level bridge — see concatCollection for the same pattern.
222
+ return wrapper as T[K] extends (...args: infer A) => boolean | Promise<boolean>
223
+ ? (...args: A) => Promise<boolean>
224
+ : never
225
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Core-capability mesh bridge.
3
+ *
4
+ * The hub's CORE routers — hand-written single-impl routers plus the
5
+ * handful of service-backed cap routers — are mounted only as the hub's
6
+ * tRPC `appRouter`. No addon registers a provider for them, so
7
+ * `addon-service-factory` never publishes a Moleculer service exposing
8
+ * their actions. A forked addon calling `ctx.api.<coreCap>.<method>`
9
+ * therefore falls through `localProviderLink` into `brokerTransportLink`,
10
+ * whose load-balanced discovery wait has no deadline — the call hangs
11
+ * forever (this was the `export-alexa` `redetectHubUrl` hang).
12
+ *
13
+ * `buildCoreCapService` walks the appRouter, picks the core namespaces,
14
+ * and wraps each query/mutation as a `$core-caps.<kebab-cap>.<method>`
15
+ * Moleculer action invoked through a trusted-mesh tRPC caller. With the
16
+ * service mounted on the hub node, `brokerTransportLink` resolves and
17
+ * routes the call exactly like any addon-provided cap.
18
+ */
19
+ import type { ServiceSchema } from 'moleculer'
20
+ import { createCoreCapService, type CoreCapAction } from '@camstack/kernel'
21
+ import { createCallerFactory } from './trpc.middleware.js'
22
+ import { createMeshTrpcContext } from './trpc.context.js'
23
+ import type { AppRouter } from './trpc.router.js'
24
+
25
+ /**
26
+ * appRouter namespaces that back the CORE API surface and must be
27
+ * reachable cross-process. This is the core-router list from
28
+ * `trpc.router.ts` (`buildCapabilityRouters`) minus the deliberate
29
+ * exclusions below.
30
+ *
31
+ * Excluded on purpose:
32
+ * - `auth` — issuing service / scoped tokens over the trusted mesh
33
+ * would let any forked addon mint admin credentials. Addons must
34
+ * not perform authentication operations; this stays hub-local.
35
+ * - `live`, `systemEvents` — listed in `NEVER_BRIDGED_CAPS`
36
+ * (`trpc-links.ts`). They are hub-only push streams, not
37
+ * request/reply, and the worker side fast-fails on them by design.
38
+ */
39
+ const CORE_NAMESPACES: ReadonlySet<string> = new Set<string>([
40
+ // Service-backed cap routers (no addon provider).
41
+ 'system',
42
+ 'toast',
43
+ 'integrations',
44
+ 'nodes',
45
+ 'addons',
46
+ // Hand-written single-impl core routers.
47
+ 'capabilities',
48
+ 'notifications',
49
+ 'logs',
50
+ 'hwaccel',
51
+ 'streamProbe',
52
+ 'settingsBackend',
53
+ 'eventBusProxy',
54
+ 'repl',
55
+ 'addonSettingsRaw',
56
+ ])
57
+
58
+ /**
59
+ * camelCase → kebab-case. Mirrors `toKebab` in `trpc-links.ts` so the
60
+ * action name matches what `brokerTransportLink` derives from `op.path`.
61
+ */
62
+ function toKebab(name: string): string {
63
+ return name.replace(/[A-Z]/g, (m, i: number) => (i > 0 ? '-' : '') + m.toLowerCase())
64
+ }
65
+
66
+ /** A tRPC procedure node — distinguished from a router by `_def.procedure`. */
67
+ interface ProcedureNode {
68
+ readonly _def: { readonly procedure?: unknown; readonly type?: unknown }
69
+ }
70
+
71
+ function isProcedureNode(value: unknown): value is ProcedureNode {
72
+ // A built tRPC procedure is a callable object — `_def.procedures`
73
+ // stores the procedure function directly, not a wrapper object.
74
+ if (value === null || (typeof value !== 'object' && typeof value !== 'function')) return false
75
+ const def: unknown = (value as { _def?: unknown })._def
76
+ return def !== null && typeof def === 'object' && (def as { procedure?: unknown }).procedure === true
77
+ }
78
+
79
+ interface DiscoveredProcedure {
80
+ readonly path: string
81
+ readonly type: string
82
+ }
83
+
84
+ /**
85
+ * Recursively flatten a tRPC router record to dotted procedure paths.
86
+ * Robust to both layouts tRPC v11 may use for `_def.procedures`: a
87
+ * nested record of sub-routers, or a flat record already keyed by
88
+ * dotted path (a flat key simply yields its key verbatim).
89
+ */
90
+ function collectProcedures(
91
+ record: Readonly<Record<string, unknown>>,
92
+ prefix: string,
93
+ out: DiscoveredProcedure[],
94
+ ): void {
95
+ for (const [key, value] of Object.entries(record)) {
96
+ const path = prefix.length > 0 ? `${prefix}.${key}` : key
97
+ if (isProcedureNode(value)) {
98
+ const type = value._def.type
99
+ out.push({ path, type: typeof type === 'string' ? type : 'query' })
100
+ } else if (value !== null && typeof value === 'object') {
101
+ collectProcedures(value as Readonly<Record<string, unknown>>, path, out)
102
+ }
103
+ }
104
+ }
105
+
106
+ type ProcedureInvoker = (input: unknown) => Promise<unknown>
107
+
108
+ /**
109
+ * Navigate the decorated caller record (a recursive proxy) to the
110
+ * procedure function at `path`. Every node on the proxy is callable,
111
+ * so navigation uses `Reflect.get` across both objects and functions.
112
+ */
113
+ function resolveInvoker(caller: unknown, path: string): ProcedureInvoker | null {
114
+ let node: unknown = caller
115
+ for (const segment of path.split('.')) {
116
+ if (node === null || (typeof node !== 'object' && typeof node !== 'function')) return null
117
+ // Documented boundary: `node` is an object or function past the
118
+ // guard above; `Reflect.get` is typed for `object` targets.
119
+ node = Reflect.get(node as object, segment)
120
+ }
121
+ return typeof node === 'function' ? (node as ProcedureInvoker) : null
122
+ }
123
+
124
+ /**
125
+ * Build the `$core-caps` Moleculer service schema from the hub
126
+ * appRouter. Query + mutation procedures in {@link CORE_NAMESPACES} are
127
+ * exposed; subscriptions are skipped (the broker transport is
128
+ * request/reply only).
129
+ */
130
+ export function buildCoreCapService(appRouter: AppRouter): ServiceSchema {
131
+ const meshCaller: unknown = createCallerFactory(appRouter)(createMeshTrpcContext())
132
+
133
+ const discovered: DiscoveredProcedure[] = []
134
+ collectProcedures(appRouter._def.procedures, '', discovered)
135
+
136
+ const actions: CoreCapAction[] = []
137
+ for (const { path, type } of discovered) {
138
+ const dot = path.indexOf('.')
139
+ if (dot < 0) continue
140
+ const namespace = path.slice(0, dot)
141
+ if (!CORE_NAMESPACES.has(namespace)) continue
142
+ if (type === 'subscription') continue
143
+
144
+ const invoke = resolveInvoker(meshCaller, path)
145
+ if (invoke === null) continue
146
+
147
+ const method = path.slice(dot + 1)
148
+ actions.push({ actionName: `${toKebab(namespace)}.${method}`, invoke })
149
+ }
150
+
151
+ return createCoreCapService({ actions })
152
+ }