@camstack/server 1.2.486 → 1.2.488

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 (38) hide show
  1. package/dist/agent/agent-mesh-join.js +121 -0
  2. package/dist/agent/agent-service.js +4 -0
  3. package/dist/agent/main.js +62 -2
  4. package/dist/api/auth-whoami.js +1 -1
  5. package/dist/api/core/auth-admin-tokens.procedures.js +138 -0
  6. package/dist/api/core/auth-limits.js +77 -0
  7. package/dist/api/core/auth-reauth.procedures.js +108 -0
  8. package/dist/api/core/auth-sessions.procedures.js +166 -0
  9. package/dist/api/core/auth.router.js +281 -80
  10. package/dist/api/health/adaptive-probe.routes.js +1 -1
  11. package/dist/api/mesh/enroll.routes.js +28 -0
  12. package/dist/api/trpc/core-cap-bridge.js +88 -5
  13. package/dist/api/trpc/generated-cap-mounts.js +19 -1
  14. package/dist/api/trpc/generated-cap-routers.js +512 -205
  15. package/dist/api/trpc/trpc.context.js +86 -34
  16. package/dist/api/trpc/trpc.middleware.js +8 -11
  17. package/dist/api/trpc/trpc.router.js +6 -1
  18. package/dist/auth/auth-network-policy.js +75 -0
  19. package/dist/auth/auth-rate-limit.js +25 -20
  20. package/dist/auth/client-address.js +68 -0
  21. package/dist/auth/external-link-intents.js +12 -3
  22. package/dist/auth/hub-sessions.js +74 -0
  23. package/dist/auth/native-link-completions.js +68 -0
  24. package/dist/auth/native-return.js +33 -0
  25. package/dist/auth/session-lifetimes.js +46 -0
  26. package/dist/auth/session-mint.js +110 -13
  27. package/dist/auth/sso-finish-deps.js +56 -1
  28. package/dist/auth/sso-finish.js +132 -17
  29. package/dist/auth/sso-routes.js +95 -0
  30. package/dist/auth/used-bridge-ids.js +52 -0
  31. package/dist/core/addon/addon-registry.service.js +5 -0
  32. package/dist/core/agent/agent-registry.service.js +23 -0
  33. package/dist/core/auth/auth.service.js +50 -1
  34. package/dist/core/auth/handoff-code.service.js +9 -3
  35. package/dist/core/moleculer/hub-mesh-guard.js +132 -0
  36. package/dist/core/moleculer/moleculer.service.js +46 -0
  37. package/dist/main.js +97 -29
  38. package/package.json +13 -13
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UsedBridgeIds = void 0;
4
+ /**
5
+ * The `jti`s of login bridges already swapped for a session or a link.
6
+ *
7
+ * A bridge lives 5 minutes; until it expires a second presentation is refused.
8
+ * Entries are kept for {@link RETENTION_MS} (longer than any bridge), and the
9
+ * set is bounded — only a bridge that verified AND matched its browser's
10
+ * state cookie is ever recorded, so filling it needs that many real sign-ins.
11
+ *
12
+ * In memory: a hub restart within a bridge's 5 minutes forgets it, and the
13
+ * browser binding (the bridge is useless without the state cookie of the
14
+ * browser that started the sign-in) is what still stands then.
15
+ */
16
+ const RETENTION_MS = 10 * 60_000;
17
+ const DEFAULT_MAX_ENTRIES = 10_000;
18
+ class UsedBridgeIds {
19
+ used = new Map();
20
+ now;
21
+ maxEntries;
22
+ constructor(opts = {}) {
23
+ this.now = opts.now ?? Date.now;
24
+ this.maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
25
+ }
26
+ /** Record `jti`; `true` the first time, `false` for every replay. */
27
+ consume(jti) {
28
+ const at = this.now();
29
+ this.prune(at);
30
+ if (this.used.has(jti))
31
+ return false;
32
+ while (this.used.size >= this.maxEntries) {
33
+ const oldest = this.used.keys().next();
34
+ if (oldest.done)
35
+ break;
36
+ this.used.delete(oldest.value);
37
+ }
38
+ this.used.set(jti, at + RETENTION_MS);
39
+ return true;
40
+ }
41
+ size() {
42
+ return this.used.size;
43
+ }
44
+ prune(at) {
45
+ for (const [jti, until] of this.used) {
46
+ if (until > at)
47
+ break; // insertion order = expiry order
48
+ this.used.delete(jti);
49
+ }
50
+ }
51
+ }
52
+ exports.UsedBridgeIds = UsedBridgeIds;
@@ -180,11 +180,15 @@ function parseSerializableRouteDescriptors(raw) {
180
180
  throw new Error('addon-routes: route descriptor missing method/path');
181
181
  }
182
182
  const description = Reflect.get(entry, 'description');
183
+ // An unknown limit name is dropped, never widened into "no limit" silently
184
+ // by a typo on the hub side: only names the hub implements are kept.
185
+ const rateLimit = Reflect.get(entry, 'rateLimit') === 'auth' ? 'auth' : undefined;
183
186
  return {
184
187
  method: asRouteMethod(method),
185
188
  path: routePath,
186
189
  access: asRouteAccess(Reflect.get(entry, 'access')),
187
190
  ...(typeof description === 'string' ? { description } : {}),
191
+ ...(rateLimit !== undefined ? { rateLimit } : {}),
188
192
  };
189
193
  });
190
194
  }
@@ -2686,6 +2690,7 @@ class AddonRegistryService {
2686
2690
  path: route.path,
2687
2691
  access: route.access,
2688
2692
  ...(route.description !== undefined ? { description: route.description } : {}),
2693
+ ...(route.rateLimit !== undefined ? { rateLimit: route.rateLimit } : {}),
2689
2694
  handler: async (req, reply) => {
2690
2695
  const envelope = await invoker.invoke({
2691
2696
  method: route.method,
@@ -385,6 +385,19 @@ class AgentRegistryService {
385
385
  tags: { nodeId },
386
386
  meta: { historyOutcome, historyRemoved, registryRemoved, stillListed },
387
387
  });
388
+ // Topology is push-only (`cluster.topology-snapshot`). Without this event
389
+ // the Cluster page kept the forgotten row until the 30 s heartbeat — the
390
+ // operator had to refresh by hand. `TopologyEmitterService` listens for
391
+ // `AgentUnregistered` and re-emits immediately.
392
+ if (historyRemoved || registryRemoved) {
393
+ this.eventBus.emit({
394
+ id: (0, node_crypto_1.randomUUID)(),
395
+ timestamp: new Date(),
396
+ source: { type: 'core', id: 'agent-registry' },
397
+ category: types_1.EventCategory.AgentUnregistered,
398
+ data: { agentId: nodeId },
399
+ });
400
+ }
388
401
  return {
389
402
  forgotten: historyRemoved || registryRemoved,
390
403
  historyRemoved,
@@ -448,6 +461,16 @@ class AgentRegistryService {
448
461
  this.moleculer.setOnAgentRegistered((agentId) => {
449
462
  void this.reconcileAgentAddons(agentId, 'node-registration');
450
463
  });
464
+ // D802: the mesh `transition` ends only once every agent this hub has
465
+ // EVER seen has authenticated — offline ones included. `null` (store not
466
+ // wired or not answering) never counts as "no agents".
467
+ this.moleculer.setKnownAgentsProvider(async () => {
468
+ const store = this.historyStore;
469
+ if (!store)
470
+ return null;
471
+ const rows = await store.tryGetAll();
472
+ return rows === null ? null : rows.filter((r) => !r.isHub).map((r) => r.id);
473
+ });
451
474
  this.broker.localBus.on('$node.connected', ({ node }) => {
452
475
  const kind = classifyNode(node.id);
453
476
  if (!kind)
@@ -1,7 +1,56 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.AuthService = void 0;
3
+ exports.AuthService = exports.SessionRevokedError = void 0;
4
4
  const system_1 = require("@camstack/system");
5
+ /**
6
+ * A session-grade token whose server-side session is gone (logged out,
7
+ * revoked, expired) or cannot be checked right now. Every caller of
8
+ * `verifyToken` already treats a throw as "not authenticated".
9
+ */
10
+ class SessionRevokedError extends Error {
11
+ reason;
12
+ constructor(reason) {
13
+ super(`Session is no longer valid (${reason})`);
14
+ this.reason = reason;
15
+ this.name = 'SessionRevokedError';
16
+ }
17
+ }
18
+ exports.SessionRevokedError = SessionRevokedError;
19
+ /** A v2 session token: string `userId`, boolean `isAdmin`, no `kind` (not a bridge/challenge). */
20
+ function isSessionGrade(payload) {
21
+ return (Reflect.get(payload, 'kind') === undefined &&
22
+ typeof payload.userId === 'string' &&
23
+ typeof payload.isAdmin === 'boolean');
24
+ }
5
25
  class AuthService extends system_1.AuthManager {
26
+ sessionCheck = null;
27
+ /**
28
+ * Wire the login-session check (`hub-sessions.ts`). From then on EVERY
29
+ * `verifyToken` of a session-grade token — the tRPC boundary, addon routes,
30
+ * the session cookie, the OAuth authorize page, health, uploads — also
31
+ * requires its session to be live, so a revocation applies everywhere at
32
+ * once and no surface can forget to ask.
33
+ */
34
+ setSessionCheck(check) {
35
+ this.sessionCheck = check;
36
+ }
37
+ /**
38
+ * Why the session behind `payload` is no longer valid, or `null` when it is
39
+ * (or when no check is wired). For a principal resolved earlier — a
40
+ * WebSocket re-checks its session on every call without re-verifying an
41
+ * access token that may have expired since connect.
42
+ */
43
+ checkSession(payload) {
44
+ return this.sessionCheck === null ? null : this.sessionCheck(payload);
45
+ }
46
+ verifyToken(token) {
47
+ const payload = super.verifyToken(token);
48
+ if (this.sessionCheck !== null && isSessionGrade(payload)) {
49
+ const refusal = this.sessionCheck(payload);
50
+ if (refusal !== null)
51
+ throw new SessionRevokedError(refusal);
52
+ }
53
+ return payload;
54
+ }
6
55
  }
7
56
  exports.AuthService = AuthService;
@@ -79,14 +79,19 @@ class HandoffCodeService {
79
79
  /** Mint a single-use code bound to `userId`. Throws when the pending
80
80
  * store is full even after pruning expired entries (fail fast — a
81
81
  * legitimate flow never has anywhere near this many in flight). */
82
- create(userId) {
82
+ create(userId, options = {}) {
83
83
  this.prune();
84
84
  if (this.pending.size >= exports.HANDOFF_CODE_MAX_PENDING) {
85
85
  throw new Error('Too many pending handoff codes — try again shortly');
86
86
  }
87
87
  const code = `${exports.HANDOFF_CODE_PREFIX}${crypto.randomBytes(32).toString('hex')}`;
88
88
  const expiresAt = this.now() + this.ttlMs;
89
- this.pending.set(hashCode(code), { userId, expiresAt });
89
+ this.pending.set(hashCode(code), {
90
+ userId,
91
+ ...(options.sessionId !== undefined ? { sessionId: options.sessionId } : {}),
92
+ purpose: options.purpose ?? 'renew',
93
+ expiresAt,
94
+ });
90
95
  return { code, expiresAt };
91
96
  }
92
97
  /** Redeem a raw code. Consumes the entry on the FIRST attempt no
@@ -99,7 +104,8 @@ class HandoffCodeService {
99
104
  this.pending.delete(hashCode(code));
100
105
  if (this.now() > entry.expiresAt)
101
106
  return null;
102
- return { userId: entry.userId };
107
+ const { expiresAt: _expiresAt, ...grant } = entry;
108
+ return grant;
103
109
  }
104
110
  /** Number of not-yet-redeemed (possibly expired) codes. Test/diag aid. */
105
111
  get pendingCount() {
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HubMeshGuard = void 0;
4
+ /**
5
+ * The hub's mesh guard (D802) — one object owning everything that decides
6
+ * whether another node may talk to the hub:
7
+ *
8
+ * - the {@link MeshAuthenticator} (who proved what, on which connection);
9
+ * - the broker middleware built on it (installed at broker construction);
10
+ * - the System → Cluster status slot;
11
+ * - the automatic end of `transition` ({@link decideMeshAutoEnforce}), checked
12
+ * after every successful join and once a minute.
13
+ *
14
+ * Kept out of `moleculer.service.ts` so that file only wires it.
15
+ */
16
+ const system_1 = require("@camstack/system");
17
+ const types_1 = require("@camstack/types");
18
+ /** How often `transition` is re-checked when nothing joins. */
19
+ const AUTO_ENFORCE_INTERVAL_MS = 60_000;
20
+ class HubMeshGuard {
21
+ ownNodeId;
22
+ logger;
23
+ authenticator;
24
+ startedAt = Date.now();
25
+ knownAgents = null;
26
+ listConnected = () => [];
27
+ timer = null;
28
+ evaluating = false;
29
+ lastWaitingFor = '';
30
+ constructor(ownNodeId, logger) {
31
+ this.ownNodeId = ownNodeId;
32
+ this.logger = logger;
33
+ this.authenticator = new system_1.MeshAuthenticator({
34
+ ownNodeId,
35
+ gate: system_1.getProcessClusterSecretGate,
36
+ mode: system_1.getProcessMeshAuthMode,
37
+ onAuthenticated: (nodeId) => {
38
+ this.logger.info('node authenticated to the mesh (challenge–response)', {
39
+ meta: { nodeId },
40
+ });
41
+ void this.evaluateAutoEnforce();
42
+ },
43
+ onTolerated: (nodeId, action) => {
44
+ this.logger.warn('node is calling the hub WITHOUT having authenticated — tolerated while System → ' +
45
+ 'Cluster is in Transition. Enter the cluster secret in this agent.', { meta: { nodeId, firstCall: action } });
46
+ },
47
+ });
48
+ (0, system_1.setProcessMeshAuthStatusSource)(() => this.authenticator.snapshot());
49
+ }
50
+ /** The middleware the hub broker must be constructed with. */
51
+ middleware() {
52
+ return (0, system_1.createMeshAccessMiddleware)({
53
+ ownNodeId: this.ownNodeId,
54
+ authenticator: this.authenticator,
55
+ logger: {
56
+ warn: (message, meta) => this.logger.warn(message, meta ? { meta } : undefined),
57
+ },
58
+ });
59
+ }
60
+ /** Start the periodic auto-enforce check; `listConnected` reads the live node list. */
61
+ start(listConnected) {
62
+ this.listConnected = listConnected;
63
+ if (this.timer !== null)
64
+ return;
65
+ this.timer = setInterval(() => void this.evaluateAutoEnforce(), AUTO_ENFORCE_INTERVAL_MS);
66
+ this.timer.unref?.();
67
+ }
68
+ /** Wired by `AgentRegistryService`, which owns the cluster-node history. */
69
+ setKnownAgentsProvider(provider) {
70
+ this.knownAgents = provider;
71
+ }
72
+ /** A node's connection dropped. */
73
+ forget(nodeId) {
74
+ this.authenticator.forget(nodeId);
75
+ }
76
+ stop() {
77
+ if (this.timer !== null)
78
+ clearInterval(this.timer);
79
+ this.timer = null;
80
+ (0, system_1.setProcessMeshAuthStatusSource)(null);
81
+ }
82
+ /** Public for the spec; the timer and every join call it. */
83
+ async evaluateAutoEnforce() {
84
+ if (this.evaluating || (0, system_1.getProcessMeshAuthMode)() !== 'transition')
85
+ return;
86
+ this.evaluating = true;
87
+ try {
88
+ let knownAgents = null;
89
+ if (this.knownAgents !== null) {
90
+ try {
91
+ knownAgents = await this.knownAgents();
92
+ }
93
+ catch {
94
+ knownAgents = null;
95
+ }
96
+ }
97
+ const decision = (0, system_1.decideMeshAutoEnforce)({
98
+ mode: (0, system_1.getProcessMeshAuthMode)(),
99
+ gate: (0, system_1.getProcessClusterSecretGate)().kind,
100
+ uptimeMs: Date.now() - this.startedAt,
101
+ knownAgents,
102
+ connected: this.listConnected(),
103
+ toleratedEver: this.authenticator.everToleratedNodeIds(),
104
+ authenticatedEver: this.authenticator.everAuthenticatedNodeIds(),
105
+ ownNodeId: this.ownNodeId,
106
+ });
107
+ if (!decision.enforce) {
108
+ const waiting = decision.waitingFor.join(',');
109
+ if (waiting.length > 0 && waiting !== this.lastWaitingFor) {
110
+ this.logger.info('mesh transition: still waiting for agents to authenticate', {
111
+ meta: { waitingFor: decision.waitingFor },
112
+ });
113
+ }
114
+ this.lastWaitingFor = waiting;
115
+ return;
116
+ }
117
+ const done = await (0, system_1.requestProcessMeshEnforcement)(decision.reason);
118
+ if (done) {
119
+ this.logger.info('mesh authentication switched to ENFORCED', {
120
+ meta: { reason: decision.reason },
121
+ });
122
+ }
123
+ }
124
+ catch (err) {
125
+ this.logger.warn('mesh auto-enforce check failed', { meta: { error: (0, types_1.errMsg)(err) } });
126
+ }
127
+ finally {
128
+ this.evaluating = false;
129
+ }
130
+ }
131
+ }
132
+ exports.HubMeshGuard = HubMeshGuard;
@@ -9,6 +9,7 @@ const types_1 = require("@camstack/types");
9
9
  const cap_router_runtime_js_1 = require("../../api/trpc/cap-router-runtime.js");
10
10
  const core_cap_bridge_js_1 = require("../../api/trpc/core-cap-bridge.js");
11
11
  const agent_readiness_pull_js_1 = require("./agent-readiness-pull.js");
12
+ const hub_mesh_guard_js_1 = require("./hub-mesh-guard.js");
12
13
  const cap_call_fn_js_1 = require("./cap-call-fn.js");
13
14
  const cap_route_authority_js_1 = require("./cap-route-authority.js");
14
15
  class MoleculerService {
@@ -23,6 +24,12 @@ class MoleculerService {
23
24
  return this.broker;
24
25
  }
25
26
  logger;
27
+ /**
28
+ * D802 — who may talk to the hub over the mesh: the challenge–response join,
29
+ * the broker middleware that refuses / tolerates unauthenticated nodes and
30
+ * strips forged identities, and the automatic end of `transition`.
31
+ */
32
+ meshGuard;
26
33
  /**
27
34
  * D3 authority: union of every node's manifest delivered via
28
35
  * `$hub.registerNode`. Populated by `onRegisterNode` in `hubDeps`.
@@ -190,9 +197,12 @@ class MoleculerService {
190
197
  // can answer, and the Moleculer namespace was never authentication (TCP
191
198
  // ignores it; UDP discovery broadcasts it in clear). The secret gates
192
199
  // `$hub.registerNode`, read from the keychain at call time.
200
+ this.meshGuard = new hub_mesh_guard_js_1.HubMeshGuard('hub', this.logging.createLogger('mesh-auth'));
193
201
  const broker = (0, system_1.createBroker)({
194
202
  nodeID: 'hub',
195
203
  mode: 'hub',
204
+ // D802: every action and event another node sends passes the mesh gate.
205
+ middlewares: [this.meshGuard.middleware()],
196
206
  logLevel: this.config.get('moleculer.logLevel') ?? 'warn',
197
207
  ...(tcpPort && !Number.isNaN(tcpPort) ? { tcpPort } : {}),
198
208
  ...(udpPort && !Number.isNaN(udpPort) ? { udpPort } : {}),
@@ -217,6 +227,23 @@ class MoleculerService {
217
227
  setOnAgentRegistered(cb) {
218
228
  this.onAgentRegisteredCb = cb;
219
229
  }
230
+ /**
231
+ * D802: the cluster-node history, for the automatic end of the mesh
232
+ * `transition` — wired by `AgentRegistryService`, which owns the history.
233
+ */
234
+ setKnownAgentsProvider(provider) {
235
+ this.meshGuard.setKnownAgentsProvider(provider);
236
+ }
237
+ /** Every node Moleculer currently lists as available (this hub included). */
238
+ listAvailableNodeIds() {
239
+ const registry = this.broker.registry;
240
+ try {
241
+ return (registry?.getNodeList?.({ onlyAvailable: true }) ?? []).map((n) => n.id);
242
+ }
243
+ catch {
244
+ return [];
245
+ }
246
+ }
220
247
  async onModuleInit() {
221
248
  const logger = this.logging.createLogger('moleculer');
222
249
  const hubDeps = {
@@ -270,6 +297,8 @@ class MoleculerService {
270
297
  },
271
298
  // System → Cluster, from the keychain (D797): off / on / unknown.
272
299
  clusterSecretGate: system_1.getProcessClusterSecretGate,
300
+ // D802: challenge–response join + who may register.
301
+ meshAuth: this.meshGuard.authenticator,
273
302
  };
274
303
  const hubService = (0, system_1.createHubService)(hubDeps);
275
304
  this.brokerSafe.createService(hubService);
@@ -613,6 +642,8 @@ class MoleculerService {
613
642
  // the incarnation) and this call is refused if the id has been reclaimed;
614
643
  // remote nodes register no owner at all, so they clean up as before.
615
644
  this.removeNodeFromRegistry(node.id);
645
+ // D802: a node authenticates again on its next connection.
646
+ this.meshGuard.forget(node.id);
616
647
  });
617
648
  // D8 reverse-leg snapshot-reconcile: on every agent (re)connect, pull
618
649
  // the agent's own readiness snapshot (`$agent-readiness.getSnapshot`)
@@ -659,8 +690,21 @@ class MoleculerService {
659
690
  probe: (url, options) => this.streamProbe.probe(url, options),
660
691
  probeField: (key, value) => this.streamProbe.probeField(key, value),
661
692
  }));
693
+ // Release A: when system-config's MeshCaKeeper becomes ready it offers
694
+ // hub leaf material here; the transporter starts TLS on :6443 alongside
695
+ // clear :6000 so enrolled agents can dual-dial without splitting the cluster.
696
+ (0, system_1.setProcessMeshTlsEnabler)(async (material) => {
697
+ const tx = (0, system_1.getBrokerTlsTransporter)(this.broker);
698
+ if (tx === null) {
699
+ logger.warn('Mesh TLS material ready but broker transporter is not TlsTcpTransporter');
700
+ return;
701
+ }
702
+ await tx.enableMeshTls(material);
703
+ logger.info('Mesh mTLS listener enabled (dual-port with clear TCP)');
704
+ });
662
705
  await this.brokerSafe.start();
663
706
  logger.info('Moleculer broker started (TCP transport)');
707
+ this.meshGuard.start(() => this.listAvailableNodeIds());
664
708
  // Construct the CapRouteResolver now that both the broker and
665
709
  // localChildRegistry are ready. The resolver reads live registry state
666
710
  // via closure accessors on every call (not a frozen snapshot), so new
@@ -1290,6 +1334,8 @@ class MoleculerService {
1290
1334
  }
1291
1335
  }
1292
1336
  async onModuleDestroy() {
1337
+ (0, system_1.setProcessMeshTlsEnabler)(null);
1338
+ this.meshGuard.stop();
1293
1339
  if (this.agentReadinessPullTimer !== null) {
1294
1340
  clearInterval(this.agentReadinessPullTimer);
1295
1341
  this.agentReadinessPullTimer = null;