@muhaven/mcp 0.2.9 → 0.3.0

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.
package/dist/broker.js CHANGED
@@ -6,6 +6,7 @@ import { connect, createServer } from 'net';
6
6
  import { mkdir, chmod, writeFile, readFile, unlink, rename, readdir, stat } from 'fs/promises';
7
7
  import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts';
8
8
  import { randomBytes } from 'crypto';
9
+ import { parseAbi, encodeFunctionData, decodeAbiParameters } from 'viem';
9
10
 
10
11
  var getFilename = () => fileURLToPath(import.meta.url);
11
12
  var getDirname = () => path.dirname(getFilename());
@@ -163,22 +164,45 @@ function loadBrokerConfig(env = process.env) {
163
164
  env.MUHAVEN_DASHBOARD_URL,
164
165
  DEFAULT_DASHBOARD_URL
165
166
  );
167
+ const chainRpcUrlRaw = readEnv("MUHAVEN_BROKER_RPC_URL", env) ?? readEnv("MUHAVEN_BUNDLER_URL", env);
168
+ const chainRpcUrl = chainRpcUrlRaw === void 0 ? void 0 : resolvePublicUrlEnv(
169
+ "MUHAVEN_BROKER_RPC_URL",
170
+ chainRpcUrlRaw,
171
+ chainRpcUrlRaw
172
+ );
173
+ const callbackServiceSecretRaw = readEnv("BROKER_CALLBACK_SERVICE_SECRET", env);
174
+ let callbackServiceSecret;
175
+ if (callbackServiceSecretRaw !== void 0) {
176
+ if (callbackServiceSecretRaw.length < 16) {
177
+ throw new Error(
178
+ "BROKER_CALLBACK_SERVICE_SECRET must be at least 16 characters (matches backend with-service-secret middleware floor)"
179
+ );
180
+ }
181
+ callbackServiceSecret = callbackServiceSecretRaw;
182
+ }
183
+ const outboundOriginHeader = readEnv("MUHAVEN_BROKER_ORIGIN", env) ?? dashboardBaseUrl;
166
184
  return {
167
185
  endpoint,
168
186
  sessionKeyHex,
169
187
  maxRequestBytes,
170
188
  requestTimeoutMs,
171
189
  backendBaseUrl,
172
- dashboardBaseUrl
190
+ dashboardBaseUrl,
191
+ chainRpcUrl,
192
+ callbackServiceSecret,
193
+ outboundOriginHeader
173
194
  };
174
195
  }
175
196
 
176
197
  // src/broker/protocol.ts
177
- var BROKER_PROTOCOL_VERSION = "0.4.0";
198
+ var BROKER_PROTOCOL_VERSION = "0.5.0";
178
199
  var HASH_HEX_RE = /^0x[0-9a-fA-F]{64}$/;
179
200
  var ADDRESS_HEX_RE2 = /^0x[0-9a-fA-F]{40}$/;
180
201
  var SELECTOR_HEX_RE = /^0x[0-9a-fA-F]{8}$/;
181
202
  var HEX_PREFIXED_RE = /^0x[0-9a-fA-F]*$/;
203
+ var ENABLE_DATA_HEX_RE = /^0x[0-9a-fA-F]{2,65536}$/;
204
+ var ENABLE_SIG_HEX_RE = /^0x[0-9a-fA-F]{256,16384}$/;
205
+ var UINT32_MAX = 4294967295;
182
206
  var JWT_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
183
207
  var SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
184
208
  var UINT256_DEC_RE = /^(0|[1-9][0-9]{0,77})$/;
@@ -298,6 +322,43 @@ function parsePolicySnapshot(raw) {
298
322
  if (!isOptionalPermissionId(obj.permissionId)) {
299
323
  return { error: "snapshot.permissionId must be a 0x-prefixed 4-byte hex when provided" };
300
324
  }
325
+ const enableData = obj.enableData;
326
+ const enableSig = obj.enableSig;
327
+ const validatorNonce = obj.validatorNonce;
328
+ const installPresent = [enableData, enableSig, validatorNonce].filter(
329
+ (v) => v !== void 0
330
+ ).length;
331
+ if (installPresent !== 0 && installPresent !== 3) {
332
+ return {
333
+ error: "snapshot.{enableData,enableSig,validatorNonce} must be all-present or all-absent (Option D Commit 3 install-material trio)"
334
+ };
335
+ }
336
+ if (enableData !== void 0) {
337
+ if (typeof enableData !== "string" || !ENABLE_DATA_HEX_RE.test(enableData)) {
338
+ return {
339
+ error: "snapshot.enableData must be a 0x-prefixed hex string of 2..65536 chars when provided"
340
+ };
341
+ }
342
+ }
343
+ if (enableSig !== void 0) {
344
+ if (typeof enableSig !== "string" || !ENABLE_SIG_HEX_RE.test(enableSig)) {
345
+ return {
346
+ error: "snapshot.enableSig must be a 0x-prefixed hex string of 256..16384 chars (WebAuthn envelope) when provided"
347
+ };
348
+ }
349
+ }
350
+ if (validatorNonce !== void 0) {
351
+ if (typeof validatorNonce !== "number" || !Number.isInteger(validatorNonce) || validatorNonce < 0 || validatorNonce > UINT32_MAX) {
352
+ return {
353
+ error: "snapshot.validatorNonce must be an integer in [0, 2^32-1] when provided"
354
+ };
355
+ }
356
+ }
357
+ if (installPresent === 3 && obj.permissionId === void 0) {
358
+ return {
359
+ error: "snapshot install material requires permissionId in the same snapshot"
360
+ };
361
+ }
301
362
  return {
302
363
  sessionId: obj.sessionId,
303
364
  mode: "scoped",
@@ -310,7 +371,10 @@ function parsePolicySnapshot(raw) {
310
371
  mintedAtSec: obj.mintedAtSec,
311
372
  ...obj.consentActionHash === void 0 ? {} : { consentActionHash: obj.consentActionHash.toLowerCase() },
312
373
  ...obj.consentTextSha256 === void 0 ? {} : { consentTextSha256: obj.consentTextSha256.toLowerCase() },
313
- ...obj.permissionId === void 0 ? {} : { permissionId: obj.permissionId.toLowerCase() }
374
+ ...obj.permissionId === void 0 ? {} : { permissionId: obj.permissionId.toLowerCase() },
375
+ ...enableData === void 0 ? {} : { enableData: enableData.toLowerCase() },
376
+ ...enableSig === void 0 ? {} : { enableSig: enableSig.toLowerCase() },
377
+ ...validatorNonce === void 0 ? {} : { validatorNonce }
314
378
  };
315
379
  }
316
380
  function parseBrokerRequest(line) {
@@ -489,6 +553,72 @@ function parseBrokerRequest(line) {
489
553
  }
490
554
  case "get_active_session_id":
491
555
  return { type: "get_active_session_id" };
556
+ case "current_nonce": {
557
+ if (!isAddressHex(obj.accountAddress)) {
558
+ return {
559
+ type: "error",
560
+ code: "invalid_request",
561
+ message: "current_nonce.accountAddress must be a 0x-prefixed 20-byte hex"
562
+ };
563
+ }
564
+ return {
565
+ type: "current_nonce",
566
+ accountAddress: obj.accountAddress.toLowerCase()
567
+ };
568
+ }
569
+ case "notify_userop_landed": {
570
+ if (!isSessionIdShape(obj.sessionId)) {
571
+ return {
572
+ type: "error",
573
+ code: "invalid_request",
574
+ message: "notify_userop_landed.sessionId must be 1-128 chars [A-Za-z0-9_-]"
575
+ };
576
+ }
577
+ if (!isAddressHex(obj.accountAddress)) {
578
+ return {
579
+ type: "error",
580
+ code: "invalid_request",
581
+ message: "notify_userop_landed.accountAddress must be a 0x-prefixed 20-byte hex"
582
+ };
583
+ }
584
+ if (!isSelectorHex(obj.permissionId)) {
585
+ return {
586
+ type: "error",
587
+ code: "invalid_request",
588
+ message: "notify_userop_landed.permissionId must be a 0x-prefixed 4-byte hex"
589
+ };
590
+ }
591
+ if (!isHashHex(obj.txHash)) {
592
+ return {
593
+ type: "error",
594
+ code: "invalid_request",
595
+ message: "notify_userop_landed.txHash must be a 0x-prefixed 32-byte hex"
596
+ };
597
+ }
598
+ if (typeof obj.blockNumber !== "number" || !Number.isFinite(obj.blockNumber) || !Number.isInteger(obj.blockNumber) || obj.blockNumber < 0) {
599
+ return {
600
+ type: "error",
601
+ code: "invalid_request",
602
+ message: "notify_userop_landed.blockNumber must be a non-negative integer"
603
+ };
604
+ }
605
+ if (typeof obj.logIndex !== "number" || !Number.isFinite(obj.logIndex) || !Number.isInteger(obj.logIndex) || obj.logIndex < 0) {
606
+ return {
607
+ type: "error",
608
+ code: "invalid_request",
609
+ message: "notify_userop_landed.logIndex must be a non-negative integer"
610
+ };
611
+ }
612
+ return {
613
+ type: "notify_userop_landed",
614
+ sessionId: obj.sessionId,
615
+ accountAddress: obj.accountAddress.toLowerCase(),
616
+ permissionId: obj.permissionId.toLowerCase(),
617
+ txHash: obj.txHash.toLowerCase(),
618
+ blockNumber: obj.blockNumber,
619
+ logIndex: obj.logIndex
620
+ };
621
+ }
492
622
  default:
493
623
  return {
494
624
  type: "error",
@@ -648,6 +778,33 @@ var BrokerClient = class {
648
778
  }
649
779
  return res;
650
780
  }
781
+ // ── Wave 5 Option D Commit 3 — install-mode pre-check + callback notify ──
782
+ async currentNonce(accountAddress) {
783
+ const res = await this.exchange({ type: "current_nonce", accountAddress });
784
+ if (res.type !== "current_nonce") {
785
+ throw new BrokerClientError(
786
+ "protocol_error",
787
+ `expected current_nonce response, got ${res.type}`
788
+ );
789
+ }
790
+ if (res.accountAddress.toLowerCase() !== accountAddress.toLowerCase()) {
791
+ throw new BrokerClientError(
792
+ "protocol_error",
793
+ `current_nonce echo mismatch (requested ${accountAddress}, got ${res.accountAddress})`
794
+ );
795
+ }
796
+ return res;
797
+ }
798
+ async notifyUseropLanded(args) {
799
+ const res = await this.exchange({ type: "notify_userop_landed", ...args });
800
+ if (res.type !== "notify_userop_landed") {
801
+ throw new BrokerClientError(
802
+ "protocol_error",
803
+ `expected notify_userop_landed response, got ${res.type}`
804
+ );
805
+ }
806
+ return res;
807
+ }
651
808
  /**
652
809
  * Detect whether the running daemon speaks Path D (protocol 0.4.0+).
653
810
  * Wraps `hello()` with a semver-gte comparison so the MCP tool layer
@@ -1438,11 +1595,285 @@ function checkPolicy(input) {
1438
1595
  }
1439
1596
  return { ok: true };
1440
1597
  }
1598
+ var KERNEL_V3_CURRENT_NONCE_ABI = parseAbi([
1599
+ "function currentNonce() view returns (uint32)"
1600
+ ]);
1601
+ var ChainRpcError = class extends Error {
1602
+ constructor(message, cause) {
1603
+ super(message);
1604
+ this.cause = cause;
1605
+ this.name = "ChainRpcError";
1606
+ }
1607
+ cause;
1608
+ };
1609
+ var CallbackError = class extends Error {
1610
+ constructor(message, cause) {
1611
+ super(message);
1612
+ this.cause = cause;
1613
+ this.name = "CallbackError";
1614
+ }
1615
+ cause;
1616
+ };
1617
+ var CALLBACK_RETRY_SCHEDULE_MS = [
1618
+ 5e3,
1619
+ 15e3,
1620
+ 6e4,
1621
+ 5 * 6e4
1622
+ ];
1623
+ var CALLBACK_MAX_ELAPSED_MS = 60 * 6e4;
1624
+ var DEFAULT_FETCH_TIMEOUT_MS = 15e3;
1625
+ var BrokerOutbound = class {
1626
+ constructor(config, log = () => {
1627
+ }) {
1628
+ this.config = config;
1629
+ this.log = log;
1630
+ this.fetchImpl = config.fetchImpl ?? fetch;
1631
+ this.fetchTimeoutMs = config.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
1632
+ this.setTimeoutImpl = config.setTimeout ?? setTimeout;
1633
+ this.clearTimeoutImpl = config.clearTimeout ?? clearTimeout;
1634
+ }
1635
+ config;
1636
+ log;
1637
+ fetchImpl;
1638
+ fetchTimeoutMs;
1639
+ setTimeoutImpl;
1640
+ clearTimeoutImpl;
1641
+ /**
1642
+ * Wave 5 Option D Commit 3 (multi-agent review SecEng-MED-3) —
1643
+ * in-process dedup of `notify_userop_landed` callbacks. Map of
1644
+ * `<sessionId>:<txHash>` → the in-flight retry loop's Promise.
1645
+ * Repeated IPC calls with the same key fold into the existing
1646
+ * loop instead of spawning a parallel POST. Defends against a
1647
+ * local-socket peer flooding the broker with replay attempts +
1648
+ * caps the retry-budget waste at one loop per real install.
1649
+ */
1650
+ inflightCallbacks = /* @__PURE__ */ new Map();
1651
+ /**
1652
+ * Read the kernel's `currentNonce()` view via `eth_call` against the
1653
+ * configured chain RPC. Returns a uint32. Throws `ChainRpcError` when
1654
+ * unconfigured / network failed / RPC returned non-decodable bytes.
1655
+ */
1656
+ async currentNonce(accountAddress) {
1657
+ if (!this.config.chainRpcUrl) {
1658
+ throw new ChainRpcError(
1659
+ "broker chain RPC unconfigured \u2014 set MUHAVEN_BROKER_RPC_URL or MUHAVEN_BUNDLER_URL"
1660
+ );
1661
+ }
1662
+ const data = encodeFunctionData({
1663
+ abi: KERNEL_V3_CURRENT_NONCE_ABI,
1664
+ functionName: "currentNonce"
1665
+ });
1666
+ const body = JSON.stringify({
1667
+ jsonrpc: "2.0",
1668
+ id: 1,
1669
+ method: "eth_call",
1670
+ params: [{ to: accountAddress, data }, "latest"]
1671
+ });
1672
+ let res;
1673
+ const ac = new AbortController();
1674
+ const timer = this.setTimeoutImpl(() => ac.abort(), this.fetchTimeoutMs);
1675
+ try {
1676
+ res = await this.fetchImpl(this.config.chainRpcUrl, {
1677
+ method: "POST",
1678
+ headers: {
1679
+ "Content-Type": "application/json",
1680
+ Accept: "application/json",
1681
+ Origin: this.config.outboundOriginHeader
1682
+ },
1683
+ body,
1684
+ signal: ac.signal
1685
+ });
1686
+ } catch (err) {
1687
+ throw new ChainRpcError(
1688
+ `chain RPC fetch failed: ${err instanceof Error ? err.message : String(err)}`,
1689
+ err
1690
+ );
1691
+ } finally {
1692
+ this.clearTimeoutImpl(timer);
1693
+ }
1694
+ if (!res.ok) {
1695
+ throw new ChainRpcError(
1696
+ `chain RPC returned HTTP ${res.status}`
1697
+ );
1698
+ }
1699
+ let parsed;
1700
+ try {
1701
+ parsed = await res.json();
1702
+ } catch (err) {
1703
+ throw new ChainRpcError(
1704
+ `chain RPC returned non-JSON: ${err instanceof Error ? err.message : String(err)}`
1705
+ );
1706
+ }
1707
+ if (parsed.error) {
1708
+ throw new ChainRpcError(
1709
+ `chain RPC error: ${parsed.error.message ?? "unknown"}`
1710
+ );
1711
+ }
1712
+ if (typeof parsed.result !== "string" || !/^0x[0-9a-fA-F]*$/.test(parsed.result)) {
1713
+ throw new ChainRpcError(
1714
+ `chain RPC returned non-hex result: ${JSON.stringify(parsed.result).slice(0, 80)}`
1715
+ );
1716
+ }
1717
+ let nonce;
1718
+ try {
1719
+ const decoded = decodeAbiParameters(
1720
+ [{ type: "uint32" }],
1721
+ parsed.result
1722
+ );
1723
+ nonce = Number(decoded[0]);
1724
+ } catch (err) {
1725
+ throw new ChainRpcError(
1726
+ `failed to decode currentNonce result: ${err instanceof Error ? err.message : String(err)}`
1727
+ );
1728
+ }
1729
+ if (!Number.isFinite(nonce) || nonce < 0 || nonce > 4294967295) {
1730
+ throw new ChainRpcError(`currentNonce out of uint32 range: ${nonce}`);
1731
+ }
1732
+ return nonce;
1733
+ }
1734
+ /**
1735
+ * Whether the callback path is wired (both secret + backend URL set).
1736
+ * The `notify_userop_landed` daemon handler checks this and returns
1737
+ * `callback_unconfigured` when false so the operator sees the gap.
1738
+ */
1739
+ isCallbackConfigured() {
1740
+ return Boolean(this.config.callbackServiceSecret) && Boolean(this.config.backendBaseUrl);
1741
+ }
1742
+ /**
1743
+ * Queue a `validator-enabled` callback POST to the backend. Returns
1744
+ * immediately; the retry loop runs in the background (5s / 15s / 60s
1745
+ * / 5m, max 1h elapsed). Failures are logged but do NOT propagate to
1746
+ * the IPC caller — the chain indexer is the authoritative safety
1747
+ * net.
1748
+ *
1749
+ * Idempotency: every POST carries an `Idempotency-Key` header
1750
+ * `<sessionId>:validator-enabled`. The backend route is no-op if the
1751
+ * mirror row's `enable_status` is already `'enabled'` (because the
1752
+ * chain indexer raced ahead).
1753
+ *
1754
+ * Returns a Promise resolved when the loop terminates (success or
1755
+ * max-elapsed). Callers don't need to await; tests use it for
1756
+ * deterministic assertions.
1757
+ */
1758
+ enqueueValidatorEnabledCallback(args) {
1759
+ if (!this.isCallbackConfigured()) {
1760
+ return Promise.resolve({
1761
+ ok: false,
1762
+ attempts: 0,
1763
+ lastError: "callback_unconfigured"
1764
+ });
1765
+ }
1766
+ const dedupKey = `${args.sessionId}:${args.txHash.toLowerCase()}:${args.accountAddress.toLowerCase()}`;
1767
+ const existing = this.inflightCallbacks.get(dedupKey);
1768
+ if (existing) {
1769
+ this.log("info", "validator-enabled callback already in flight \u2014 folded", {
1770
+ sessionId: args.sessionId
1771
+ });
1772
+ return existing;
1773
+ }
1774
+ const promise = this.runCallbackLoop(args).finally(() => {
1775
+ this.inflightCallbacks.delete(dedupKey);
1776
+ });
1777
+ this.inflightCallbacks.set(dedupKey, promise);
1778
+ return promise;
1779
+ }
1780
+ async runCallbackLoop(args) {
1781
+ const url = `${this.config.backendBaseUrl.replace(/\/+$/, "")}/api/v1/agent/policy/scoped-session/${encodeURIComponent(args.sessionId)}/validator-enabled`;
1782
+ const body = JSON.stringify({
1783
+ userId: args.userId,
1784
+ accountAddress: args.accountAddress,
1785
+ permissionId: args.permissionId,
1786
+ txHash: args.txHash,
1787
+ blockNumber: args.blockNumber,
1788
+ logIndex: args.logIndex
1789
+ });
1790
+ const startedAt = Date.now();
1791
+ let attempts = 0;
1792
+ let lastError;
1793
+ for (let i = 0; i <= CALLBACK_RETRY_SCHEDULE_MS.length; i++) {
1794
+ if (i > 0) {
1795
+ const delay = CALLBACK_RETRY_SCHEDULE_MS[i - 1] ?? 0;
1796
+ const elapsed = Date.now() - startedAt;
1797
+ if (elapsed + delay > CALLBACK_MAX_ELAPSED_MS) {
1798
+ lastError = `retry budget exhausted after ${attempts} attempts (last error: ${lastError ?? "unknown"})`;
1799
+ this.log("error", "validator-enabled callback abandoned", {
1800
+ sessionId: args.sessionId,
1801
+ attempts,
1802
+ lastError
1803
+ });
1804
+ return { ok: false, attempts, lastError };
1805
+ }
1806
+ await this.sleep(delay);
1807
+ }
1808
+ attempts++;
1809
+ try {
1810
+ const ok = await this.postCallback(url, body, args.sessionId);
1811
+ if (ok) {
1812
+ this.log("info", "validator-enabled callback succeeded", {
1813
+ sessionId: args.sessionId,
1814
+ attempts
1815
+ });
1816
+ return { ok: true, attempts };
1817
+ }
1818
+ lastError = "non-2xx response";
1819
+ } catch (err) {
1820
+ lastError = err instanceof Error ? err.message : String(err);
1821
+ this.log("warn", "validator-enabled callback attempt failed", {
1822
+ sessionId: args.sessionId,
1823
+ attempt: attempts,
1824
+ err: lastError
1825
+ });
1826
+ }
1827
+ }
1828
+ this.log("error", "validator-enabled callback retry budget exhausted", {
1829
+ sessionId: args.sessionId,
1830
+ attempts,
1831
+ lastError
1832
+ });
1833
+ return { ok: false, attempts, lastError };
1834
+ }
1835
+ async postCallback(url, body, sessionId) {
1836
+ const ac = new AbortController();
1837
+ const timer = this.setTimeoutImpl(() => ac.abort(), this.fetchTimeoutMs);
1838
+ let res;
1839
+ try {
1840
+ res = await this.fetchImpl(url, {
1841
+ method: "POST",
1842
+ headers: {
1843
+ "Content-Type": "application/json",
1844
+ Accept: "application/json",
1845
+ Authorization: `Bearer ${this.config.callbackServiceSecret}`,
1846
+ "Idempotency-Key": `${sessionId}:validator-enabled`,
1847
+ Origin: this.config.outboundOriginHeader
1848
+ },
1849
+ body,
1850
+ signal: ac.signal
1851
+ });
1852
+ } finally {
1853
+ this.clearTimeoutImpl(timer);
1854
+ }
1855
+ if (res.status === 409) {
1856
+ this.log("info", "callback returned 409 (row already enabled \u2014 idempotent)", {
1857
+ sessionId
1858
+ });
1859
+ return true;
1860
+ }
1861
+ if (res.ok) return true;
1862
+ throw new CallbackError(
1863
+ `backend callback returned HTTP ${res.status}`
1864
+ );
1865
+ }
1866
+ sleep(ms) {
1867
+ return new Promise((resolve) => {
1868
+ this.setTimeoutImpl(() => resolve(), ms);
1869
+ });
1870
+ }
1871
+ };
1441
1872
 
1442
1873
  // src/broker/daemon.ts
1443
1874
  var noopLogger = (_e) => {
1444
1875
  };
1445
- async function handleBrokerRequest(req, signer, keystore, nowSec = () => Math.floor(Date.now() / 1e3), options = {}, policyStore) {
1876
+ async function handleBrokerRequest(req, signer, keystore, nowSec = () => Math.floor(Date.now() / 1e3), options = {}, policyStore, outbound) {
1446
1877
  switch (req.type) {
1447
1878
  case "hello": {
1448
1879
  let hasJwt = false;
@@ -1650,6 +2081,57 @@ async function handleBrokerRequest(req, signer, keystore, nowSec = () => Math.fl
1650
2081
  );
1651
2082
  }
1652
2083
  }
2084
+ case "current_nonce": {
2085
+ if (!outbound) {
2086
+ return errorResponse(
2087
+ "chain_rpc_failed",
2088
+ "broker daemon was not configured with a chain RPC URL"
2089
+ );
2090
+ }
2091
+ try {
2092
+ const nonce = await outbound.currentNonce(req.accountAddress);
2093
+ return {
2094
+ type: "current_nonce",
2095
+ nonce,
2096
+ accountAddress: req.accountAddress
2097
+ };
2098
+ } catch (err) {
2099
+ if (err instanceof ChainRpcError) {
2100
+ return errorResponse("chain_rpc_failed", err.message);
2101
+ }
2102
+ return errorResponse(
2103
+ "chain_rpc_failed",
2104
+ err instanceof Error ? err.message : "chain RPC eth_call failed"
2105
+ );
2106
+ }
2107
+ }
2108
+ case "notify_userop_landed": {
2109
+ if (!outbound) {
2110
+ return errorResponse(
2111
+ "callback_unconfigured",
2112
+ "broker daemon was not configured with the callback service secret"
2113
+ );
2114
+ }
2115
+ if (!outbound.isCallbackConfigured()) {
2116
+ return errorResponse(
2117
+ "callback_unconfigured",
2118
+ "BROKER_CALLBACK_SERVICE_SECRET or backend URL is unset \u2014 validator-enabled callback skipped (chain indexer is the safety net)"
2119
+ );
2120
+ }
2121
+ void outbound.enqueueValidatorEnabledCallback({
2122
+ sessionId: req.sessionId,
2123
+ accountAddress: req.accountAddress,
2124
+ permissionId: req.permissionId,
2125
+ txHash: req.txHash,
2126
+ blockNumber: req.blockNumber,
2127
+ logIndex: req.logIndex
2128
+ });
2129
+ return {
2130
+ type: "notify_userop_landed",
2131
+ queued: true,
2132
+ sessionId: req.sessionId
2133
+ };
2134
+ }
1653
2135
  }
1654
2136
  }
1655
2137
  function errorResponse(code, message) {
@@ -1680,6 +2162,7 @@ var BrokerDaemon = class {
1680
2162
  config;
1681
2163
  keystore;
1682
2164
  policyStore;
2165
+ outbound;
1683
2166
  /**
1684
2167
  * Whether a session-key private half is actually loaded. `false` =
1685
2168
  * daemon booted in read-only posture (no `MUHAVEN_BROKER_SESSION_KEY`
@@ -1700,6 +2183,15 @@ var BrokerDaemon = class {
1700
2183
  }
1701
2184
  this.keystore = options.keystore ?? null;
1702
2185
  this.policyStore = options.policyStore ?? new FilePolicyStore(FilePolicyStore.defaultDir());
2186
+ this.outbound = options.outbound ?? new BrokerOutbound(
2187
+ {
2188
+ chainRpcUrl: options.config.chainRpcUrl,
2189
+ backendBaseUrl: options.config.backendBaseUrl,
2190
+ callbackServiceSecret: options.config.callbackServiceSecret,
2191
+ outboundOriginHeader: options.config.outboundOriginHeader ?? options.config.dashboardBaseUrl
2192
+ },
2193
+ (level, msg, meta) => (options.logger ?? noopLogger)({ level, msg, meta })
2194
+ );
1703
2195
  this.log = options.logger ?? noopLogger;
1704
2196
  this.server = createServer((socket) => this.onConnection(socket));
1705
2197
  }
@@ -1832,7 +2324,8 @@ var BrokerDaemon = class {
1832
2324
  },
1833
2325
  pid: process.pid
1834
2326
  },
1835
- this.policyStore
2327
+ this.policyStore,
2328
+ this.outbound
1836
2329
  );
1837
2330
  socket.end(serializeResponse(res));
1838
2331
  } catch (err) {
@@ -2785,7 +3278,7 @@ function printUsage() {
2785
3278
  }
2786
3279
  function getBrokerPackageVersion() {
2787
3280
  {
2788
- return "0.2.9";
3281
+ return "0.3.0";
2789
3282
  }
2790
3283
  }
2791
3284
  function printVersion() {