@camstack/server 1.2.578 → 1.2.580

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 (37) hide show
  1. package/dist/agent/agent-config.js +2 -11
  2. package/dist/agent/agent-http-auth.js +91 -78
  3. package/dist/agent/agent-http.js +89 -139
  4. package/dist/agent/agent-hub-registration.js +31 -58
  5. package/dist/agent/agent-json-file.js +59 -0
  6. package/dist/agent/agent-mesh-gate.js +57 -0
  7. package/dist/agent/agent-node-certificates.js +7 -7
  8. package/dist/agent/agent-pairing-routes.js +1 -4
  9. package/dist/agent/agent-pairing-store.js +8 -8
  10. package/dist/agent/agent-service.js +0 -9
  11. package/dist/agent/main.js +41 -99
  12. package/dist/api/core/auth.router.js +11 -0
  13. package/dist/api/core/cap-providers.js +3 -1
  14. package/dist/api/core/certificates.router.js +2 -2
  15. package/dist/api/core/server-name.js +64 -0
  16. package/dist/api/oauth2/consent-page.js +22 -31
  17. package/dist/api/oauth2/oauth2-routes.js +5 -0
  18. package/dist/api/trpc/trpc.context.js +3 -3
  19. package/dist/api/trpc/trpc.router.js +29 -2
  20. package/dist/auth/app-confirm-page.js +38 -24
  21. package/dist/auth/app-confirm-routes.js +8 -1
  22. package/dist/auth/app-page-shell.js +123 -0
  23. package/dist/auth/app-page-strings.js +157 -0
  24. package/dist/auth/app-passkey-page.js +47 -26
  25. package/dist/auth/app-passkey-routes.js +9 -2
  26. package/dist/auth/app-refused-route.js +43 -0
  27. package/dist/auth/session-connections.js +136 -0
  28. package/dist/auth/sso-finish-app.js +7 -1
  29. package/dist/boot/boot-config.js +7 -5
  30. package/dist/core/agent/agent-registry.service.js +0 -10
  31. package/dist/core/moleculer/hub-mesh-guard.js +30 -101
  32. package/dist/core/moleculer/moleculer.service.js +8 -32
  33. package/dist/main.js +33 -2
  34. package/package.json +9 -9
  35. package/dist/agent/agent-mesh-enroll.js +0 -317
  36. package/dist/agent/agent-mesh-join.js +0 -140
  37. package/dist/api/mesh/enroll.routes.js +0 -34
@@ -19,13 +19,15 @@ exports.appPasskeyRefusalPage = appPasskeyRefusalPage;
19
19
  * specific is ever placed in the script; the only server-rendered value is the
20
20
  * (allow-listed, HTML-escaped) cancel link.
21
21
  */
22
- const node_crypto_1 = require("node:crypto");
22
+ const app_page_shell_js_1 = require("./app-page-shell.js");
23
+ const app_page_strings_js_1 = require("./app-page-strings.js");
23
24
  const PAGE_SCRIPT = `(function () {
24
25
  var q = new URLSearchParams(location.search);
25
26
  var base = location.pathname.replace(/\\/+$/, '');
26
27
  var btn = document.getElementById('go');
27
28
  var msg = document.getElementById('msg');
28
- var KEYS = ['code_challenge', 'code_challenge_method', 'state', 'purpose', 'redirect'];
29
+ var S = function (k) { return document.body.getAttribute('data-s-' + k) || ''; };
30
+ var KEYS = ['code_challenge', 'code_challenge_method', 'state', 'purpose', 'redirect', 'lang'];
29
31
  var APP_CALLBACK = /^camstack(\\.dev)?:\\/\\/auth-callback(\\?|$)/;
30
32
  function fromB64u(s) {
31
33
  s = s.replace(/-/g, '+').replace(/_/g, '/');
@@ -58,10 +60,10 @@ const PAGE_SCRIPT = `(function () {
58
60
  btn.disabled = true;
59
61
  msg.textContent = '';
60
62
  if (!window.PublicKeyCredential || !navigator.credentials) {
61
- return fail('This browser cannot use passkeys.');
63
+ return fail(S('passkey-unsupported'));
62
64
  }
63
65
  post('/begin', flow()).then(function (begin) {
64
- if (!begin.ok) throw new Error(begin.body.error || 'Passkeys are not available.');
66
+ if (!begin.ok) throw new Error('unavailable');
65
67
  var opts = begin.body.optionsJSON;
66
68
  var pk = {
67
69
  challenge: fromB64u(opts.challenge),
@@ -98,38 +100,57 @@ const PAGE_SCRIPT = `(function () {
98
100
  }
99
101
  var target = done.body && done.body.location;
100
102
  if (typeof target !== 'string' || !APP_CALLBACK.test(target)) {
101
- throw new Error((done.body && done.body.error) || 'Sign-in failed.');
103
+ throw new Error('failed');
102
104
  }
103
105
  location.assign(target);
104
106
  }).catch(function (e) {
105
- fail(e && e.name === 'NotAllowedError' ? 'The passkey request was cancelled.' : 'Sign-in failed. Try again.');
107
+ fail(
108
+ e && e.name === 'NotAllowedError' ? S('passkey-cancelled')
109
+ : e && e.message === 'unavailable' ? S('passkey-unavailable')
110
+ : S('sign-in-failed')
111
+ );
106
112
  });
107
113
  }
108
114
  btn.addEventListener('click', go);
109
115
  })();`;
110
- const PAGE_STYLE = `body{font-family:system-ui,sans-serif;max-width:26rem;margin:4rem auto;padding:0 1rem;text-align:center}button{font:inherit;padding:.75rem 1.25rem;border-radius:.5rem;border:1px solid #888;cursor:pointer}p#msg{color:#b00020;min-height:1.5em}a{display:inline-block;margin-top:1rem}`;
111
- const sha256 = (text) => `'sha256-${(0, node_crypto_1.createHash)('sha256').update(text).digest('base64')}'`;
112
116
  /**
113
- * The page's CSP: the one script and one style by hash, same-origin requests
114
- * only, no framing, no base rewrite, no form posts, no plugins.
117
+ * The page's CSP: the shell's style and the one script by hash, same-origin
118
+ * requests only, no framing, no base rewrite, no form posts, no plugins.
115
119
  */
116
- exports.APP_PASSKEY_CSP = [
117
- "default-src 'none'",
118
- `script-src ${sha256(PAGE_SCRIPT)}`,
119
- `style-src ${sha256(PAGE_STYLE)}`,
120
- "connect-src 'self'",
121
- "base-uri 'none'",
122
- "form-action 'none'",
123
- "frame-ancestors 'none'",
124
- ].join('; ');
125
- const escapeHtml = (text) => text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
120
+ exports.APP_PASSKEY_CSP = (0, app_page_shell_js_1.appPageCsp)({ script: PAGE_SCRIPT });
126
121
  /** The page; `cancelHref` is the app callback carrying `error=cancelled`. */
127
- function appPasskeyPage(cancelHref) {
128
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><title>Sign in with a passkey</title><style>${PAGE_STYLE}</style></head><body><h1>CamStack</h1><button id="go" type="button">Sign in with a passkey</button><p id="msg" role="alert"></p><a href="${escapeHtml(cancelHref)}">Cancel</a><noscript><p>JavaScript is required to use a passkey.</p></noscript><script>${PAGE_SCRIPT}</script></body></html>`;
122
+ function appPasskeyPage(cancelHref, lang) {
123
+ const t = (0, app_page_strings_js_1.appStrings)(lang);
124
+ return (0, app_page_shell_js_1.renderAppPage)({
125
+ lang,
126
+ title: t.passkey.title,
127
+ heading: t.passkey.heading,
128
+ noReferrer: true,
129
+ script: PAGE_SCRIPT,
130
+ scriptStrings: {
131
+ 'passkey-unsupported': t.script.passkeyUnsupported,
132
+ 'passkey-unavailable': t.script.passkeyUnavailable,
133
+ 'passkey-cancelled': t.script.passkeyCancelled,
134
+ 'sign-in-failed': t.script.signInFailed,
135
+ },
136
+ body: [
137
+ (0, app_page_shell_js_1.appParagraph)(t.passkey.body),
138
+ (0, app_page_shell_js_1.appButton)(t.passkey.button, 'id="go"'),
139
+ (0, app_page_shell_js_1.appErrorLine)('msg'),
140
+ (0, app_page_shell_js_1.appCancelLink)(cancelHref, t.passkey.cancel),
141
+ `<noscript><p class="error">${(0, app_page_shell_js_1.escapeHtml)(t.passkey.noscript)}</p></noscript>`,
142
+ ].join(''),
143
+ });
129
144
  }
130
145
  /** The page for a request the hub refuses to serve: a sentence, no script, no assets. */
131
- function appPasskeyRefusalPage(message) {
132
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sign-in request refused</title></head><body><h1>Sign-in request refused</h1><p>${escapeHtml(message)}</p><p>Close this window and try again from the app.</p></body></html>`;
146
+ function appPasskeyRefusalPage(message, lang) {
147
+ const t = (0, app_page_strings_js_1.appStrings)(lang).refused;
148
+ return (0, app_page_shell_js_1.renderAppPage)({
149
+ lang,
150
+ title: t.title,
151
+ heading: t.title,
152
+ body: (0, app_page_shell_js_1.appParagraph)(message) + (0, app_page_shell_js_1.appHint)(t.hint),
153
+ });
133
154
  }
134
- /** CSP of the refusal page: nothing runs, nothing loads. */
135
- exports.APP_PASSKEY_REFUSAL_CSP = "default-src 'none'; frame-ancestors 'none'";
155
+ /** CSP of the refusal page: the shell's style only, nothing runs. */
156
+ exports.APP_PASSKEY_REFUSAL_CSP = (0, app_page_shell_js_1.appPageCsp)();
@@ -22,6 +22,7 @@ const types_1 = require("@camstack/types");
22
22
  const zod_1 = require("zod");
23
23
  const app_confirm_routes_js_1 = require("./app-confirm-routes.js");
24
24
  const app_passkey_page_js_1 = require("./app-passkey-page.js");
25
+ const app_page_strings_js_1 = require("./app-page-strings.js");
25
26
  const auth_rate_limit_js_1 = require("./auth-rate-limit.js");
26
27
  const session_mint_js_1 = require("./session-mint.js");
27
28
  exports.APP_PASSKEY_PATH = '/auth/app/passkey';
@@ -32,6 +33,7 @@ const FinishBodySchema = zod_1.z.object({
32
33
  state: zod_1.z.string().optional(),
33
34
  purpose: zod_1.z.string().optional(),
34
35
  redirect: zod_1.z.string().optional(),
36
+ lang: zod_1.z.string().optional(),
35
37
  });
36
38
  function noStore(reply) {
37
39
  reply.header('Cache-Control', 'no-store');
@@ -53,6 +55,10 @@ function registerAppPasskeyRoutes(fastify, deps) {
53
55
  noStore(reply);
54
56
  reply.type('text/html; charset=utf-8');
55
57
  const parsed = (0, types_1.parseAppFlowRequest)(request.query);
58
+ const lang = (0, app_page_strings_js_1.resolveAppPageLang)({
59
+ lang: parsed.ok ? parsed.request.lang : request.query['lang'],
60
+ acceptLanguage: request.headers['accept-language'],
61
+ });
56
62
  if (!parsed.ok) {
57
63
  deps.logger.warn('Passkey app sign-in refused at the page', {
58
64
  meta: { reason: parsed.reason },
@@ -60,11 +66,11 @@ function registerAppPasskeyRoutes(fastify, deps) {
60
66
  reply.header('Content-Security-Policy', app_passkey_page_js_1.APP_PASSKEY_REFUSAL_CSP);
61
67
  return reply
62
68
  .status(400)
63
- .send((0, app_passkey_page_js_1.appPasskeyRefusalPage)(types_1.APP_FLOW_REFUSAL_MESSAGES[parsed.reason]));
69
+ .send((0, app_passkey_page_js_1.appPasskeyRefusalPage)((0, app_page_strings_js_1.appStrings)(lang).refused.reasons[parsed.reason], lang));
64
70
  }
65
71
  reply.header('Content-Security-Policy', app_passkey_page_js_1.APP_PASSKEY_CSP);
66
72
  const cancel = (0, types_1.appFlowLocation)(parsed.request.redirect, { error: 'cancelled' }, parsed.request.state);
67
- return reply.send((0, app_passkey_page_js_1.appPasskeyPage)(cancel));
73
+ return reply.send((0, app_passkey_page_js_1.appPasskeyPage)(cancel, lang));
68
74
  });
69
75
  fastify.post(`${exports.APP_PASSKEY_PATH}/begin`, async (request, reply) => {
70
76
  noStore(reply);
@@ -103,6 +109,7 @@ function registerAppPasskeyRoutes(fastify, deps) {
103
109
  state: body.data.state,
104
110
  purpose: body.data.purpose,
105
111
  redirect: body.data.redirect,
112
+ lang: body.data.lang,
106
113
  });
107
114
  if (!parsed.ok)
108
115
  return reply.status(400).send({ error: types_1.APP_FLOW_REFUSAL_MESSAGES[parsed.reason] });
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.APP_REFUSED_CSP = void 0;
4
+ exports.appRefusedPage = appRefusedPage;
5
+ exports.registerAppRefusedRoute = registerAppRefusedRoute;
6
+ /**
7
+ * `GET /auth/app/refused?reason=<code>` - the page that says why a native app
8
+ * sign-in could not start (D858).
9
+ *
10
+ * The OIDC start lives in an addon, which cannot reach the hub's theme; it
11
+ * redirects here with a reason CODE (and the app's optional `lang`) and the hub renders the
12
+ * sentence, in the language of `lang`/`Accept-Language`, in the same shell as every other app page. The query is never echoed: an unknown or
13
+ * absent code gets a generic sentence, a known one its fixed words.
14
+ */
15
+ const types_1 = require("@camstack/types");
16
+ const app_page_shell_js_1 = require("./app-page-shell.js");
17
+ const app_page_strings_js_1 = require("./app-page-strings.js");
18
+ /** The refusal page for `reason` (a known code, or anything: unknown renders the generic sentence). */
19
+ function appRefusedPage(reason, lang) {
20
+ const t = (0, app_page_strings_js_1.appStrings)(lang).refused;
21
+ const message = (0, types_1.isAppStartRefusal)(reason) ? t.reasons[reason] : t.generic;
22
+ return (0, app_page_shell_js_1.renderAppPage)({
23
+ lang,
24
+ title: t.title,
25
+ heading: t.title,
26
+ body: (0, app_page_shell_js_1.appParagraph)(message) + (0, app_page_shell_js_1.appHint)(t.hint),
27
+ });
28
+ }
29
+ /** CSP of the refusal page: the shell's style only, nothing runs. */
30
+ exports.APP_REFUSED_CSP = (0, app_page_shell_js_1.appPageCsp)();
31
+ function registerAppRefusedRoute(fastify) {
32
+ fastify.get(types_1.APP_REFUSED_PATH, async (request, reply) => {
33
+ reply.header('Cache-Control', 'no-store');
34
+ reply.header('Referrer-Policy', 'no-referrer');
35
+ reply.header('Content-Security-Policy', exports.APP_REFUSED_CSP);
36
+ reply.type('text/html; charset=utf-8');
37
+ const lang = (0, app_page_strings_js_1.resolveAppPageLang)({
38
+ lang: request.query['lang'],
39
+ acceptLanguage: request.headers['accept-language'],
40
+ });
41
+ return reply.status(400).send(appRefusedPage(request.query['reason'], lang));
42
+ });
43
+ }
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ /**
3
+ * What a login session still HOLDS on the hub — its open WebSockets and its
4
+ * live media sessions — so that revoking the session ends them.
5
+ *
6
+ * A revoked `sid` is refused on the NEXT call (`ctx.sessionLive`), which is not
7
+ * the same as ending anything: the socket stayed open, the client kept polling
8
+ * as an anonymous principal and its stream kept playing until the app was
9
+ * killed (2026-10-16, "Sign out all my sessions"). The login-session table
10
+ * announces every revocation (`onSessionsRevoked`); this registry turns that
11
+ * into closed sockets and closed media, touching ONLY the sessions named.
12
+ *
13
+ * - A socket closes with {@link SESSION_ENDED_CLOSE_CODE} / {@link SESSION_ENDED_CLOSE_REASON}
14
+ * so the client can tell "your session ended" from a network drop.
15
+ * - A media hold is whatever the creating call registered as its way to end
16
+ * the session (the WebRTC `closeSession`); recorded playback rides the same
17
+ * cap, so the same hold covers it.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.SessionConnections = exports.SESSION_ENDED_CLOSE_REASON = exports.SESSION_ENDED_CLOSE_CODE = void 0;
21
+ exports.getSessionConnections = getSessionConnections;
22
+ exports.__resetSessionConnectionsForTests = __resetSessionConnectionsForTests;
23
+ /** WebSocket close code a client recognises as "the hub ended this session". */
24
+ exports.SESSION_ENDED_CLOSE_CODE = 4401;
25
+ exports.SESSION_ENDED_CLOSE_REASON = 'session-ended';
26
+ /** Bound on live media holds kept per login session (oldest dropped past it). */
27
+ const MAX_MEDIA_HOLDS_PER_SESSION = 256;
28
+ class SessionConnections {
29
+ sockets = new Set();
30
+ media = new Map();
31
+ logger = null;
32
+ setLogger(logger) {
33
+ this.logger = logger;
34
+ }
35
+ /** Track an authenticated socket; returns the untrack (call on socket close). */
36
+ trackSocket(socket, identity) {
37
+ const tracked = { socket, identity };
38
+ this.sockets.add(tracked);
39
+ return () => {
40
+ this.sockets.delete(tracked);
41
+ };
42
+ }
43
+ /** Track a live media session opened under `sessionId`; returns the untrack. */
44
+ trackMedia(sessionId, hold) {
45
+ const holds = this.media.get(sessionId) ?? new Map();
46
+ holds.set(hold.mediaSessionId, hold);
47
+ if (holds.size > MAX_MEDIA_HOLDS_PER_SESSION) {
48
+ const oldest = holds.keys().next();
49
+ if (!oldest.done)
50
+ holds.delete(oldest.value);
51
+ }
52
+ this.media.set(sessionId, holds);
53
+ return () => {
54
+ const current = this.media.get(sessionId);
55
+ current?.delete(hold.mediaSessionId);
56
+ if (current?.size === 0)
57
+ this.media.delete(sessionId);
58
+ };
59
+ }
60
+ /** A media session the client closed itself: nothing left to end. */
61
+ forgetMedia(sessionId, mediaSessionId) {
62
+ const current = this.media.get(sessionId);
63
+ current?.delete(mediaSessionId);
64
+ if (current?.size === 0)
65
+ this.media.delete(sessionId);
66
+ }
67
+ /** End what the revoked sessions hold. Sessions not named are never touched. */
68
+ endSessions(input) {
69
+ const named = new Set(input.sessionIds);
70
+ let sockets = 0;
71
+ for (const tracked of [...this.sockets]) {
72
+ const { sessionId, userId } = tracked.identity;
73
+ const hit = sessionId !== undefined
74
+ ? named.has(sessionId)
75
+ : input.allOfUser && input.userId !== null && userId === input.userId;
76
+ if (!hit)
77
+ continue;
78
+ this.sockets.delete(tracked);
79
+ try {
80
+ tracked.socket.close(exports.SESSION_ENDED_CLOSE_CODE, exports.SESSION_ENDED_CLOSE_REASON);
81
+ sockets += 1;
82
+ }
83
+ catch (error) {
84
+ this.logger?.warn('Closing a socket of an ended session failed', {
85
+ meta: { error: error instanceof Error ? error.message : String(error) },
86
+ });
87
+ }
88
+ }
89
+ let mediaSessions = 0;
90
+ for (const sessionId of named) {
91
+ const holds = this.media.get(sessionId);
92
+ if (holds === undefined)
93
+ continue;
94
+ this.media.delete(sessionId);
95
+ for (const hold of holds.values()) {
96
+ mediaSessions += 1;
97
+ void this.closeMedia(hold, input.reason);
98
+ }
99
+ }
100
+ if (sockets > 0 || mediaSessions > 0) {
101
+ this.logger?.info('Revoked sessions ended their connections', {
102
+ meta: { reason: input.reason, sessions: input.sessionIds.length, sockets, mediaSessions },
103
+ });
104
+ }
105
+ return { sockets, mediaSessions };
106
+ }
107
+ async closeMedia(hold, reason) {
108
+ try {
109
+ await hold.close();
110
+ this.logger?.info('Media session closed by session revocation', {
111
+ tags: { deviceId: hold.deviceId },
112
+ meta: { mediaSessionId: hold.mediaSessionId, reason },
113
+ });
114
+ }
115
+ catch (error) {
116
+ this.logger?.warn('Closing a media session of an ended session failed', {
117
+ tags: { deviceId: hold.deviceId },
118
+ meta: {
119
+ mediaSessionId: hold.mediaSessionId,
120
+ error: error instanceof Error ? error.message : String(error),
121
+ },
122
+ });
123
+ }
124
+ }
125
+ }
126
+ exports.SessionConnections = SessionConnections;
127
+ let instance = null;
128
+ /** The process-wide registry (the WS factory, the WebRTC wrapper and the revoke listener share it). */
129
+ function getSessionConnections() {
130
+ instance ??= new SessionConnections();
131
+ return instance;
132
+ }
133
+ /** Test-only. */
134
+ function __resetSessionConnectionsForTests() {
135
+ instance = null;
136
+ }
@@ -82,7 +82,13 @@ async function finishSsoApp(bridge, deps) {
82
82
  const parked = deps.confirmations.create({
83
83
  userId: record.id,
84
84
  username: record.username,
85
- app: { codeChallenge: app.codeChallenge, state: app.state, purpose: app.purpose, redirect },
85
+ app: {
86
+ codeChallenge: app.codeChallenge,
87
+ state: app.state,
88
+ purpose: app.purpose,
89
+ redirect,
90
+ ...(app.lang !== undefined ? { lang: app.lang } : {}),
91
+ },
86
92
  authMethod: 'sso',
87
93
  });
88
94
  return { kind: 'confirm', ...parked };
@@ -56,8 +56,9 @@ const CONFIG_DEFAULTS = {
56
56
  };
57
57
  /**
58
58
  * Bootstrap env overrides. No SECRET is among them (D783, D797): the session
59
- * signing key and the cluster secret live in the keychain, and the first-boot
60
- * admin is fixed (`admin` / `changeme`).
59
+ * signing key lives in the keychain, the first-boot admin is fixed
60
+ * (`admin` / `changeme`), and there is no cluster secret at all (D857 — a
61
+ * node's identity is its mesh certificate).
61
62
  */
62
63
  const ENV_VAR_MAP = {
63
64
  CAMSTACK_PORT: 'server.port',
@@ -66,7 +67,8 @@ const ENV_VAR_MAP = {
66
67
  CAMSTACK_TLS_ENABLED: 'tls.enabled',
67
68
  };
68
69
  /**
69
- * Env variables that once carried a secret into the hub. Ignored since D797 —
70
+ * Env variables that once carried a secret into the hub. Ignored since D797
71
+ * (`CAMSTACK_CLUSTER_SECRET`: the secret itself was removed by D857) —
70
72
  * still named so a container template that sets them is told, not silently
71
73
  * obeyed or silently dropped. Only their PRESENCE is read, never the value.
72
74
  */
@@ -194,8 +196,8 @@ function loadBootstrapConfig(configPath) {
194
196
  const retired = retiredSecretSources(process.env, raw);
195
197
  if (retired.length > 0) {
196
198
  console.warn(`[Phase1] WARN ${retired.join(', ')} ignored — no secret is read from the environment or ` +
197
- 'config.yaml any more (D797). The session signing key and the cluster secret live in the ' +
198
- 'keychain (System → Cluster for the cluster secret); a fresh hub seeds admin / changeme. ' +
199
+ 'config.yaml any more (D797). The session signing key lives in the ' +
200
+ 'keychain; a fresh hub seeds admin / changeme. ' +
199
201
  'Remove these entries — after importing their values with scripts/ops/keychain-migrate-hub.mjs.');
200
202
  }
201
203
  const ignored = ignoredTlsCertSources(process.env, raw);
@@ -469,16 +469,6 @@ class AgentRegistryService {
469
469
  this.moleculer.setOnAgentRegistered((agentId) => {
470
470
  void this.reconcileAgentAddons(agentId, 'node-registration');
471
471
  });
472
- // D802: the mesh `transition` ends only once every agent this hub has
473
- // EVER seen has authenticated — offline ones included. `null` (store not
474
- // wired or not answering) never counts as "no agents".
475
- this.moleculer.setKnownAgentsProvider(async () => {
476
- const store = this.historyStore;
477
- if (!store)
478
- return null;
479
- const rows = await store.tryGetAll();
480
- return rows === null ? null : rows.filter((r) => !r.isHub).map((r) => r.id);
481
- });
482
472
  this.broker.localBus.on('$node.connected', ({ node }) => {
483
473
  const kind = classifyNode(node.id);
484
474
  if (!kind)
@@ -2,132 +2,61 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.HubMeshGuard = void 0;
4
4
  /**
5
- * The hub's mesh guard (D802) — one object owning everything that decides
6
- * whether another node may talk to the hub:
5
+ * The hub's mesh guard (D802, D857) — the one object that decides whether
6
+ * another node may talk to the hub.
7
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.
8
+ * A node is admitted by its CERTIFICATE: the mutual-TLS handshake chained to
9
+ * the mesh CA, the leaf CN is the node id it speaks as, and the serial is not
10
+ * revoked. The transport answers that (`TlsTcpTransporter.verifiedPeer`,
11
+ * published through the process peer lookup); this class turns the answer
12
+ * into the broker middleware and says, once per node and minute, who was
13
+ * turned away and why. There is no shared secret, no mode and no transition.
13
14
  *
14
15
  * Kept out of `moleculer.service.ts` so that file only wires it.
15
16
  */
16
17
  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;
18
+ /** At most one "refused" line per node per this long. */
19
+ const REFUSED_LOG_INTERVAL_MS = 60_000;
20
20
  class HubMeshGuard {
21
21
  ownNodeId;
22
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) {
23
+ now;
24
+ gate;
25
+ lastLoggedAt = new Map();
26
+ constructor(ownNodeId, logger, now = Date.now, isVerifiedPeer = system_1.isProcessVerifiedMeshPeer) {
31
27
  this.ownNodeId = ownNodeId;
32
28
  this.logger = logger;
33
- this.authenticator = new system_1.MeshAuthenticator({
29
+ this.now = now;
30
+ this.gate = new system_1.MeshIdentityGate({
34
31
  ownNodeId,
35
- gate: system_1.getProcessClusterSecretGate,
36
- mode: system_1.getProcessMeshAuthMode,
37
- isVerifiedPeer: system_1.isProcessVerifiedMeshPeer,
38
- onAuthenticated: (nodeId) => {
39
- this.logger.info('node authenticated to the mesh (challenge–response)', {
40
- meta: { nodeId },
41
- });
42
- void this.evaluateAutoEnforce();
43
- },
44
- onTolerated: (nodeId, action) => {
45
- this.logger.warn('node is calling the hub WITHOUT having authenticated — tolerated while System → ' +
46
- 'Cluster is in Transition. Enter the cluster secret in this agent.', { meta: { nodeId, firstCall: action } });
47
- },
32
+ isVerifiedPeer,
33
+ onRefused: (nodeId, what) => this.noteRefused(nodeId, what),
48
34
  });
49
- (0, system_1.setProcessMeshAuthStatusSource)(() => this.authenticator.snapshot());
50
35
  }
51
36
  /** The middleware the hub broker must be constructed with. */
52
37
  middleware() {
53
38
  return (0, system_1.createMeshAccessMiddleware)({
54
39
  ownNodeId: this.ownNodeId,
55
- authenticator: this.authenticator,
40
+ gate: this.gate,
56
41
  logger: {
57
42
  warn: (message, meta) => this.logger.warn(message, meta ? { meta } : undefined),
58
43
  },
59
44
  });
60
45
  }
61
- /** Start the periodic auto-enforce check; `listConnected` reads the live node list. */
62
- start(listConnected) {
63
- this.listConnected = listConnected;
64
- if (this.timer !== null)
65
- return;
66
- this.timer = setInterval(() => void this.evaluateAutoEnforce(), AUTO_ENFORCE_INTERVAL_MS);
67
- this.timer.unref?.();
68
- }
69
- /** Wired by `AgentRegistryService`, which owns the cluster-node history. */
70
- setKnownAgentsProvider(provider) {
71
- this.knownAgents = provider;
72
- }
73
- /** A node's connection dropped. */
46
+ /** A node's connection dropped: forget the throttle, not an identity (there is none to forget). */
74
47
  forget(nodeId) {
75
- this.authenticator.forget(nodeId);
76
- }
77
- stop() {
78
- if (this.timer !== null)
79
- clearInterval(this.timer);
80
- this.timer = null;
81
- (0, system_1.setProcessMeshAuthStatusSource)(null);
48
+ this.lastLoggedAt.delete(nodeId);
82
49
  }
83
- /** Public for the spec; the timer and every join call it. */
84
- async evaluateAutoEnforce() {
85
- if (this.evaluating || (0, system_1.getProcessMeshAuthMode)() !== 'transition')
50
+ noteRefused(nodeId, what) {
51
+ const at = this.now();
52
+ const last = this.lastLoggedAt.get(nodeId);
53
+ if (last !== undefined && at - last < REFUSED_LOG_INTERVAL_MS)
86
54
  return;
87
- this.evaluating = true;
88
- try {
89
- let knownAgents = null;
90
- if (this.knownAgents !== null) {
91
- try {
92
- knownAgents = await this.knownAgents();
93
- }
94
- catch {
95
- knownAgents = null;
96
- }
97
- }
98
- const decision = (0, system_1.decideMeshAutoEnforce)({
99
- mode: (0, system_1.getProcessMeshAuthMode)(),
100
- gate: (0, system_1.getProcessClusterSecretGate)().kind,
101
- uptimeMs: Date.now() - this.startedAt,
102
- knownAgents,
103
- connected: this.listConnected(),
104
- toleratedEver: this.authenticator.everToleratedNodeIds(),
105
- authenticatedEver: this.authenticator.everAuthenticatedNodeIds(),
106
- ownNodeId: this.ownNodeId,
107
- });
108
- if (!decision.enforce) {
109
- const waiting = decision.waitingFor.join(',');
110
- if (waiting.length > 0 && waiting !== this.lastWaitingFor) {
111
- this.logger.info('mesh transition: still waiting for agents to authenticate', {
112
- meta: { waitingFor: decision.waitingFor },
113
- });
114
- }
115
- this.lastWaitingFor = waiting;
116
- return;
117
- }
118
- const done = await (0, system_1.requestProcessMeshEnforcement)(decision.reason);
119
- if (done) {
120
- this.logger.info('mesh authentication switched to ENFORCED', {
121
- meta: { reason: decision.reason },
122
- });
123
- }
124
- }
125
- catch (err) {
126
- this.logger.warn('mesh auto-enforce check failed', { meta: { error: (0, types_1.errMsg)(err) } });
127
- }
128
- finally {
129
- this.evaluating = false;
130
- }
55
+ if (this.lastLoggedAt.size >= 256)
56
+ this.lastLoggedAt.clear();
57
+ this.lastLoggedAt.set(nodeId, at);
58
+ this.logger.warn('a node without a valid mesh certificate called the hub — refused. Pair it from ' +
59
+ 'System → Cluster → Agents', { tags: { nodeId }, meta: { firstCall: what } });
131
60
  }
132
61
  }
133
62
  exports.HubMeshGuard = HubMeshGuard;