@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
@@ -66,6 +66,7 @@ import * as path from "node:path";
66
66
  import * as fs from "node:fs";
67
67
  import { pathToFileURL } from "node:url";
68
68
  import { createAddonSettingsProvider } from "./addon-settings-provider.js";
69
+ import { AddonCallGateway } from "./addon-call-gateway.js";
69
70
  import { addonSettingsCapability } from "@camstack/types";
70
71
  import { DisposerChain } from "@camstack/types";
71
72
 
@@ -111,6 +112,87 @@ function isSettingsStore(
111
112
  return true;
112
113
  }
113
114
 
115
+ /**
116
+ * The bridge-dispatch contract on an `addon-routes` provider. The operator-
117
+ * facing `IAddonRouteProvider` interface declares only `id` + `getRoutes`; the
118
+ * UDS proxy (and `buildAddonRouteProvider`) also expose `invoke`, which the
119
+ * forked-routes bridge calls per request. Narrowed via `isAddonRoutesInvoker`.
120
+ */
121
+ interface AddonRoutesInvoker {
122
+ invoke(
123
+ input: import("@camstack/types").AddonRouteInvokeRequest,
124
+ ): Promise<import("@camstack/types").AddonRouteReplyEnvelope>;
125
+ }
126
+
127
+ /** Structural guard: true when an `addon-routes` provider also exposes `invoke`. */
128
+ function isAddonRoutesInvoker<T extends object>(
129
+ provider: T,
130
+ ): provider is T & AddonRoutesInvoker {
131
+ return typeof Reflect.get(provider, "invoke") === "function";
132
+ }
133
+
134
+ const ROUTE_METHODS: readonly import("@camstack/types").IAddonHttpRoute["method"][] = [
135
+ "GET",
136
+ "POST",
137
+ "PUT",
138
+ "DELETE",
139
+ ];
140
+ const ROUTE_ACCESS: readonly import("@camstack/types").RouteAccess[] = [
141
+ "public",
142
+ "authenticated",
143
+ "admin",
144
+ ];
145
+
146
+ function asRouteMethod(value: string): import("@camstack/types").IAddonHttpRoute["method"] {
147
+ const upper = value.toUpperCase();
148
+ for (const m of ROUTE_METHODS) if (m === upper) return m;
149
+ throw new Error(`addon-routes: unsupported HTTP method "${value}"`);
150
+ }
151
+
152
+ function asRouteAccess(value: unknown): import("@camstack/types").RouteAccess {
153
+ if (typeof value === "string") {
154
+ for (const a of ROUTE_ACCESS) if (a === value) return a;
155
+ }
156
+ // Default to the most restrictive sensible default the original mount used.
157
+ return "public";
158
+ }
159
+
160
+ /**
161
+ * Parse the wire-boundary `unknown` returned by `callAddonOnChild(...,
162
+ * {target:'routes'})` into typed route descriptors. The child sends route
163
+ * descriptors WITHOUT handlers (functions don't cross MsgPack); only
164
+ * `method`/`path`/`access`/`description` are present.
165
+ */
166
+ interface ParsedRouteDescriptor {
167
+ readonly method: import("@camstack/types").IAddonHttpRoute["method"];
168
+ readonly path: string;
169
+ readonly access: import("@camstack/types").RouteAccess;
170
+ readonly description?: string;
171
+ }
172
+
173
+ function parseSerializableRouteDescriptors(raw: unknown): readonly ParsedRouteDescriptor[] {
174
+ if (!Array.isArray(raw)) {
175
+ throw new Error("addon-routes: child returned a non-array route descriptor set");
176
+ }
177
+ return raw.map((entry: unknown): ParsedRouteDescriptor => {
178
+ if (entry === null || typeof entry !== "object") {
179
+ throw new Error("addon-routes: route descriptor is not an object");
180
+ }
181
+ const method = Reflect.get(entry, "method");
182
+ const path = Reflect.get(entry, "path");
183
+ if (typeof method !== "string" || typeof path !== "string") {
184
+ throw new Error("addon-routes: route descriptor missing method/path");
185
+ }
186
+ const description = Reflect.get(entry, "description");
187
+ return {
188
+ method: asRouteMethod(method),
189
+ path,
190
+ access: asRouteAccess(Reflect.get(entry, "access")),
191
+ ...(typeof description === "string" ? { description } : {}),
192
+ };
193
+ });
194
+ }
195
+
114
196
  interface AddonEntry {
115
197
  readonly addon: ICamstackAddon;
116
198
  initialized: boolean;
@@ -140,6 +222,8 @@ interface AddonEntry {
140
222
  export class AddonRegistryService {
141
223
  private readonly addonEntries = new Map<string, AddonEntry>();
142
224
  private readonly capabilityRegistry: CapabilityRegistry;
225
+ /** Single router for addon-level calls (routes / custom / settings). */
226
+ private addonCallGateway!: AddonCallGateway;
143
227
  // Task 7.1: hub-wide registry of addon custom actions. Populated on
144
228
  // each addon's initialize() (in-process path); Task 7.2 will dispatch
145
229
  // through this from the `api.addons.custom` tRPC procedure.
@@ -272,6 +356,24 @@ export class AddonRegistryService {
272
356
  },
273
357
  );
274
358
 
359
+ this.capabilityRegistry.setNodeConfigReader(
360
+ (capability: string, nodeId: string): string | undefined => {
361
+ try {
362
+ return (
363
+ this.configService.get<string>(
364
+ `capabilities.singletonNode.${capability}.${nodeId}`,
365
+ ) ?? undefined
366
+ );
367
+ } catch (err) {
368
+ this.logger.debug(
369
+ 'settings-store not wired yet during early boot',
370
+ { meta: { capability, nodeId, error: errMsg(err) } },
371
+ );
372
+ return undefined;
373
+ }
374
+ },
375
+ );
376
+
275
377
  // Collection-provider enable/disable persistence. Reads the same
276
378
  // `capabilities.collection.<cap>` ConfigService key the `capabilities`
277
379
  // router writes (`JSON.stringify({ disabled: string[] })`), so a
@@ -341,29 +443,33 @@ export class AddonRegistryService {
341
443
  // former `$addonHost` Moleculer service. The provider resolves
342
444
  // addonId → local addon instance (hub) or remote Moleculer call.
343
445
  this.capabilityRegistry.declareCapability(addonSettingsCapability);
344
- const settingsProvider = createAddonSettingsProvider({
345
- getAddon: (addonId) => {
346
- const entry = this.addonEntries.get(addonId);
347
- return entry?.addon ?? null;
348
- },
446
+ // The SINGLE router for addon-level calls (routes / custom / settings).
447
+ // It classifies in-process | hub-local-child (UDS) | remote-agent
448
+ // (Moleculer) in ONE place — `resolveNode` reports only the BASE node
449
+ // (the hub for every hub-local addon, forked or not); the gateway's
450
+ // `isChildKnown` distinguishes a forked UDS child from an in-process
451
+ // builtin. This is the fix for forked-addon settings: they were forced
452
+ // down a dead `<addonId>.settings.*` Moleculer path because the old
453
+ // `resolveNode` marked them non-local; now they route over UDS like
454
+ // their routes + custom-actions already do.
455
+ this.addonCallGateway = new AddonCallGateway({
456
+ hubNodeId: this.broker.nodeID,
349
457
  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
458
  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";
459
+ // Every addon in `addonEntries` is hub-local — report the hub node;
460
+ // forked-vs-in-process is decided by `isChildKnown` in the gateway.
461
+ return entry ? this.broker.nodeID : "hub";
363
462
  },
463
+ getChildRegistry: () => this.moleculer.childRegistry,
364
464
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- moleculer types unresolvable; see `broker` getter docstring
365
465
  broker: this.moleculer.broker,
366
- hubNodeId: this.broker.nodeID,
466
+ });
467
+ const settingsProvider = createAddonSettingsProvider({
468
+ getAddon: (addonId) => {
469
+ const entry = this.addonEntries.get(addonId);
470
+ return entry?.addon ?? null;
471
+ },
472
+ gateway: this.addonCallGateway,
367
473
  });
368
474
  this.capabilityRegistry.registerProvider(
369
475
  "addon-settings",
@@ -487,38 +593,97 @@ export class AddonRegistryService {
487
593
 
488
594
  // Native-cap cross-process bridge: when a hub consumer resolves a native
489
595
  // 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.
596
+ // return a proxy that routes calls to the correct transport.
492
597
  //
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.
598
+ // G2 single ownership authority: the CapRouteResolver is consulted FIRST
599
+ // for hub-local native caps. If the resolver's snapshot identifies a
600
+ // hub-local-uds route for (capName, deviceId), we build the proxy directly
601
+ // no `resolveNativeCapOwnerSync` consult needed for this branch. The resolver's
602
+ // `hubLocalChildProvides(capName, deviceId)` is the canonical hub-local authority
603
+ // (fed by the same LocalChildRegistry that owns UDS dispatch), so the old
604
+ // `owner.nodeId.startsWith(hubNodeId + '/')` gate is replaced by the resolver's
605
+ // own verdict.
499
606
  //
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.
607
+ // `resolveNativeCapOwnerSync` is only consulted for the REMOTE branch: when the
608
+ // resolver throws no-provider for the hub node (no hub-local-uds child owns the cap),
609
+ // the fallback queries `resolveNativeCapOwnerSync` to find a remote owner. That
610
+ // method carries data (push-fed `remoteNativeCaps` from `DeviceBindingsChanged`
611
+ // events) that the resolver's `NodeCapAuthority` doesn't see, so it stays as the
612
+ // remote-branch source. If `resolveNativeCapOwnerSync` also returns null, the cap
613
+ // genuinely has no cross-process provider and the fallback returns null — wrappers
614
+ // cleanly fall through to their own secondary strategy.
507
615
  {
508
616
  const { buildNativeCapProxy } = await import("@camstack/kernel");
509
617
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- moleculer types unresolvable; see `broker` getter docstring
510
618
  const broker = this.moleculer.broker;
511
619
  this.capabilityRegistry.setNativeFallback(
512
620
  (capName: string, deviceId: number): unknown | null => {
621
+ const resolver = this.moleculer.capRouteResolver;
622
+ const hubNodeId = this.moleculer.nodeId;
623
+
624
+ // 1. Hub-local path: the resolver's hub-local-child authority is the
625
+ // single source of truth for whether a forked hub-local UDS child
626
+ // owns (capName, deviceId) — via hubLocalChildProvides +
627
+ // getHubLocalChildId (both deviceId-aware, M1).
628
+ //
629
+ // We deliberately use `resolveHubLocalUdsRoute`, NOT
630
+ // `resolveCapRoute`: this fallback's contract is to reach the
631
+ // NATIVE provider in the forked vendor child. `resolveCapRoute`
632
+ // gives Priority 1 to `hub-in-process`, so a cap that ALSO has an
633
+ // in-hub provider for the same name (a wrapper — today `snapshot`)
634
+ // would classify as `hub-in-process` (the wrapper) and never reach
635
+ // the hub-local-uds native. That made the wrapper's
636
+ // `getNativeProvider` return null and silently fall through to its
637
+ // secondary strategy (e.g. snapshot's ffmpeg frame grab), so the
638
+ // vendor native snapshot was never invoked. `resolveHubLocalUdsRoute`
639
+ // skips the in-process branch and returns the native child route.
640
+ if (resolver !== null) {
641
+ const route = resolver.resolveHubLocalUdsRoute(capName, deviceId)
642
+ if (route !== null) {
643
+ const childId = route.childId
644
+ const target: Record<string, (input: unknown) => Promise<unknown>> = {}
645
+ return new Proxy(target, {
646
+ get(_target, property): ((input: unknown) => Promise<unknown>) | undefined {
647
+ if (typeof property !== 'string') return undefined
648
+ return (input: unknown): Promise<unknown> => {
649
+ const mergedInput =
650
+ typeof input === 'object' && input !== null
651
+ ? { ...input, deviceId }
652
+ : { deviceId }
653
+ // Re-resolve at call time so a child that respawned under a
654
+ // new runner id is still reachable (route is cheap to build).
655
+ const r = resolver.resolveHubLocalUdsRoute(capName, deviceId)
656
+ ?? { kind: 'hub-local-uds' as const, capName, childId }
657
+ return resolver.dispatch(r, property, mergedInput)
658
+ }
659
+ },
660
+ })
661
+ }
662
+ // No hub-local child owns (capName, deviceId). Fall through to the
663
+ // remote branch — resolveNativeCapOwnerSync is still consulted there
664
+ // and may find a remote owner for this (capName, deviceId).
665
+ }
666
+
667
+ // 2. Remote path: resolver has no hub-local route for this (capName, deviceId).
668
+ // Consult resolveNativeCapOwnerSync — it includes push-fed remoteNativeCaps
669
+ // (DeviceBindingsChanged events from forked workers) that the resolver's
670
+ // NodeCapAuthority (backed by HubNodeRegistry) doesn't carry.
513
671
  const dm = this.capabilityRegistry.getSingleton<{
514
672
  resolveNativeCapOwnerSync?: (
515
673
  capName: string,
516
674
  deviceId: number,
517
675
  ) => { addonId: string; nodeId: string } | null;
518
676
  }>("device-manager");
519
- const owner =
520
- dm?.resolveNativeCapOwnerSync?.(capName, deviceId) ?? null;
677
+ const owner = dm?.resolveNativeCapOwnerSync?.(capName, deviceId) ?? null;
521
678
  if (!owner) return null;
679
+
680
+ // Guard: skip hub-local owners here — they were already handled (or skipped
681
+ // because the resolver was null / not initialised yet). Returning null avoids
682
+ // building a Moleculer proxy that points at a UDS-only child.
683
+ if (owner.nodeId.startsWith(`${hubNodeId}/`)) return null;
684
+
685
+ // Remote native cap → Moleculer with native-provider infix.
686
+ // buildNativeCapProxy uses the action `${addonId}.native-provider.${cap}.${method}`.
522
687
  return buildNativeCapProxy(broker, owner.addonId, capName, deviceId);
523
688
  },
524
689
  );
@@ -1118,8 +1283,8 @@ export class AddonRegistryService {
1118
1283
  // vs in-process being the transport, exactly like cap methods.
1119
1284
  await this.registerForkedAddonCustomActions(
1120
1285
  id,
1121
- // `shouldFork` already asserted `entry.declaration?.execution`.
1122
- resolveRunnerId(entry.declaration!, id),
1286
+ // `isForkedAddonEntry` narrowed `entry.declaration` to non-null.
1287
+ resolveRunnerId(entry.declaration, id),
1123
1288
  );
1124
1289
 
1125
1290
  entry.initialized = true;
@@ -1153,7 +1318,9 @@ export class AddonRegistryService {
1153
1318
  // ProviderRegistration.kind/defaultActive are deprecated hints — if an
1154
1319
  // addon still sets them and they disagree, warn (the cap def wins).
1155
1320
  const kindDrift = describeProviderKindDrift(capName, reg.capability, {
1321
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- this IS the drift detector; it reads the deprecated hint on purpose
1156
1322
  kind: reg.kind,
1323
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- this IS the drift detector; it reads the deprecated hint on purpose
1157
1324
  defaultActive: reg.defaultActive,
1158
1325
  });
1159
1326
  if (kindDrift) {
@@ -1650,18 +1817,28 @@ export class AddonRegistryService {
1650
1817
  }
1651
1818
 
1652
1819
  /**
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.
1820
+ * True when the entry boots in its own forked runner. This MUST mirror
1821
+ * the fork authority `buildAddonGroupPlan`, which spawns a runner for
1822
+ * every addon that ships an on-disk `addonDir` + a manifest
1823
+ * `declaration`, is NOT a `@camstack/core` builtin, and is NOT
1824
+ * `agent-only`. The `execution` block is OPTIONAL — an addon with no
1825
+ * `execution` declaration still forks on its own dedicated runner
1826
+ * (`resolveRunnerId` falls back to the addon id). Requiring `execution`
1827
+ * here previously diverged from `buildAddonGroupPlan`: an addon like
1828
+ * `auth-oidc` (no `execution` block) was forked at boot yet classified
1829
+ * in-process by this predicate, so its route-mount took the co-located
1830
+ * path and received the async UDS cap proxy whose `getRoutes()` returns
1831
+ * a Promise (→ `getRoutes(...).map is not a function`). Only
1832
+ * `@camstack/core` builtins boot in-process on the hub. The type
1833
+ * predicate narrows both `addonDir` and `declaration` for callers.
1658
1834
  */
1659
1835
  private isForkedAddonEntry(
1660
1836
  entry: AddonEntry,
1661
- ): entry is AddonEntry & { addonDir: string } {
1837
+ ): entry is AddonEntry & { addonDir: string; declaration: AddonDeclaration } {
1662
1838
  return !!(
1663
- entry.declaration?.execution &&
1839
+ entry.declaration &&
1664
1840
  entry.addonDir &&
1841
+ entry.packageName !== "@camstack/core" &&
1665
1842
  resolveAddonPlacement(entry.declaration) !== 'agent-only'
1666
1843
  );
1667
1844
  }
@@ -2282,53 +2459,118 @@ export class AddonRegistryService {
2282
2459
  /**
2283
2460
  * Mount the `addon-routes` provider for `addonId` into the
2284
2461
  * `AddonRouteRegistry`. Handles both co-located (in-process) and
2285
- * forked/group addons:
2462
+ * forked addons:
2286
2463
  *
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.
2464
+ * - For a hub-local FORKED addon the route handlers live in the
2465
+ * child process and cannot cross the UDS wire (MsgPack can't encode
2466
+ * a function). We fetch the handler-stripped route descriptors over
2467
+ * UDS via `LocalChildRegistry.callAddonOnChild(addonId,
2468
+ * {target:'routes'})` (F3 replaces the removed per-addon Moleculer
2469
+ * `getRoutes` action), synthesize bridge handlers that dispatch
2470
+ * through the `addon-routes` cap's `invoke(...)` method (a normal
2471
+ * serializable cap call over UDS), and translate the captured
2472
+ * envelope back onto the Fastify reply.
2473
+ * - For a co-located (hub-resident) addon `getRoutes()` resolves to the
2474
+ * live route list with real handlers, so we register the provider
2475
+ * directly.
2295
2476
  */
2296
2477
  private async mountAddonRoutes(addonId: string): Promise<void> {
2297
- if (!this.capabilityRegistry) return;
2478
+ if (!this.capabilityRegistry || !this.addonRouteRegistry) return;
2479
+ const addonRouteRegistry = this.addonRouteRegistry;
2298
2480
  const routeProvider = this.capabilityRegistry.getProviderByAddon(
2299
2481
  "addon-routes",
2300
2482
  addonId,
2301
2483
  );
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
- })
2484
+ if (!routeProvider) return;
2485
+
2486
+ // ── Forked addon: fetch handler-stripped routes over UDS (F3) ──────
2487
+ // EVERY non-`@camstack/core` addon forks (the fork authority is
2488
+ // `buildAddonGroupPlan`, which does NOT require an `execution` block an
2489
+ // addon like `auth-oidc` with no `execution` still runs in its own
2490
+ // runner). `isForkedAddonEntry` now mirrors that rule. For a forked addon
2491
+ // the `getProviderByAddon('addon-routes', …)` result is the async UDS cap
2492
+ // proxy whose `getRoutes()` returns a Promise registering it directly
2493
+ // would crash `AddonRouteRegistry.registerRoutes` (`getRoutes(...).map is
2494
+ // not a function`), and dispatching the proxy's `getRoutes` cap method
2495
+ // over UDS would try to MsgPack-serialize the child's live handler
2496
+ // functions (→ "Unrecognized object: [object AsyncFunction]"). Instead we
2497
+ // fetch handler-STRIPPED descriptors via `callAddonOnChild(target:
2498
+ // 'routes')` and bridge each through the `invoke` cap method. The UDS
2499
+ // childId for a hub-local single-addon runner equals the addonId
2500
+ // (`resolveRunnerId` — no shipped addon declares a group). Gate on
2501
+ // `isChildKnown` (the child completed its UDS handshake) rather than
2502
+ // `childProvides('addon-routes')`: the `addon-call` route fetch works as
2503
+ // long as the child is connected, and the child is added to the UDS
2504
+ // registry BEFORE its manifest fires the cap-changed event that triggers
2505
+ // this mount, so the gate is satisfied on the happy path.
2506
+ const entry = this.addonEntries.get(addonId);
2507
+ const childRegistry = this.moleculer.childRegistry;
2508
+ if (entry && this.isForkedAddonEntry(entry)) {
2509
+ if (childRegistry !== null && childRegistry.isChildKnown(addonId)) {
2510
+ await this.mountForkedAddonRoutes(addonId, routeProvider, addonRouteRegistry);
2511
+ return;
2317
2512
  }
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',
2513
+ // Child not yet connected over UDS: defer rather than register the async
2514
+ // proxy directly (which would crash on `.map`). The `addon-routes`
2515
+ // cap-changed event re-fires once the child finishes its handshake.
2516
+ this.logger.warn('Deferring forked addon route mount child not yet UDS-reachable', {
2517
+ meta: { phase: 'v2', addonId },
2518
+ });
2519
+ return;
2520
+ }
2521
+
2522
+ // ── Co-located addon (`@camstack/core` builtin): live handlers, register
2523
+ // the provider directly. The route handlers run in-process against the
2524
+ // real Fastify reply, so no wire bridge is needed.
2525
+ addonRouteRegistry.registerRoutes(routeProvider.id, routeProvider);
2526
+ this.logger.info('Addon routes mounted', {
2527
+ meta: { phase: 'v2', routeProviderId: routeProvider.id },
2528
+ });
2529
+ }
2530
+
2531
+ /**
2532
+ * Mount a hub-local FORKED addon's HTTP routes (F3). The route handlers live
2533
+ * in the child process; only handler-stripped descriptors cross the UDS wire.
2534
+ * We:
2535
+ * 1. fetch the descriptors via `callAddonOnChild(addonId, {target:'routes'})`,
2536
+ * 2. synthesize a bridge handler per route that dispatches the captured
2537
+ * request through the `addon-routes` cap's `invoke(...)` method (a normal
2538
+ * serializable UDS cap call on `routeProvider`), and
2539
+ * 3. translate the returned reply envelope back onto the Fastify reply.
2540
+ *
2541
+ * `addonRouteRegistry` is narrowed non-null by the caller's guard in
2542
+ * `mountAddonRoutes` — no assertion needed here.
2543
+ */
2544
+ private async mountForkedAddonRoutes(
2545
+ addonId: string,
2546
+ routeProvider: import('@camstack/types').IAddonRouteProvider,
2547
+ addonRouteRegistry: AddonRouteRegistry,
2548
+ ): Promise<void> {
2549
+ // The cap-registry typed surface for `addon-routes` is the operator-facing
2550
+ // `IAddonRouteProvider` (id + getRoutes). The bridge dispatch contract
2551
+ // `invoke(...)` is present on the UDS proxy but not on that interface —
2552
+ // narrow it via a structural type guard (no cast).
2553
+ if (!isAddonRoutesInvoker(routeProvider)) {
2554
+ this.logger.warn(
2555
+ 'Forked addon-routes provider missing `invoke` method — routes will not dispatch. Use `buildAddonRouteProvider()` from @camstack/types.',
2556
+ { meta: { phase: 'v2', routeProviderId: routeProvider.id } },
2557
+ );
2558
+ return;
2559
+ }
2560
+ const invoker = routeProvider;
2561
+
2562
+ const rawRoutes = await this.addonCallGateway.callForked(addonId, {
2563
+ target: 'routes',
2564
+ });
2565
+ const descriptors = parseSerializableRouteDescriptors(rawRoutes);
2566
+ const bridgeRoutes: import('@camstack/types').IAddonHttpRoute[] = descriptors.map(
2567
+ (route) => ({
2568
+ method: route.method,
2322
2569
  path: route.path,
2323
- access: (route.access ?? 'public') as 'public' | 'authenticated' | 'admin',
2570
+ access: route.access,
2324
2571
  ...(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({
2572
+ handler: async (req, reply) => {
2573
+ const envelope = await invoker.invoke({
2332
2574
  method: route.method,
2333
2575
  path: route.path,
2334
2576
  params: req.params,
@@ -2336,31 +2578,27 @@ export class AddonRegistryService {
2336
2578
  body: req.body,
2337
2579
  headers: req.headers,
2338
2580
  ...(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)
2581
+ ...(req.scopedToken !== undefined ? { scopedToken: req.scopedToken } : {}),
2582
+ });
2583
+ reply.code(envelope.status);
2584
+ if (envelope.contentType) reply.type(envelope.contentType);
2585
+ for (const [k, v] of Object.entries(envelope.headers)) reply.header(k, v);
2344
2586
  if (envelope.redirectUrl !== null) {
2345
- reply.header('Location', envelope.redirectUrl)
2346
- reply.send('')
2587
+ reply.header('Location', envelope.redirectUrl);
2588
+ reply.send('');
2347
2589
  } else {
2348
- reply.send(envelope.body)
2590
+ reply.send(envelope.body);
2349
2591
  }
2350
2592
  },
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
- }
2593
+ }),
2594
+ );
2595
+ addonRouteRegistry.registerRoutes(routeProvider.id, {
2596
+ id: routeProvider.id,
2597
+ getRoutes: () => bridgeRoutes,
2598
+ });
2599
+ this.logger.info('Addon routes mounted (forked-bridge over UDS)', {
2600
+ meta: { phase: 'v2', routeProviderId: routeProvider.id, routes: bridgeRoutes.length },
2601
+ });
2364
2602
  }
2365
2603
 
2366
2604
  // Cleanup: `addonHasConfigFields` deleted. It was the last reader of
@@ -2838,8 +3076,11 @@ export class AddonRegistryService {
2838
3076
  *
2839
3077
  * The catalog (zod `input`/`output` specs) is read STATICALLY from the
2840
3078
  * 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.
3079
+ * over UDS via `LocalChildRegistry.callAddonOnChild(addonId,
3080
+ * {target:'custom', action, args})` (F3 replaces the removed per-addon
3081
+ * Moleculer `custom.<action>` action), so the only divergence vs an
3082
+ * in-process addon is the transport, exactly like cap methods. The hub's
3083
+ * `CustomActionRegistry` validates input/output around this dispatch.
2843
3084
  *
2844
3085
  * Why a fresh import: `this.addonLoader`'s `module` namespace is
2845
3086
  * captured once at boot. After a hot-update (`installFromTgz` →
@@ -2916,11 +3157,29 @@ export class AddonRegistryService {
2916
3157
  this.customActionRegistry.registerAddon(
2917
3158
  addonId,
2918
3159
  catalog as import("@camstack/types").CustomActionsSpec,
2919
- (action, input) => this.broker.call(`${addonId}.custom.${action}`, input),
3160
+ (action, input) => this.dispatchForkedCustomAction(addonId, action, input),
2920
3161
  );
2921
3162
  this.logger.info("Runner addon custom actions registered", {
2922
3163
  tags: { addonId },
2923
3164
  meta: { runnerId },
2924
3165
  });
2925
3166
  }
3167
+
3168
+ /**
3169
+ * Dispatch a forked addon's custom action through the shared
3170
+ * {@link AddonCallGateway} (UDS to the hub-local child). The gateway owns the
3171
+ * routing + the child-availability error; there is no broker fallback after
3172
+ * the per-addon Moleculer broker was removed.
3173
+ */
3174
+ private async dispatchForkedCustomAction(
3175
+ addonId: string,
3176
+ action: string,
3177
+ input: unknown,
3178
+ ): Promise<unknown> {
3179
+ return this.addonCallGateway.callForked(addonId, {
3180
+ target: "custom",
3181
+ action,
3182
+ args: input,
3183
+ });
3184
+ }
2926
3185
  }