@camstack/server 0.1.6 → 0.1.8

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 (60) hide show
  1. package/package.json +3 -3
  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/cap-providers-location-import.spec.ts +186 -0
  6. package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +243 -0
  7. package/src/__tests__/cap-providers-bulk-update.spec.ts +388 -0
  8. package/src/__tests__/cap-route-adapter.spec.ts +289 -0
  9. package/src/__tests__/cap-routers/broker-routing.router.spec.ts +169 -0
  10. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +123 -0
  11. package/src/__tests__/cap-routers/capabilities-node.spec.ts +55 -0
  12. package/src/__tests__/cap-routers/device-link-overlay.spec.ts +132 -0
  13. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +30 -0
  14. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +249 -0
  15. package/src/__tests__/framework-installer-defer-restart.spec.ts +165 -0
  16. package/src/__tests__/moleculer/uds-readiness.spec.ts +143 -0
  17. package/src/__tests__/moleculer/uds-topology.spec.ts +390 -0
  18. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +329 -0
  19. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +39 -4
  20. package/src/__tests__/native-cap-route.spec.ts +404 -0
  21. package/src/__tests__/oauth2-account-linking.spec.ts +85 -0
  22. package/src/__tests__/uds-addon-call-wiring.spec.ts +237 -0
  23. package/src/__tests__/uds-log-ingest.spec.ts +183 -0
  24. package/src/api/addon-upload.ts +27 -1
  25. package/src/api/capabilities.router.ts +1 -1
  26. package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
  27. package/src/api/core/bulk-update-coordinator.ts +302 -0
  28. package/src/api/core/cap-providers.ts +211 -9
  29. package/src/api/core/capabilities.router.ts +26 -3
  30. package/src/api/core/logs.router.ts +4 -0
  31. package/src/api/oauth2/oauth2-routes.ts +5 -1
  32. package/src/api/trpc/__tests__/client-ip.spec.ts +146 -0
  33. package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +128 -0
  34. package/src/api/trpc/cap-mount-helpers.ts +12 -1
  35. package/src/api/trpc/cap-route-error-formatter.ts +163 -0
  36. package/src/api/trpc/client-ip.ts +147 -0
  37. package/src/api/trpc/generated-cap-mounts.ts +299 -8
  38. package/src/api/trpc/generated-cap-routers.ts +2384 -302
  39. package/src/api/trpc/trpc.middleware.ts +5 -1
  40. package/src/api/trpc/trpc.router.ts +84 -3
  41. package/src/boot/__tests__/integration-id-backfill.spec.ts +116 -0
  42. package/src/boot/integration-id-backfill.ts +109 -0
  43. package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
  44. package/src/core/addon/addon-call-gateway.ts +157 -0
  45. package/src/core/addon/addon-package.service.ts +9 -0
  46. package/src/core/addon/addon-registry.service.ts +453 -107
  47. package/src/core/addon/addon-row-manifest.ts +29 -0
  48. package/src/core/addon/addon-settings-provider.ts +40 -116
  49. package/src/core/capability/capability.service.ts +9 -0
  50. package/src/core/logging/logging.service.ts +7 -2
  51. package/src/core/moleculer/cap-call-fn.spec.ts +166 -0
  52. package/src/core/moleculer/cap-call-fn.ts +103 -0
  53. package/src/core/moleculer/cap-route-authority.ts +182 -0
  54. package/src/core/moleculer/moleculer.service.ts +408 -36
  55. package/src/core/network/network-quality.service.spec.ts +2 -1
  56. package/src/main.ts +137 -12
  57. package/src/core/storage/settings-store.spec.ts +0 -213
  58. package/src/core/storage/settings-store.ts +0 -2
  59. package/src/core/storage/sql-schema.spec.ts +0 -140
  60. package/src/core/storage/sql-schema.ts +0 -3
@@ -1,6 +1,7 @@
1
1
  /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access -- pre-existing lint debt across this 2200-line orchestration class. The flagged sites (StorageService.setLocationManager / setSettingsBackend, LoggingService.addDestination, RouteRegistry, etc.) are typed as `unknown` by their owning services to break circular construction-order dependencies; runtime contracts are validated structurally. Tracked separately; do not amend in unrelated edits. */
2
2
  import * as os from "node:os";
3
3
  import { ConfigService } from "../config/config.service";
4
+ import { overlayDeclaration } from "./addon-row-manifest";
4
5
  import { LoggingService } from "../logging/logging.service";
5
6
  import { EventBusService } from "../events/event-bus.service";
6
7
  import { StorageService } from "../storage/storage.service";
@@ -60,12 +61,13 @@ import type {
60
61
  IStorageProvider as INewStorageProvider,
61
62
  ISettingsBackend,
62
63
  } from "@camstack/types";
63
- import { AddonRouteRegistry } from "@camstack/core";
64
+ import { AddonRouteRegistry, DataPlaneRegistry } from "@camstack/core";
64
65
  import { randomUUID } from "node:crypto";
65
66
  import * as path from "node:path";
66
67
  import * as fs from "node:fs";
67
68
  import { pathToFileURL } from "node:url";
68
69
  import { createAddonSettingsProvider } from "./addon-settings-provider.js";
70
+ import { AddonCallGateway } from "./addon-call-gateway.js";
69
71
  import { addonSettingsCapability } from "@camstack/types";
70
72
  import { DisposerChain } from "@camstack/types";
71
73
 
@@ -111,6 +113,110 @@ function isSettingsStore(
111
113
  return true;
112
114
  }
113
115
 
116
+ /**
117
+ * The bridge-dispatch contract on an `addon-routes` provider. The operator-
118
+ * facing `IAddonRouteProvider` interface declares only `id` + `getRoutes`; the
119
+ * UDS proxy (and `buildAddonRouteProvider`) also expose `invoke`, which the
120
+ * forked-routes bridge calls per request. Narrowed via `isAddonRoutesInvoker`.
121
+ */
122
+ interface AddonRoutesInvoker {
123
+ invoke(
124
+ input: import("@camstack/types").AddonRouteInvokeRequest,
125
+ ): Promise<import("@camstack/types").AddonRouteReplyEnvelope>;
126
+ }
127
+
128
+ /** Structural guard: true when an `addon-routes` provider also exposes `invoke`. */
129
+ function isAddonRoutesInvoker<T extends object>(
130
+ provider: T,
131
+ ): provider is T & AddonRoutesInvoker {
132
+ return typeof Reflect.get(provider, "invoke") === "function";
133
+ }
134
+
135
+ const ROUTE_METHODS: readonly import("@camstack/types").IAddonHttpRoute["method"][] = [
136
+ "GET",
137
+ "POST",
138
+ "PUT",
139
+ "DELETE",
140
+ ];
141
+ const ROUTE_ACCESS: readonly import("@camstack/types").RouteAccess[] = [
142
+ "public",
143
+ "authenticated",
144
+ "admin",
145
+ ];
146
+
147
+ function asRouteMethod(value: string): import("@camstack/types").IAddonHttpRoute["method"] {
148
+ const upper = value.toUpperCase();
149
+ for (const m of ROUTE_METHODS) if (m === upper) return m;
150
+ throw new Error(`addon-routes: unsupported HTTP method "${value}"`);
151
+ }
152
+
153
+ function asRouteAccess(value: unknown): import("@camstack/types").RouteAccess {
154
+ if (typeof value === "string") {
155
+ for (const a of ROUTE_ACCESS) if (a === value) return a;
156
+ }
157
+ // Default to the most restrictive sensible default the original mount used.
158
+ return "public";
159
+ }
160
+
161
+ /**
162
+ * Parse the wire-boundary `unknown` returned by `callAddonOnChild(...,
163
+ * {target:'routes'})` into typed route descriptors. The child sends route
164
+ * descriptors WITHOUT handlers (functions don't cross MsgPack); only
165
+ * `method`/`path`/`access`/`description` are present.
166
+ */
167
+ interface ParsedRouteDescriptor {
168
+ readonly method: import("@camstack/types").IAddonHttpRoute["method"];
169
+ readonly path: string;
170
+ readonly access: import("@camstack/types").RouteAccess;
171
+ readonly description?: string;
172
+ }
173
+
174
+ function parseSerializableRouteDescriptors(raw: unknown): readonly ParsedRouteDescriptor[] {
175
+ if (!Array.isArray(raw)) {
176
+ throw new Error("addon-routes: child returned a non-array route descriptor set");
177
+ }
178
+ return raw.map((entry: unknown): ParsedRouteDescriptor => {
179
+ if (entry === null || typeof entry !== "object") {
180
+ throw new Error("addon-routes: route descriptor is not an object");
181
+ }
182
+ const method = Reflect.get(entry, "method");
183
+ const path = Reflect.get(entry, "path");
184
+ if (typeof method !== "string" || typeof path !== "string") {
185
+ throw new Error("addon-routes: route descriptor missing method/path");
186
+ }
187
+ const description = Reflect.get(entry, "description");
188
+ return {
189
+ method: asRouteMethod(method),
190
+ path,
191
+ access: asRouteAccess(Reflect.get(entry, "access")),
192
+ ...(typeof description === "string" ? { description } : {}),
193
+ };
194
+ });
195
+ }
196
+
197
+ /**
198
+ * Parse the wire-boundary `unknown` from `callForked(..., {target:'data-planes'})`
199
+ * into typed data-plane endpoint descriptors (`prefix/access/baseUrl/secret`).
200
+ * Pure data — the addon's `ctx.dataPlane` facility produced them.
201
+ */
202
+ function parseDataPlaneEndpoints(raw: unknown): readonly import("@camstack/types").AddonDataPlaneEndpoint[] {
203
+ if (!Array.isArray(raw)) {
204
+ throw new Error("data-planes: child returned a non-array endpoint set");
205
+ }
206
+ return raw.map((entry: unknown): import("@camstack/types").AddonDataPlaneEndpoint => {
207
+ if (entry === null || typeof entry !== "object") {
208
+ throw new Error("data-planes: endpoint descriptor is not an object");
209
+ }
210
+ const prefix = Reflect.get(entry, "prefix");
211
+ const baseUrl = Reflect.get(entry, "baseUrl");
212
+ const secret = Reflect.get(entry, "secret");
213
+ if (typeof prefix !== "string" || typeof baseUrl !== "string" || typeof secret !== "string") {
214
+ throw new Error("data-planes: endpoint descriptor missing prefix/baseUrl/secret");
215
+ }
216
+ return { prefix, access: asRouteAccess(Reflect.get(entry, "access")), baseUrl, secret };
217
+ });
218
+ }
219
+
114
220
  interface AddonEntry {
115
221
  readonly addon: ICamstackAddon;
116
222
  initialized: boolean;
@@ -140,6 +246,8 @@ interface AddonEntry {
140
246
  export class AddonRegistryService {
141
247
  private readonly addonEntries = new Map<string, AddonEntry>();
142
248
  private readonly capabilityRegistry: CapabilityRegistry;
249
+ /** Single router for addon-level calls (routes / custom / settings). */
250
+ private addonCallGateway!: AddonCallGateway;
143
251
  // Task 7.1: hub-wide registry of addon custom actions. Populated on
144
252
  // each addon's initialize() (in-process path); Task 7.2 will dispatch
145
253
  // through this from the `api.addons.custom` tRPC procedure.
@@ -156,6 +264,7 @@ export class AddonRegistryService {
156
264
  private addonLoader!: AddonLoader;
157
265
  private healthMonitor!: AddonHealthMonitor;
158
266
  private addonRouteRegistry: AddonRouteRegistry | null = null;
267
+ private dataPlaneRegistry: DataPlaneRegistry | null = null;
159
268
 
160
269
  // Broker-routed AddonApi proxy — every addon's `ctx.api` resolves
161
270
  // to this. Calls go through `broker.call('${addonId}.${capName}.${method}')`
@@ -272,6 +381,24 @@ export class AddonRegistryService {
272
381
  },
273
382
  );
274
383
 
384
+ this.capabilityRegistry.setNodeConfigReader(
385
+ (capability: string, nodeId: string): string | undefined => {
386
+ try {
387
+ return (
388
+ this.configService.get<string>(
389
+ `capabilities.singletonNode.${capability}.${nodeId}`,
390
+ ) ?? undefined
391
+ );
392
+ } catch (err) {
393
+ this.logger.debug(
394
+ 'settings-store not wired yet during early boot',
395
+ { meta: { capability, nodeId, error: errMsg(err) } },
396
+ );
397
+ return undefined;
398
+ }
399
+ },
400
+ );
401
+
275
402
  // Collection-provider enable/disable persistence. Reads the same
276
403
  // `capabilities.collection.<cap>` ConfigService key the `capabilities`
277
404
  // router writes (`JSON.stringify({ disabled: string[] })`), so a
@@ -341,29 +468,33 @@ export class AddonRegistryService {
341
468
  // former `$addonHost` Moleculer service. The provider resolves
342
469
  // addonId → local addon instance (hub) or remote Moleculer call.
343
470
  this.capabilityRegistry.declareCapability(addonSettingsCapability);
344
- const settingsProvider = createAddonSettingsProvider({
345
- getAddon: (addonId) => {
346
- const entry = this.addonEntries.get(addonId);
347
- return entry?.addon ?? null;
348
- },
471
+ // The SINGLE router for addon-level calls (routes / custom / settings).
472
+ // It classifies in-process | hub-local-child (UDS) | remote-agent
473
+ // (Moleculer) in ONE place — `resolveNode` reports only the BASE node
474
+ // (the hub for every hub-local addon, forked or not); the gateway's
475
+ // `isChildKnown` distinguishes a forked UDS child from an in-process
476
+ // builtin. This is the fix for forked-addon settings: they were forced
477
+ // down a dead `<addonId>.settings.*` Moleculer path because the old
478
+ // `resolveNode` marked them non-local; now they route over UDS like
479
+ // their routes + custom-actions already do.
480
+ this.addonCallGateway = new AddonCallGateway({
481
+ hubNodeId: this.broker.nodeID,
349
482
  resolveNode: (addonId) => {
350
- // Group-runner addons live in a child Moleculer node (e.g.
351
- // `hub/pipeline`). The hub-side `entry.addon` instance never
352
- // runs `initialize()` and has no `_ctx.settings`, so calling
353
- // `addon.getGlobalSettings()` on it returns defaults only —
354
- // the real store lives in the worker. Route those via the
355
- // remote path so the worker's `<addonId>.settings.*` handler
356
- // serves the canonical response.
357
483
  const entry = this.addonEntries.get(addonId);
358
- if (entry?.declaration && entry.addonDir) {
359
- const runnerId = resolveRunnerId(entry.declaration, addonId);
360
- return `${this.broker.nodeID}/${runnerId}`;
361
- }
362
- return "hub";
484
+ // Every addon in `addonEntries` is hub-local — report the hub node;
485
+ // forked-vs-in-process is decided by `isChildKnown` in the gateway.
486
+ return entry ? this.broker.nodeID : "hub";
363
487
  },
488
+ getChildRegistry: () => this.moleculer.childRegistry,
364
489
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- moleculer types unresolvable; see `broker` getter docstring
365
490
  broker: this.moleculer.broker,
366
- hubNodeId: this.broker.nodeID,
491
+ });
492
+ const settingsProvider = createAddonSettingsProvider({
493
+ getAddon: (addonId) => {
494
+ const entry = this.addonEntries.get(addonId);
495
+ return entry?.addon ?? null;
496
+ },
497
+ gateway: this.addonCallGateway,
367
498
  });
368
499
  this.capabilityRegistry.registerProvider(
369
500
  "addon-settings",
@@ -487,38 +618,97 @@ export class AddonRegistryService {
487
618
 
488
619
  // Native-cap cross-process bridge: when a hub consumer resolves a native
489
620
  // provider for a device whose IDevice lives in a forked worker, we
490
- // return a broker proxy that routes calls to
491
- // `<addonId>.native-provider.<capName>.<method>` via standard Moleculer RPC.
621
+ // return a proxy that routes calls to the correct transport.
492
622
  //
493
- // The resolver keys off `(capName, deviceId)`, NOT device ownership.
494
- // An addon can own a device without registering every conceivable cap
495
- // natively (e.g. RtspCamera without `snapshotUrl` does not publish a
496
- // snapshot provider). Synthesizing a proxy against the device owner
497
- // would point at a Moleculer service that was never mounted and make
498
- // every call throw "service not found" at runtime.
623
+ // G2 single ownership authority: the CapRouteResolver is consulted FIRST
624
+ // for hub-local native caps. If the resolver's snapshot identifies a
625
+ // hub-local-uds route for (capName, deviceId), we build the proxy directly
626
+ // no `resolveNativeCapOwnerSync` consult needed for this branch. The resolver's
627
+ // `hubLocalChildProvides(capName, deviceId)` is the canonical hub-local authority
628
+ // (fed by the same LocalChildRegistry that owns UDS dispatch), so the old
629
+ // `owner.nodeId.startsWith(hubNodeId + '/')` gate is replaced by the resolver's
630
+ // own verdict.
499
631
  //
500
- // `resolveNativeCapOwnerSync` consults both hub-local native
501
- // registrations and `remoteNativeCaps` (populated via
502
- // DeviceBindingsChanged events emitted by every worker). It returns
503
- // null when no provider published the cap for that device — the
504
- // fallback surfaces that as null so wrappers cleanly fall through to
505
- // whatever secondary strategy they own (e.g. snapshot ffmpeg frame
506
- // grab), instead of throwing against a phantom service.
632
+ // `resolveNativeCapOwnerSync` is only consulted for the REMOTE branch: when the
633
+ // resolver throws no-provider for the hub node (no hub-local-uds child owns the cap),
634
+ // the fallback queries `resolveNativeCapOwnerSync` to find a remote owner. That
635
+ // method carries data (push-fed `remoteNativeCaps` from `DeviceBindingsChanged`
636
+ // events) that the resolver's `NodeCapAuthority` doesn't see, so it stays as the
637
+ // remote-branch source. If `resolveNativeCapOwnerSync` also returns null, the cap
638
+ // genuinely has no cross-process provider and the fallback returns null — wrappers
639
+ // cleanly fall through to their own secondary strategy.
507
640
  {
508
641
  const { buildNativeCapProxy } = await import("@camstack/kernel");
509
642
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- moleculer types unresolvable; see `broker` getter docstring
510
643
  const broker = this.moleculer.broker;
511
644
  this.capabilityRegistry.setNativeFallback(
512
645
  (capName: string, deviceId: number): unknown | null => {
646
+ const resolver = this.moleculer.capRouteResolver;
647
+ const hubNodeId = this.moleculer.nodeId;
648
+
649
+ // 1. Hub-local path: the resolver's hub-local-child authority is the
650
+ // single source of truth for whether a forked hub-local UDS child
651
+ // owns (capName, deviceId) — via hubLocalChildProvides +
652
+ // getHubLocalChildId (both deviceId-aware, M1).
653
+ //
654
+ // We deliberately use `resolveHubLocalUdsRoute`, NOT
655
+ // `resolveCapRoute`: this fallback's contract is to reach the
656
+ // NATIVE provider in the forked vendor child. `resolveCapRoute`
657
+ // gives Priority 1 to `hub-in-process`, so a cap that ALSO has an
658
+ // in-hub provider for the same name (a wrapper — today `snapshot`)
659
+ // would classify as `hub-in-process` (the wrapper) and never reach
660
+ // the hub-local-uds native. That made the wrapper's
661
+ // `getNativeProvider` return null and silently fall through to its
662
+ // secondary strategy (e.g. snapshot's ffmpeg frame grab), so the
663
+ // vendor native snapshot was never invoked. `resolveHubLocalUdsRoute`
664
+ // skips the in-process branch and returns the native child route.
665
+ if (resolver !== null) {
666
+ const route = resolver.resolveHubLocalUdsRoute(capName, deviceId)
667
+ if (route !== null) {
668
+ const childId = route.childId
669
+ const target: Record<string, (input: unknown) => Promise<unknown>> = {}
670
+ return new Proxy(target, {
671
+ get(_target, property): ((input: unknown) => Promise<unknown>) | undefined {
672
+ if (typeof property !== 'string') return undefined
673
+ return (input: unknown): Promise<unknown> => {
674
+ const mergedInput =
675
+ typeof input === 'object' && input !== null
676
+ ? { ...input, deviceId }
677
+ : { deviceId }
678
+ // Re-resolve at call time so a child that respawned under a
679
+ // new runner id is still reachable (route is cheap to build).
680
+ const r = resolver.resolveHubLocalUdsRoute(capName, deviceId)
681
+ ?? { kind: 'hub-local-uds' as const, capName, childId }
682
+ return resolver.dispatch(r, property, mergedInput)
683
+ }
684
+ },
685
+ })
686
+ }
687
+ // No hub-local child owns (capName, deviceId). Fall through to the
688
+ // remote branch — resolveNativeCapOwnerSync is still consulted there
689
+ // and may find a remote owner for this (capName, deviceId).
690
+ }
691
+
692
+ // 2. Remote path: resolver has no hub-local route for this (capName, deviceId).
693
+ // Consult resolveNativeCapOwnerSync — it includes push-fed remoteNativeCaps
694
+ // (DeviceBindingsChanged events from forked workers) that the resolver's
695
+ // NodeCapAuthority (backed by HubNodeRegistry) doesn't carry.
513
696
  const dm = this.capabilityRegistry.getSingleton<{
514
697
  resolveNativeCapOwnerSync?: (
515
698
  capName: string,
516
699
  deviceId: number,
517
700
  ) => { addonId: string; nodeId: string } | null;
518
701
  }>("device-manager");
519
- const owner =
520
- dm?.resolveNativeCapOwnerSync?.(capName, deviceId) ?? null;
702
+ const owner = dm?.resolveNativeCapOwnerSync?.(capName, deviceId) ?? null;
521
703
  if (!owner) return null;
704
+
705
+ // Guard: skip hub-local owners here — they were already handled (or skipped
706
+ // because the resolver was null / not initialised yet). Returning null avoids
707
+ // building a Moleculer proxy that points at a UDS-only child.
708
+ if (owner.nodeId.startsWith(`${hubNodeId}/`)) return null;
709
+
710
+ // Remote native cap → Moleculer with native-provider infix.
711
+ // buildNativeCapProxy uses the action `${addonId}.native-provider.${cap}.${method}`.
522
712
  return buildNativeCapProxy(broker, owner.addonId, capName, deviceId);
523
713
  },
524
714
  );
@@ -864,6 +1054,41 @@ export class AddonRegistryService {
864
1054
  this.addonRouteRegistry = registry;
865
1055
  }
866
1056
 
1057
+ /** Set the DataPlaneRegistry the hub's `/addon/:id/*` dispatch reverse-proxies
1058
+ * against (addon HTTP data-planes). */
1059
+ setDataPlaneRegistry(registry: DataPlaneRegistry): void {
1060
+ this.dataPlaneRegistry = registry;
1061
+ }
1062
+
1063
+ /**
1064
+ * Pull a forked addon's HTTP data-plane endpoints over UDS and register them so
1065
+ * the hub can reverse-proxy `/addon/<addonId>/<prefix>/*` to the addon's own
1066
+ * listener. Idempotent (replace-all); an addon with no data-plane registers an
1067
+ * empty set (cleared). Called off the capabilities-changed signal — a data-plane
1068
+ * isn't a cap, but the child is UDS-reachable and has served its endpoints by
1069
+ * the time any of its caps register.
1070
+ */
1071
+ private async mountAddonDataPlanes(addonId: string): Promise<void> {
1072
+ const registry = this.dataPlaneRegistry;
1073
+ if (!registry) return;
1074
+ const entry = this.addonEntries.get(addonId);
1075
+ const childRegistry = this.moleculer.childRegistry;
1076
+ // Forked addons only for now — co-located builtins would publish into the
1077
+ // registry directly via a hub-side sink (deferred; no builtin serves a
1078
+ // data-plane yet).
1079
+ if (!entry || !this.isForkedAddonEntry(entry)) return;
1080
+ if (childRegistry === null || !childRegistry.isChildKnown(addonId)) return;
1081
+
1082
+ const raw = await this.addonCallGateway.callForked(addonId, { target: "data-planes" });
1083
+ const endpoints = parseDataPlaneEndpoints(raw);
1084
+ registry.registerAddon(addonId, endpoints);
1085
+ if (endpoints.length > 0) {
1086
+ this.logger.info("Addon data-planes mounted (reverse-proxy)", {
1087
+ meta: { phase: "v2", addonId, prefixes: endpoints.map((e) => e.prefix) },
1088
+ });
1089
+ }
1090
+ }
1091
+
867
1092
  /**
868
1093
  * Called after app.init() when the tRPC router is available.
869
1094
  * No-op now: addon `ctx.api` resolves to a broker-routed proxy, so
@@ -1118,8 +1343,8 @@ export class AddonRegistryService {
1118
1343
  // vs in-process being the transport, exactly like cap methods.
1119
1344
  await this.registerForkedAddonCustomActions(
1120
1345
  id,
1121
- // `shouldFork` already asserted `entry.declaration?.execution`.
1122
- resolveRunnerId(entry.declaration!, id),
1346
+ // `isForkedAddonEntry` narrowed `entry.declaration` to non-null.
1347
+ resolveRunnerId(entry.declaration, id),
1123
1348
  );
1124
1349
 
1125
1350
  entry.initialized = true;
@@ -1153,7 +1378,9 @@ export class AddonRegistryService {
1153
1378
  // ProviderRegistration.kind/defaultActive are deprecated hints — if an
1154
1379
  // addon still sets them and they disagree, warn (the cap def wins).
1155
1380
  const kindDrift = describeProviderKindDrift(capName, reg.capability, {
1381
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- this IS the drift detector; it reads the deprecated hint on purpose
1156
1382
  kind: reg.kind,
1383
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- this IS the drift detector; it reads the deprecated hint on purpose
1157
1384
  defaultActive: reg.defaultActive,
1158
1385
  });
1159
1386
  if (kindDrift) {
@@ -1650,18 +1877,28 @@ export class AddonRegistryService {
1650
1877
  }
1651
1878
 
1652
1879
  /**
1653
- * True when the entry boots in its own forked runner: it declares an
1654
- * `execution` block, ships an on-disk `addonDir`, and is not
1655
- * `agent-only`. Everything else (only `@camstack/core` builtins)
1656
- * boots in-process on the hub. The type predicate narrows `addonDir`
1657
- * to `string` for callers.
1880
+ * True when the entry boots in its own forked runner. This MUST mirror
1881
+ * the fork authority `buildAddonGroupPlan`, which spawns a runner for
1882
+ * every addon that ships an on-disk `addonDir` + a manifest
1883
+ * `declaration`, is NOT a `@camstack/core` builtin, and is NOT
1884
+ * `agent-only`. The `execution` block is OPTIONAL — an addon with no
1885
+ * `execution` declaration still forks on its own dedicated runner
1886
+ * (`resolveRunnerId` falls back to the addon id). Requiring `execution`
1887
+ * here previously diverged from `buildAddonGroupPlan`: an addon like
1888
+ * `auth-oidc` (no `execution` block) was forked at boot yet classified
1889
+ * in-process by this predicate, so its route-mount took the co-located
1890
+ * path and received the async UDS cap proxy whose `getRoutes()` returns
1891
+ * a Promise (→ `getRoutes(...).map is not a function`). Only
1892
+ * `@camstack/core` builtins boot in-process on the hub. The type
1893
+ * predicate narrows both `addonDir` and `declaration` for callers.
1658
1894
  */
1659
1895
  private isForkedAddonEntry(
1660
1896
  entry: AddonEntry,
1661
- ): entry is AddonEntry & { addonDir: string } {
1897
+ ): entry is AddonEntry & { addonDir: string; declaration: AddonDeclaration } {
1662
1898
  return !!(
1663
- entry.declaration?.execution &&
1899
+ entry.declaration &&
1664
1900
  entry.addonDir &&
1901
+ entry.packageName !== "@camstack/core" &&
1665
1902
  resolveAddonPlacement(entry.declaration) !== 'agent-only'
1666
1903
  );
1667
1904
  }
@@ -1735,7 +1972,14 @@ export class AddonRegistryService {
1735
1972
  }
1736
1973
  return {
1737
1974
  manifest: {
1738
- ...entry.addon.manifest!,
1975
+ // Overlay the fresh on-disk declaration (refreshed by
1976
+ // `loadNewAddons` on every `camstack deploy`) onto the live
1977
+ // instance manifest, so a redeployed addon's new capabilities /
1978
+ // brokerKind surface here without a full backend restart. See
1979
+ // `overlayDeclaration` — this is what keeps Home Assistant
1980
+ // (post broker rework: broker + device-adoption) visible in the
1981
+ // "+ New Integration" picker.
1982
+ ...overlayDeclaration(entry.addon.manifest!, entry.declaration),
1739
1983
  packageName: entry.packageName,
1740
1984
  packageVersion: entry.packageVersion,
1741
1985
  packageDisplayName: entry.packageDisplayName,
@@ -2275,6 +2519,16 @@ export class AddonRegistryService {
2275
2519
  default:
2276
2520
  break;
2277
2521
  }
2522
+
2523
+ // HTTP data-planes aren't capabilities, so no `case` fires for them —
2524
+ // pull them off ANY cap-changed signal for this (forked) addon. Cheap +
2525
+ // idempotent (replace-all), and re-pulls the fresh baseUrl/secret after a
2526
+ // restart (the child re-handshakes → caps re-register → this re-fires).
2527
+ void this.mountAddonDataPlanes(addonId).catch((err: unknown) => {
2528
+ this.logger.error('Failed to mount addon data-planes', {
2529
+ meta: { phase: 'v2', addonId, error: errMsg(err) },
2530
+ })
2531
+ })
2278
2532
  },
2279
2533
  );
2280
2534
  }
@@ -2282,53 +2536,118 @@ export class AddonRegistryService {
2282
2536
  /**
2283
2537
  * Mount the `addon-routes` provider for `addonId` into the
2284
2538
  * `AddonRouteRegistry`. Handles both co-located (in-process) and
2285
- * forked/group addons:
2539
+ * forked addons:
2286
2540
  *
2287
- * - For a forked addon the provider is a Moleculer proxy whose
2288
- * `getRoutes()` / `invoke()` return Promises and whose route
2289
- * descriptors have their `handler` functions stripped by JSON
2290
- * serialization. We `await getRoutes()`, synthesize bridge
2291
- * handlers that dispatch through `provider.invoke(...)`, and
2292
- * translate the captured envelope back onto the Fastify reply.
2293
- * - For a co-located addon `getRoutes()` resolves to the live route
2294
- * list with real handlers, so we register the provider directly.
2541
+ * - For a hub-local FORKED addon the route handlers live in the
2542
+ * child process and cannot cross the UDS wire (MsgPack can't encode
2543
+ * a function). We fetch the handler-stripped route descriptors over
2544
+ * UDS via `LocalChildRegistry.callAddonOnChild(addonId,
2545
+ * {target:'routes'})` (F3 replaces the removed per-addon Moleculer
2546
+ * `getRoutes` action), synthesize bridge handlers that dispatch
2547
+ * through the `addon-routes` cap's `invoke(...)` method (a normal
2548
+ * serializable cap call over UDS), and translate the captured
2549
+ * envelope back onto the Fastify reply.
2550
+ * - For a co-located (hub-resident) addon `getRoutes()` resolves to the
2551
+ * live route list with real handlers, so we register the provider
2552
+ * directly.
2295
2553
  */
2296
2554
  private async mountAddonRoutes(addonId: string): Promise<void> {
2297
- if (!this.capabilityRegistry) return;
2555
+ if (!this.capabilityRegistry || !this.addonRouteRegistry) return;
2556
+ const addonRouteRegistry = this.addonRouteRegistry;
2298
2557
  const routeProvider = this.capabilityRegistry.getProviderByAddon(
2299
2558
  "addon-routes",
2300
2559
  addonId,
2301
2560
  );
2302
- if (!routeProvider || !this.addonRouteRegistry) return;
2303
-
2304
- // `getRoutes()` is synchronous on a co-located provider but
2305
- // returns a Promise on the Moleculer proxy of a forked addon —
2306
- // `await` normalizes both. If any route is missing its `handler`
2307
- // we synthesize one that dispatches via `provider.invoke(...)`
2308
- // and translates the captured envelope back to the Fastify reply.
2309
- const liveRoutes = await routeProvider.getRoutes()
2310
- const handlersMissing = liveRoutes.some((r: { handler?: unknown }) => typeof r.handler !== 'function')
2311
- if (handlersMissing) {
2312
- const invokeFn = (routeProvider as { invoke?: (input: unknown) => Promise<unknown> }).invoke
2313
- if (typeof invokeFn !== 'function') {
2314
- this.logger.warn('Forked addon-routes provider missing `invoke` method — routes will not dispatch. Use `buildAddonRouteProvider()` from @camstack/types.', {
2315
- meta: { phase: 'v2', routeProviderId: routeProvider.id },
2316
- })
2561
+ if (!routeProvider) return;
2562
+
2563
+ // ── Forked addon: fetch handler-stripped routes over UDS (F3) ──────
2564
+ // EVERY non-`@camstack/core` addon forks (the fork authority is
2565
+ // `buildAddonGroupPlan`, which does NOT require an `execution` block an
2566
+ // addon like `auth-oidc` with no `execution` still runs in its own
2567
+ // runner). `isForkedAddonEntry` now mirrors that rule. For a forked addon
2568
+ // the `getProviderByAddon('addon-routes', …)` result is the async UDS cap
2569
+ // proxy whose `getRoutes()` returns a Promise registering it directly
2570
+ // would crash `AddonRouteRegistry.registerRoutes` (`getRoutes(...).map is
2571
+ // not a function`), and dispatching the proxy's `getRoutes` cap method
2572
+ // over UDS would try to MsgPack-serialize the child's live handler
2573
+ // functions (→ "Unrecognized object: [object AsyncFunction]"). Instead we
2574
+ // fetch handler-STRIPPED descriptors via `callAddonOnChild(target:
2575
+ // 'routes')` and bridge each through the `invoke` cap method. The UDS
2576
+ // childId for a hub-local single-addon runner equals the addonId
2577
+ // (`resolveRunnerId` — no shipped addon declares a group). Gate on
2578
+ // `isChildKnown` (the child completed its UDS handshake) rather than
2579
+ // `childProvides('addon-routes')`: the `addon-call` route fetch works as
2580
+ // long as the child is connected, and the child is added to the UDS
2581
+ // registry BEFORE its manifest fires the cap-changed event that triggers
2582
+ // this mount, so the gate is satisfied on the happy path.
2583
+ const entry = this.addonEntries.get(addonId);
2584
+ const childRegistry = this.moleculer.childRegistry;
2585
+ if (entry && this.isForkedAddonEntry(entry)) {
2586
+ if (childRegistry !== null && childRegistry.isChildKnown(addonId)) {
2587
+ await this.mountForkedAddonRoutes(addonId, routeProvider, addonRouteRegistry);
2588
+ return;
2317
2589
  }
2318
- // Wrap each route with a bridge handler so the
2319
- // AddonRouteRegistry can dispatch through the worker.
2320
- const bridgeRoutes: import('@camstack/types').IAddonHttpRoute[] = (liveRoutes as ReadonlyArray<{ method: string; path: string; access?: string; description?: string }>).map((route) => ({
2321
- method: route.method as 'GET' | 'POST' | 'PUT' | 'DELETE',
2590
+ // Child not yet connected over UDS: defer rather than register the async
2591
+ // proxy directly (which would crash on `.map`). The `addon-routes`
2592
+ // cap-changed event re-fires once the child finishes its handshake.
2593
+ this.logger.warn('Deferring forked addon route mount child not yet UDS-reachable', {
2594
+ meta: { phase: 'v2', addonId },
2595
+ });
2596
+ return;
2597
+ }
2598
+
2599
+ // ── Co-located addon (`@camstack/core` builtin): live handlers, register
2600
+ // the provider directly. The route handlers run in-process against the
2601
+ // real Fastify reply, so no wire bridge is needed.
2602
+ addonRouteRegistry.registerRoutes(routeProvider.id, routeProvider);
2603
+ this.logger.info('Addon routes mounted', {
2604
+ meta: { phase: 'v2', routeProviderId: routeProvider.id },
2605
+ });
2606
+ }
2607
+
2608
+ /**
2609
+ * Mount a hub-local FORKED addon's HTTP routes (F3). The route handlers live
2610
+ * in the child process; only handler-stripped descriptors cross the UDS wire.
2611
+ * We:
2612
+ * 1. fetch the descriptors via `callAddonOnChild(addonId, {target:'routes'})`,
2613
+ * 2. synthesize a bridge handler per route that dispatches the captured
2614
+ * request through the `addon-routes` cap's `invoke(...)` method (a normal
2615
+ * serializable UDS cap call on `routeProvider`), and
2616
+ * 3. translate the returned reply envelope back onto the Fastify reply.
2617
+ *
2618
+ * `addonRouteRegistry` is narrowed non-null by the caller's guard in
2619
+ * `mountAddonRoutes` — no assertion needed here.
2620
+ */
2621
+ private async mountForkedAddonRoutes(
2622
+ addonId: string,
2623
+ routeProvider: import('@camstack/types').IAddonRouteProvider,
2624
+ addonRouteRegistry: AddonRouteRegistry,
2625
+ ): Promise<void> {
2626
+ // The cap-registry typed surface for `addon-routes` is the operator-facing
2627
+ // `IAddonRouteProvider` (id + getRoutes). The bridge dispatch contract
2628
+ // `invoke(...)` is present on the UDS proxy but not on that interface —
2629
+ // narrow it via a structural type guard (no cast).
2630
+ if (!isAddonRoutesInvoker(routeProvider)) {
2631
+ this.logger.warn(
2632
+ 'Forked addon-routes provider missing `invoke` method — routes will not dispatch. Use `buildAddonRouteProvider()` from @camstack/types.',
2633
+ { meta: { phase: 'v2', routeProviderId: routeProvider.id } },
2634
+ );
2635
+ return;
2636
+ }
2637
+ const invoker = routeProvider;
2638
+
2639
+ const rawRoutes = await this.addonCallGateway.callForked(addonId, {
2640
+ target: 'routes',
2641
+ });
2642
+ const descriptors = parseSerializableRouteDescriptors(rawRoutes);
2643
+ const bridgeRoutes: import('@camstack/types').IAddonHttpRoute[] = descriptors.map(
2644
+ (route) => ({
2645
+ method: route.method,
2322
2646
  path: route.path,
2323
- access: (route.access ?? 'public') as 'public' | 'authenticated' | 'admin',
2647
+ access: route.access,
2324
2648
  ...(route.description !== undefined ? { description: route.description } : {}),
2325
- handler: async (req: { params: Record<string, string>; query: Record<string, string>; body: unknown; headers: Record<string, string>; user?: { id: string; username: string; isAdmin: boolean }; scopedToken?: unknown }, reply: { code: (n: number) => unknown; header: (k: string, v: string) => unknown; type: (m: string) => unknown; send: (data: unknown) => void; redirect: (url: string) => void }) => {
2326
- if (typeof invokeFn !== 'function') {
2327
- reply.code(500)
2328
- reply.send({ error: 'Forked addon-routes provider missing invoke' })
2329
- return
2330
- }
2331
- const envelope = await invokeFn({
2649
+ handler: async (req, reply) => {
2650
+ const envelope = await invoker.invoke({
2332
2651
  method: route.method,
2333
2652
  path: route.path,
2334
2653
  params: req.params,
@@ -2336,31 +2655,27 @@ export class AddonRegistryService {
2336
2655
  body: req.body,
2337
2656
  headers: req.headers,
2338
2657
  ...(req.user ? { user: req.user } : {}),
2339
- ...(req.scopedToken ? { scopedToken: req.scopedToken } : {}),
2340
- }) as { status: number; headers: Record<string, string>; redirectUrl: string | null; body?: unknown; contentType?: string }
2341
- reply.code(envelope.status)
2342
- if (envelope.contentType) reply.type(envelope.contentType)
2343
- for (const [k, v] of Object.entries(envelope.headers)) reply.header(k, v)
2658
+ ...(req.scopedToken !== undefined ? { scopedToken: req.scopedToken } : {}),
2659
+ });
2660
+ reply.code(envelope.status);
2661
+ if (envelope.contentType) reply.type(envelope.contentType);
2662
+ for (const [k, v] of Object.entries(envelope.headers)) reply.header(k, v);
2344
2663
  if (envelope.redirectUrl !== null) {
2345
- reply.header('Location', envelope.redirectUrl)
2346
- reply.send('')
2664
+ reply.header('Location', envelope.redirectUrl);
2665
+ reply.send('');
2347
2666
  } else {
2348
- reply.send(envelope.body)
2667
+ reply.send(envelope.body);
2349
2668
  }
2350
2669
  },
2351
- }))
2352
- this.addonRouteRegistry.registerRoutes(
2353
- routeProvider.id,
2354
- { id: routeProvider.id, getRoutes: () => bridgeRoutes },
2355
- )
2356
- this.logger.info('Addon routes mounted (forked-bridge)', { meta: { phase: 'v2', routeProviderId: routeProvider.id, routes: bridgeRoutes.length } })
2357
- } else {
2358
- this.addonRouteRegistry.registerRoutes(
2359
- routeProvider.id,
2360
- routeProvider,
2361
- );
2362
- this.logger.info('Addon routes mounted', { meta: { phase: 'v2', routeProviderId: routeProvider.id } });
2363
- }
2670
+ }),
2671
+ );
2672
+ addonRouteRegistry.registerRoutes(routeProvider.id, {
2673
+ id: routeProvider.id,
2674
+ getRoutes: () => bridgeRoutes,
2675
+ });
2676
+ this.logger.info('Addon routes mounted (forked-bridge over UDS)', {
2677
+ meta: { phase: 'v2', routeProviderId: routeProvider.id, routes: bridgeRoutes.length },
2678
+ });
2364
2679
  }
2365
2680
 
2366
2681
  // Cleanup: `addonHasConfigFields` deleted. It was the last reader of
@@ -2634,12 +2949,22 @@ export class AddonRegistryService {
2634
2949
  streamProbe: kernelStreamProbe,
2635
2950
  hwaccel: createKernelHwAccel(),
2636
2951
  capabilityRegistry: this.capabilityRegistry,
2952
+ // Per-addon storage-location declarations across every installed
2953
+ // addon — surfaced from the kernel's AddonLoader so the
2954
+ // storage-orchestrator builtin can aggregate them and seed defaults.
2955
+ listStorageLocationDeclarations: () =>
2956
+ this.addonLoader.listStorageLocationDeclarations(),
2637
2957
  readinessRegistry: this.moleculer.readinessRegistry,
2638
2958
  // D3: handshake-fed native-cap view of the whole cluster. Backed by
2639
2959
  // `HubNodeRegistry.listNativeCapEntries()` populated by every
2640
2960
  // `$hub.registerNode` re-handshake. Used by device-manager as the
2641
2961
  // reliable fallback when push events were lost mid-transport.
2642
2962
  listClusterNativeCaps: () => this.moleculer.listClusterNativeCaps(),
2963
+ // Per-device slice of the above — O(caps-for-device) via the registry's
2964
+ // deviceId index. The per-device `getBindings` resolver prefers this
2965
+ // over filtering the whole-cluster flat view.
2966
+ listClusterNativeCapsForDevice: (deviceId: number) =>
2967
+ this.moleculer.listClusterNativeCapsForDevice(deviceId),
2643
2968
  },
2644
2969
  registerProvider,
2645
2970
  resolveProvider: <T = unknown>(capName: string): T | null => {
@@ -2838,8 +3163,11 @@ export class AddonRegistryService {
2838
3163
  *
2839
3164
  * The catalog (zod `input`/`output` specs) is read STATICALLY from the
2840
3165
  * addon module's `customActions` named export — the handler dispatches
2841
- * via `broker.call('<addonId>.custom.<action>')`, so the only divergence
2842
- * vs an in-process addon is the transport, exactly like cap methods.
3166
+ * over UDS via `LocalChildRegistry.callAddonOnChild(addonId,
3167
+ * {target:'custom', action, args})` (F3 replaces the removed per-addon
3168
+ * Moleculer `custom.<action>` action), so the only divergence vs an
3169
+ * in-process addon is the transport, exactly like cap methods. The hub's
3170
+ * `CustomActionRegistry` validates input/output around this dispatch.
2843
3171
  *
2844
3172
  * Why a fresh import: `this.addonLoader`'s `module` namespace is
2845
3173
  * captured once at boot. After a hot-update (`installFromTgz` →
@@ -2916,11 +3244,29 @@ export class AddonRegistryService {
2916
3244
  this.customActionRegistry.registerAddon(
2917
3245
  addonId,
2918
3246
  catalog as import("@camstack/types").CustomActionsSpec,
2919
- (action, input) => this.broker.call(`${addonId}.custom.${action}`, input),
3247
+ (action, input) => this.dispatchForkedCustomAction(addonId, action, input),
2920
3248
  );
2921
3249
  this.logger.info("Runner addon custom actions registered", {
2922
3250
  tags: { addonId },
2923
3251
  meta: { runnerId },
2924
3252
  });
2925
3253
  }
3254
+
3255
+ /**
3256
+ * Dispatch a forked addon's custom action through the shared
3257
+ * {@link AddonCallGateway} (UDS to the hub-local child). The gateway owns the
3258
+ * routing + the child-availability error; there is no broker fallback after
3259
+ * the per-addon Moleculer broker was removed.
3260
+ */
3261
+ private async dispatchForkedCustomAction(
3262
+ addonId: string,
3263
+ action: string,
3264
+ input: unknown,
3265
+ ): Promise<unknown> {
3266
+ return this.addonCallGateway.callForked(addonId, {
3267
+ target: "custom",
3268
+ action,
3269
+ args: input,
3270
+ });
3271
+ }
2926
3272
  }