@camstack/system 1.2.419 → 1.2.420

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.
@@ -39,7 +39,7 @@ export { buildStorageLocationRegistry } from "./storage-location-registry.js";
39
39
  export type { StorageLocationRegistry } from "./storage-location-registry.js";
40
40
  export { createBroker, deriveAgentListenPort, getBrokerTlsTransporter } from "./moleculer/broker-factory.js";
41
41
  export type { BrokerConfig } from "./moleculer/broker-factory.js";
42
- export { caFingerprint, caFingerprintCompact, createMeshCsrPem, enrollMeshNode, exportMeshPrivateKeyPem, getProcessNodeMeshTransport, setProcessMeshPeerTransportLookup, generateMeshCa, generateMeshKeyPair, getProcessMeshCaState, issueMeshLeafForNode, issueMeshLeafFromCsr, JoinTokenJtiStore, loadMeshCa, MESH_CA_FIELD, MESH_CA_KEY_REF, MESH_CA_SETTINGS_KEY, MeshCaKeeper, MeshCrl, mintMeshJoinToken, processMeshCrl, processJoinTokenJtiStore, requireProcessMeshCa, setProcessMeshTlsEnabler, TlsTcpTransporter, verifyMeshJoinToken, DEFAULT_HUB_TLS_TCP_PORT } from "./moleculer/mesh-mtls/index.js";
42
+ export { caFingerprint, caFingerprintCompact, createMeshCsrPem, enrollMeshNode, exportMeshPrivateKeyPem, getProcessNodeMeshTransport, setProcessMeshPeerTransportLookup, generateMeshCa, generateMeshKeyPair, getProcessMeshCaState, issueMeshLeafForNode, issueMeshLeafFromCsr, JoinTokenJtiStore, loadMeshCa, MESH_CA_FIELD, MESH_CA_KEY_REF, MESH_CA_SETTINGS_KEY, MeshCaKeeper, MeshCrl, mintMeshJoinToken, processMeshCrl, processJoinTokenJtiStore, requireProcessMeshCa, setProcessMeshTlsEnabler, TlsTcpTransporter, verifyMeshJoinToken, DEFAULT_HUB_TLS_TCP_PORT, MESH_TLS_PORT_HELLO_FIELD } from "./moleculer/mesh-mtls/index.js";
43
43
  export type { MeshCaSigner, MeshEnrollResult, MeshPeerTransportLookup, MeshTransport, MeshJoinTokenClaims, MeshTlsMaterial } from "./moleculer/mesh-mtls/index.js";
44
44
  export { type AddonDeploySource, isAddonDeploySource } from "./moleculer/addon-deploy-source.js";
45
45
  export { createAddonService, validateProviderRegistrations } from "./moleculer/addon-service-factory.js";
@@ -62,6 +62,10 @@ export { createMeshAccessMiddleware, stripForgedIdentity } from "./moleculer/mes
62
62
  export type { MeshAccessMiddlewareOptions, MeshCallGate } from "./moleculer/mesh-access-middleware.js";
63
63
  export { AgentMeshAuthenticator, AGENT_REGISTER_NODE_ACTION, isOwnChildNodeId, isRemoteForbiddenOnAgent } from "./moleculer/agent-mesh-authenticator.js";
64
64
  export type { AgentMeshAuthenticatorDeps } from "./moleculer/agent-mesh-authenticator.js";
65
+ export { DEFAULT_MESH_JOIN_MAX_HELD, DEFAULT_MESH_JOIN_MAX_HOLD_MS, MeshJoinHold } from "./moleculer/mesh-join-hold.js";
66
+ export type { MeshGateVerdict, MeshHeldKind, MeshHeldVerdict, MeshJoinHoldOptions, MeshJoinHoldReport } from "./moleculer/mesh-join-hold.js";
67
+ export { createAgentEventScope, isPeerEventPacket } from "./moleculer/agent-event-scope.js";
68
+ export type { AgentEventScope, AgentEventScopeOptions, AgentEventScopeStats, OutboundPacket, TransitPublish } from "./moleculer/agent-event-scope.js";
65
69
  export { MeshJoinClient } from "./moleculer/mesh-join-client.js";
66
70
  export type { MeshJoinCall, MeshJoinClientOptions, MeshJoinState } from "./moleculer/mesh-join-client.js";
67
71
  export { computeHubJoinProof, computeNodeJoinProof, meshErrorType, MESH_ACTION_FORBIDDEN_TYPE, MESH_JOIN_AUTHENTICATE_ACTION, MESH_JOIN_CHALLENGE_ACTION, MESH_JOIN_CHALLENGE_INVALID_TYPE, MESH_JOIN_PROTOCOL, MESH_NODE_UNAUTHENTICATED_TYPE } from "./moleculer/mesh-join-proof.js";
@@ -0,0 +1,49 @@
1
+ /**
2
+ * An agent's events go to the hub, and to no other node (D848).
3
+ *
4
+ * `getBrokerEventBus(...).emit` broadcasts `camstack.evt.<cat>` to every node
5
+ * whose `$event-bus` subscribes to it — and every node's does
6
+ * (`camstack.evt.**`). On an agent that meant every telemetry event
7
+ * (`pipeline.inference-result`, `pipeline.trace`, `motion.*`: ~3,000/min per
8
+ * agent on the 2026-10-13 cluster) was also written to every PEER agent, in
9
+ * clear text, where the peer's inbound gate (`AgentMeshAuthenticator`, D802)
10
+ * refused it by design: only the hub may send an agent anything. The bytes
11
+ * were paid for nothing.
12
+ *
13
+ * This middleware stops them at the source: an EVENT packet an agent
14
+ * publishes to any node other than the hub is not written. Nothing a peer
15
+ * agent receives changes — it refused every such packet already — and the
16
+ * hub, the only consumer, still gets every event. A packet to the agent's own
17
+ * node id never reaches the transport (Moleculer delivers it in-process).
18
+ *
19
+ * Counted, never silent: {@link AgentEventScope.stats} reports what was held
20
+ * back per peer, and the first event withheld from each peer is logged.
21
+ */
22
+ import type { Middleware } from "moleculer";
23
+ /** The part of a Moleculer `Packet` this gate reads. */
24
+ export interface OutboundPacket {
25
+ readonly type: string;
26
+ readonly target: string | null;
27
+ }
28
+ /** `Transit.publish` — what a `transitPublish` hook wraps. */
29
+ export type TransitPublish = (packet: OutboundPacket) => Promise<unknown>;
30
+ export interface AgentEventScopeOptions {
31
+ /** The only node an agent's events are written to. */
32
+ readonly hubNodeId: string;
33
+ /** Told once per peer, the first time an event to it is withheld. */
34
+ readonly log?: (message: string) => void;
35
+ }
36
+ export interface AgentEventScopeStats {
37
+ /** EVENT packets written to the hub. */
38
+ readonly toHub: number;
39
+ /** EVENT packets withheld, per peer node id. */
40
+ readonly withheld: Readonly<Record<string, number>>;
41
+ }
42
+ export interface AgentEventScope {
43
+ /** Pass to `createBroker({ middlewares })` on every agent broker. */
44
+ readonly middleware: Middleware;
45
+ stats(): AgentEventScopeStats;
46
+ }
47
+ /** Is this an event packet addressed to a node other than the hub? */
48
+ export declare function isPeerEventPacket(packet: OutboundPacket, hubNodeId: string): boolean;
49
+ export declare function createAgentEventScope(opts: AgentEventScopeOptions): AgentEventScope;
@@ -17,6 +17,10 @@
17
17
  * cluster secret, `transition` when it does not — so an upgrading agent
18
18
  * without a secret still serves the hub.
19
19
  *
20
+ * The hub's requests that arrive while a join is in flight are HELD, not
21
+ * refused (D848 §3, {@link MeshJoinHold}): delivered when the join proves the
22
+ * hub, refused and reported when it fails or the bound runs out.
23
+ *
20
24
  * Unlike the hub gate, `$process.*` is allowed from an authenticated hub:
21
25
  * the hub polls `$process.list` on agents. `$node.*` stays forbidden from
22
26
  * every remote peer. Forged `caller` / `callerAddonId` are stripped by the
@@ -26,8 +30,8 @@
26
30
  * (`docs/design/2026-10-09-mesh-mtls-plan.md`) — same residual as the hub.
27
31
  */
28
32
  import type { MeshAuthMode, MeshNodeAuthState } from "./mesh-auth-mode.js";
29
- import type { MeshCallVerdict } from "./mesh-authenticator.js";
30
33
  import type { MeshJoinState } from "./mesh-join-client.js";
34
+ import { type MeshGateVerdict, type MeshJoinHoldReport } from "./mesh-join-hold.js";
31
35
  /** The child's register action on its parent agent. */
32
36
  export declare const AGENT_REGISTER_NODE_ACTION = "$agent.registerNode";
33
37
  export declare function isRemoteForbiddenOnAgent(action: string): boolean;
@@ -40,13 +44,21 @@ export interface AgentMeshAuthenticatorDeps {
40
44
  readonly now?: () => number;
41
45
  /** First time a peer is tolerated without proof on this connection. */
42
46
  readonly onTolerated?: (nodeId: string, action: string) => void;
47
+ /** Every decision about the hub's requests held before the join settled. */
48
+ readonly onHoldReport?: (report: MeshJoinHoldReport) => void;
49
+ /** Bounds of that hold (defaults: 1000 items, 15 s). */
50
+ readonly maxHeld?: number;
51
+ readonly maxHoldMs?: number;
43
52
  }
44
53
  export declare class AgentMeshAuthenticator {
45
54
  private readonly deps;
46
55
  private readonly nodes;
47
56
  private readonly hubNodeId;
48
57
  private readonly now;
58
+ private readonly hold;
49
59
  constructor(deps: AgentMeshAuthenticatorDeps);
60
+ /** How many of the hub's requests wait for the join right now. */
61
+ heldCount(): number;
50
62
  /** Current mode: secret configured → enforce; else tolerate (upgrade path). */
51
63
  mode(): MeshAuthMode;
52
64
  /**
@@ -59,8 +71,8 @@ export declare class AgentMeshAuthenticator {
59
71
  noteChildRegistered(sender: string, proof?: "authenticated" | "legacy"): void;
60
72
  /** The peer's connection dropped. */
61
73
  forget(nodeId: string): void;
62
- admitCall(sender: string, action: string): MeshCallVerdict;
63
- admitEvent(sender: string, eventName: string): MeshCallVerdict;
74
+ admitCall(sender: string, action: string): MeshGateVerdict;
75
+ admitEvent(sender: string, eventName: string): MeshGateVerdict;
64
76
  stateOf(nodeId: string): MeshNodeAuthState;
65
77
  authenticatedNodeIds(): readonly string[];
66
78
  private admitSender;
@@ -25,6 +25,12 @@ interface BrokerConfig {
25
25
  */
26
26
  readonly agentListenPort?: number;
27
27
  /**
28
+ * Port an ENROLLED agent listens on for the hub's mutual-TLS dial (default
29
+ * 6443). It is advertised to the hub in every gossip HELLO, so two
30
+ * nodes on one host (a dev or test cluster) need not collide on 6443.
31
+ */
32
+ readonly agentTlsListenPort?: number;
33
+ /**
28
34
  * Broker middlewares (D802): the hub passes its mesh gate
29
35
  * (`createMeshAccessMiddleware`); an agent passes inbound access
30
36
  * (`AgentMeshAuthenticator` + the same middleware) and its outbound join
@@ -14,11 +14,15 @@
14
14
  * Calls a node makes to itself (`ctx.nodeID` = own) pass untouched.
15
15
  */
16
16
  import { type Middleware } from "moleculer";
17
- import type { MeshCallVerdict } from "./mesh-authenticator.js";
18
- /** Minimal surface the middleware needs — hub and agent authenticators both fit. */
17
+ import type { MeshGateVerdict } from "./mesh-join-hold.js";
18
+ /**
19
+ * Minimal surface the middleware needs — hub and agent authenticators both fit.
20
+ * Only the agent's ever answers `hold` (its hub's requests before the join
21
+ * settles, D848): the request waits for that verdict, then runs or is refused.
22
+ */
19
23
  export interface MeshCallGate {
20
- admitCall(sender: string, action: string): MeshCallVerdict;
21
- admitEvent(sender: string, eventName: string): MeshCallVerdict;
24
+ admitCall(sender: string, action: string): MeshGateVerdict;
25
+ admitEvent(sender: string, eventName: string): MeshGateVerdict;
22
26
  }
23
27
  /**
24
28
  * The params of a request from another node, with every identity it claimed
@@ -0,0 +1,85 @@
1
+ /**
2
+ * What an agent does with the hub's events and calls that arrive BEFORE its
3
+ * mesh join has settled (D848 §3).
4
+ *
5
+ * The agent's inbound gate admits the hub only once the mutual join has
6
+ * proven it (`AgentMeshAuthenticator`, D802). The hub, though, starts sending
7
+ * the moment it sees the agent's connection — a one-shot event broadcast at
8
+ * that instant reached a gate that had not yet heard the join's answer, and
9
+ * was refused: dropped, with only a rate-limited "mesh event refused" line.
10
+ * An ordering fix on the hub cannot close it: the agent verifies the hub's
11
+ * proof asynchronously, after the answer that carries it, so even an event
12
+ * the hub sends strictly after its answer can be decoded first.
13
+ *
14
+ * So the agent HOLDS what the hub sends while a join is in flight, and
15
+ * decides when the join decides:
16
+ * - the join proves the hub → everything held is delivered, in arrival order;
17
+ * - the join fails (mismatch, unverified hub, no answer) → everything held is
18
+ * refused, and said so with the count;
19
+ * - the bound runs out first ({@link MeshJoinHoldOptions.maxHoldMs} since the
20
+ * first item was held) → refused and said so, and nothing more is held until
21
+ * the next join starts;
22
+ * - more than {@link MeshJoinHoldOptions.maxHeld} items → the excess is refused
23
+ * on arrival and counted into the next report.
24
+ *
25
+ * The timer is the bound itself, never a wait for a node (D3): delivery is
26
+ * driven by the join settling. Nothing held is ever dropped without a report
27
+ * (D49).
28
+ */
29
+ import type { MeshCallVerdict } from "./mesh-authenticator.js";
30
+ /** The gate cannot answer yet: the request waits for the join. */
31
+ export interface MeshHeldVerdict {
32
+ readonly verdict: "hold";
33
+ readonly settled: Promise<MeshCallVerdict>;
34
+ }
35
+ /** Every verdict a mesh gate may give (`MeshCallGate`). */
36
+ export type MeshGateVerdict = MeshCallVerdict | MeshHeldVerdict;
37
+ export type MeshHeldKind = "call" | "event";
38
+ export interface MeshJoinHoldReport {
39
+ /** `delivered` when the join proved the hub, else `refused`. */
40
+ readonly outcome: "delivered" | "refused";
41
+ /** `authenticated`, the failed join state, or `bound` (time ran out). */
42
+ readonly reason: string;
43
+ readonly events: number;
44
+ readonly calls: number;
45
+ /** Refused on arrival because the hold was full. */
46
+ readonly overflow: number;
47
+ /** From the first held item to the decision. */
48
+ readonly heldForMs: number;
49
+ }
50
+ export interface MeshJoinHoldOptions {
51
+ readonly hubNodeId: string;
52
+ /** Most items held at once (default 1000). */
53
+ readonly maxHeld?: number;
54
+ /** Longest an item is held, from the first one (default 15 s). */
55
+ readonly maxHoldMs?: number;
56
+ readonly now?: () => number;
57
+ readonly onReport?: (report: MeshJoinHoldReport) => void;
58
+ }
59
+ export declare const DEFAULT_MESH_JOIN_MAX_HELD = 1e3;
60
+ export declare const DEFAULT_MESH_JOIN_MAX_HOLD_MS = 15e3;
61
+ export declare class MeshJoinHold {
62
+ private readonly opts;
63
+ private waiters;
64
+ private firstHeldAt;
65
+ private overflow;
66
+ private timer;
67
+ /** Holding is open while a join is in flight; closed after a decision or the bound. */
68
+ private open;
69
+ private readonly now;
70
+ constructor(opts: MeshJoinHoldOptions);
71
+ /** Whether a request arriving now would be held (a join is in flight). */
72
+ get holding(): boolean;
73
+ /** How many requests wait right now. */
74
+ get size(): number;
75
+ /** A join started: hold again. */
76
+ reopen(): void;
77
+ /** Hold one request; `null` when holding is closed (the caller refuses it). */
78
+ hold(kind: MeshHeldKind): MeshHeldVerdict | null;
79
+ /** The join proved the hub: deliver everything held, in order. */
80
+ release(): void;
81
+ /** The join failed, or the bound ran out: refuse everything held. */
82
+ refuse(reason: string): void;
83
+ private refusal;
84
+ private settle;
85
+ }
@@ -7,4 +7,4 @@ export { enrollMeshNode } from "./mesh-enroll.js";
7
7
  export type { EnrollMeshNodeDeps, MeshEnrollRequest, MeshEnrollResult } from "./mesh-enroll.js";
8
8
  export { JoinTokenJtiStore, mintMeshJoinToken, processJoinTokenJtiStore, verifyMeshJoinToken, type MeshJoinTokenClaims, type MintJoinTokenInput } from "./mesh-join-token.js";
9
9
  export { getProcessMeshTlsEnabler, requestProcessMeshTlsEnable, setProcessMeshTlsEnabler, type MeshTlsEnabler } from "./mesh-tls-enabler.js";
10
- export { TlsTcpTransporter, type MeshTlsMaterial, type TlsTcpTransporterOptions } from "./tls-tcp-transporter.js";
10
+ export { helloMeshTlsPort, keepOnlyGossipNodes, MESH_TLS_PORT_HELLO_FIELD, TlsTcpTransporter, type FilteredGossip, type MeshTlsMaterial, type TlsTcpTransporterOptions } from "./tls-tcp-transporter.js";
@@ -1,5 +1,7 @@
1
1
  import type { Server as NetServer, Socket as NetSocket } from "node:net";
2
2
  import { type MeshCrl } from "./mesh-crl.js";
3
+ /** Field of the gossip HELLO payload carrying the sender's mesh-TLS listen port. */
4
+ export declare const MESH_TLS_PORT_HELLO_FIELD = "meshTlsPort";
3
5
  type TcpTransporterConstructor = new (opts?: Record<string, unknown>) => TlsTcpTransporterBase;
4
6
  declare const TcpTransporterBase: TcpTransporterConstructor;
5
7
  interface TlsTcpTransporterBase {
@@ -34,6 +36,13 @@ interface TlsTcpTransporterBase {
34
36
  registry: {
35
37
  regenerateLocalRawInfo(force: boolean): void;
36
38
  };
39
+ broker: {
40
+ serializer: {
41
+ serialize(payload: unknown, type: string): Buffer;
42
+ deserialize(buf: Buffer, type: string): unknown;
43
+ };
44
+ };
45
+ nodeID: string;
37
46
  startTcpServer(): Promise<number>;
38
47
  startUdpServer(): Promise<void>;
39
48
  startTimers(): void;
@@ -43,6 +52,7 @@ interface TlsTcpTransporterBase {
43
52
  disconnect(): Promise<void>;
44
53
  onIncomingMessage(type: string, message: Buffer, socket?: NetSocket): unknown;
45
54
  receive(cmd: string, data: Buffer, socket?: NetSocket): unknown;
55
+ publish(packet: OutgoingPacket): Promise<unknown>;
46
56
  deserialize(type: string, data: Buffer): {
47
57
  payload: {
48
58
  sender?: string;
@@ -71,7 +81,38 @@ export interface TlsTcpTransporterOptions {
71
81
  readonly crl?: MeshCrl;
72
82
  /** Hub only (D826): never bind clear TCP or UDP discovery. */
73
83
  readonly hubTlsOnly?: boolean;
84
+ /**
85
+ * Enrolled agent only (D848): the hub is the only mesh peer — no clear
86
+ * reader, TLS accepted from the hub's certificate only, nothing dialled but
87
+ * the hub, other nodes in the hub's gossip ignored.
88
+ */
89
+ readonly hubOnlyPeers?: boolean;
90
+ /** The hub's node id (default `hub`). */
91
+ readonly hubNodeId?: string;
92
+ /**
93
+ * Port to dial a TLS peer on when it advertises none (default: `tlsPort`).
94
+ * An agent sets the hub's TLS port here; its own listener stays on `tlsPort`.
95
+ */
96
+ readonly peerTlsPort?: number;
97
+ }
98
+ /** A packet on its way out (Moleculer `Packet`). */
99
+ interface OutgoingPacket {
100
+ readonly type: string;
101
+ readonly target: string | null;
102
+ readonly payload: Record<string, unknown>;
103
+ }
104
+ /** The mesh-TLS port a gossip HELLO payload advertises, or null. */
105
+ export declare function helloMeshTlsPort(payload: unknown): number | null;
106
+ /** A gossip payload with only the kept nodes, and the ids that were removed. */
107
+ export interface FilteredGossip {
108
+ readonly payload: unknown;
109
+ readonly removed: readonly string[];
74
110
  }
111
+ /**
112
+ * A gossip response with every node but `keep` removed. With nothing to
113
+ * remove the payload is returned unchanged.
114
+ */
115
+ export declare function keepOnlyGossipNodes(payload: unknown, keep: ReadonlySet<string>): FilteredGossip;
75
116
  export declare class TlsTcpTransporter extends TcpTransporterBase {
76
117
  private tlsServer;
77
118
  private meshTls;
@@ -81,19 +122,29 @@ export declare class TlsTcpTransporter extends TcpTransporterBase {
81
122
  private readonly tlsPeers;
82
123
  private clearDisabled;
83
124
  private readonly hubTlsOnly;
125
+ private readonly hubOnlyPeers;
126
+ private readonly hubNodeId;
127
+ private readonly peerTlsPortOption;
128
+ /** Peers already reported as not adopted (D848) — one line each. */
129
+ private readonly ignoredPeers;
130
+ /** Node id → the mesh-TLS port its last HELLO advertised. */
131
+ private readonly helloTlsPorts;
84
132
  /** Clear-text `TcpWriter.connect` captured once before the TLS wrap (A→B). */
85
133
  private clearWriterConnect;
86
134
  private writerTlsPatched;
87
135
  constructor(opts?: TlsTcpTransporterOptions);
136
+ /** Clear TCP is never served: the TLS-only hub, or an agent whose only peer is the hub. */
137
+ private get tlsOnly();
88
138
  override startTcpServer(): Promise<number>;
89
139
  override connect(): Promise<void>;
90
140
  /**
91
- * Outbound dial uses mTLS when the peer is already known on TLS (inbound
92
- * handshake), or when dialling the hub node id (agent → hub). Everyone else
93
- * (runners, unenrolled agents) stays on the clear writer — the hub must not
94
- * TLS-wrap every `connect` after deferred `enableMeshTls`.
141
+ * Outbound dial uses mTLS when dialling the hub node id (agent → hub), on a
142
+ * TLS-only hub (it has no clear listener, so no clear peer exists to dial —
143
+ * D848 closes the hub → agent clear leg), or when the peer is already known
144
+ * on TLS (inbound handshake).
95
145
  */
96
146
  private shouldDialPeerWithTls;
147
+ /** The peer's advertised mesh-TLS port, else the configured dial port, else 6443. */
97
148
  private peerTlsDialPort;
98
149
  /**
99
150
  * Wrap `TcpWriter.connect` so TLS peers are dialed with mutual TLS on the
@@ -135,6 +186,16 @@ export declare class TlsTcpTransporter extends TcpTransporterBase {
135
186
  /** Close sockets whose peer serial was just revoked. */
136
187
  dropRevokedSockets(): void;
137
188
  override disconnect(): Promise<void>;
189
+ /**
190
+ * D848: an enrolled agent adopts no node from the hub's gossip but the hub
191
+ * itself, so it never learns — and therefore never dials or broadcasts to —
192
+ * another agent. Everything else is Moleculer's own handling.
193
+ */
194
+ override receive(cmd: string, data: Buffer, socket?: NetSocket): unknown;
195
+ /** Remember the TLS port a peer's HELLO advertises (absent: an older node → the default). */
196
+ private noteHelloTlsPort;
197
+ /** A TLS-listening node says where in every HELLO it sends. */
198
+ override publish(packet: OutgoingPacket): Promise<unknown>;
138
199
  override onIncomingMessage(type: string, message: Buffer, socket?: NetSocket): unknown;
139
200
  private startTlsServerIfReady;
140
201
  private onTlsClientConnected;