@camstack/system 1.2.386 → 1.2.388

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.
@@ -15,5 +15,17 @@ export declare const systemConfigMeshActions: {
15
15
  }, z.core.$strip>, "mutation", "admin", {
16
16
  readonly kind: "system";
17
17
  }>;
18
+ /** Hub admin → short-lived ticket the agent redeems for a tab session (D821). */
19
+ mintAgentUiUnlockTicket: import('@camstack/types').CustomActionSpec<z.ZodObject<{
20
+ nodeId: z.ZodString;
21
+ dashboardBaseUrl: z.ZodOptional<z.ZodString>;
22
+ }, z.core.$strip>, z.ZodObject<{
23
+ ticket: z.ZodString;
24
+ exp: z.ZodNumber;
25
+ nodeId: z.ZodString;
26
+ dashboardUrl: z.ZodOptional<z.ZodString>;
27
+ }, z.core.$strip>, "mutation", "admin", {
28
+ readonly kind: "system";
29
+ }>;
18
30
  };
19
31
  export type SystemConfigMeshActions = typeof systemConfigMeshActions;
@@ -23,6 +23,17 @@ export declare class SystemConfigAddon extends BaseAddon {
23
23
  }, import('zod/v4/core').$strip>, "mutation", "admin", {
24
24
  readonly kind: "system";
25
25
  }>;
26
+ mintAgentUiUnlockTicket: import('@camstack/types').CustomActionSpec<import('zod').ZodObject<{
27
+ nodeId: import('zod').ZodString;
28
+ dashboardBaseUrl: import('zod').ZodOptional<import('zod').ZodString>;
29
+ }, import('zod/v4/core').$strip>, import('zod').ZodObject<{
30
+ ticket: import('zod').ZodString;
31
+ exp: import('zod').ZodNumber;
32
+ nodeId: import('zod').ZodString;
33
+ dashboardUrl: import('zod').ZodOptional<import('zod').ZodString>;
34
+ }, import('zod/v4/core').$strip>, "mutation", "admin", {
35
+ readonly kind: "system";
36
+ }>;
26
37
  };
27
38
  actionHandlers: {
28
39
  mintJoinToken: (input: unknown) => Promise<{
@@ -33,6 +44,12 @@ export declare class SystemConfigAddon extends BaseAddon {
33
44
  caFingerprint: string;
34
45
  nodeId?: string;
35
46
  }>;
47
+ mintAgentUiUnlockTicket: (input: unknown) => Promise<{
48
+ ticket: string;
49
+ exp: number;
50
+ nodeId: string;
51
+ dashboardUrl?: string;
52
+ }>;
36
53
  };
37
54
  }>;
38
55
  protected onShutdown(): Promise<void>;
@@ -44,6 +61,9 @@ export declare class SystemConfigAddon extends BaseAddon {
44
61
  private applyStoredClearPort;
45
62
  private onMeshCaReady;
46
63
  private mintJoinToken;
64
+ /** D821 — short-lived ticket for the agent's `:4444` unlock session. */
65
+ private mintAgentUiUnlock;
66
+ private readAgentDashboardOrigins;
47
67
  private resolveHubUrl;
48
68
  private resolveHubMeshAddress;
49
69
  /** The keychain handle for owner `system`; `null` where there is no door. */
@@ -55,7 +75,8 @@ export declare class SystemConfigAddon extends BaseAddon {
55
75
  * The cluster secret's save path: the value goes to the keychain (owner
56
76
  * `system`), the row gets the marker; `''` / a view keeps. `$secretClear` is
57
77
  * REFUSED: the secret is mandatory (D802). A value the keychain cannot store
58
- * is REFUSED — never written inline (D787, P5). `meshAuth` is a plain value.
78
+ * is REFUSED — never written inline (D787, P5). Mesh auth mode is always
79
+ * enforced (D821) — a legacy `meshAuth` in the patch is ignored.
59
80
  */
60
81
  private writeClusterSection;
61
82
  /**
@@ -64,13 +85,12 @@ export declare class SystemConfigAddon extends BaseAddon {
64
85
  * Persist, then apply — an `on` save always passes.
65
86
  */
66
87
  private writeMeshClearPort;
67
- /** `cluster.meshAuth` (D802), then republish it. */
88
+ /** Persist + publish mesh auth mode (always `enforced` after D821). */
68
89
  private writeMeshAuthMode;
69
90
  /**
70
91
  * First boot, or a keychain reset that cleared the marker (D802): a fresh
71
- * random secret, stored like an operator save. A NEW secret means every
72
- * agent must be given it, so the mesh goes (back) to `transition`; on a hub
73
- * with no agent yet, the hub's auto-enforce ends it at once.
92
+ * random secret, stored like an operator save. Mesh auth stays enforced
93
+ * (D821) — every agent must present the secret; there is no transition window.
74
94
  */
75
95
  private generateClusterSecret;
76
96
  private storeClusterSecret;
@@ -150,6 +150,145 @@ function getProcessMeshAuthStatus() {
150
150
  return typeof source === "function" ? source() : null;
151
151
  }
152
152
  //#endregion
153
+ //#region src/kernel/moleculer/agent-ui-unlock.ts
154
+ /**
155
+ * Agent UI remote unlock tickets (D821).
156
+ *
157
+ * A hub admin mints a short-lived HMAC ticket bound to one agent `nodeId`.
158
+ * The agent redeems it once for a tab session Bearer — the cluster secret
159
+ * stays a mesh / pairing credential and is not pasted into the browser for
160
+ * daily unlock.
161
+ *
162
+ * K = SHA-256(cluster secret) — same as mesh join (D802)
163
+ * ticket = base64url(JSON payload) + '.' + base64url(HMAC-SHA256(K, DOMAIN|payloadB64))
164
+ * payload = { v: 1, nodeId, exp (ms), jti }
165
+ */
166
+ var AGENT_UI_UNLOCK_DOMAIN = "camstack-agent-ui-unlock/v1";
167
+ var AGENT_UI_UNLOCK_VERSION = 1;
168
+ /** Ticket lifetime — long enough for a redirect hop, short enough to steal poorly. */
169
+ var AGENT_UI_UNLOCK_TTL_MS = 5 * 6e4;
170
+ /**
171
+ * Cluster settings key: extra origins allowed as `returnTo` for Continue-with-hub
172
+ * (NPM hostnames that are not `:4444` on a LAN IP). Comma-separated origins.
173
+ */
174
+ var AGENT_DASHBOARD_ORIGINS_FIELD = "agentDashboardOrigins";
175
+ /** In-memory spent `jti` until `exp` (ms). */
176
+ var UnlockTicketJtiStore = class {
177
+ spent = /* @__PURE__ */ new Map();
178
+ isSpent(jti, nowMs = Date.now()) {
179
+ this.gc(nowMs);
180
+ return this.spent.has(jti);
181
+ }
182
+ /** Record a jti as used until `exp`. Returns false if already spent. */
183
+ consume(jti, exp, nowMs = Date.now()) {
184
+ this.gc(nowMs);
185
+ if (this.spent.has(jti)) return false;
186
+ this.spent.set(jti, exp);
187
+ return true;
188
+ }
189
+ /** Test helper. */
190
+ size() {
191
+ return this.spent.size;
192
+ }
193
+ gc(nowMs) {
194
+ for (const [jti, exp] of this.spent) if (exp <= nowMs) this.spent.delete(jti);
195
+ }
196
+ };
197
+ function mintAgentUiUnlockTicket(input) {
198
+ const nowMs = input.nowMs ?? Date.now();
199
+ const ttl = Math.min(Math.max(1e3, input.ttlMs ?? 3e5), AGENT_UI_UNLOCK_TTL_MS);
200
+ const payload = {
201
+ v: 1,
202
+ nodeId: input.nodeId,
203
+ exp: nowMs + ttl,
204
+ jti: input.jti ?? (0, node_crypto.randomBytes)(16).toString("base64url")
205
+ };
206
+ const payloadB64 = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
207
+ return `${payloadB64}.${sign(input.secretHash, payloadB64)}`;
208
+ }
209
+ /**
210
+ * Verify a ticket under `secretHash`. Returns the payload, or `null` when the
211
+ * signature, shape, expiry or nodeId fails (no reason string — refuse quietly).
212
+ */
213
+ function verifyAgentUiUnlockTicket(secretHash, ticket, options) {
214
+ const nowMs = options.nowMs ?? Date.now();
215
+ const dot = ticket.indexOf(".");
216
+ if (dot <= 0 || dot === ticket.length - 1) return null;
217
+ const payloadB64 = ticket.slice(0, dot);
218
+ const sig = ticket.slice(dot + 1);
219
+ if (!sigsEqual(sign(secretHash, payloadB64), sig)) return null;
220
+ let parsed;
221
+ try {
222
+ parsed = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf8"));
223
+ } catch {
224
+ return null;
225
+ }
226
+ const payload = parsePayload(parsed);
227
+ if (payload === null) return null;
228
+ if (payload.exp <= nowMs) return null;
229
+ if (payload.nodeId !== options.expectedNodeId) return null;
230
+ return payload;
231
+ }
232
+ /**
233
+ * True when `returnTo` may receive a ticket redirect. Default: any http(s)
234
+ * origin whose port is 4444. Extra origins come from
235
+ * {@link AGENT_DASHBOARD_ORIGINS_FIELD} (exact origin match).
236
+ */
237
+ function isAllowedAgentDashboardReturnTo(returnTo, extraOrigins = []) {
238
+ let url;
239
+ try {
240
+ url = new URL(returnTo);
241
+ } catch {
242
+ return false;
243
+ }
244
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
245
+ if (url.username !== "" || url.password !== "") return false;
246
+ if (url.pathname !== "/" && url.pathname !== "") return false;
247
+ if (url.port === "4444") return true;
248
+ const origin = url.origin;
249
+ return extraOrigins.some((o) => o === origin);
250
+ }
251
+ /** Parse a stored comma/whitespace-separated origins list. */
252
+ function parseAgentDashboardOrigins(stored) {
253
+ if (typeof stored !== "string" || stored.trim().length === 0) return [];
254
+ const out = [];
255
+ for (const part of stored.split(/[,\s]+/)) {
256
+ const t = part.trim();
257
+ if (t.length === 0) continue;
258
+ try {
259
+ const u = new URL(t);
260
+ if (u.protocol === "http:" || u.protocol === "https:") out.push(u.origin);
261
+ } catch {}
262
+ }
263
+ return out;
264
+ }
265
+ function sign(secretHash, payloadB64) {
266
+ return (0, node_crypto.createHmac)("sha256", Buffer.from(secretHash, "utf8")).update(`${AGENT_UI_UNLOCK_DOMAIN}|${payloadB64}`, "utf8").digest("base64url");
267
+ }
268
+ function sigsEqual(expected, presented) {
269
+ const a = Buffer.from(expected, "utf8");
270
+ const b = Buffer.from(presented, "utf8");
271
+ if (a.length !== b.length) return false;
272
+ return (0, node_crypto.timingSafeEqual)(a, b);
273
+ }
274
+ function parsePayload(value) {
275
+ if (typeof value !== "object" || value === null) return null;
276
+ const rec = value;
277
+ if (rec["v"] !== 1) return null;
278
+ const nodeId = rec["nodeId"];
279
+ const exp = rec["exp"];
280
+ const jti = rec["jti"];
281
+ if (typeof nodeId !== "string" || nodeId.length === 0) return null;
282
+ if (typeof exp !== "number" || !Number.isFinite(exp)) return null;
283
+ if (typeof jti !== "string" || jti.length === 0) return null;
284
+ return {
285
+ v: 1,
286
+ nodeId,
287
+ exp,
288
+ jti
289
+ };
290
+ }
291
+ //#endregion
153
292
  //#region src/kernel/moleculer/mesh-mtls/mesh-ca.ts
154
293
  require_x509_es.cryptoProvider.set(node_crypto.webcrypto);
155
294
  var MS_PER_DAY = 864e5;
@@ -87378,6 +87517,9 @@ var TlsTcpTransporter = class extends TcpTransporter {
87378
87517
  /** Node id (certificate CN) → serial of every peer that completed a mutual-TLS handshake. */
87379
87518
  tlsPeers = /* @__PURE__ */ new Map();
87380
87519
  clearDisabled = false;
87520
+ /** Clear-text `TcpWriter.connect` captured once before the TLS wrap (A→B). */
87521
+ clearWriterConnect = null;
87522
+ writerTlsPatched = false;
87381
87523
  constructor(opts = {}) {
87382
87524
  super({ ...opts });
87383
87525
  this.meshTls = opts.meshTls ?? null;
@@ -87400,20 +87542,43 @@ var TlsTcpTransporter = class extends TcpTransporter {
87400
87542
  }).then(() => this.onConnected());
87401
87543
  }
87402
87544
  /**
87403
- * Agent / outbound: wrap `TcpWriter.connect` so peers are dialed with
87404
- * mutual TLS and the peer CN must match the expected node id.
87545
+ * Outbound dial uses mTLS when the peer is already known on TLS (inbound
87546
+ * handshake), or when dialling the hub node id (agent → hub). Everyone else
87547
+ * (runners, unenrolled agents) stays on the clear writer — the hub must not
87548
+ * TLS-wrap every `connect` after deferred `enableMeshTls`.
87549
+ */
87550
+ shouldDialPeerWithTls(nodeID) {
87551
+ if (nodeID === "hub") return true;
87552
+ return this.tlsPeers.has(nodeID);
87553
+ }
87554
+ peerTlsDialPort() {
87555
+ return this.opts.tlsPort ?? 6443;
87556
+ }
87557
+ /**
87558
+ * Wrap `TcpWriter.connect` so TLS peers are dialed with mutual TLS on the
87559
+ * well-known mesh TLS port (default 6443). Idempotent: the clear connect is
87560
+ * captured once; material refresh rebinds the wrapper.
87405
87561
  */
87406
87562
  patchWriterForTlsClient() {
87407
- const material = this.meshTls;
87408
- if (material === null) return;
87563
+ if (this.meshTls === null) return;
87409
87564
  const writer = this.writer;
87410
87565
  if (writer === null || typeof writer.connect !== "function") return;
87566
+ if (!this.writerTlsPatched) {
87567
+ this.clearWriterConnect = writer.connect.bind(writer);
87568
+ this.writerTlsPatched = true;
87569
+ }
87570
+ const clearConnect = this.clearWriterConnect;
87571
+ if (clearConnect === null) return;
87411
87572
  const crl = this.crl;
87573
+ const self = this;
87412
87574
  writer.connect = (nodeID) => {
87575
+ if (!self.shouldDialPeerWithTls(nodeID)) return clearConnect(nodeID);
87576
+ const material = self.meshTls;
87577
+ if (material === null) return clearConnect(nodeID);
87413
87578
  const node = writer.transporter.getNode(nodeID);
87414
87579
  if (!node) return writer.Promise.reject(/* @__PURE__ */ new Error(`Missing node info for '${nodeID}'!`));
87415
87580
  const host = writer.transporter.getNodeAddress(node);
87416
- const port = node.port;
87581
+ const port = self.peerTlsDialPort();
87417
87582
  writer.logger.debug(`Connecting (TLS) to '${nodeID}' via ${host}:${port}`);
87418
87583
  return new writer.Promise((resolve, reject) => {
87419
87584
  const socket = node_tls.connect({
@@ -87442,6 +87607,11 @@ var TlsTcpTransporter = class extends TcpTransporter {
87442
87607
  if (identity) {
87443
87608
  tlsSock.meshPeerCn = identity.cn;
87444
87609
  tlsSock.meshPeerSerial = identity.serial;
87610
+ self.tlsPeers.set(identity.cn, identity.serial);
87611
+ self.tlsSockets.add(tlsSock);
87612
+ tlsSock.on("close", () => {
87613
+ self.tlsSockets.delete(tlsSock);
87614
+ });
87445
87615
  }
87446
87616
  writer.addSocket(nodeID, tlsSock, true);
87447
87617
  writer.logger.debug(`Connected successfully (TLS) to '${nodeID}'.`);
@@ -87464,11 +87634,13 @@ var TlsTcpTransporter = class extends TcpTransporter {
87464
87634
  }
87465
87635
  /**
87466
87636
  * Enable (or refresh) the TLS listener once the mesh CA / hub leaf are ready.
87467
- * Safe to call after connect — starts listening on `tlsPort` alongside clear.
87637
+ * Safe to call after connect — starts listening on `tlsPort` alongside clear
87638
+ * and patches the writer so hub→agent dials use mTLS (Release A→B).
87468
87639
  */
87469
87640
  async enableMeshTls(material) {
87470
87641
  this.meshTls = material;
87471
87642
  await this.startTlsServerIfReady();
87643
+ this.patchWriterForTlsClient();
87472
87644
  }
87473
87645
  /**
87474
87646
  * How `nodeId` reaches this transporter: `tls` once it completed a mutual-TLS
@@ -87660,7 +87832,7 @@ function isTlsSocket(socket) {
87660
87832
  * - no marker → the secret is MANDATORY (D802): the keeper asks
87661
87833
  * its owner to generate one (`generateSecret`), and
87662
87834
  * until that succeeds the gate is `off` — no node can
87663
- * authenticate, and only `transition` lets them in;
87835
+ * authenticate (mesh auth is always enforced, D821);
87664
87836
  * - marker + value → `on` — nodes prove the secret;
87665
87837
  * - marker, no answer → `unknown` — refused, retryably, and retried here.
87666
87838
  * A locked keychain or a value lost to a reset NEVER opens the gate, and
@@ -87723,7 +87895,7 @@ var ClusterSecretKeeper = class {
87723
87895
  reason: `settings unreadable: ${errText(err)}`
87724
87896
  };
87725
87897
  }
87726
- setProcessMeshAuthMode(parseMeshAuthMode(section[MESH_AUTH_MODE_FIELD]));
87898
+ setProcessMeshAuthMode("enforced");
87727
87899
  const stored = section[CLUSTER_SECRET_FIELD];
87728
87900
  if (stored === void 0 || stored === null || stored === "") return { kind: "off" };
87729
87901
  if (!require_dist.isSecretMarker(stored)) return {
@@ -87791,28 +87963,48 @@ function errText(err) {
87791
87963
  //#endregion
87792
87964
  //#region src/builtins/system-config/mesh-actions.ts
87793
87965
  /**
87794
- * System → Cluster — mint a single-use mesh join token (Release A).
87966
+ * System → Cluster — mint a single-use mesh join token (Release A) and
87967
+ * agent-ui unlock tickets (D821).
87795
87968
  */
87796
- var systemConfigMeshActions = require_dist.defineCustomActions({ mintJoinToken: require_dist.customAction(zod.z.object({
87797
- /** Optional pinned node id for the CSR CN. */
87798
- nodeId: zod.z.string().min(1).optional(),
87799
- /** Override hub HTTPS base (default: local-network publicEndpoint). */
87800
- hubUrl: zod.z.string().url().optional(),
87801
- /** Override `host:6000` clear mesh address carried in the token. */
87802
- hubMeshAddress: zod.z.string().min(3).optional(),
87803
- /** Seconds until expiry; capped at 1 h. */
87804
- ttlSec: zod.z.number().int().positive().max(3600).optional()
87805
- }).optional(), zod.z.object({
87806
- token: zod.z.string().min(1),
87807
- exp: zod.z.number().int(),
87808
- hubUrl: zod.z.string(),
87809
- hubMeshAddress: zod.z.string(),
87810
- caFingerprint: zod.z.string(),
87811
- nodeId: zod.z.string().optional()
87812
- }), {
87813
- kind: "mutation",
87814
- auth: "admin"
87815
- }) });
87969
+ var systemConfigMeshActions = require_dist.defineCustomActions({
87970
+ mintJoinToken: require_dist.customAction(zod.z.object({
87971
+ /** Optional pinned node id for the CSR CN. */
87972
+ nodeId: zod.z.string().min(1).optional(),
87973
+ /** Override hub HTTPS base (default: local-network publicEndpoint). */
87974
+ hubUrl: zod.z.string().url().optional(),
87975
+ /** Override `host:6000` clear mesh address carried in the token. */
87976
+ hubMeshAddress: zod.z.string().min(3).optional(),
87977
+ /** Seconds until expiry; capped at 1 h. */
87978
+ ttlSec: zod.z.number().int().positive().max(3600).optional()
87979
+ }).optional(), zod.z.object({
87980
+ token: zod.z.string().min(1),
87981
+ exp: zod.z.number().int(),
87982
+ hubUrl: zod.z.string(),
87983
+ hubMeshAddress: zod.z.string(),
87984
+ caFingerprint: zod.z.string(),
87985
+ nodeId: zod.z.string().optional()
87986
+ }), {
87987
+ kind: "mutation",
87988
+ auth: "admin"
87989
+ }),
87990
+ /** Hub admin → short-lived ticket the agent redeems for a tab session (D821). */
87991
+ mintAgentUiUnlockTicket: require_dist.customAction(zod.z.object({
87992
+ nodeId: zod.z.string().min(1),
87993
+ /**
87994
+ * Preferred agent dashboard base (`http://host:4444`). When set, the
87995
+ * answer includes `dashboardUrl` with `?ticket=` already attached.
87996
+ */
87997
+ dashboardBaseUrl: zod.z.string().url().optional()
87998
+ }), zod.z.object({
87999
+ ticket: zod.z.string().min(1),
88000
+ exp: zod.z.number().int(),
88001
+ nodeId: zod.z.string().min(1),
88002
+ dashboardUrl: zod.z.string().url().optional()
88003
+ }), {
88004
+ kind: "mutation",
88005
+ auth: "admin"
88006
+ })
88007
+ });
87816
88008
  //#endregion
87817
88009
  //#region src/builtins/system-config/system-config.addon.ts
87818
88010
  /**
@@ -87877,7 +88069,8 @@ var KEY_TO_SECTION = {
87877
88069
  trustedNetworks: "auth",
87878
88070
  [CLUSTER_SECRET_FIELD]: "cluster",
87879
88071
  [MESH_AUTH_MODE_FIELD]: "cluster",
87880
- [MESH_CLEAR_PORT_FIELD]: "cluster"
88072
+ [MESH_CLEAR_PORT_FIELD]: "cluster",
88073
+ [AGENT_DASHBOARD_ORIGINS_FIELD]: "cluster"
87881
88074
  };
87882
88075
  /** Bytes of a generated cluster secret (D802) — 256 bits, base64url (43 chars). */
87883
88076
  var GENERATED_SECRET_BYTES = 32;
@@ -87886,10 +88079,10 @@ var STATE_LABELS = {
87886
88079
  legacy: "legacy hash only — update the agent",
87887
88080
  unauthenticated: "not authenticated — enter the cluster secret in this agent"
87888
88081
  };
87889
- /** The Cluster page's live line per node (D802). */
88082
+ /** The Cluster page's live line per node (D802). Mode is always enforced (D821). */
87890
88083
  function describeMeshStatus(status) {
87891
88084
  if (status === null) return "The mesh status is available on the hub only.";
87892
- const head = status.mode === "enforced" ? "Enforced: a node that has not authenticated is refused." : "Transition: nodes without the secret are still let in (with a warning). The hub switches to Enforced by itself once every known agent has authenticated.";
88085
+ const head = "Enforced: a node that has not authenticated is refused.";
87893
88086
  if (status.nodes.length === 0) return `${head}\nNo other node is connected.`;
87894
88087
  return `${head}\n${status.nodes.map((n) => `• ${n.nodeId}: ${STATE_LABELS[n.state] ?? n.state}`).join("\n")}`;
87895
88088
  }
@@ -87920,6 +88113,9 @@ var SystemConfigAddon = class extends require_dist.BaseAddon {
87920
88113
  this.ctx.logger.info("Mesh authentication is now ENFORCED", { meta: { reason } });
87921
88114
  });
87922
88115
  await this.clusterSecretKeeper.start();
88116
+ try {
88117
+ await this.writeMeshAuthMode("enforced");
88118
+ } catch {}
87923
88119
  this.meshCaKeeper = new MeshCaKeeper({
87924
88120
  secrets: this.systemSecrets(),
87925
88121
  readClusterSection: async () => await this.ctx.settings?.getSection("cluster") ?? {},
@@ -87934,7 +88130,10 @@ var SystemConfigAddon = class extends require_dist.BaseAddon {
87934
88130
  this.ctx.logger.info("Initialized — exposes yml-backed sections via getGlobalSettings");
87935
88131
  return {
87936
88132
  customActions: systemConfigMeshActions,
87937
- actionHandlers: { mintJoinToken: async (input) => this.mintJoinToken(parseMintJoinTokenInput(input)) }
88133
+ actionHandlers: {
88134
+ mintJoinToken: async (input) => this.mintJoinToken(parseMintJoinTokenInput(input)),
88135
+ mintAgentUiUnlockTicket: async (input) => this.mintAgentUiUnlock(parseMintUnlockInput(input))
88136
+ }
87938
88137
  };
87939
88138
  }
87940
88139
  async onShutdown() {
@@ -87987,6 +88186,41 @@ var SystemConfigAddon = class extends require_dist.BaseAddon {
87987
88186
  ...input.nodeId !== void 0 ? { nodeId: input.nodeId } : {}
87988
88187
  };
87989
88188
  }
88189
+ /** D821 — short-lived ticket for the agent's `:4444` unlock session. */
88190
+ async mintAgentUiUnlock(input) {
88191
+ const secrets = this.systemSecrets();
88192
+ if (secrets === null) throw new Error("system-config: no keychain door — cannot mint an unlock ticket");
88193
+ const entry = (await secrets.resolve([require_constants$8.CLUSTER_SECRET_REF]))[require_constants$8.CLUSTER_SECRET_REF];
88194
+ if (entry?.kind !== "value") throw new Error("system-config: cluster secret is not available — cannot mint an unlock ticket");
88195
+ if (input.dashboardBaseUrl !== void 0) {
88196
+ const origins = await this.readAgentDashboardOrigins();
88197
+ if (!isAllowedAgentDashboardReturnTo(input.dashboardBaseUrl, origins)) throw new Error("dashboardBaseUrl is not an allowed agent dashboard origin (:4444 or agentDashboardOrigins)");
88198
+ }
88199
+ const ticket = mintAgentUiUnlockTicket({
88200
+ secretHash: require_constants$8.hashClusterSecret(entry.value),
88201
+ nodeId: input.nodeId
88202
+ });
88203
+ const payloadJson = Buffer.from(ticket.split(".")[0] ?? "", "base64url").toString("utf8");
88204
+ const exp = JSON.parse(payloadJson).exp ?? 0;
88205
+ const dashboardUrl = input.dashboardBaseUrl === void 0 ? void 0 : (() => {
88206
+ const u = new URL(input.dashboardBaseUrl);
88207
+ u.searchParams.set("ticket", ticket);
88208
+ return u.toString();
88209
+ })();
88210
+ return {
88211
+ ticket,
88212
+ exp,
88213
+ nodeId: input.nodeId,
88214
+ ...dashboardUrl !== void 0 ? { dashboardUrl } : {}
88215
+ };
88216
+ }
88217
+ async readAgentDashboardOrigins() {
88218
+ try {
88219
+ return parseAgentDashboardOrigins((await this.ctx.settings?.getSection("cluster"))?.[AGENT_DASHBOARD_ORIGINS_FIELD]);
88220
+ } catch {
88221
+ return [];
88222
+ }
88223
+ }
87990
88224
  async resolveHubUrl() {
87991
88225
  try {
87992
88226
  const book = await this.ctx.api?.localNetwork?.getAddresses?.query({ scheme: "https" });
@@ -88106,20 +88340,6 @@ var SystemConfigAddon = class extends require_dist.BaseAddon {
88106
88340
  label: "Cluster secret",
88107
88341
  description: "Mandatory — generated by the hub at first boot and stored in the keychain. To pair an agent: Reveal and copy it here, then enter it in the agent (its status page, or the desktop setup window). Saving a new value rotates it: every agent must then be given the new one."
88108
88342
  },
88109
- {
88110
- type: "select",
88111
- key: MESH_AUTH_MODE_FIELD,
88112
- label: "Agents without the secret",
88113
- description: "Transition lets an agent that has not authenticated keep working (with a warning) while you give each agent the secret; it ends by itself once every known agent has authenticated. Enforced refuses it.",
88114
- default: "transition",
88115
- options: [{
88116
- value: "transition",
88117
- label: "Transition — allow, warn"
88118
- }, {
88119
- value: "enforced",
88120
- label: "Enforced — refuse"
88121
- }]
88122
- },
88123
88343
  {
88124
88344
  type: "select",
88125
88345
  key: MESH_CLEAR_PORT_FIELD,
@@ -88141,6 +88361,12 @@ var SystemConfigAddon = class extends require_dist.BaseAddon {
88141
88361
  content: describeMeshStatus(getProcessMeshAuthStatus()),
88142
88362
  variant: "info"
88143
88363
  },
88364
+ {
88365
+ type: "string",
88366
+ key: AGENT_DASHBOARD_ORIGINS_FIELD,
88367
+ label: "Agent dashboard origins (NPM)",
88368
+ description: "Extra origins allowed for Continue-with-hub unlock redirects (comma-separated), e.g. https://agent.example.com. LAN agents on :4444 are always allowed."
88369
+ },
88144
88370
  {
88145
88371
  type: "addon-action-button",
88146
88372
  key: "mintJoinTokenAction",
@@ -88213,15 +88439,14 @@ var SystemConfigAddon = class extends require_dist.BaseAddon {
88213
88439
  * The cluster secret's save path: the value goes to the keychain (owner
88214
88440
  * `system`), the row gets the marker; `''` / a view keeps. `$secretClear` is
88215
88441
  * REFUSED: the secret is mandatory (D802). A value the keychain cannot store
88216
- * is REFUSED — never written inline (D787, P5). `meshAuth` is a plain value.
88442
+ * is REFUSED — never written inline (D787, P5). Mesh auth mode is always
88443
+ * enforced (D821) — a legacy `meshAuth` in the patch is ignored.
88217
88444
  */
88218
88445
  async writeClusterSection(patch) {
88219
- const { [MESH_AUTH_MODE_FIELD]: mode, [MESH_CLEAR_PORT_FIELD]: clearPort, ...secretPatch } = patch;
88446
+ const { [MESH_AUTH_MODE_FIELD]: _legacyMode, [MESH_CLEAR_PORT_FIELD]: clearPort, [AGENT_DASHBOARD_ORIGINS_FIELD]: dashboardOrigins, ...secretPatch } = patch;
88220
88447
  if (clearPort !== void 0) await this.writeMeshClearPort(clearPort);
88221
- if (mode !== void 0) {
88222
- if (!isMeshAuthMode(mode)) throw new Error(`system-config: meshAuth must be 'transition' or 'enforced'`);
88223
- await this.writeMeshAuthMode(mode);
88224
- }
88448
+ if (dashboardOrigins !== void 0) await this.ctx.settings.setSection("cluster", { [AGENT_DASHBOARD_ORIGINS_FIELD]: typeof dashboardOrigins === "string" ? dashboardOrigins : "" });
88449
+ await this.writeMeshAuthMode("enforced");
88225
88450
  if (Object.keys(secretPatch).length === 0) return;
88226
88451
  if (require_dist.isSecretClear(secretPatch["clusterSecret"])) throw new Error("The cluster secret is mandatory and cannot be cleared — save a new value to rotate it");
88227
88452
  await this.storeClusterSecret(secretPatch);
@@ -88249,16 +88474,15 @@ var SystemConfigAddon = class extends require_dist.BaseAddon {
88249
88474
  await applyProcessMeshClearPort(value);
88250
88475
  this.ctx.logger.info("Mesh clear port switched", { meta: { mode: value } });
88251
88476
  }
88252
- /** `cluster.meshAuth` (D802), then republish it. */
88477
+ /** Persist + publish mesh auth mode (always `enforced` after D821). */
88253
88478
  async writeMeshAuthMode(mode) {
88254
88479
  await this.ctx.settings.setSection("cluster", { [MESH_AUTH_MODE_FIELD]: mode });
88255
- await this.clusterSecretKeeper?.reload();
88480
+ setProcessMeshAuthMode(mode);
88256
88481
  }
88257
88482
  /**
88258
88483
  * First boot, or a keychain reset that cleared the marker (D802): a fresh
88259
- * random secret, stored like an operator save. A NEW secret means every
88260
- * agent must be given it, so the mesh goes (back) to `transition`; on a hub
88261
- * with no agent yet, the hub's auto-enforce ends it at once.
88484
+ * random secret, stored like an operator save. Mesh auth stays enforced
88485
+ * (D821) — every agent must present the secret; there is no transition window.
88262
88486
  */
88263
88487
  async generateClusterSecret() {
88264
88488
  const settings = this.ctx.settings;
@@ -88273,7 +88497,8 @@ var SystemConfigAddon = class extends require_dist.BaseAddon {
88273
88497
  if (existing?.kind !== "missing") throw new Error(`the keychain cannot answer for the cluster secret (${existing?.kind ?? "no answer"})`);
88274
88498
  const value = (0, node_crypto.randomBytes)(GENERATED_SECRET_BYTES).toString("base64url");
88275
88499
  await this.storeClusterSecret({ [CLUSTER_SECRET_FIELD]: value });
88276
- await settings.setSection("cluster", { [MESH_AUTH_MODE_FIELD]: "transition" });
88500
+ await settings.setSection("cluster", { [MESH_AUTH_MODE_FIELD]: "enforced" });
88501
+ setProcessMeshAuthMode("enforced");
88277
88502
  }
88278
88503
  async storeClusterSecret(patch) {
88279
88504
  const settings = this.ctx.settings;
@@ -88306,6 +88531,16 @@ function parseMintJoinTokenInput(input) {
88306
88531
  if (typeof ttlSec === "number") out.ttlSec = ttlSec;
88307
88532
  return out;
88308
88533
  }
88534
+ function parseMintUnlockInput(input) {
88535
+ if (input === null || typeof input !== "object") throw new Error("system-config: mintAgentUiUnlockTicket requires { nodeId }");
88536
+ const nodeId = Reflect.get(input, "nodeId");
88537
+ if (typeof nodeId !== "string" || nodeId.length === 0) throw new Error("system-config: mintAgentUiUnlockTicket requires { nodeId }");
88538
+ const dashboardBaseUrl = Reflect.get(input, "dashboardBaseUrl");
88539
+ return {
88540
+ nodeId,
88541
+ ...typeof dashboardBaseUrl === "string" ? { dashboardBaseUrl } : {}
88542
+ };
88543
+ }
88309
88544
  /** Read `exp` from an unencrypted JWT payload (no verify — already minted here). */
88310
88545
  function readJwtExp(token) {
88311
88546
  const parts = token.split(".");
@@ -88317,6 +88552,10 @@ function readJwtExp(token) {
88317
88552
  return typeof exp === "number" ? exp : 0;
88318
88553
  }
88319
88554
  //#endregion
88555
+ exports.AGENT_DASHBOARD_ORIGINS_FIELD = AGENT_DASHBOARD_ORIGINS_FIELD;
88556
+ exports.AGENT_UI_UNLOCK_DOMAIN = AGENT_UI_UNLOCK_DOMAIN;
88557
+ exports.AGENT_UI_UNLOCK_TTL_MS = AGENT_UI_UNLOCK_TTL_MS;
88558
+ exports.AGENT_UI_UNLOCK_VERSION = AGENT_UI_UNLOCK_VERSION;
88320
88559
  exports.Errors = Errors;
88321
88560
  exports.JoinTokenJtiStore = JoinTokenJtiStore;
88322
88561
  exports.MESH_AUTH_MODE_FIELD = MESH_AUTH_MODE_FIELD;
@@ -88328,6 +88567,7 @@ exports.ServiceBroker = ServiceBroker;
88328
88567
  exports.SystemConfigAddon = SystemConfigAddon;
88329
88568
  exports.default = SystemConfigAddon;
88330
88569
  exports.TlsTcpTransporter = TlsTcpTransporter;
88570
+ exports.UnlockTicketJtiStore = UnlockTicketJtiStore;
88331
88571
  exports.applyProcessMeshClearPort = applyProcessMeshClearPort;
88332
88572
  exports.caFingerprint = caFingerprint;
88333
88573
  exports.caFingerprintCompact = caFingerprintCompact;
@@ -88343,12 +88583,15 @@ exports.getProcessMeshAuthMode = getProcessMeshAuthMode;
88343
88583
  exports.getProcessMeshAuthStatus = getProcessMeshAuthStatus;
88344
88584
  exports.getProcessMeshCaState = getProcessMeshCaState;
88345
88585
  exports.getProcessNodeMeshTransport = getProcessNodeMeshTransport;
88586
+ exports.isAllowedAgentDashboardReturnTo = isAllowedAgentDashboardReturnTo;
88346
88587
  exports.isMeshAuthMode = isMeshAuthMode;
88347
88588
  exports.issueMeshLeafForNode = issueMeshLeafForNode;
88348
88589
  exports.issueMeshLeafFromCsr = issueMeshLeafFromCsr;
88349
88590
  exports.loadMeshCa = loadMeshCa;
88591
+ exports.mintAgentUiUnlockTicket = mintAgentUiUnlockTicket;
88350
88592
  exports.mintMeshJoinToken = mintMeshJoinToken;
88351
88593
  exports.normalizeIp = normalizeIp;
88594
+ exports.parseAgentDashboardOrigins = parseAgentDashboardOrigins;
88352
88595
  exports.parseCidrList = parseCidrList;
88353
88596
  exports.parseMeshAuthMode = parseMeshAuthMode;
88354
88597
  exports.processJoinTokenJtiStore = processJoinTokenJtiStore;
@@ -88362,4 +88605,5 @@ exports.setProcessMeshClearPortControl = setProcessMeshClearPortControl;
88362
88605
  exports.setProcessMeshEnforcer = setProcessMeshEnforcer;
88363
88606
  exports.setProcessMeshTlsEnabler = setProcessMeshTlsEnabler;
88364
88607
  exports.systemConfigMeshActions = systemConfigMeshActions;
88608
+ exports.verifyAgentUiUnlockTicket = verifyAgentUiUnlockTicket;
88365
88609
  exports.verifyMeshJoinToken = verifyMeshJoinToken;