@toon-protocol/townhouse 0.1.0-rc5 → 0.1.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.
@@ -8,11 +8,13 @@ import {
8
8
  DVM_HEALTH_PORT,
9
9
  MILL_HEALTH_PORT,
10
10
  NODE_BTP_PORT,
11
- TOWN_HEALTH_PORT,
11
+ TOWN_HEALTH_PORT
12
+ } from "./chunk-GQNBZJ6F.js";
13
+ import {
12
14
  __commonJS,
13
15
  __require,
14
16
  __toESM
15
- } from "./chunk-UTFWPLTB.js";
17
+ } from "./chunk-I2R4CRUX.js";
16
18
 
17
19
  // ../../node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/constants.js
18
20
  var require_constants = __commonJS({
@@ -41,7 +43,7 @@ var require_constants = __commonJS({
41
43
  var require_node_gyp_build = __commonJS({
42
44
  "../../node_modules/.pnpm/node-gyp-build@4.8.4/node_modules/node-gyp-build/node-gyp-build.js"(exports, module) {
43
45
  "use strict";
44
- var fs = __require("fs");
46
+ var fs6 = __require("fs");
45
47
  var path = __require("path");
46
48
  var os = __require("os");
47
49
  var runtimeRequire = typeof __webpack_require__ === "function" ? __non_webpack_require__ : __require;
@@ -71,9 +73,9 @@ var require_node_gyp_build = __commonJS({
71
73
  var debug = getFirst(path.join(dir, "build/Debug"), matchBuild);
72
74
  if (debug) return debug;
73
75
  }
74
- var prebuild = resolve(dir);
76
+ var prebuild = resolve2(dir);
75
77
  if (prebuild) return prebuild;
76
- var nearby = resolve(path.dirname(process.execPath));
78
+ var nearby = resolve2(path.dirname(process.execPath));
77
79
  if (nearby) return nearby;
78
80
  var target = [
79
81
  "platform=" + platform,
@@ -89,7 +91,7 @@ var require_node_gyp_build = __commonJS({
89
91
  // eslint-disable-line
90
92
  ].filter(Boolean).join(" ");
91
93
  throw new Error("No native build was found for " + target + "\n loaded from: " + dir + "\n");
92
- function resolve(dir2) {
94
+ function resolve2(dir2) {
93
95
  var tuples = readdirSync(path.join(dir2, "prebuilds")).map(parseTuple);
94
96
  var tuple = tuples.filter(matchTuple(platform, arch)).sort(compareTuples)[0];
95
97
  if (!tuple) return;
@@ -102,7 +104,7 @@ var require_node_gyp_build = __commonJS({
102
104
  };
103
105
  function readdirSync(dir) {
104
106
  try {
105
- return fs.readdirSync(dir);
107
+ return fs6.readdirSync(dir);
106
108
  } catch (err) {
107
109
  return [];
108
110
  }
@@ -196,7 +198,7 @@ var require_node_gyp_build = __commonJS({
196
198
  return typeof window !== "undefined" && window.process && window.process.type === "renderer";
197
199
  }
198
200
  function isAlpine(platform2) {
199
- return platform2 === "linux" && fs.existsSync("/etc/alpine-release");
201
+ return platform2 === "linux" && fs6.existsSync("/etc/alpine-release");
200
202
  }
201
203
  load.parseTags = parseTags;
202
204
  load.matchTags = matchTags;
@@ -2327,7 +2329,7 @@ var require_extension = __commonJS({
2327
2329
  if (dest[name] === void 0) dest[name] = [elem];
2328
2330
  else dest[name].push(elem);
2329
2331
  }
2330
- function parse2(header) {
2332
+ function parse3(header) {
2331
2333
  const offers = /* @__PURE__ */ Object.create(null);
2332
2334
  let params = /* @__PURE__ */ Object.create(null);
2333
2335
  let mustUnescape = false;
@@ -2467,7 +2469,7 @@ var require_extension = __commonJS({
2467
2469
  }).join(", ");
2468
2470
  }).join(", ");
2469
2471
  }
2470
- module.exports = { format, parse: parse2 };
2472
+ module.exports = { format, parse: parse3 };
2471
2473
  }
2472
2474
  });
2473
2475
 
@@ -2480,7 +2482,7 @@ var require_websocket = __commonJS({
2480
2482
  var http3 = __require("http");
2481
2483
  var net2 = __require("net");
2482
2484
  var tls = __require("tls");
2483
- var { randomBytes: randomBytes2, createHash } = __require("crypto");
2485
+ var { randomBytes: randomBytes2, createHash: createHash2 } = __require("crypto");
2484
2486
  var { Duplex, Readable } = __require("stream");
2485
2487
  var { URL: URL2 } = __require("url");
2486
2488
  var PerMessageDeflate = require_permessage_deflate();
@@ -2501,7 +2503,7 @@ var require_websocket = __commonJS({
2501
2503
  var {
2502
2504
  EventTarget: { addEventListener, removeEventListener }
2503
2505
  } = require_event_target();
2504
- var { format, parse: parse2 } = require_extension();
2506
+ var { format, parse: parse3 } = require_extension();
2505
2507
  var { toBuffer } = require_buffer_util();
2506
2508
  var kAborted = /* @__PURE__ */ Symbol("kAborted");
2507
2509
  var protocolVersions = [8, 13];
@@ -3140,7 +3142,7 @@ var require_websocket = __commonJS({
3140
3142
  abortHandshake(websocket2, socket, "Invalid Upgrade header");
3141
3143
  return;
3142
3144
  }
3143
- const digest = createHash("sha1").update(key + GUID).digest("base64");
3145
+ const digest = createHash2("sha1").update(key + GUID).digest("base64");
3144
3146
  if (res.headers["sec-websocket-accept"] !== digest) {
3145
3147
  abortHandshake(websocket2, socket, "Invalid Sec-WebSocket-Accept header");
3146
3148
  return;
@@ -3170,7 +3172,7 @@ var require_websocket = __commonJS({
3170
3172
  }
3171
3173
  let extensions;
3172
3174
  try {
3173
- extensions = parse2(secWebSocketExtensions);
3175
+ extensions = parse3(secWebSocketExtensions);
3174
3176
  } catch (err) {
3175
3177
  const message = "Invalid Sec-WebSocket-Extensions header";
3176
3178
  abortHandshake(websocket2, socket, message);
@@ -3460,7 +3462,7 @@ var require_subprotocol = __commonJS({
3460
3462
  "../../node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/subprotocol.js"(exports, module) {
3461
3463
  "use strict";
3462
3464
  var { tokenChars } = require_validation();
3463
- function parse2(header) {
3465
+ function parse3(header) {
3464
3466
  const protocols = /* @__PURE__ */ new Set();
3465
3467
  let start = -1;
3466
3468
  let end = -1;
@@ -3496,7 +3498,7 @@ var require_subprotocol = __commonJS({
3496
3498
  protocols.add(protocol);
3497
3499
  return protocols;
3498
3500
  }
3499
- module.exports = { parse: parse2 };
3501
+ module.exports = { parse: parse3 };
3500
3502
  }
3501
3503
  });
3502
3504
 
@@ -3507,7 +3509,7 @@ var require_websocket_server = __commonJS({
3507
3509
  var EventEmitter2 = __require("events");
3508
3510
  var http3 = __require("http");
3509
3511
  var { Duplex } = __require("stream");
3510
- var { createHash } = __require("crypto");
3512
+ var { createHash: createHash2 } = __require("crypto");
3511
3513
  var extension = require_extension();
3512
3514
  var PerMessageDeflate = require_permessage_deflate();
3513
3515
  var subprotocol = require_subprotocol();
@@ -3808,7 +3810,7 @@ var require_websocket_server = __commonJS({
3808
3810
  );
3809
3811
  }
3810
3812
  if (this._state > RUNNING) return abortHandshake(socket, 503);
3811
- const digest = createHash("sha1").update(key + GUID).digest("base64");
3813
+ const digest = createHash2("sha1").update(key + GUID).digest("base64");
3812
3814
  const headers = [
3813
3815
  "HTTP/1.1 101 Switching Protocols",
3814
3816
  "Upgrade: websocket",
@@ -3896,6 +3898,16 @@ var require_websocket_server = __commonJS({
3896
3898
  // src/config/defaults.ts
3897
3899
  import { homedir } from "os";
3898
3900
  import { join } from "path";
3901
+ var DEFAULT_HS_CHAIN_PROVIDERS = [
3902
+ Object.freeze({
3903
+ chainType: "evm",
3904
+ chainId: "evm:base:31337",
3905
+ rpcUrl: "http://127.0.0.1:19999",
3906
+ registryAddress: "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512",
3907
+ tokenAddress: "0x5FbDB2315678afecb367f032d93F642f64180aa3",
3908
+ keyId: "0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6"
3909
+ })
3910
+ ];
3899
3911
  function getDefaultConfig() {
3900
3912
  return {
3901
3913
  nodes: {
@@ -3924,6 +3936,8 @@ function getDefaultConfig() {
3924
3936
  }
3925
3937
 
3926
3938
  // src/config/validator.ts
3939
+ var VALID_CHAIN_TYPES = /* @__PURE__ */ new Set(["evm"]);
3940
+ var HEX_ADDRESS = /^0x[a-fA-F0-9]+$/;
3927
3941
  var ConfigValidationError = class extends Error {
3928
3942
  constructor(message) {
3929
3943
  super(message);
@@ -4094,6 +4108,52 @@ function validateConfig(raw) {
4094
4108
  'config.transport.mode="ator" requires either config.transport.externalUrl (operator-managed anon binary) or config.transport.hiddenService (connector-managed anon binary). Without one of these, the underlying connector will reject the manifest at boot.'
4095
4109
  );
4096
4110
  }
4111
+ let chainProviders;
4112
+ if (raw["chainProviders"] !== void 0) {
4113
+ if (!Array.isArray(raw["chainProviders"])) {
4114
+ throw new ConfigValidationError(
4115
+ "config.chainProviders must be an array of ChainProviderEntry"
4116
+ );
4117
+ }
4118
+ chainProviders = raw["chainProviders"].map((entry, idx) => {
4119
+ const path = `config.chainProviders[${idx}]`;
4120
+ assertObject(entry, path);
4121
+ assertString(entry["chainType"], `${path}.chainType`);
4122
+ if (!VALID_CHAIN_TYPES.has(entry["chainType"])) {
4123
+ throw new ConfigValidationError(
4124
+ `${path}.chainType must be one of: ${[...VALID_CHAIN_TYPES].join(", ")}`
4125
+ );
4126
+ }
4127
+ assertString(entry["chainId"], `${path}.chainId`);
4128
+ assertString(entry["rpcUrl"], `${path}.rpcUrl`);
4129
+ assertString(entry["registryAddress"], `${path}.registryAddress`);
4130
+ if (!HEX_ADDRESS.test(entry["registryAddress"])) {
4131
+ throw new ConfigValidationError(
4132
+ `${path}.registryAddress must match /^0x[a-fA-F0-9]+$/`
4133
+ );
4134
+ }
4135
+ assertString(entry["tokenAddress"], `${path}.tokenAddress`);
4136
+ if (!HEX_ADDRESS.test(entry["tokenAddress"])) {
4137
+ throw new ConfigValidationError(
4138
+ `${path}.tokenAddress must match /^0x[a-fA-F0-9]+$/`
4139
+ );
4140
+ }
4141
+ assertString(entry["keyId"], `${path}.keyId`);
4142
+ if (!HEX_ADDRESS.test(entry["keyId"])) {
4143
+ throw new ConfigValidationError(
4144
+ `${path}.keyId must match /^0x[a-fA-F0-9]+$/`
4145
+ );
4146
+ }
4147
+ return {
4148
+ chainType: entry["chainType"],
4149
+ chainId: entry["chainId"],
4150
+ rpcUrl: entry["rpcUrl"],
4151
+ registryAddress: entry["registryAddress"],
4152
+ tokenAddress: entry["tokenAddress"],
4153
+ keyId: entry["keyId"]
4154
+ };
4155
+ });
4156
+ }
4097
4157
  assertObject(raw["api"], "config.api");
4098
4158
  const api = raw["api"];
4099
4159
  assertNumber(api["port"], "config.api.port");
@@ -4144,7 +4204,8 @@ function validateConfig(raw) {
4144
4204
  },
4145
4205
  logging: {
4146
4206
  level: logging["level"]
4147
- }
4207
+ },
4208
+ ...chainProviders !== void 0 ? { chainProviders } : {}
4148
4209
  };
4149
4210
  }
4150
4211
  function pickOptional(obj, keys) {
@@ -4353,6 +4414,16 @@ var ConnectorConfigGenerator = class {
4353
4414
  peers: [],
4354
4415
  routes: []
4355
4416
  };
4417
+ if (this.config.chainProviders !== void 0 && this.config.chainProviders.length > 0) {
4418
+ yamlObj["chainProviders"] = this.config.chainProviders.map((p) => ({
4419
+ chainType: p.chainType,
4420
+ chainId: p.chainId,
4421
+ rpcUrl: p.rpcUrl,
4422
+ registryAddress: p.registryAddress,
4423
+ tokenAddress: p.tokenAddress,
4424
+ keyId: p.keyId
4425
+ }));
4426
+ }
4356
4427
  return yamlStringify(yamlObj);
4357
4428
  }
4358
4429
  // ── Private helpers ──
@@ -4435,151 +4506,956 @@ var ConnectorConfigGenerator = class {
4435
4506
  }
4436
4507
  };
4437
4508
 
4438
- // src/docker/orchestrator.ts
4439
- import { EventEmitter } from "events";
4440
- import { mkdirSync, writeFileSync as writeFileSync2 } from "fs";
4441
- import { dirname, join as join2 } from "path";
4442
- var TOWN_RELAY_PORT = 7100;
4443
- var STATS_CACHE_TTL_MS = 5e3;
4444
- var NETWORK_NAME = "townhouse-net";
4445
- var DEFAULT_NODE_IMAGES = {
4446
- town: "toon:town",
4447
- mill: "toon:mill",
4448
- dvm: "toon:dvm"
4449
- };
4450
- var MAX_START_RETRIES = 3;
4451
- var CONNECTOR_INTERNAL_PORT = 3e3;
4452
- var RELAY_ATOR_SIDECAR_IMAGE = "toon:townhouse-ator-sidecar";
4453
- var RELAY_ATOR_SOCKS_PORT = 9051;
4454
- function normalizeImageTag(image) {
4455
- const lastSlash = image.lastIndexOf("/");
4456
- const nameAndTag = lastSlash >= 0 ? image.slice(lastSlash + 1) : image;
4457
- if (nameAndTag.includes(":")) {
4458
- return image;
4509
+ // src/connector/admin-client.ts
4510
+ var DEFAULT_TIMEOUT_MS = 5e3;
4511
+ var ConnectorAdminClient = class {
4512
+ baseUrl;
4513
+ timeoutMs;
4514
+ /**
4515
+ * @param baseUrl - Base URL for the connector admin API (e.g., 'http://localhost:9402')
4516
+ * @param timeoutMs - Request timeout in milliseconds (default: 5000)
4517
+ */
4518
+ constructor(baseUrl, timeoutMs = DEFAULT_TIMEOUT_MS) {
4519
+ this.baseUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
4520
+ this.timeoutMs = timeoutMs;
4459
4521
  }
4460
- return `${image}:latest`;
4461
- }
4462
- var DockerOrchestrator = class extends EventEmitter {
4463
- docker;
4464
- config;
4465
- configGenerator;
4466
- walletManager;
4467
- activeNodes = [];
4468
- statsCache = /* @__PURE__ */ new Map();
4469
- constructor(docker, config, walletManager) {
4470
- super();
4471
- this.docker = docker;
4472
- this.config = config;
4473
- this.configGenerator = new ConnectorConfigGenerator(config);
4474
- this.walletManager = walletManager;
4522
+ /** Public read of the configured base URL (used by drill-command probes to derive a sibling client). */
4523
+ getBaseUrl() {
4524
+ return this.baseUrl;
4475
4525
  }
4476
4526
  /**
4477
- * Orchestrate full startup sequence:
4478
- * 1. Ensure network exists
4479
- * 2. Pull images (with progress)
4480
- * 3. Start connector, wait for health
4481
- * 4. Start enabled node containers in parallel
4527
+ * GET /health on the admin-API port — checks HTTP reachability of the
4528
+ * connector without validating the rich HealthStatus shape. Use this from
4529
+ * the drill-command health probe when only the admin URL is available
4530
+ * (port 9401), not the healthCheckPort (8080). The admin server's /health
4531
+ * returns `{status:'healthy', service:'admin-api', nodeId, timestamp}`
4532
+ * a different shape from `getHealth()`'s validator. This method returns
4533
+ * a coarse status from a 200 response and reads `nodeId` if present.
4534
+ *
4535
+ * @throws Error when connector is unreachable or returns non-2xx.
4482
4536
  */
4483
- async up(profiles) {
4484
- this.activeNodes = [...profiles];
4485
- await this.ensureNetwork();
4486
- await this.pullImages(profiles);
4487
- await this.startConnector();
4488
- await this.waitForHealth("townhouse-connector");
4489
- await Promise.all(profiles.map((type) => this.startNode(type)));
4490
- if (profiles.includes("town") && this.config.transport.relayHiddenService) {
4491
- await this.startRelayAtorSidecar();
4492
- }
4537
+ async pingAdminLive() {
4538
+ const response = await this.fetch("/health");
4539
+ const body = await response.json().catch(() => ({}));
4540
+ const nodeId = typeof body === "object" && body !== null && typeof body["nodeId"] === "string" ? body["nodeId"] : void 0;
4541
+ return nodeId !== void 0 ? { status: "healthy", nodeId } : { status: "healthy" };
4493
4542
  }
4494
4543
  /**
4495
- * Regenerate connector config and restart the connector container
4496
- * with updated environment variables (peer list).
4544
+ * GET /health returns the connector's HealthStatus from the healthCheckPort server.
4497
4545
  *
4498
- * Sequence: emit connectorRestarting -> stop -> remove -> create -> start -> health -> emit connectorRestarted
4546
+ * @throws Error when connector is not running, returns non-200, or shape is invalid
4499
4547
  */
4500
- async regenerateConnectorConfig(activeNodes) {
4501
- this.activeNodes = [...activeNodes];
4502
- this.emit("connectorRestarting", { reason: "peer list updated" });
4503
- const connectorName = `${CONTAINER_PREFIX}connector`;
4504
- const existingContainer = this.docker.getContainer(connectorName);
4505
- try {
4506
- await existingContainer.stop({ t: 5 });
4507
- } catch {
4548
+ async getHealth() {
4549
+ const response = await this.fetch("/health");
4550
+ const body = await response.json();
4551
+ if (typeof body !== "object" || body === null) {
4552
+ throw new Error("Connector admin API: invalid health response shape");
4508
4553
  }
4509
- try {
4510
- await existingContainer.remove();
4511
- } catch {
4554
+ const obj = body;
4555
+ const status = obj["status"];
4556
+ if (status !== "healthy" && status !== "unhealthy" && status !== "starting" && status !== "degraded") {
4557
+ throw new Error("Connector admin API: invalid health response shape");
4512
4558
  }
4513
- await this.ensureNetwork();
4514
- try {
4515
- await this.startConnector();
4516
- await this.waitForHealth(connectorName);
4517
- } finally {
4518
- this.emit("connectorRestarted", { peers: activeNodes });
4559
+ if (typeof obj["uptime"] !== "number" || typeof obj["peersConnected"] !== "number" || typeof obj["totalPeers"] !== "number" || typeof obj["timestamp"] !== "string") {
4560
+ throw new Error("Connector admin API: invalid health response shape");
4519
4561
  }
4562
+ return body;
4520
4563
  }
4521
4564
  /**
4522
- * Hot-add a node after initial startup.
4523
- * Starts the node container, then restarts the connector with updated peer list.
4565
+ * GET /admin/hs-hostname returns the connector's published .anyone hidden-service
4566
+ * hostname (Epic 45 / Story 44.1). Returns 200 with {hostname, publishedAt} both
4567
+ * possibly null while bootstrap is in progress, both non-null once anon publishes.
4568
+ * Returns 503 when the connector is anon-disabled (anon.enabled: false in config).
4569
+ *
4570
+ * @throws Error('connector is anon-disabled (HTTP 503)') on 503 — caller can match
4571
+ * on this exact prefix for actionable diagnostics.
4572
+ * @throws Error on non-200/503 status, network error, or shape-validation failure.
4524
4573
  */
4525
- async addNode(type) {
4526
- if (!this.activeNodes.includes(type)) {
4527
- this.activeNodes.push(type);
4574
+ async getHsHostname() {
4575
+ const url = `${this.baseUrl}/admin/hs-hostname`;
4576
+ const controller = new AbortController();
4577
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
4578
+ let body;
4579
+ try {
4580
+ let response;
4581
+ try {
4582
+ response = await fetch(url, { signal: controller.signal });
4583
+ } catch (error) {
4584
+ if (error instanceof Error && error.name === "AbortError") {
4585
+ throw new Error(
4586
+ `Connector admin API request timeout after ${this.timeoutMs}ms: ${url}`
4587
+ );
4588
+ }
4589
+ const msg = error instanceof Error ? error.message : String(error);
4590
+ throw new Error(`Connector admin API connection refused: ${msg}`);
4591
+ }
4592
+ if (response.status === 503) {
4593
+ throw new Error("connector is anon-disabled (HTTP 503)");
4594
+ }
4595
+ if (!response.ok) {
4596
+ throw new Error(
4597
+ `Connector admin API unexpected status ${response.status} on /admin/hs-hostname \u2014 expected 200 or 503 (connector image may be too old or misconfigured)`
4598
+ );
4599
+ }
4600
+ try {
4601
+ body = await response.json();
4602
+ } catch (error) {
4603
+ if (error instanceof Error && error.name === "AbortError") {
4604
+ throw new Error(
4605
+ `Connector admin API request timeout after ${this.timeoutMs}ms: ${url}`
4606
+ );
4607
+ }
4608
+ const msg = error instanceof Error ? error.message : String(error);
4609
+ throw new Error(
4610
+ `Connector admin API: invalid JSON in hs-hostname response: ${msg}`
4611
+ );
4612
+ }
4613
+ } finally {
4614
+ clearTimeout(timer);
4528
4615
  }
4529
- await this.startNode(type);
4530
- await this.regenerateConnectorConfig(this.activeNodes);
4616
+ if (typeof body !== "object" || body === null) {
4617
+ throw new Error(
4618
+ "Connector admin API: invalid hs-hostname response shape"
4619
+ );
4620
+ }
4621
+ const obj = body;
4622
+ const hostname = obj["hostname"];
4623
+ const publishedAt = obj["publishedAt"];
4624
+ if (hostname !== null && typeof hostname !== "string" || publishedAt !== null && typeof publishedAt !== "string") {
4625
+ throw new Error(
4626
+ "Connector admin API: invalid hs-hostname response shape"
4627
+ );
4628
+ }
4629
+ if (typeof hostname === "string" && hostname.length === 0) {
4630
+ throw new Error(
4631
+ "Connector admin API: invalid hs-hostname response shape"
4632
+ );
4633
+ }
4634
+ if (typeof publishedAt === "string" && publishedAt.length === 0) {
4635
+ throw new Error(
4636
+ "Connector admin API: invalid hs-hostname response shape"
4637
+ );
4638
+ }
4639
+ if (typeof hostname === "string" && !hostname.endsWith(".anon")) {
4640
+ throw new Error(
4641
+ "Connector admin API: invalid hs-hostname response shape"
4642
+ );
4643
+ }
4644
+ return body;
4531
4645
  }
4532
4646
  /**
4533
- * Hot-remove a node.
4534
- * Stops the node container, then restarts the connector with updated peer list.
4647
+ * GET /admin/metrics.json — returns the connector's per-peer ILP counters
4648
+ * with an aggregate rollup, mirroring `AdminMetricsJsonResponse`.
4649
+ *
4650
+ * @throws Error when connector is not running, returns non-200, or shape is invalid
4535
4651
  */
4536
- async removeNode(type) {
4537
- this.activeNodes = this.activeNodes.filter((n) => n !== type);
4538
- const containerName = `${CONTAINER_PREFIX}${type}`;
4539
- await this.stopAndRemove(containerName);
4540
- await this.regenerateConnectorConfig(this.activeNodes);
4652
+ async getMetrics() {
4653
+ const response = await this.fetch("/admin/metrics.json");
4654
+ const body = await response.json();
4655
+ if (typeof body !== "object" || body === null) {
4656
+ throw new Error("Connector admin API: invalid metrics response shape");
4657
+ }
4658
+ const obj = body;
4659
+ const aggregate = obj["aggregate"];
4660
+ if (typeof obj["uptimeSeconds"] !== "number" || typeof aggregate !== "object" || aggregate === null || !Array.isArray(obj["peers"]) || typeof obj["timestamp"] !== "string") {
4661
+ throw new Error("Connector admin API: invalid metrics response shape");
4662
+ }
4663
+ const agg = aggregate;
4664
+ if (typeof agg["packetsForwarded"] !== "number" || typeof agg["packetsRejected"] !== "number" || typeof agg["bytesSent"] !== "number") {
4665
+ throw new Error("Connector admin API: invalid metrics response shape");
4666
+ }
4667
+ return body;
4541
4668
  }
4542
4669
  /**
4543
- * Graceful shutdownstops containers in reverse order:
4544
- * 1. Stop all node containers in parallel
4545
- * 2. Stop connector
4546
- * 3. Remove network
4670
+ * GET /admin/earnings.jsonreturns the connector's per-peer per-asset
4671
+ * earnings projection, mirroring `AdminEarningsJsonResponse` (connector v3.2.0+).
4672
+ *
4673
+ * Source of truth: @toon-protocol/connector
4674
+ * packages/connector/src/http/admin-api.ts:1864-1945
4675
+ *
4676
+ * Returns HTTP 503 when the connector is started without settlement config
4677
+ * (accountManager / claimReceiver not wired). Townhouse's apex always wires
4678
+ * both; 503 in production indicates connector misconfiguration.
4679
+ *
4680
+ * Wire-shape adaptation: the connector's `timestamp: string` field is
4681
+ * wrapped into `{ iso: string }` on the way out (EarningsTimestamp).
4682
+ *
4683
+ * @throws Error when connector is not running, returns non-200, or shape is invalid
4547
4684
  */
4548
- async down() {
4549
- const containers = await this.docker.listContainers({ all: true });
4550
- const nodeContainerNames = [];
4551
- let connectorName;
4552
- for (const info of containers) {
4553
- for (const name of info.Names) {
4554
- const cleanName = name.startsWith("/") ? name.slice(1) : name;
4555
- if (!cleanName.startsWith(CONTAINER_PREFIX)) continue;
4556
- if (cleanName === `${CONTAINER_PREFIX}connector`) {
4557
- connectorName = cleanName;
4558
- } else {
4559
- nodeContainerNames.push(cleanName);
4685
+ async getEarnings() {
4686
+ const response = await this.fetch("/admin/earnings.json");
4687
+ const body = await response.json();
4688
+ if (typeof body !== "object" || body === null) {
4689
+ throw new Error("Connector admin API: invalid earnings response shape");
4690
+ }
4691
+ const obj = body;
4692
+ if (typeof obj["uptimeSeconds"] !== "number" || !Array.isArray(obj["peers"]) || !Array.isArray(obj["connectorFees"]) || !Array.isArray(obj["recentClaims"]) || typeof obj["timestamp"] !== "string") {
4693
+ throw new Error("Connector admin API: invalid earnings response shape");
4694
+ }
4695
+ const peers = obj["peers"];
4696
+ for (const peer of peers) {
4697
+ if (typeof peer !== "object" || peer === null) {
4698
+ throw new Error("Connector admin API: invalid earnings response shape");
4699
+ }
4700
+ const p = peer;
4701
+ if (typeof p["peerId"] !== "string" || !Array.isArray(p["byAsset"])) {
4702
+ throw new Error("Connector admin API: invalid earnings response shape");
4703
+ }
4704
+ for (const asset of p["byAsset"]) {
4705
+ if (typeof asset !== "object" || asset === null) {
4706
+ throw new Error(
4707
+ "Connector admin API: invalid earnings response shape"
4708
+ );
4709
+ }
4710
+ const a = asset;
4711
+ if (typeof a["assetCode"] !== "string" || typeof a["assetScale"] !== "number" || typeof a["claimsReceivedTotal"] !== "string" || typeof a["claimsSentTotal"] !== "string" || typeof a["netBalance"] !== "string" || a["lastClaimAt"] !== null && typeof a["lastClaimAt"] !== "string") {
4712
+ throw new Error(
4713
+ "Connector admin API: invalid earnings response shape"
4714
+ );
4560
4715
  }
4561
4716
  }
4562
4717
  }
4563
- await Promise.all(
4564
- nodeContainerNames.map((name) => this.stopAndRemove(name))
4565
- );
4566
- if (connectorName) {
4567
- await this.stopAndRemove(connectorName);
4718
+ const fees = obj["connectorFees"];
4719
+ for (const fee of fees) {
4720
+ if (typeof fee !== "object" || fee === null) {
4721
+ throw new Error("Connector admin API: invalid earnings response shape");
4722
+ }
4723
+ const f = fee;
4724
+ if (typeof f["assetCode"] !== "string" || typeof f["assetScale"] !== "number" || typeof f["total"] !== "string") {
4725
+ throw new Error("Connector admin API: invalid earnings response shape");
4726
+ }
4568
4727
  }
4569
- await this.removeNetwork();
4728
+ const claims = obj["recentClaims"];
4729
+ for (const claim of claims) {
4730
+ if (typeof claim !== "object" || claim === null) {
4731
+ throw new Error("Connector admin API: invalid earnings response shape");
4732
+ }
4733
+ const c = claim;
4734
+ if (typeof c["peerId"] !== "string" || typeof c["assetCode"] !== "string" || typeof c["assetScale"] !== "number" || typeof c["amount"] !== "string" || c["direction"] !== "inbound" && c["direction"] !== "outbound" || typeof c["at"] !== "string") {
4735
+ throw new Error("Connector admin API: invalid earnings response shape");
4736
+ }
4737
+ }
4738
+ const timestamp = { iso: obj["timestamp"] };
4739
+ return {
4740
+ uptimeSeconds: obj["uptimeSeconds"],
4741
+ peers,
4742
+ connectorFees: fees,
4743
+ recentClaims: claims,
4744
+ timestamp
4745
+ };
4570
4746
  }
4571
4747
  /**
4572
- * Resolve the Nostr relay WebSocket URL for a Town node instance.
4573
- *
4574
- * Inspects the container's port bindings to get the host-bound port for
4575
- * the relay WebSocket (7100/tcp). Falls back to the Docker-internal URL
4576
- * when the server is running inside the Docker network or bindings are absent.
4748
+ * GET /admin/peers returns the connector's peer roster with route counts
4749
+ * and ILP addresses. Returns the unwrapped peers array (the wrapper's
4750
+ * nodeId / peerCount / connectedCount fields are dropped).
4577
4751
  *
4578
- * @param nodeId - The `NodeInfo.id` value (e.g. 'town', 'dev-town-01')
4752
+ * @throws Error when connector is not running, returns non-200, or shape is invalid
4579
4753
  */
4580
- async getNodeRelayEndpoint(nodeId) {
4581
- const containerName = `${CONTAINER_PREFIX}${nodeId}`;
4582
- try {
4754
+ async getPeers() {
4755
+ const response = await this.fetch("/admin/peers");
4756
+ const body = await response.json();
4757
+ if (typeof body !== "object" || body === null) {
4758
+ throw new Error("Connector admin API: invalid peers response shape");
4759
+ }
4760
+ const obj = body;
4761
+ if (!Array.isArray(obj["peers"])) {
4762
+ throw new Error("Connector admin API: invalid peers response shape");
4763
+ }
4764
+ return body.peers;
4765
+ }
4766
+ /**
4767
+ * GET /admin/channels — returns the connector's payment-channel summaries
4768
+ * across all registered chain providers. Multi-chain: one entry per channel
4769
+ * regardless of chain.
4770
+ *
4771
+ * @throws Error when connector is not running, returns non-200, or shape is invalid
4772
+ */
4773
+ async getChannels() {
4774
+ const response = await this.fetch("/admin/channels");
4775
+ const body = await response.json();
4776
+ if (!Array.isArray(body)) {
4777
+ throw new Error("Connector admin API: invalid channels response shape");
4778
+ }
4779
+ for (const entry of body) {
4780
+ if (typeof entry !== "object" || entry === null) {
4781
+ throw new Error("Connector admin API: invalid channels response shape");
4782
+ }
4783
+ const e = entry;
4784
+ if (typeof e["channelId"] !== "string" || typeof e["peerId"] !== "string" || typeof e["chain"] !== "string" || typeof e["status"] !== "string" || typeof e["deposit"] !== "string" || typeof e["lastActivity"] !== "string") {
4785
+ throw new Error("Connector admin API: invalid channels response shape");
4786
+ }
4787
+ }
4788
+ return body;
4789
+ }
4790
+ /**
4791
+ * POST /admin/peers — register (or re-register, idempotent) a child peer
4792
+ * with the connector. Used by the boot reconciler (Story 46.1) to
4793
+ * re-register peers present in `nodes.yaml` but missing from the
4794
+ * connector's runtime peer roster (e.g., after a connector restart).
4795
+ *
4796
+ * The connector's POST /admin/peers handler treats a POST whose `id`
4797
+ * matches an existing peer as a re-registration (no-op for the peer
4798
+ * itself; routes are appended). A POST with a new `id` triggers
4799
+ * `addPeer()` and BTP connection setup.
4800
+ *
4801
+ * @param input.id - peer identifier (matches `nodes.yaml`'s `peerId` and
4802
+ * the connector's `PeerStatus.id`).
4803
+ * @param input.url - BTP WebSocket URL the connector dials. MUST start
4804
+ * with `ws://` or `wss://` (the connector validates this).
4805
+ * @param input.authToken - shared auth token; pass empty string for
4806
+ * internal Townhouse peers (no auth).
4807
+ * @param input.routes - optional ILP route prefixes to register against
4808
+ * this peer. The reconciler passes the peer's ilpAddress.
4809
+ * @param input.transport - optional per-peer transport selection
4810
+ * (connector >= 3.6.2). `'direct'` forces the connector to bypass the
4811
+ * global SOCKS5 transport for this peer, even when the apex itself
4812
+ * runs in `transport.type: socks5` mode. Required for Docker-sibling
4813
+ * peers in HS mode — the anon SOCKS5 proxy cannot resolve internal
4814
+ * Docker hostnames. When omitted, the peer inherits the connector's
4815
+ * global transport (back-compat with pre-3.6.2 connectors).
4816
+ *
4817
+ * @throws Error on non-2xx response, timeout, or connection refused.
4818
+ */
4819
+ async registerPeer(input) {
4820
+ if (!input.url.startsWith("ws://") && !input.url.startsWith("wss://")) {
4821
+ throw new Error(
4822
+ `Connector admin API: registerPeer.url must start with ws:// or wss:// (got: ${input.url})`
4823
+ );
4824
+ }
4825
+ const url = `${this.baseUrl}/admin/peers`;
4826
+ const controller = new AbortController();
4827
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
4828
+ try {
4829
+ let response;
4830
+ try {
4831
+ response = await fetch(url, {
4832
+ method: "POST",
4833
+ headers: { "content-type": "application/json" },
4834
+ body: JSON.stringify(input),
4835
+ signal: controller.signal
4836
+ });
4837
+ } catch (error) {
4838
+ if (error instanceof Error && error.name === "AbortError") {
4839
+ throw new Error(
4840
+ `Connector admin API request timeout after ${this.timeoutMs}ms: POST ${url}`
4841
+ );
4842
+ }
4843
+ const msg = error instanceof Error ? error.message : String(error);
4844
+ throw new Error(
4845
+ `Connector admin API request failed: POST ${url} \u2014 ${msg}`
4846
+ );
4847
+ }
4848
+ if (!response.ok) {
4849
+ let body = "";
4850
+ try {
4851
+ body = await response.text();
4852
+ } catch (error) {
4853
+ if (error instanceof Error && error.name === "AbortError") {
4854
+ throw new Error(
4855
+ `Connector admin API request timeout after ${this.timeoutMs}ms: POST ${url} (body read)`
4856
+ );
4857
+ }
4858
+ }
4859
+ throw new Error(
4860
+ `Connector admin API error: POST /admin/peers returned ${response.status} ${response.statusText}${body ? ` \u2014 ${body}` : ""}`
4861
+ );
4862
+ }
4863
+ } finally {
4864
+ clearTimeout(timer);
4865
+ }
4866
+ }
4867
+ /**
4868
+ * DELETE /admin/peers/:peerId?removeRoutes=true — deregister a child peer.
4869
+ *
4870
+ * Idempotent: a 404 from the connector (peer already removed) is treated as
4871
+ * success so callers can safely use this as a rollback step without knowing
4872
+ * whether the peer was ever registered.
4873
+ *
4874
+ * `removeRoutes=true` is always sent so the connector drops the ILP routing
4875
+ * entries for this peer along with the BTP connection config.
4876
+ *
4877
+ * @throws Error on empty peerId (rejected at client, no network request made)
4878
+ * @throws Error on non-2xx/404 response, timeout, or connection refused
4879
+ */
4880
+ async removePeer(peerId) {
4881
+ if (!peerId) {
4882
+ throw new Error(
4883
+ "Connector admin API: removePeer requires a non-empty peerId"
4884
+ );
4885
+ }
4886
+ const url = `${this.baseUrl}/admin/peers/${encodeURIComponent(peerId)}?removeRoutes=true`;
4887
+ const controller = new AbortController();
4888
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
4889
+ try {
4890
+ let response;
4891
+ try {
4892
+ response = await fetch(url, {
4893
+ method: "DELETE",
4894
+ signal: controller.signal
4895
+ });
4896
+ } catch (error) {
4897
+ if (error instanceof Error && error.name === "AbortError") {
4898
+ throw new Error(
4899
+ `Connector admin API request timeout after ${this.timeoutMs}ms: DELETE ${url}`
4900
+ );
4901
+ }
4902
+ const msg = error instanceof Error ? error.message : String(error);
4903
+ throw new Error(
4904
+ `Connector admin API request failed: DELETE ${url} \u2014 ${msg}`
4905
+ );
4906
+ }
4907
+ if (response.status === 404) {
4908
+ return;
4909
+ }
4910
+ if (!response.ok) {
4911
+ let body = "";
4912
+ try {
4913
+ body = await response.text();
4914
+ } catch (error) {
4915
+ if (error instanceof Error && error.name === "AbortError") {
4916
+ throw new Error(
4917
+ `Connector admin API request timeout after ${this.timeoutMs}ms: DELETE ${url} (body read)`
4918
+ );
4919
+ }
4920
+ }
4921
+ throw new Error(
4922
+ `Connector admin API error: DELETE /admin/peers/${peerId} returned ${response.status} ${response.statusText}${body ? ` \u2014 ${body}` : ""}`
4923
+ );
4924
+ }
4925
+ } finally {
4926
+ clearTimeout(timer);
4927
+ }
4928
+ }
4929
+ /**
4930
+ * GET /packets — returns the connector's raw packet log filtered by the
4931
+ * given criteria. Used by the timeseries aggregation route (story 21.10).
4932
+ *
4933
+ * Townhouse-Side Contract: see packages/sdk/CONNECTOR_MIGRATION.md §getPacketLog.
4934
+ * If the connector image does not expose GET /packets, this method throws
4935
+ * with a `ConnectorEndpointNotFound` error code so the route can return 503.
4936
+ *
4937
+ * @throws Error with code='ConnectorEndpointNotFound' when connector returns 404
4938
+ * @throws Error when connector is not running, returns non-200, or shape is invalid
4939
+ */
4940
+ async getPacketLog(filter = {}) {
4941
+ const params = new URLSearchParams();
4942
+ if (filter.ilpAddress !== void 0)
4943
+ params.set("ilpAddress", filter.ilpAddress);
4944
+ if (filter.since !== void 0) params.set("since", String(filter.since));
4945
+ if (filter.limit !== void 0) params.set("limit", String(filter.limit));
4946
+ const path = params.toString() ? `/packets?${params.toString()}` : "/packets";
4947
+ let response;
4948
+ try {
4949
+ response = await this.fetch(path);
4950
+ } catch (error) {
4951
+ const msg = error instanceof Error ? error.message : String(error);
4952
+ if (msg.includes("404")) {
4953
+ const err = new Error(
4954
+ "Connector does not expose GET /packets \u2014 endpoint not found"
4955
+ );
4956
+ err.code = "ConnectorEndpointNotFound";
4957
+ throw err;
4958
+ }
4959
+ throw error;
4960
+ }
4961
+ const body = await response.json();
4962
+ if (!Array.isArray(body)) {
4963
+ throw new Error(
4964
+ "Connector admin API: invalid packet log response shape \u2014 expected array"
4965
+ );
4966
+ }
4967
+ return body;
4968
+ }
4969
+ // ── Private helpers ──
4970
+ /**
4971
+ * Perform an HTTP GET request to the connector admin API.
4972
+ * Wraps fetch with error handling for connection refused and non-200 responses.
4973
+ */
4974
+ async fetch(path) {
4975
+ const url = `${this.baseUrl}${path}`;
4976
+ const controller = new AbortController();
4977
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
4978
+ try {
4979
+ let response;
4980
+ try {
4981
+ response = await fetch(url, { signal: controller.signal });
4982
+ } catch (error) {
4983
+ if (error instanceof Error && error.name === "AbortError") {
4984
+ throw new Error(
4985
+ `Connector admin API request timeout after ${this.timeoutMs}ms: ${url}`
4986
+ );
4987
+ }
4988
+ const msg = error instanceof Error ? error.message : String(error);
4989
+ throw new Error(`Connector admin API connection refused: ${msg}`);
4990
+ }
4991
+ if (!response.ok) {
4992
+ throw new Error(
4993
+ `Connector admin API error: ${response.status} ${response.statusText}`
4994
+ );
4995
+ }
4996
+ return response;
4997
+ } finally {
4998
+ clearTimeout(timer);
4999
+ }
5000
+ }
5001
+ };
5002
+
5003
+ // src/docker/orchestrator.ts
5004
+ import { EventEmitter } from "events";
5005
+ import { spawn } from "child_process";
5006
+ import { existsSync, mkdirSync, writeFileSync as writeFileSync2 } from "fs";
5007
+ import { dirname, isAbsolute, join as join2 } from "path";
5008
+ function runDockerCompose(file, args, options = {}) {
5009
+ const {
5010
+ timeout,
5011
+ maxBuffer = 16 * 1024 * 1024,
5012
+ inheritStdio = false,
5013
+ env
5014
+ } = options;
5015
+ return new Promise((resolve2, reject) => {
5016
+ const child = spawn(file, Array.from(args), {
5017
+ stdio: inheritStdio ? ["ignore", "inherit", "pipe"] : ["ignore", "pipe", "pipe"],
5018
+ ...env !== void 0 ? { env } : {}
5019
+ });
5020
+ const stderrChunks = [];
5021
+ const stdoutChunks = [];
5022
+ let stderrLen = 0;
5023
+ let stdoutLen = 0;
5024
+ let timedOut = false;
5025
+ const timer = timeout !== void 0 && timeout > 0 ? setTimeout(() => {
5026
+ timedOut = true;
5027
+ child.kill("SIGTERM");
5028
+ setTimeout(() => {
5029
+ if (!child.killed) child.kill("SIGKILL");
5030
+ }, 5e3).unref();
5031
+ }, timeout) : null;
5032
+ child.stderr?.on("data", (chunk) => {
5033
+ if (stderrLen < maxBuffer) {
5034
+ stderrChunks.push(chunk);
5035
+ stderrLen += chunk.length;
5036
+ }
5037
+ });
5038
+ child.stdout?.on("data", (chunk) => {
5039
+ if (stdoutLen < maxBuffer) {
5040
+ stdoutChunks.push(chunk);
5041
+ stdoutLen += chunk.length;
5042
+ }
5043
+ });
5044
+ child.on("error", (err) => {
5045
+ if (timer) clearTimeout(timer);
5046
+ reject(err);
5047
+ });
5048
+ child.on("close", (code, signal) => {
5049
+ if (timer) clearTimeout(timer);
5050
+ const stderr = Buffer.concat(stderrChunks).toString("utf8");
5051
+ const stdout = Buffer.concat(stdoutChunks).toString("utf8");
5052
+ if (timedOut) {
5053
+ const err2 = new Error(
5054
+ `docker subprocess timed out after ${timeout}ms`
5055
+ );
5056
+ err2.stdout = stdout;
5057
+ err2.stderr = stderr;
5058
+ err2.code = "ETIMEDOUT";
5059
+ err2.signal = signal;
5060
+ return reject(err2);
5061
+ }
5062
+ if (code === 0) {
5063
+ return resolve2({ stdout, stderr });
5064
+ }
5065
+ const err = new Error(
5066
+ `docker subprocess exited with ${code !== null ? `code ${code}` : `signal ${signal}`}`
5067
+ );
5068
+ err.stdout = stdout;
5069
+ err.stderr = stderr;
5070
+ if (code !== null) err.code = code;
5071
+ if (signal !== null) err.signal = signal;
5072
+ reject(err);
5073
+ });
5074
+ });
5075
+ }
5076
+ var TOWN_RELAY_PORT = 7100;
5077
+ var STATS_CACHE_TTL_MS = 5e3;
5078
+ var NETWORK_NAME = "townhouse-net";
5079
+ var DEFAULT_NODE_IMAGES = {
5080
+ town: "toon:town",
5081
+ mill: "toon:mill",
5082
+ dvm: "toon:dvm"
5083
+ };
5084
+ var MAX_START_RETRIES = 3;
5085
+ var CONNECTOR_INTERNAL_PORT = 3e3;
5086
+ var RELAY_ATOR_SIDECAR_IMAGE = "toon:townhouse-ator-sidecar";
5087
+ var RELAY_ATOR_SOCKS_PORT = 9051;
5088
+ var OrchestratorError = class extends Error {
5089
+ service;
5090
+ exitCode;
5091
+ stderr;
5092
+ constructor(message, options = {}) {
5093
+ super(message, options.cause ? { cause: options.cause } : void 0);
5094
+ this.name = "OrchestratorError";
5095
+ if (options.service !== void 0) this.service = options.service;
5096
+ if (options.exitCode !== void 0) this.exitCode = options.exitCode;
5097
+ if (options.stderr !== void 0) this.stderr = options.stderr;
5098
+ }
5099
+ };
5100
+ function redactSecretsInComposeStderr(stderr) {
5101
+ const SECRET_KEYS2 = [
5102
+ "TOWN_SECRET_KEY",
5103
+ "MILL_SECRET_KEY",
5104
+ "DVM_SECRET_KEY",
5105
+ "TOWN_SETTLEMENT_PRIVATE_KEY",
5106
+ "MILL_SETTLEMENT_PRIVATE_KEY",
5107
+ "DVM_SETTLEMENT_PRIVATE_KEY",
5108
+ "MILL_MNEMONIC",
5109
+ "TOWNHOUSE_WALLET_PASSWORD"
5110
+ ];
5111
+ const pattern = new RegExp(`(${SECRET_KEYS2.join("|")})=[^\\s"'\\n\\r]+`, "g");
5112
+ return stderr.replace(pattern, "$1=[REDACTED]");
5113
+ }
5114
+ function normalizeImageTag(image) {
5115
+ const lastSlash = image.lastIndexOf("/");
5116
+ const nameAndTag = lastSlash >= 0 ? image.slice(lastSlash + 1) : image;
5117
+ if (nameAndTag.includes(":")) {
5118
+ return image;
5119
+ }
5120
+ return `${image}:latest`;
5121
+ }
5122
+ var DockerOrchestrator = class extends EventEmitter {
5123
+ docker;
5124
+ config;
5125
+ configGenerator;
5126
+ walletManager;
5127
+ activeNodes = [];
5128
+ statsCache = /* @__PURE__ */ new Map();
5129
+ profile;
5130
+ composePath;
5131
+ execFileAsync;
5132
+ adminClientFactory;
5133
+ constructor(docker, config, walletManager, options = {}) {
5134
+ super();
5135
+ this.docker = docker;
5136
+ this.config = config;
5137
+ this.configGenerator = new ConnectorConfigGenerator(config);
5138
+ this.walletManager = walletManager;
5139
+ this.profile = options.profile ?? "dev";
5140
+ const trimmedComposePath = options.composePath?.trim();
5141
+ this.composePath = trimmedComposePath !== void 0 && trimmedComposePath.length > 0 ? trimmedComposePath : void 0;
5142
+ this.execFileAsync = options.execFileAsync ?? runDockerCompose;
5143
+ this.adminClientFactory = options.adminClientFactory ?? ((url, t) => new ConnectorAdminClient(url, t));
5144
+ if (this.profile === "hs" && !this.composePath) {
5145
+ throw new OrchestratorError(
5146
+ `profile: 'hs' requires a non-empty composePath. Pass options.composePath pointing at the rendered HS template (typically the composePath returned by materializeComposeTemplate('hs')).`
5147
+ );
5148
+ }
5149
+ }
5150
+ /**
5151
+ * Orchestrate full startup sequence. Branches on profile:
5152
+ * - 'dev' (default): dockerode-based, preserves existing dev-stack behavior
5153
+ * - 'hs': docker compose subprocess + HS hostname readiness gate
5154
+ */
5155
+ async up(profiles) {
5156
+ if (this.profile === "hs") {
5157
+ await this.upHs(profiles);
5158
+ this.activeNodes = [...profiles];
5159
+ } else {
5160
+ this.activeNodes = [...profiles];
5161
+ await this.upDev(profiles);
5162
+ }
5163
+ }
5164
+ async upDev(profiles) {
5165
+ await this.ensureNetwork();
5166
+ await this.pullImages(profiles);
5167
+ await this.startConnector();
5168
+ await this.waitForHealth("townhouse-connector");
5169
+ await Promise.all(profiles.map((type) => this.startNode(type)));
5170
+ if (profiles.includes("town") && this.config.transport.relayHiddenService) {
5171
+ await this.startRelayAtorSidecar();
5172
+ }
5173
+ }
5174
+ /**
5175
+ * Narrow `this.composePath` to a definite string. The constructor enforces
5176
+ * this invariant for `profile: 'hs'`; this helper exists so the HS-path
5177
+ * methods don't need a non-null assertion (lint-clean) and so a constructor
5178
+ * regression surfaces as an `OrchestratorError` rather than a `TypeError`.
5179
+ */
5180
+ requireComposePath() {
5181
+ if (!this.composePath) {
5182
+ throw new OrchestratorError(
5183
+ `internal: composePath unset for HS profile (constructor invariant violated)`
5184
+ );
5185
+ }
5186
+ return this.composePath;
5187
+ }
5188
+ /**
5189
+ * validate that composePath is absolute and exists on disk before
5190
+ * passing it to any subprocess call. Defence-in-depth — callers pass paths
5191
+ * from materializeComposeTemplate so this should never fire in normal use.
5192
+ */
5193
+ validateComposePath(composePath) {
5194
+ if (!isAbsolute(composePath)) {
5195
+ throw new OrchestratorError(
5196
+ `composePath must be an absolute path, got: ${composePath}`
5197
+ );
5198
+ }
5199
+ if (!existsSync(composePath)) {
5200
+ throw new OrchestratorError(
5201
+ `composePath does not exist on disk: ${composePath}`
5202
+ );
5203
+ }
5204
+ }
5205
+ /** HS-mode startup: shell out to `docker compose up -d`, wait for HS hostname. */
5206
+ async upHs(profiles) {
5207
+ const composePath = this.requireComposePath();
5208
+ this.validateComposePath(composePath);
5209
+ const PROFILE_ORDER = ["town", "mill", "dvm"];
5210
+ for (const p of profiles) {
5211
+ if (!PROFILE_ORDER.includes(p)) {
5212
+ throw new OrchestratorError(
5213
+ `Unknown profile '${String(p)}'. Expected one of: ${PROFILE_ORDER.join(", ")}.`
5214
+ );
5215
+ }
5216
+ }
5217
+ const args = ["compose", "-f", composePath];
5218
+ for (const type of PROFILE_ORDER) {
5219
+ if (profiles.includes(type)) {
5220
+ args.push("--profile", type);
5221
+ }
5222
+ }
5223
+ args.push("up", "-d");
5224
+ try {
5225
+ await this.execFileAsync("docker", args, {
5226
+ timeout: 18e4,
5227
+ maxBuffer: 16 * 1024 * 1024,
5228
+ inheritStdio: true
5229
+ });
5230
+ } catch (err) {
5231
+ const e = err;
5232
+ const stderr = String(e.stderr ?? "");
5233
+ const numericExit = typeof e.code === "number" ? e.code : void 0;
5234
+ const codeLabel = String(e.code ?? e.signal ?? "unknown");
5235
+ let message;
5236
+ if (e.code === "ENOENT") {
5237
+ message = `docker CLI not found on PATH (ENOENT): ${stderr.trim().slice(0, 2e3)}`;
5238
+ } else if (e.code === "ETIMEDOUT") {
5239
+ message = `docker compose up timed out after 180000ms: ${stderr.trim().slice(0, 2e3)}`;
5240
+ } else {
5241
+ message = `docker compose up failed (exit ${codeLabel}): ${stderr.trim().slice(0, 2e3)}`;
5242
+ }
5243
+ this.surfaceComposeFailure(stderr);
5244
+ throw new OrchestratorError(message, {
5245
+ ...numericExit !== void 0 ? { exitCode: numericExit } : {},
5246
+ stderr,
5247
+ cause: err instanceof Error ? err : void 0
5248
+ });
5249
+ }
5250
+ try {
5251
+ await this.waitForHsHostname();
5252
+ } catch (err) {
5253
+ await this.downHs().catch(() => {
5254
+ });
5255
+ throw err;
5256
+ }
5257
+ }
5258
+ /**
5259
+ * Parse Docker Compose stderr for failed-service names and emit a
5260
+ * containerState event per failed service so callers see the failure via
5261
+ * the same channel dev-mode uses (AC #6 — "for each failed service
5262
+ * identified, it emits..."). When no pattern matches, emit a single
5263
+ * fallback event with name `'compose-up'`.
5264
+ */
5265
+ surfaceComposeFailure(stderr) {
5266
+ const patterns = [
5267
+ /failed to start (?:service\s+)?["']([^"']+)["']/gi,
5268
+ /service\s+["']([^"']+)["']\s+failed/gi,
5269
+ /Container\s+[\w-]+-([a-z][\w-]*?)(?:-\d+)?\s+Error/gi
5270
+ ];
5271
+ const detail = stderr.trim().slice(0, 2e3);
5272
+ const seen = /* @__PURE__ */ new Set();
5273
+ for (const pattern of patterns) {
5274
+ for (const match of stderr.matchAll(pattern)) {
5275
+ const name = match[1];
5276
+ if (name && !seen.has(name)) {
5277
+ seen.add(name);
5278
+ this.emit("containerState", { name, state: "error", detail });
5279
+ }
5280
+ }
5281
+ }
5282
+ if (seen.size === 0) {
5283
+ this.emit("containerState", {
5284
+ name: "compose-up",
5285
+ state: "error",
5286
+ detail
5287
+ });
5288
+ }
5289
+ }
5290
+ async waitForHsHostname() {
5291
+ const adminUrl = `http://127.0.0.1:${this.config.connector.adminPort}`;
5292
+ const client = this.adminClientFactory(adminUrl, 5e3);
5293
+ const deadlineNs = process.hrtime.bigint() + 120000000000n;
5294
+ const pollInterval = 2e3;
5295
+ let lastResponse;
5296
+ let lastError;
5297
+ while (process.hrtime.bigint() < deadlineNs) {
5298
+ try {
5299
+ lastResponse = await client.getHsHostname();
5300
+ lastError = void 0;
5301
+ if (lastResponse.hostname !== null && lastResponse.publishedAt !== null) {
5302
+ return;
5303
+ }
5304
+ } catch (err) {
5305
+ const msg = err instanceof Error ? err.message : String(err);
5306
+ lastError = err instanceof Error ? err : new Error(String(err));
5307
+ if (msg.includes("anon-disabled")) {
5308
+ throw new OrchestratorError(
5309
+ `connector is anon-disabled \u2014 set anon.enabled: true in the connector config`,
5310
+ { cause: err instanceof Error ? err : void 0 }
5311
+ );
5312
+ }
5313
+ if (msg.includes("invalid hs-hostname response shape") || msg.includes("invalid JSON in hs-hostname response")) {
5314
+ throw new OrchestratorError(
5315
+ `connector returned a malformed /admin/hs-hostname response: ${msg}`,
5316
+ { cause: err instanceof Error ? err : void 0 }
5317
+ );
5318
+ }
5319
+ if (msg.includes("unexpected status")) {
5320
+ throw new OrchestratorError(msg, {
5321
+ cause: err instanceof Error ? err : void 0
5322
+ });
5323
+ }
5324
+ }
5325
+ await new Promise((r) => setTimeout(r, pollInterval));
5326
+ }
5327
+ const tail = lastError ? ` (last error: ${lastError.message})` : lastResponse ? ` (last response: ${JSON.stringify(lastResponse)})` : " (no successful response received)";
5328
+ throw new OrchestratorError(
5329
+ `HS hostname publication timeout after 120000ms` + tail,
5330
+ lastError ? { cause: lastError } : {}
5331
+ );
5332
+ }
5333
+ /**
5334
+ * Regenerate connector config and restart the connector container
5335
+ * with updated environment variables (peer list).
5336
+ *
5337
+ * Sequence: emit connectorRestarting -> stop -> remove -> create -> start -> health -> emit connectorRestarted
5338
+ */
5339
+ async regenerateConnectorConfig(activeNodes) {
5340
+ this.activeNodes = [...activeNodes];
5341
+ this.emit("connectorRestarting", { reason: "peer list updated" });
5342
+ const connectorName = `${CONTAINER_PREFIX}connector`;
5343
+ const existingContainer = this.docker.getContainer(connectorName);
5344
+ try {
5345
+ await existingContainer.stop({ t: 5 });
5346
+ } catch {
5347
+ }
5348
+ try {
5349
+ await existingContainer.remove();
5350
+ } catch {
5351
+ }
5352
+ await this.ensureNetwork();
5353
+ try {
5354
+ await this.startConnector();
5355
+ await this.waitForHealth(connectorName);
5356
+ } finally {
5357
+ this.emit("connectorRestarted", { peers: activeNodes });
5358
+ }
5359
+ }
5360
+ /**
5361
+ * Hot-add a node after initial startup.
5362
+ * Starts the node container, then restarts the connector with updated peer list.
5363
+ */
5364
+ async addNode(type) {
5365
+ if (!this.activeNodes.includes(type)) {
5366
+ this.activeNodes.push(type);
5367
+ }
5368
+ await this.startNode(type);
5369
+ await this.regenerateConnectorConfig(this.activeNodes);
5370
+ }
5371
+ /**
5372
+ * Hot-remove a node.
5373
+ * Stops the node container, then restarts the connector with updated peer list.
5374
+ */
5375
+ async removeNode(type) {
5376
+ this.activeNodes = this.activeNodes.filter((n) => n !== type);
5377
+ const containerName = `${CONTAINER_PREFIX}${type}`;
5378
+ await this.stopAndRemove(containerName);
5379
+ await this.regenerateConnectorConfig(this.activeNodes);
5380
+ }
5381
+ /**
5382
+ * Graceful shutdown. Branches on profile:
5383
+ * - 'dev' (default): dockerode-based teardown
5384
+ * - 'hs': docker compose subprocess
5385
+ */
5386
+ async down() {
5387
+ if (this.profile === "hs") {
5388
+ await this.downHs();
5389
+ } else {
5390
+ await this.downDev();
5391
+ }
5392
+ }
5393
+ async downDev() {
5394
+ const containers = await this.docker.listContainers({ all: true });
5395
+ const nodeContainerNames = [];
5396
+ let connectorName;
5397
+ for (const info of containers) {
5398
+ for (const name of info.Names) {
5399
+ const cleanName = name.startsWith("/") ? name.slice(1) : name;
5400
+ if (!cleanName.startsWith(CONTAINER_PREFIX)) continue;
5401
+ if (cleanName === `${CONTAINER_PREFIX}connector`) {
5402
+ connectorName = cleanName;
5403
+ } else {
5404
+ nodeContainerNames.push(cleanName);
5405
+ }
5406
+ }
5407
+ }
5408
+ await Promise.all(
5409
+ nodeContainerNames.map((name) => this.stopAndRemove(name))
5410
+ );
5411
+ if (connectorName) {
5412
+ await this.stopAndRemove(connectorName);
5413
+ }
5414
+ await this.removeNetwork();
5415
+ }
5416
+ async downHs() {
5417
+ const composePath = this.requireComposePath();
5418
+ const args = ["compose", "-f", composePath, "down"];
5419
+ try {
5420
+ await this.execFileAsync("docker", args, {
5421
+ timeout: 12e4,
5422
+ maxBuffer: 16 * 1024 * 1024
5423
+ });
5424
+ } catch (err) {
5425
+ const e = err;
5426
+ const stderr = String(e.stderr ?? "");
5427
+ const numericExit = typeof e.code === "number" ? e.code : void 0;
5428
+ const codeLabel = String(e.code ?? e.signal ?? "unknown");
5429
+ let message;
5430
+ if (e.code === "ENOENT") {
5431
+ message = `docker CLI not found on PATH (ENOENT): ${stderr.trim().slice(0, 2e3)}`;
5432
+ } else if (e.code === "ETIMEDOUT") {
5433
+ message = `docker compose down timed out after 120000ms: ${stderr.trim().slice(0, 2e3)}`;
5434
+ } else {
5435
+ if (stderr.includes("no such service") || stderr.includes("no containers to remove") || stderr.includes("No such container") || stderr.includes("network") && stderr.includes("not found")) {
5436
+ return;
5437
+ }
5438
+ message = `docker compose down failed (exit ${codeLabel}): ${stderr.trim().slice(0, 2e3)}`;
5439
+ }
5440
+ throw new OrchestratorError(message, {
5441
+ ...numericExit !== void 0 ? { exitCode: numericExit } : {},
5442
+ stderr,
5443
+ cause: err instanceof Error ? err : void 0
5444
+ });
5445
+ }
5446
+ }
5447
+ /**
5448
+ * Resolve the Nostr relay WebSocket URL for a Town node instance.
5449
+ *
5450
+ * Inspects the container's port bindings to get the host-bound port for
5451
+ * the relay WebSocket (7100/tcp). Falls back to the Docker-internal URL
5452
+ * when the server is running inside the Docker network or bindings are absent.
5453
+ *
5454
+ * @param nodeId - The `NodeInfo.id` value (e.g. 'town', 'dev-town-01')
5455
+ */
5456
+ async getNodeRelayEndpoint(nodeId) {
5457
+ const containerName = `${CONTAINER_PREFIX}${nodeId}`;
5458
+ try {
4583
5459
  const container = this.docker.getContainer(containerName);
4584
5460
  const info = await container.inspect();
4585
5461
  const portBindings = info.HostConfig?.PortBindings;
@@ -4739,18 +5615,158 @@ var DockerOrchestrator = class extends EventEmitter {
4739
5615
  if (profiles.includes("town") && this.config.transport.relayHiddenService) {
4740
5616
  imagesToPull.add(normalizeImageTag(RELAY_ATOR_SIDECAR_IMAGE));
4741
5617
  }
5618
+ for (const image of imagesToPull) {
5619
+ await this.pullImage(image);
5620
+ }
5621
+ }
5622
+ /**
5623
+ * Pull a single image by its reference (tag or digest form).
5624
+ *
5625
+ * Skips the pull when the image already exists locally (matches against
5626
+ * both RepoTags and RepoDigests so digest-form refs like
5627
+ * `ghcr.io/toon-protocol/town@sha256:abc...` are found correctly).
5628
+ * Throws `OrchestratorError` on pull failure.
5629
+ */
5630
+ async pullImage(image) {
4742
5631
  const existingImages = await this.docker.listImages();
4743
5632
  const existingRefs = /* @__PURE__ */ new Set();
4744
5633
  for (const img of existingImages) {
4745
5634
  for (const tag of img.RepoTags ?? []) existingRefs.add(tag);
4746
5635
  for (const digest of img.RepoDigests ?? []) existingRefs.add(digest);
4747
5636
  }
4748
- for (const image of imagesToPull) {
4749
- if (existingRefs.has(image)) {
4750
- continue;
4751
- }
5637
+ if (existingRefs.has(image)) {
5638
+ return;
5639
+ }
5640
+ try {
4752
5641
  const stream = await this.docker.pull(image);
4753
5642
  await this.followPullProgress(image, stream);
5643
+ } catch (err) {
5644
+ throw new OrchestratorError(
5645
+ `Failed to pull image ${image}: ${err instanceof Error ? err.message : String(err)}`,
5646
+ { cause: err instanceof Error ? err : void 0 }
5647
+ );
5648
+ }
5649
+ }
5650
+ /**
5651
+ * Start a child peer node via `docker compose --profile <type> up -d <type>`.
5652
+ *
5653
+ * HS-profile only — throws `OrchestratorError` when called on the dev profile.
5654
+ *
5655
+ * The `env` parameter supplies the per-node wallet secrets (e.g.
5656
+ * `TOWN_SECRET_KEY`, `MILL_MNEMONIC`). It is layered on top of `process.env`
5657
+ * so that PATH, HOME, and other process-level env vars are preserved for the
5658
+ * docker CLI subprocess.
5659
+ *
5660
+ * Logging guard: the caller (nodes-lifecycle route) must NOT log the `env`
5661
+ * argument — it contains secret keys and the wallet mnemonic.
5662
+ */
5663
+ async startNodeViaCompose(type, env) {
5664
+ if (this.profile === "dev") {
5665
+ throw new OrchestratorError(
5666
+ `startNodeViaCompose is only available in HS profile; current profile is 'dev'`
5667
+ );
5668
+ }
5669
+ const composePath = this.requireComposePath();
5670
+ this.validateComposePath(composePath);
5671
+ const args = [
5672
+ "compose",
5673
+ "-f",
5674
+ composePath,
5675
+ "--profile",
5676
+ type,
5677
+ "up",
5678
+ "-d",
5679
+ type
5680
+ ];
5681
+ try {
5682
+ await this.execFileAsync("docker", args, {
5683
+ timeout: 18e4,
5684
+ maxBuffer: 16 * 1024 * 1024,
5685
+ inheritStdio: true,
5686
+ // Layer node secrets on top of process.env — preserves PATH, HOME, etc.
5687
+ env: { ...process.env, ...env }
5688
+ });
5689
+ } catch (err) {
5690
+ const e = err;
5691
+ const stderr = redactSecretsInComposeStderr(String(e.stderr ?? ""));
5692
+ const numericExit = typeof e.code === "number" ? e.code : void 0;
5693
+ const codeLabel = String(e.code ?? e.signal ?? "unknown");
5694
+ let message;
5695
+ if (e.code === "ENOENT") {
5696
+ message = `docker CLI not found on PATH (ENOENT): ${stderr.trim().slice(0, 2e3)}`;
5697
+ } else if (e.code === "ETIMEDOUT") {
5698
+ message = `docker compose up timed out after 180000ms: ${stderr.trim().slice(0, 2e3)}`;
5699
+ } else {
5700
+ message = `docker compose up failed (exit ${codeLabel}): ${stderr.trim().slice(0, 2e3)}`;
5701
+ }
5702
+ this.surfaceComposeFailure(stderr);
5703
+ throw new OrchestratorError(message, {
5704
+ ...numericExit !== void 0 ? { exitCode: numericExit } : {},
5705
+ stderr,
5706
+ cause: err instanceof Error ? err : void 0
5707
+ });
5708
+ }
5709
+ }
5710
+ /**
5711
+ * Stop and remove a child peer node via `docker compose stop` + `rm -f`.
5712
+ *
5713
+ * HS-profile only — throws `OrchestratorError` when called on the dev profile.
5714
+ * Idempotent: stderr patterns indicating the service/container is already gone
5715
+ * (`'no such service'`, `'no containers to remove'`, `'No such container'`)
5716
+ * are treated as success so callers can run this as a rollback without
5717
+ * worrying about the container's prior state.
5718
+ */
5719
+ async stopNodeViaCompose(type) {
5720
+ if (this.profile === "dev") {
5721
+ throw new OrchestratorError(
5722
+ `stopNodeViaCompose is only available in HS profile; current profile is 'dev'`
5723
+ );
5724
+ }
5725
+ const composePath = this.requireComposePath();
5726
+ const idempotentStderr = (stderr) => stderr.includes("no such service") || stderr.includes("no containers to remove") || stderr.includes("No such container");
5727
+ try {
5728
+ await this.execFileAsync(
5729
+ "docker",
5730
+ ["compose", "-f", composePath, "--profile", type, "stop", type],
5731
+ { timeout: 6e4, maxBuffer: 16 * 1024 * 1024 }
5732
+ );
5733
+ } catch (err) {
5734
+ const e = err;
5735
+ const stderr = redactSecretsInComposeStderr(String(e.stderr ?? ""));
5736
+ if (!idempotentStderr(stderr)) {
5737
+ const numericExit = typeof e.code === "number" ? e.code : void 0;
5738
+ const codeLabel = String(e.code ?? "unknown");
5739
+ throw new OrchestratorError(
5740
+ `docker compose stop failed (exit ${codeLabel}): ${stderr.trim().slice(0, 2e3)}`,
5741
+ {
5742
+ ...numericExit !== void 0 ? { exitCode: numericExit } : {},
5743
+ stderr,
5744
+ cause: err instanceof Error ? err : void 0
5745
+ }
5746
+ );
5747
+ }
5748
+ }
5749
+ try {
5750
+ await this.execFileAsync(
5751
+ "docker",
5752
+ ["compose", "-f", composePath, "--profile", type, "rm", "-f", type],
5753
+ { timeout: 6e4, maxBuffer: 16 * 1024 * 1024 }
5754
+ );
5755
+ } catch (err) {
5756
+ const e = err;
5757
+ const stderr = redactSecretsInComposeStderr(String(e.stderr ?? ""));
5758
+ if (!idempotentStderr(stderr)) {
5759
+ const numericExit = typeof e.code === "number" ? e.code : void 0;
5760
+ const codeLabel = String(e.code ?? "unknown");
5761
+ throw new OrchestratorError(
5762
+ `docker compose rm failed (exit ${codeLabel}): ${stderr.trim().slice(0, 2e3)}`,
5763
+ {
5764
+ ...numericExit !== void 0 ? { exitCode: numericExit } : {},
5765
+ stderr,
5766
+ cause: err instanceof Error ? err : void 0
5767
+ }
5768
+ );
5769
+ }
4754
5770
  }
4755
5771
  }
4756
5772
  /**
@@ -4783,7 +5799,7 @@ var DockerOrchestrator = class extends EventEmitter {
4783
5799
  attempt
4784
5800
  });
4785
5801
  }
4786
- await new Promise((resolve) => setTimeout(resolve, interval));
5802
+ await new Promise((resolve2) => setTimeout(resolve2, interval));
4787
5803
  }
4788
5804
  throw new Error(
4789
5805
  `Health check timeout: ${containerName} did not become healthy within ${timeout}ms`
@@ -4874,7 +5890,7 @@ var DockerOrchestrator = class extends EventEmitter {
4874
5890
  const name = `${CONTAINER_PREFIX}${type}`;
4875
5891
  const nodeConfig = this.config.nodes[type];
4876
5892
  const image = nodeConfig.image ?? DEFAULT_NODE_IMAGES[type];
4877
- const env = this.buildNodeEnv(type);
5893
+ const env = await this.buildNodeEnv(type);
4878
5894
  let lastError;
4879
5895
  for (let attempt = 1; attempt <= MAX_START_RETRIES; attempt++) {
4880
5896
  try {
@@ -4984,244 +6000,112 @@ var DockerOrchestrator = class extends EventEmitter {
4984
6000
  } catch {
4985
6001
  }
4986
6002
  }
4987
- /**
4988
- * Build environment variables for the connector container.
4989
- * Delegates to ConnectorConfigGenerator for consistent config generation.
4990
- */
4991
- buildConnectorEnv() {
4992
- const runtimeConfig = this.configGenerator.generate(this.activeNodes);
4993
- return this.configGenerator.toEnvArray(runtimeConfig);
4994
- }
4995
- /**
4996
- * Build environment variables for a node container.
4997
- * If a WalletManager is provided, injects per-node identity keys.
4998
- */
4999
- buildNodeEnv(type) {
5000
- const connectorUrl = `ws://${CONTAINER_PREFIX}connector:${CONNECTOR_INTERNAL_PORT}`;
5001
- const env = [`CONNECTOR_URL=${connectorUrl}`];
5002
- switch (type) {
5003
- case "town": {
5004
- const feePerEvent = this.config.nodes.town.feePerEvent;
5005
- if (feePerEvent !== void 0) {
5006
- env.push(`FEE_PER_EVENT=${feePerEvent}`);
5007
- }
5008
- const relayHs = this.config.transport.relayHiddenService;
5009
- if (relayHs?.externalUrl) {
5010
- env.push(`TOON_EXTERNAL_RELAY_URL=${relayHs.externalUrl}`);
5011
- }
5012
- break;
5013
- }
5014
- case "mill": {
5015
- const feeBasisPoints = this.config.nodes.mill.feeBasisPoints;
5016
- if (feeBasisPoints !== void 0) {
5017
- env.push(`FEE_BASIS_POINTS=${feeBasisPoints}`);
5018
- }
5019
- break;
5020
- }
5021
- case "dvm": {
5022
- const feePerJob = this.config.nodes.dvm.feePerJob;
5023
- if (feePerJob !== void 0) {
5024
- env.push(`FEE_PER_JOB=${feePerJob}`);
5025
- }
5026
- const kindPricing = this.config.nodes.dvm.kindPricing;
5027
- if (kindPricing) {
5028
- for (const [kind, value] of Object.entries(kindPricing)) {
5029
- env.push(`KIND_PRICING_${kind}=${value}`);
5030
- }
5031
- }
5032
- const turboToken = process.env["TURBO_TOKEN"];
5033
- if (turboToken) {
5034
- env.push(`TURBO_TOKEN=${turboToken}`);
5035
- }
5036
- break;
5037
- }
5038
- }
5039
- if (this.walletManager) {
5040
- try {
5041
- const keys = this.walletManager.getNodeKeys(type);
5042
- env.push(`NODE_NOSTR_PUBKEY=${keys.nostrPubkey}`);
5043
- env.push(`NODE_EVM_ADDRESS=${keys.evmAddress}`);
5044
- const secretHex = Buffer.from(keys.nostrSecretKey).toString("hex");
5045
- env.push(`NODE_NOSTR_SECRET_KEY=${secretHex}`);
5046
- } catch {
5047
- }
5048
- }
5049
- return env;
5050
- }
5051
- /**
5052
- * Follow a Docker pull stream and emit progress events.
5053
- */
5054
- async followPullProgress(image, stream) {
5055
- return new Promise((resolve, reject) => {
5056
- this.docker.modem.followProgress(
5057
- stream,
5058
- (err) => {
5059
- if (err) {
5060
- reject(err);
5061
- } else {
5062
- resolve();
5063
- }
5064
- },
5065
- (event) => {
5066
- this.emit("pullProgress", {
5067
- image,
5068
- status: event.status ?? "",
5069
- id: event.id,
5070
- progress: event.progress
5071
- });
5072
- }
5073
- );
5074
- });
5075
- }
5076
- };
5077
-
5078
- // src/connector/admin-client.ts
5079
- var DEFAULT_TIMEOUT_MS = 5e3;
5080
- var ConnectorAdminClient = class {
5081
- baseUrl;
5082
- timeoutMs;
5083
- /**
5084
- * @param baseUrl - Base URL for the connector admin API (e.g., 'http://localhost:9402')
5085
- * @param timeoutMs - Request timeout in milliseconds (default: 5000)
5086
- */
5087
- constructor(baseUrl, timeoutMs = DEFAULT_TIMEOUT_MS) {
5088
- this.baseUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
5089
- this.timeoutMs = timeoutMs;
5090
- }
5091
- /**
5092
- * GET /health — returns the connector's HealthStatus from the healthCheckPort server.
5093
- *
5094
- * @throws Error when connector is not running, returns non-200, or shape is invalid
5095
- */
5096
- async getHealth() {
5097
- const response = await this.fetch("/health");
5098
- const body = await response.json();
5099
- if (typeof body !== "object" || body === null) {
5100
- throw new Error("Connector admin API: invalid health response shape");
5101
- }
5102
- const obj = body;
5103
- const status = obj["status"];
5104
- if (status !== "healthy" && status !== "unhealthy" && status !== "starting" && status !== "degraded") {
5105
- throw new Error("Connector admin API: invalid health response shape");
5106
- }
5107
- if (typeof obj["uptime"] !== "number" || typeof obj["peersConnected"] !== "number" || typeof obj["totalPeers"] !== "number" || typeof obj["timestamp"] !== "string") {
5108
- throw new Error("Connector admin API: invalid health response shape");
5109
- }
5110
- return body;
5111
- }
5112
- /**
5113
- * GET /admin/metrics.json — returns the connector's per-peer ILP counters
5114
- * with an aggregate rollup, mirroring `AdminMetricsJsonResponse`.
5115
- *
5116
- * @throws Error when connector is not running, returns non-200, or shape is invalid
5117
- */
5118
- async getMetrics() {
5119
- const response = await this.fetch("/admin/metrics.json");
5120
- const body = await response.json();
5121
- if (typeof body !== "object" || body === null) {
5122
- throw new Error("Connector admin API: invalid metrics response shape");
5123
- }
5124
- const obj = body;
5125
- const aggregate = obj["aggregate"];
5126
- if (typeof obj["uptimeSeconds"] !== "number" || typeof aggregate !== "object" || aggregate === null || !Array.isArray(obj["peers"]) || typeof obj["timestamp"] !== "string") {
5127
- throw new Error("Connector admin API: invalid metrics response shape");
5128
- }
5129
- const agg = aggregate;
5130
- if (typeof agg["packetsForwarded"] !== "number" || typeof agg["packetsRejected"] !== "number" || typeof agg["bytesSent"] !== "number") {
5131
- throw new Error("Connector admin API: invalid metrics response shape");
5132
- }
5133
- return body;
5134
- }
5135
- /**
5136
- * GET /admin/peers — returns the connector's peer roster with route counts
5137
- * and ILP addresses. Returns the unwrapped peers array (the wrapper's
5138
- * nodeId / peerCount / connectedCount fields are dropped).
5139
- *
5140
- * @throws Error when connector is not running, returns non-200, or shape is invalid
5141
- */
5142
- async getPeers() {
5143
- const response = await this.fetch("/admin/peers");
5144
- const body = await response.json();
5145
- if (typeof body !== "object" || body === null) {
5146
- throw new Error("Connector admin API: invalid peers response shape");
5147
- }
5148
- const obj = body;
5149
- if (!Array.isArray(obj["peers"])) {
5150
- throw new Error("Connector admin API: invalid peers response shape");
5151
- }
5152
- return body.peers;
5153
- }
5154
- /**
5155
- * GET /packets — returns the connector's raw packet log filtered by the
5156
- * given criteria. Used by the timeseries aggregation route (story 21.10).
5157
- *
5158
- * Townhouse-Side Contract: see packages/sdk/CONNECTOR_MIGRATION.md §getPacketLog.
5159
- * If the connector image does not expose GET /packets, this method throws
5160
- * with a `ConnectorEndpointNotFound` error code so the route can return 503.
5161
- *
5162
- * @throws Error with code='ConnectorEndpointNotFound' when connector returns 404
5163
- * @throws Error when connector is not running, returns non-200, or shape is invalid
5164
- */
5165
- async getPacketLog(filter = {}) {
5166
- const params = new URLSearchParams();
5167
- if (filter.ilpAddress !== void 0)
5168
- params.set("ilpAddress", filter.ilpAddress);
5169
- if (filter.since !== void 0) params.set("since", String(filter.since));
5170
- if (filter.limit !== void 0) params.set("limit", String(filter.limit));
5171
- const path = params.toString() ? `/packets?${params.toString()}` : "/packets";
5172
- let response;
5173
- try {
5174
- response = await this.fetch(path);
5175
- } catch (error) {
5176
- const msg = error instanceof Error ? error.message : String(error);
5177
- if (msg.includes("404")) {
5178
- const err = new Error(
5179
- "Connector does not expose GET /packets \u2014 endpoint not found"
5180
- );
5181
- err.code = "ConnectorEndpointNotFound";
5182
- throw err;
5183
- }
5184
- throw error;
5185
- }
5186
- const body = await response.json();
5187
- if (!Array.isArray(body)) {
5188
- throw new Error(
5189
- "Connector admin API: invalid packet log response shape \u2014 expected array"
5190
- );
5191
- }
5192
- return body;
6003
+ /**
6004
+ * Build environment variables for the connector container.
6005
+ * Delegates to ConnectorConfigGenerator for consistent config generation.
6006
+ */
6007
+ buildConnectorEnv() {
6008
+ const runtimeConfig = this.configGenerator.generate(this.activeNodes);
6009
+ return this.configGenerator.toEnvArray(runtimeConfig);
5193
6010
  }
5194
- // ── Private helpers ──
5195
6011
  /**
5196
- * Perform an HTTP GET request to the connector admin API.
5197
- * Wraps fetch with error handling for connection refused and non-200 responses.
6012
+ * Build environment variables for a node container.
6013
+ * If a WalletManager is provided, injects per-node identity keys.
6014
+ *
6015
+ * Async because the DVM path may need to derive an RSA-4096 Arweave key
6016
+ * via `walletManager.ensureArweaveKey('dvm')` — that derivation takes
6017
+ * 5–30s on first call per unlocked wallet (cached thereafter).
5198
6018
  */
5199
- async fetch(path) {
5200
- const url = `${this.baseUrl}${path}`;
5201
- const controller = new AbortController();
5202
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
5203
- try {
5204
- let response;
5205
- try {
5206
- response = await fetch(url, { signal: controller.signal });
5207
- } catch (error) {
5208
- if (error instanceof Error && error.name === "AbortError") {
5209
- throw new Error(
5210
- `Connector admin API request timeout after ${this.timeoutMs}ms: ${url}`
5211
- );
6019
+ async buildNodeEnv(type) {
6020
+ const connectorUrl = `ws://${CONTAINER_PREFIX}connector:${CONNECTOR_INTERNAL_PORT}`;
6021
+ const env = [`CONNECTOR_URL=${connectorUrl}`];
6022
+ switch (type) {
6023
+ case "town": {
6024
+ const feePerEvent = this.config.nodes.town.feePerEvent;
6025
+ if (feePerEvent !== void 0) {
6026
+ env.push(`FEE_PER_EVENT=${feePerEvent}`);
5212
6027
  }
5213
- const msg = error instanceof Error ? error.message : String(error);
5214
- throw new Error(`Connector admin API connection refused: ${msg}`);
6028
+ const relayHs = this.config.transport.relayHiddenService;
6029
+ if (relayHs?.externalUrl) {
6030
+ env.push(`TOON_EXTERNAL_RELAY_URL=${relayHs.externalUrl}`);
6031
+ }
6032
+ break;
5215
6033
  }
5216
- if (!response.ok) {
5217
- throw new Error(
5218
- `Connector admin API error: ${response.status} ${response.statusText}`
5219
- );
6034
+ case "mill": {
6035
+ const feeBasisPoints = this.config.nodes.mill.feeBasisPoints;
6036
+ if (feeBasisPoints !== void 0) {
6037
+ env.push(`FEE_BASIS_POINTS=${feeBasisPoints}`);
6038
+ }
6039
+ break;
6040
+ }
6041
+ case "dvm": {
6042
+ const feePerJob = this.config.nodes.dvm.feePerJob;
6043
+ if (feePerJob !== void 0) {
6044
+ env.push(`FEE_PER_JOB=${feePerJob}`);
6045
+ }
6046
+ const kindPricing = this.config.nodes.dvm.kindPricing;
6047
+ if (kindPricing) {
6048
+ for (const [kind, value] of Object.entries(kindPricing)) {
6049
+ env.push(`KIND_PRICING_${kind}=${value}`);
6050
+ }
6051
+ }
6052
+ if (this.walletManager) {
6053
+ try {
6054
+ console.log(
6055
+ "[orchestrator] Deriving DVM Arweave key (first boot, this can take 5-30s)..."
6056
+ );
6057
+ await this.walletManager.ensureArweaveKey("dvm");
6058
+ const jwk = this.walletManager.getArweaveJwk("dvm");
6059
+ const jwkB64 = Buffer.from(JSON.stringify(jwk), "utf-8").toString(
6060
+ "base64"
6061
+ );
6062
+ env.push(`DVM_ARWEAVE_JWK_B64=${jwkB64}`);
6063
+ } catch {
6064
+ }
6065
+ }
6066
+ const turboToken = process.env["TURBO_TOKEN"];
6067
+ if (turboToken) {
6068
+ env.push(`TURBO_TOKEN=${turboToken}`);
6069
+ }
6070
+ break;
6071
+ }
6072
+ }
6073
+ if (this.walletManager) {
6074
+ try {
6075
+ const keys = this.walletManager.getNodeKeys(type);
6076
+ env.push(`NODE_NOSTR_PUBKEY=${keys.nostrPubkey}`);
6077
+ env.push(`NODE_EVM_ADDRESS=${keys.evmAddress}`);
6078
+ const secretHex = Buffer.from(keys.nostrSecretKey).toString("hex");
6079
+ env.push(`NODE_NOSTR_SECRET_KEY=${secretHex}`);
6080
+ } catch {
5220
6081
  }
5221
- return response;
5222
- } finally {
5223
- clearTimeout(timer);
5224
6082
  }
6083
+ return env;
6084
+ }
6085
+ /**
6086
+ * Follow a Docker pull stream and emit progress events.
6087
+ */
6088
+ async followPullProgress(image, stream) {
6089
+ return new Promise((resolve2, reject) => {
6090
+ this.docker.modem.followProgress(
6091
+ stream,
6092
+ (err) => {
6093
+ if (err) {
6094
+ reject(err);
6095
+ } else {
6096
+ resolve2();
6097
+ }
6098
+ },
6099
+ (event) => {
6100
+ this.emit("pullProgress", {
6101
+ image,
6102
+ status: event.status ?? "",
6103
+ id: event.id,
6104
+ progress: event.progress
6105
+ });
6106
+ }
6107
+ );
6108
+ });
5225
6109
  }
5226
6110
  };
5227
6111
 
@@ -5340,7 +6224,7 @@ var TransportProbe = class {
5340
6224
  this.logTransition(prev, this.status, hostPort);
5341
6225
  }
5342
6226
  probeTcp(host, port) {
5343
- return new Promise((resolve) => {
6227
+ return new Promise((resolve2) => {
5344
6228
  const start = Date.now();
5345
6229
  const socket = net.createConnection({ host, port });
5346
6230
  let settled = false;
@@ -5351,7 +6235,7 @@ var TransportProbe = class {
5351
6235
  socket.destroy();
5352
6236
  } catch {
5353
6237
  }
5354
- resolve(result);
6238
+ resolve2(result);
5355
6239
  };
5356
6240
  const timeout = setTimeout(() => {
5357
6241
  settle({ reachable: false, latencyMs: null, error: "timeout" });
@@ -5371,13 +6255,13 @@ var TransportProbe = class {
5371
6255
  });
5372
6256
  }
5373
6257
  probeDirectLatency() {
5374
- return new Promise((resolve) => {
6258
+ return new Promise((resolve2) => {
5375
6259
  const start = Date.now();
5376
6260
  let settled = false;
5377
6261
  const settle = (ms) => {
5378
6262
  if (settled) return;
5379
6263
  settled = true;
5380
- resolve(ms);
6264
+ resolve2(ms);
5381
6265
  };
5382
6266
  const isHttps = this.directProbeUrl.startsWith("https://");
5383
6267
  const requester = isHttps ? https : http;
@@ -5404,35 +6288,598 @@ var TransportProbe = class {
5404
6288
  console.debug(
5405
6289
  `[TransportProbe] direct latency probe failed: ${err.code ?? err.message}`
5406
6290
  );
5407
- settle(null);
6291
+ settle(null);
6292
+ });
6293
+ req.end();
6294
+ } catch (err) {
6295
+ clearTimeout(timeout);
6296
+ const msg = err instanceof Error ? err.message : String(err);
6297
+ console.debug(`[TransportProbe] direct latency probe threw: ${msg}`);
6298
+ settle(null);
6299
+ }
6300
+ });
6301
+ }
6302
+ logTransition(prev, next, hostPort) {
6303
+ if (prev.lastProbedAt === 0) return;
6304
+ const target = hostPort ? ` (${hostPort})` : "";
6305
+ if (prev.reachable && !next.reachable) {
6306
+ console.warn(
6307
+ `[TransportProbe] proxy became unreachable${target}: ${next.probeError ?? "unknown"}`
6308
+ );
6309
+ } else if (!prev.reachable && next.reachable) {
6310
+ console.debug(`[TransportProbe] proxy reachable${target}`);
6311
+ }
6312
+ }
6313
+ };
6314
+
6315
+ // src/connector/hs-config-writer.ts
6316
+ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync3, chmodSync } from "fs";
6317
+ import { join as join3 } from "path";
6318
+ import { parse as parse2, stringify as yamlStringify2 } from "yaml";
6319
+ var HS_DIR = "/var/lib/anon/hs";
6320
+ var HS_PORT = 3e3;
6321
+ function writeHsConnectorConfig(configDir, config, options = {}) {
6322
+ const yamlPath = join3(configDir, "connector.yaml");
6323
+ if (!options.force && existsSync2(yamlPath)) {
6324
+ try {
6325
+ const existing = parse2(readFileSync2(yamlPath, "utf-8"));
6326
+ const anon = existing["anon"];
6327
+ if (anon?.["enabled"] === true) {
6328
+ return { yamlPath, created: false };
6329
+ }
6330
+ } catch {
6331
+ }
6332
+ }
6333
+ const hsConfig = config.chainProviders !== void 0 && config.chainProviders.length > 0 ? config : { ...config, chainProviders: [...DEFAULT_HS_CHAIN_PROVIDERS] };
6334
+ const generator = new ConnectorConfigGenerator(hsConfig);
6335
+ const baseConfig = generator.generate([]);
6336
+ const HS_LOCAL_SOCKS_PROXY = "socks5h://127.0.0.1:9050";
6337
+ const hsRuntimeConfig = {
6338
+ ...baseConfig,
6339
+ transport: {
6340
+ mode: "ator",
6341
+ socksProxy: HS_LOCAL_SOCKS_PROXY,
6342
+ externalUrl: "auto",
6343
+ hiddenService: {
6344
+ dir: HS_DIR,
6345
+ port: HS_PORT,
6346
+ // The orchestrator polls getHsHostname() for up to 120s; give the
6347
+ // connector the same budget so the internal timeout doesn't fire first.
6348
+ startupTimeoutMs: 12e4
6349
+ }
6350
+ }
6351
+ };
6352
+ const baseYaml = generator.toYaml(hsRuntimeConfig);
6353
+ const parsed = parse2(baseYaml);
6354
+ parsed["anon"] = { enabled: true };
6355
+ const finalYaml = yamlStringify2(parsed);
6356
+ writeFileSync3(yamlPath, finalYaml, { mode: 384, encoding: "utf-8" });
6357
+ chmodSync(yamlPath, 384);
6358
+ return { yamlPath, created: true };
6359
+ }
6360
+
6361
+ // src/compose-loader.ts
6362
+ import {
6363
+ readFileSync as readFileSync3,
6364
+ writeFileSync as writeFileSync4,
6365
+ mkdirSync as mkdirSync2,
6366
+ chmodSync as chmodSync2,
6367
+ statSync,
6368
+ lstatSync,
6369
+ existsSync as existsSync3
6370
+ } from "fs";
6371
+ import { dirname as dirname2, join as join4, resolve, isAbsolute as isAbsolute2 } from "path";
6372
+ import { fileURLToPath } from "url";
6373
+ import { homedir as homedir2 } from "os";
6374
+ var VALID_PROFILES = ["dev", "hs"];
6375
+ var ComposeLoaderError = class extends Error {
6376
+ constructor(message) {
6377
+ super(message);
6378
+ this.name = "ComposeLoaderError";
6379
+ }
6380
+ };
6381
+ function defaultDistDir() {
6382
+ const here = dirname2(fileURLToPath(import.meta.url));
6383
+ return resolve(here, "..", "dist");
6384
+ }
6385
+ function assertValidProfile(profile) {
6386
+ if (!VALID_PROFILES.includes(profile)) {
6387
+ throw new ComposeLoaderError(
6388
+ `invalid compose profile: '${profile}'. Must be one of: ${VALID_PROFILES.join(", ")}.`
6389
+ );
6390
+ }
6391
+ }
6392
+ var SYSTEM_PATH_PREFIXES = [
6393
+ "/etc",
6394
+ "/usr",
6395
+ "/bin",
6396
+ "/sbin",
6397
+ "/lib",
6398
+ "/lib64",
6399
+ "/proc",
6400
+ "/sys",
6401
+ "/dev",
6402
+ "/boot",
6403
+ "/root"
6404
+ ];
6405
+ function assertValidTownhouseHome(home) {
6406
+ if (!home) {
6407
+ throw new ComposeLoaderError(
6408
+ "townhouseHome resolved to an empty path. Set $HOME or pass options.townhouseHome explicitly."
6409
+ );
6410
+ }
6411
+ if (!isAbsolute2(home)) {
6412
+ throw new ComposeLoaderError(
6413
+ `townhouseHome must be an absolute path; got '${home}'.`
6414
+ );
6415
+ }
6416
+ if (home === "/" || home === "\\") {
6417
+ throw new ComposeLoaderError(
6418
+ `townhouseHome must not be the filesystem root; got '${home}'. This usually means $HOME is unset and homedir() returned '/'.`
6419
+ );
6420
+ }
6421
+ for (const prefix of SYSTEM_PATH_PREFIXES) {
6422
+ if (home === prefix || home.startsWith(prefix + "/")) {
6423
+ throw new ComposeLoaderError(
6424
+ `townhouseHome must not target a system directory; got '${home}'. Allowed paths: under $HOME, under tmpdir(), or any user-writable location.`
6425
+ );
6426
+ }
6427
+ }
6428
+ }
6429
+ function assertNotSymlink(filePath) {
6430
+ try {
6431
+ const lst = lstatSync(filePath);
6432
+ if (lst.isSymbolicLink()) {
6433
+ throw new ComposeLoaderError(
6434
+ `${filePath} is a symlink; refusing to write through it. If this is intentional, remove the symlink and re-run.`
6435
+ );
6436
+ }
6437
+ } catch (err) {
6438
+ const code = err.code;
6439
+ if (code !== "ENOENT") throw err;
6440
+ }
6441
+ }
6442
+ function loadComposeTemplate(profile, options = {}) {
6443
+ assertValidProfile(profile);
6444
+ const distDir = options.distDir ?? defaultDistDir();
6445
+ const composePath = join4(distDir, "compose", `townhouse-${profile}.yml`);
6446
+ if (!existsSync3(composePath)) {
6447
+ throw new ComposeLoaderError(
6448
+ `compose template not found: ${composePath}. Did you run 'pnpm --filter @toon-protocol/townhouse build' first?`
6449
+ );
6450
+ }
6451
+ return readFileSync3(composePath, "utf-8");
6452
+ }
6453
+ function materializeComposeTemplate(profile, options = {}) {
6454
+ assertValidProfile(profile);
6455
+ const home = options.townhouseHome || join4(homedir2(), ".townhouse");
6456
+ assertValidTownhouseHome(home);
6457
+ const distDir = options.distDir ?? defaultDistDir();
6458
+ const manifestSrc = join4(distDir, "image-manifest.json");
6459
+ if (profile === "hs" && !existsSync3(manifestSrc)) {
6460
+ throw new ComposeLoaderError(
6461
+ `image-manifest.json not found at ${manifestSrc}. HS mode requires a digest-pinned image manifest. Reinstall @toon-protocol/townhouse from npm to restore the manifest.`
6462
+ );
6463
+ }
6464
+ const yaml = loadComposeTemplate(profile, options);
6465
+ const composeDir = join4(home, "compose");
6466
+ mkdirSync2(composeDir, { recursive: true, mode: 448 });
6467
+ for (const dir of [home, composeDir]) {
6468
+ const lst = lstatSync(dir);
6469
+ if (lst.isSymbolicLink()) {
6470
+ const target = statSync(dir);
6471
+ if (!target.isDirectory()) {
6472
+ throw new ComposeLoaderError(
6473
+ `${dir} is a symlink to a non-directory; refusing to materialize.`
6474
+ );
6475
+ }
6476
+ continue;
6477
+ }
6478
+ const currentMode = lst.mode & 511;
6479
+ if ((currentMode & 63) !== 0) {
6480
+ chmodSync2(dir, 448);
6481
+ }
6482
+ }
6483
+ const composePath = join4(composeDir, `townhouse-${profile}.yml`);
6484
+ assertNotSymlink(composePath);
6485
+ writeFileSync4(composePath, yaml, { mode: 384, encoding: "utf-8" });
6486
+ chmodSync2(composePath, 384);
6487
+ const manifestPath = join4(home, "image-manifest.json");
6488
+ if (existsSync3(manifestSrc)) {
6489
+ assertNotSymlink(manifestPath);
6490
+ const manifest = readFileSync3(manifestSrc, "utf-8");
6491
+ writeFileSync4(manifestPath, manifest, { mode: 384, encoding: "utf-8" });
6492
+ chmodSync2(manifestPath, 384);
6493
+ }
6494
+ return { composePath, manifestPath };
6495
+ }
6496
+
6497
+ // src/state/nodes-yaml.ts
6498
+ import { promises as fs } from "fs";
6499
+ import { dirname as dirname3 } from "path";
6500
+ import { parse as yamlParse, stringify as yamlStringify3 } from "yaml";
6501
+ import { z } from "zod";
6502
+ var NodesYamlEntrySchema = z.object({
6503
+ id: z.string().min(1),
6504
+ type: z.enum(["town", "mill", "dvm"]),
6505
+ peerId: z.string().min(1),
6506
+ ilpAddress: z.string().min(1),
6507
+ derivationIndex: z.number().int().nonnegative(),
6508
+ enabledAt: z.string().datetime({ offset: true }),
6509
+ lastSeenAt: z.string().datetime({ offset: true }).nullable()
6510
+ }).strict();
6511
+ var NodesYamlSchema = z.object({
6512
+ entries: z.array(NodesYamlEntrySchema)
6513
+ }).strict().superRefine((data, ctx) => {
6514
+ const seenPeerIds = /* @__PURE__ */ new Set();
6515
+ const seenDerivationIndexes = /* @__PURE__ */ new Set();
6516
+ for (const [i, e] of data.entries.entries()) {
6517
+ if (seenPeerIds.has(e.peerId)) {
6518
+ ctx.addIssue({
6519
+ code: z.ZodIssueCode.custom,
6520
+ path: ["entries", i, "peerId"],
6521
+ message: `duplicate peerId: ${e.peerId}`
6522
+ });
6523
+ }
6524
+ seenPeerIds.add(e.peerId);
6525
+ if (seenDerivationIndexes.has(e.derivationIndex)) {
6526
+ ctx.addIssue({
6527
+ code: z.ZodIssueCode.custom,
6528
+ path: ["entries", i, "derivationIndex"],
6529
+ message: `duplicate derivationIndex: ${e.derivationIndex}`
6530
+ });
6531
+ }
6532
+ seenDerivationIndexes.add(e.derivationIndex);
6533
+ }
6534
+ });
6535
+ async function readNodesYaml(path) {
6536
+ let raw;
6537
+ try {
6538
+ raw = await fs.readFile(path, "utf-8");
6539
+ } catch (err) {
6540
+ if (err.code === "ENOENT") {
6541
+ return { entries: [] };
6542
+ }
6543
+ throw err;
6544
+ }
6545
+ const parsed = yamlParse(raw);
6546
+ if (parsed === null || parsed === void 0) {
6547
+ return { entries: [] };
6548
+ }
6549
+ return NodesYamlSchema.parse(parsed);
6550
+ }
6551
+ async function writeNodesYaml(path, data) {
6552
+ const validated = NodesYamlSchema.parse(data);
6553
+ const yamlContent = yamlStringify3(validated);
6554
+ const tmpPath = `${path}.tmp`;
6555
+ await fs.mkdir(dirname3(path), { recursive: true, mode: 448 });
6556
+ await fs.writeFile(tmpPath, yamlContent, { encoding: "utf-8", mode: 384 });
6557
+ await fs.rename(tmpPath, path);
6558
+ await fs.chmod(path, 384);
6559
+ }
6560
+
6561
+ // src/reconciler.ts
6562
+ import { promises as fs2 } from "fs";
6563
+ import { dirname as dirname4 } from "path";
6564
+ var BootReconciler = class {
6565
+ constructor(adminClient, nodesYamlPath, reconcilerLogPath) {
6566
+ this.adminClient = adminClient;
6567
+ this.nodesYamlPath = nodesYamlPath;
6568
+ this.reconcilerLogPath = reconcilerLogPath;
6569
+ }
6570
+ logDirEnsured = false;
6571
+ logFileChmodEnsured = false;
6572
+ /**
6573
+ * Diff `nodes.yaml` (truth) against `GET /admin/peers` (derived state)
6574
+ * and converge.
6575
+ *
6576
+ * Ordering rule (Epic 46.2 dependency — load-bearing):
6577
+ * `nodes.yaml` write happens BEFORE connector registration
6578
+ * (`POST /admin/peers`).
6579
+ *
6580
+ * The drift window resolves in the safe direction:
6581
+ * - yaml entry without a connector peer = harmless. The reconciler
6582
+ * re-registers it on next `hs up` (this method does that).
6583
+ * - connector peer without a yaml entry = treated as `'external'` and
6584
+ * left alone (operators may legitimately route non-Townhouse peers
6585
+ * through the same connector).
6586
+ *
6587
+ * The unsafe direction (register first, then write yaml) creates a
6588
+ * window where the connector routes to a peer Townhouse cannot clean
6589
+ * up. Epic 46.2's provisioning pipeline MUST honor the yaml-first rule.
6590
+ *
6591
+ * Failures fetching `getPeers()` are surfaced (not swallowed) so the
6592
+ * caller in `handleHsUp` can decide whether to treat reconciler
6593
+ * divergence as fatal. (Today: non-fatal — see cli.ts wire point.)
6594
+ *
6595
+ * Per-divergence appendLog failures are caught so a single log-write
6596
+ * failure does not abort the rest of the reconciliation pass.
6597
+ */
6598
+ async reconcile() {
6599
+ const yaml = await readNodesYaml(this.nodesYamlPath);
6600
+ const peers = await this.adminClient.getPeers();
6601
+ const plans = this.diff(yaml, peers);
6602
+ const summary = {
6603
+ reregistered: 0,
6604
+ failed: 0,
6605
+ external: 0
6606
+ };
6607
+ for (const plan of plans) {
6608
+ if (plan.intent === "reregister") {
6609
+ const entry = yaml.entries.find((e) => e.peerId === plan.peerId);
6610
+ if (!entry) continue;
6611
+ try {
6612
+ await this.adminClient.registerPeer({
6613
+ id: entry.peerId,
6614
+ url: deriveBtpUrl(entry),
6615
+ authToken: "",
6616
+ routes: [{ prefix: entry.ilpAddress, priority: 0 }]
6617
+ });
6618
+ summary.reregistered++;
6619
+ await this.tryAppendLog({
6620
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
6621
+ peerId: plan.peerId,
6622
+ action: "reregistered"
6623
+ });
6624
+ } catch (err) {
6625
+ summary.failed++;
6626
+ const msg = err instanceof Error ? err.message : String(err);
6627
+ await this.tryAppendLog({
6628
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
6629
+ peerId: plan.peerId,
6630
+ action: "reregister-failed",
6631
+ detail: msg
6632
+ });
6633
+ }
6634
+ } else {
6635
+ summary.external++;
6636
+ await this.tryAppendLog({
6637
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
6638
+ peerId: plan.peerId,
6639
+ action: "external"
6640
+ });
6641
+ }
6642
+ }
6643
+ return summary;
6644
+ }
6645
+ /**
6646
+ * Compute divergences without mutating the connector. Exposed for
6647
+ * testability — production callers use `reconcile()`.
6648
+ */
6649
+ diff(yaml, peers) {
6650
+ const peerIds = new Set(peers.map((p) => p.id));
6651
+ const yamlPeerIds = new Set(yaml.entries.map((e) => e.peerId));
6652
+ const out = [];
6653
+ for (const entry of yaml.entries) {
6654
+ if (!peerIds.has(entry.peerId)) {
6655
+ out.push({ peerId: entry.peerId, intent: "reregister" });
6656
+ }
6657
+ }
6658
+ for (const peer of peers) {
6659
+ if (!yamlPeerIds.has(peer.id)) {
6660
+ out.push({ peerId: peer.id, intent: "external" });
6661
+ }
6662
+ }
6663
+ return out;
6664
+ }
6665
+ /**
6666
+ * Append one divergence record without aborting the whole reconciliation
6667
+ * pass on a single log-write failure (disk full, EACCES, etc.). Failures
6668
+ * are themselves logged to stderr — not silently swallowed — so the
6669
+ * operator can see them in the same `hs up` session.
6670
+ */
6671
+ async tryAppendLog(div) {
6672
+ try {
6673
+ await this.appendLog(div);
6674
+ } catch (err) {
6675
+ const msg = err instanceof Error ? err.message : String(err);
6676
+ console.error(
6677
+ `[townhouse boot-reconciler] failed to append divergence log: ${msg}`
6678
+ );
6679
+ }
6680
+ }
6681
+ /**
6682
+ * Append one divergence record to the reconciler log as a single line of
6683
+ * JSON (jsonl-style — easy to grep, easy to parse).
6684
+ *
6685
+ * `mkdir` runs once per reconciler instance. `chmod 0o600` on the log file
6686
+ * also runs once — `fs.appendFile`'s `mode` option only applies on
6687
+ * creation, so without a post-create chmod a pre-existing log file with
6688
+ * permissive mode would never be tightened.
6689
+ */
6690
+ async appendLog(div) {
6691
+ const line = JSON.stringify(div) + "\n";
6692
+ if (!this.logDirEnsured) {
6693
+ await fs2.mkdir(dirname4(this.reconcilerLogPath), {
6694
+ recursive: true,
6695
+ mode: 448
6696
+ });
6697
+ this.logDirEnsured = true;
6698
+ }
6699
+ await fs2.appendFile(this.reconcilerLogPath, line, {
6700
+ encoding: "utf-8",
6701
+ mode: 384
6702
+ });
6703
+ if (!this.logFileChmodEnsured) {
6704
+ try {
6705
+ await fs2.chmod(this.reconcilerLogPath, 384);
6706
+ } catch {
6707
+ }
6708
+ this.logFileChmodEnsured = true;
6709
+ }
6710
+ }
6711
+ };
6712
+ function deriveBtpUrl(entry) {
6713
+ return `ws://${CONTAINER_PREFIX}${entry.type}:${NODE_BTP_PORT}`;
6714
+ }
6715
+
6716
+ // src/earnings/snapshot-writer.ts
6717
+ import { promises as fs3 } from "fs";
6718
+ import { dirname as dirname5 } from "path";
6719
+ var SnapshotWriter = class {
6720
+ constructor(opts) {
6721
+ this.opts = opts;
6722
+ }
6723
+ timer = null;
6724
+ tickPending = false;
6725
+ start() {
6726
+ if (this.timer !== null) return;
6727
+ const intervalMs = this.opts.tickIntervalMs ?? 36e5;
6728
+ this.timer = setInterval(() => {
6729
+ void this.tick();
6730
+ }, intervalMs);
6731
+ if (this.opts.fireOnStart) {
6732
+ void this.tick();
6733
+ }
6734
+ }
6735
+ stop() {
6736
+ if (this.timer !== null) {
6737
+ clearInterval(this.timer);
6738
+ this.timer = null;
6739
+ }
6740
+ }
6741
+ /** Exposed for test ergonomics — runs one full append+prune cycle. */
6742
+ async tick() {
6743
+ if (this.tickPending) {
6744
+ this.opts.logger?.warn(
6745
+ { snapshotPath: this.opts.snapshotPath },
6746
+ "snapshot writer: tick skipped \u2014 previous tick still in flight"
6747
+ );
6748
+ return;
6749
+ }
6750
+ this.tickPending = true;
6751
+ try {
6752
+ await this.runTick();
6753
+ } finally {
6754
+ this.tickPending = false;
6755
+ }
6756
+ }
6757
+ async runTick() {
6758
+ const now = (this.opts.now ?? (() => /* @__PURE__ */ new Date()))();
6759
+ const tsMs = Math.floor(now.getTime() / 36e5) * 36e5;
6760
+ const ts = new Date(tsMs).toISOString();
6761
+ let earnings;
6762
+ try {
6763
+ earnings = await this.opts.connectorAdmin.getEarnings();
6764
+ } catch (err) {
6765
+ this.opts.logger?.warn(
6766
+ { err },
6767
+ "snapshot writer: getEarnings failed \u2014 skipping this tick"
6768
+ );
6769
+ return;
6770
+ }
6771
+ try {
6772
+ const entries = [];
6773
+ for (const peer of earnings.peers ?? []) {
6774
+ if (peer.peerId === "__apex__") {
6775
+ this.opts.logger?.warn(
6776
+ { peerId: peer.peerId },
6777
+ 'snapshot writer: peer with reserved id "__apex__" \u2014 row dropped'
6778
+ );
6779
+ continue;
6780
+ }
6781
+ for (const a of peer.byAsset ?? []) {
6782
+ entries.push({
6783
+ ts,
6784
+ peerId: peer.peerId,
6785
+ assetCode: a.assetCode,
6786
+ claimsReceivedTotal: a.claimsReceivedTotal
6787
+ });
6788
+ }
6789
+ }
6790
+ for (const fee of earnings.connectorFees ?? []) {
6791
+ entries.push({
6792
+ ts,
6793
+ peerId: "__apex__",
6794
+ assetCode: fee.assetCode,
6795
+ claimsReceivedTotal: fee.total
5408
6796
  });
5409
- req.end();
5410
- } catch (err) {
5411
- clearTimeout(timeout);
5412
- const msg = err instanceof Error ? err.message : String(err);
5413
- console.debug(`[TransportProbe] direct latency probe threw: ${msg}`);
5414
- settle(null);
5415
6797
  }
6798
+ if (entries.length === 0) {
6799
+ await this.pruneIfNeeded(now);
6800
+ return;
6801
+ }
6802
+ await this.appendEntries(entries);
6803
+ await this.pruneIfNeeded(now);
6804
+ } catch (err) {
6805
+ this.opts.logger?.warn(
6806
+ { err },
6807
+ "snapshot writer: append/prune failed \u2014 skipping this tick"
6808
+ );
6809
+ }
6810
+ }
6811
+ async appendEntries(entries) {
6812
+ await fs3.mkdir(dirname5(this.opts.snapshotPath), {
6813
+ recursive: true,
6814
+ mode: 448
6815
+ });
6816
+ const body = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
6817
+ await fs3.appendFile(this.opts.snapshotPath, body, {
6818
+ encoding: "utf-8",
6819
+ mode: 384
5416
6820
  });
6821
+ await fs3.chmod(this.opts.snapshotPath, 384);
5417
6822
  }
5418
- logTransition(prev, next, hostPort) {
5419
- if (prev.lastProbedAt === 0) return;
5420
- const target = hostPort ? ` (${hostPort})` : "";
5421
- if (prev.reachable && !next.reachable) {
5422
- console.warn(
5423
- `[TransportProbe] proxy became unreachable${target}: ${next.probeError ?? "unknown"}`
5424
- );
5425
- } else if (!prev.reachable && next.reachable) {
5426
- console.debug(`[TransportProbe] proxy reachable${target}`);
6823
+ async pruneIfNeeded(now) {
6824
+ const WATERMARK = 256 * 1024;
6825
+ let stat3 = null;
6826
+ try {
6827
+ stat3 = await fs3.stat(this.opts.snapshotPath);
6828
+ } catch {
6829
+ return;
6830
+ }
6831
+ if (stat3.size < WATERMARK) return;
6832
+ const retentionMonths = this.opts.retentionMonths ?? 13;
6833
+ const cutoff = new Date(now);
6834
+ cutoff.setUTCMonth(cutoff.getUTCMonth() - retentionMonths);
6835
+ const cutoffMs = cutoff.getTime();
6836
+ let raw;
6837
+ try {
6838
+ raw = await fs3.readFile(this.opts.snapshotPath, "utf-8");
6839
+ } catch {
6840
+ return;
6841
+ }
6842
+ const lines = raw.split("\n").filter((l) => l.length > 0);
6843
+ let anyDropped = false;
6844
+ const kept = [];
6845
+ for (const line of lines) {
6846
+ let entry;
6847
+ try {
6848
+ entry = JSON.parse(line);
6849
+ } catch {
6850
+ anyDropped = true;
6851
+ continue;
6852
+ }
6853
+ if (!isSnapshotEntry(entry)) {
6854
+ anyDropped = true;
6855
+ continue;
6856
+ }
6857
+ const entryMs = new Date(entry.ts).getTime();
6858
+ if (isNaN(entryMs) || entryMs < cutoffMs) {
6859
+ anyDropped = true;
6860
+ } else {
6861
+ kept.push(line);
6862
+ }
5427
6863
  }
6864
+ if (!anyDropped) return;
6865
+ const tmpPath = `${this.opts.snapshotPath}.tmp`;
6866
+ const newContent = kept.length > 0 ? kept.join("\n") + "\n" : "";
6867
+ await fs3.writeFile(tmpPath, newContent, { encoding: "utf-8", mode: 384 });
6868
+ await fs3.rename(tmpPath, this.opts.snapshotPath);
6869
+ await fs3.chmod(this.opts.snapshotPath, 384);
5428
6870
  }
5429
6871
  };
6872
+ function isSnapshotEntry(v) {
6873
+ if (typeof v !== "object" || v === null) return false;
6874
+ const e = v;
6875
+ return typeof e["ts"] === "string" && typeof e["peerId"] === "string" && typeof e["assetCode"] === "string" && typeof e["claimsReceivedTotal"] === "string";
6876
+ }
5430
6877
 
5431
6878
  // src/wallet/storage.ts
5432
6879
  import { writeFile, readFile, mkdir, stat } from "fs/promises";
5433
- import { dirname as dirname2 } from "path";
6880
+ import { dirname as dirname6 } from "path";
5434
6881
  async function saveWallet(path, encrypted) {
5435
- const dir = dirname2(path);
6882
+ const dir = dirname6(path);
5436
6883
  await mkdir(dir, { recursive: true, mode: 448 });
5437
6884
  const data = JSON.stringify(encrypted, null, 2);
5438
6885
  await writeFile(path, data, { encoding: "utf-8", mode: 384 });
@@ -5534,6 +6981,115 @@ function decryptWallet(encrypted, password) {
5534
6981
  key.fill(0);
5535
6982
  }
5536
6983
  }
6984
+ function encryptString(plaintext, password) {
6985
+ const salt = randomBytes(SALT_LEN);
6986
+ const iv = randomBytes(IV_LEN);
6987
+ const key = scryptSync(password, salt, SCRYPT_KEY_LEN, {
6988
+ N: SCRYPT_N,
6989
+ r: SCRYPT_R,
6990
+ p: SCRYPT_P,
6991
+ maxmem: SCRYPT_MAXMEM
6992
+ });
6993
+ try {
6994
+ const cipher = createCipheriv("aes-256-gcm", key, iv, {
6995
+ authTagLength: AUTH_TAG_LEN
6996
+ });
6997
+ const ciphertext = Buffer.concat([
6998
+ cipher.update(plaintext, "utf8"),
6999
+ cipher.final()
7000
+ ]);
7001
+ const tag = cipher.getAuthTag();
7002
+ return {
7003
+ salt: salt.toString("base64"),
7004
+ iv: iv.toString("base64"),
7005
+ ciphertext: ciphertext.toString("base64"),
7006
+ tag: tag.toString("base64")
7007
+ };
7008
+ } finally {
7009
+ key.fill(0);
7010
+ }
7011
+ }
7012
+ function decryptString(encrypted, password) {
7013
+ const salt = Buffer.from(encrypted.salt, "base64");
7014
+ const iv = Buffer.from(encrypted.iv, "base64");
7015
+ const ciphertext = Buffer.from(encrypted.ciphertext, "base64");
7016
+ const tag = Buffer.from(encrypted.tag, "base64");
7017
+ const key = scryptSync(password, salt, SCRYPT_KEY_LEN, {
7018
+ N: SCRYPT_N,
7019
+ r: SCRYPT_R,
7020
+ p: SCRYPT_P,
7021
+ maxmem: SCRYPT_MAXMEM
7022
+ });
7023
+ try {
7024
+ const decipher = createDecipheriv("aes-256-gcm", key, iv, {
7025
+ authTagLength: AUTH_TAG_LEN
7026
+ });
7027
+ decipher.setAuthTag(tag);
7028
+ try {
7029
+ const plaintext = Buffer.concat([
7030
+ decipher.update(ciphertext),
7031
+ decipher.final()
7032
+ ]);
7033
+ return plaintext.toString("utf8");
7034
+ } catch {
7035
+ throw new Error(
7036
+ "Decryption failed: wrong password or corrupted ciphertext"
7037
+ );
7038
+ }
7039
+ } finally {
7040
+ key.fill(0);
7041
+ }
7042
+ }
7043
+ function encryptArweaveJwk(jwk, password) {
7044
+ return encryptString(JSON.stringify(jwk), password);
7045
+ }
7046
+ function decryptArweaveJwk(encrypted, password) {
7047
+ const plaintext = decryptString(encrypted, password);
7048
+ let parsed;
7049
+ try {
7050
+ parsed = JSON.parse(plaintext);
7051
+ } catch {
7052
+ throw new Error(
7053
+ "Arweave JWK cache is corrupt: plaintext is not valid JSON"
7054
+ );
7055
+ }
7056
+ if (typeof parsed !== "object" || parsed === null || parsed.kty !== "RSA" || typeof parsed.n !== "string" || typeof parsed.e !== "string") {
7057
+ throw new Error(
7058
+ "Arweave JWK cache is corrupt: plaintext is not a well-formed RSA JWK"
7059
+ );
7060
+ }
7061
+ return parsed;
7062
+ }
7063
+
7064
+ // src/state/image-manifest.ts
7065
+ import { promises as fs4 } from "fs";
7066
+ import { z as z2 } from "zod";
7067
+ var ImageEntrySchema = z2.object({
7068
+ name: z2.string().min(1),
7069
+ tag: z2.string().min(1),
7070
+ digest: z2.string().regex(/^sha256:[0-9a-f]{64}$/)
7071
+ }).strict();
7072
+ var ImageManifestSchema = z2.object({
7073
+ schemaVersion: z2.literal(1),
7074
+ townhouseVersion: z2.string(),
7075
+ builtAt: z2.string().datetime({ offset: true }),
7076
+ images: z2.object({
7077
+ "townhouse-api": ImageEntrySchema,
7078
+ town: ImageEntrySchema,
7079
+ mill: ImageEntrySchema,
7080
+ dvm: ImageEntrySchema,
7081
+ connector: ImageEntrySchema
7082
+ }).strict()
7083
+ }).strict();
7084
+ var SYNTHETIC_DIGEST_SENTINEL = "sha256:dead000000000000000000000000000000000000000000000000000000000000";
7085
+ function isSyntheticDigest(digest) {
7086
+ return digest === SYNTHETIC_DIGEST_SENTINEL;
7087
+ }
7088
+ async function readImageManifest(path) {
7089
+ const raw = await fs4.readFile(path, "utf-8");
7090
+ const parsed = JSON.parse(raw);
7091
+ return ImageManifestSchema.parse(parsed);
7092
+ }
5537
7093
 
5538
7094
  // src/wallet/manager.ts
5539
7095
  import {
@@ -5547,6 +7103,112 @@ import { getPublicKey } from "nostr-tools/pure";
5547
7103
  import { bytesToHex } from "@noble/hashes/utils";
5548
7104
  import { keccak_256 } from "@noble/hashes/sha3";
5549
7105
  import { secp256k1 } from "@noble/curves/secp256k1";
7106
+ import { createPrivateKey, createHash } from "crypto";
7107
+
7108
+ // src/wallet/ar-cache.ts
7109
+ import { mkdir as mkdir2, readFile as readFile2, stat as stat2, writeFile as writeFile2, unlink } from "fs/promises";
7110
+ import { dirname as dirname7 } from "path";
7111
+ function arweaveCachePath(encryptedWalletPath) {
7112
+ return `${dirname7(encryptedWalletPath)}/wallet.arweave.enc`;
7113
+ }
7114
+ async function loadArweaveCacheFile(path) {
7115
+ let data;
7116
+ try {
7117
+ data = await readFile2(path, "utf-8");
7118
+ } catch (err) {
7119
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
7120
+ return null;
7121
+ }
7122
+ throw err;
7123
+ }
7124
+ try {
7125
+ const stats = await stat2(path);
7126
+ const mode = stats.mode & 511;
7127
+ if (mode & 63) {
7128
+ console.error(
7129
+ `Warning: arweave cache ${path} has permissions ${mode.toString(8)} (should be 600)`
7130
+ );
7131
+ }
7132
+ } catch {
7133
+ }
7134
+ let parsed;
7135
+ try {
7136
+ parsed = JSON.parse(data);
7137
+ } catch {
7138
+ throw new Error(
7139
+ `Arweave JWK cache at ${path} is corrupt: not valid JSON. Delete the file and re-run to re-derive.`
7140
+ );
7141
+ }
7142
+ if (typeof parsed !== "object" || parsed === null || parsed.version !== 1 || typeof parsed.nodes !== "object" || parsed.nodes === null) {
7143
+ throw new Error(
7144
+ `Arweave JWK cache at ${path} is corrupt: unexpected envelope shape. Delete the file and re-run to re-derive.`
7145
+ );
7146
+ }
7147
+ return parsed;
7148
+ }
7149
+ async function readArweaveJwkFromCache(path, nodeType, password, expectedFingerprint) {
7150
+ const file = await loadArweaveCacheFile(path);
7151
+ if (!file) return { status: "miss" };
7152
+ const entry = file.nodes[nodeType];
7153
+ if (!entry) return { status: "miss" };
7154
+ if (typeof entry.subSeedFingerprint !== "string" || typeof entry.arweaveAddress !== "string" || typeof entry.encryptedJwk !== "object" || entry.encryptedJwk === null) {
7155
+ throw new Error(
7156
+ `Arweave JWK cache entry for ${nodeType} at ${path} is corrupt: missing fields.`
7157
+ );
7158
+ }
7159
+ if (entry.subSeedFingerprint !== expectedFingerprint) {
7160
+ return {
7161
+ status: "stale",
7162
+ cachedFingerprint: entry.subSeedFingerprint,
7163
+ cachedAddress: entry.arweaveAddress
7164
+ };
7165
+ }
7166
+ const jwk = decryptArweaveJwk(entry.encryptedJwk, password);
7167
+ return { status: "hit", jwk, entry };
7168
+ }
7169
+ async function writeArweaveJwkToCache(path, nodeType, jwk, password, subSeedFingerprint, arweaveAddress) {
7170
+ const existing = await loadArweaveCacheFile(path);
7171
+ const entry = {
7172
+ subSeedFingerprint,
7173
+ arweaveAddress,
7174
+ encryptedJwk: encryptArweaveJwk(jwk, password)
7175
+ };
7176
+ const file = existing ?? {
7177
+ version: 1,
7178
+ nodes: {}
7179
+ };
7180
+ file.nodes[nodeType] = entry;
7181
+ const dir = dirname7(path);
7182
+ await mkdir2(dir, { recursive: true, mode: 448 });
7183
+ await writeFile2(path, JSON.stringify(file, null, 2), {
7184
+ encoding: "utf-8",
7185
+ mode: 384
7186
+ });
7187
+ }
7188
+ async function deleteArweaveJwkFromCache(path, nodeType) {
7189
+ const file = await loadArweaveCacheFile(path);
7190
+ if (!file) return;
7191
+ if (!(nodeType in file.nodes)) return;
7192
+ const { [nodeType]: _removed, ...remaining } = file.nodes;
7193
+ file.nodes = remaining;
7194
+ if (Object.keys(file.nodes).length === 0) {
7195
+ try {
7196
+ await unlink(path);
7197
+ } catch (err) {
7198
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
7199
+ return;
7200
+ }
7201
+ throw err;
7202
+ }
7203
+ return;
7204
+ }
7205
+ await writeFile2(path, JSON.stringify(file, null, 2), {
7206
+ encoding: "utf-8",
7207
+ mode: 384
7208
+ });
7209
+ }
7210
+
7211
+ // src/wallet/manager.ts
5550
7212
  import { deriveMillKeys } from "@toon-protocol/mill";
5551
7213
  var BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
5552
7214
  function base58Encode(bytes) {
@@ -5633,10 +7295,15 @@ var WalletManager = class {
5633
7295
  nostrDerivationPath: keys.nostrDerivationPath,
5634
7296
  evmDerivationPath: keys.evmDerivationPath
5635
7297
  };
5636
- if (nodeType === "mill") {
5637
- if (keys.solanaAddress) info.solanaAddress = keys.solanaAddress;
5638
- if (keys.minaAddress) info.minaAddress = keys.minaAddress;
5639
- }
7298
+ if (keys.solanaAddress) info.solanaAddress = keys.solanaAddress;
7299
+ if (keys.solanaDerivationPath)
7300
+ info.solanaDerivationPath = keys.solanaDerivationPath;
7301
+ if (nodeType === "mill" && keys.minaAddress) {
7302
+ info.minaAddress = keys.minaAddress;
7303
+ }
7304
+ if (keys.arweaveAddress) info.arweaveAddress = keys.arweaveAddress;
7305
+ if (keys.arweaveDerivationPath)
7306
+ info.arweaveDerivationPath = keys.arweaveDerivationPath;
5640
7307
  return info;
5641
7308
  });
5642
7309
  }
@@ -5657,56 +7324,273 @@ var WalletManager = class {
5657
7324
  const keys = this.state.keys[nodeType];
5658
7325
  keys.nostrSecretKey.fill(0);
5659
7326
  keys.evmPrivateKey.fill(0);
7327
+ if (keys.solanaPrivateKey) keys.solanaPrivateKey.fill(0);
7328
+ if (keys.arweaveJwk) zeroArweaveJwk(keys.arweaveJwk);
5660
7329
  }
5661
7330
  this.state = null;
5662
7331
  }
5663
7332
  /**
5664
- * Derive keys for all node types from a mnemonic.
7333
+ * Return the BIP-39 mnemonic from in-memory wallet state.
7334
+ * Returns null when the wallet is locked or not initialized.
5665
7335
  */
5666
- async deriveAllKeys(mnemonic) {
7336
+ getMnemonic() {
7337
+ return this.state?.mnemonic ?? null;
7338
+ }
7339
+ /**
7340
+ * Get derived keys for a specific node type at a given derivation index.
7341
+ *
7342
+ * Pure derivation — does NOT mutate `state`. Re-derives from the stored
7343
+ * mnemonic each time it is called. For every node type, also derives the
7344
+ * Solana key at the same account index. For 'dvm', also derives Arweave.
7345
+ * Throws if the wallet is locked.
7346
+ *
7347
+ * v1 callers MUST pass `derivationIndex = ACCOUNT_INDEX_{type}` for the
7348
+ * first (and only) instance per type. Multi-instance support is out of
7349
+ * scope for v1 — the route layer enforces single-instance-per-type.
7350
+ */
7351
+ async deriveNodeKey(type, derivationIndex) {
7352
+ if (!this.state) {
7353
+ throw new Error(
7354
+ "Wallet not initialized. Call generate() or fromMnemonic() first."
7355
+ );
7356
+ }
7357
+ const mnemonic = this.state.mnemonic;
5667
7358
  let seed;
5668
7359
  try {
5669
7360
  seed = mnemonicToSeedSync(mnemonic);
5670
- const millBaseKeys = this.deriveNodeKeys(seed, "mill");
7361
+ const baseKeys = this.deriveNodeKeys(seed, type, derivationIndex);
7362
+ const chains = type === "mill" ? ["solana", "mina"] : ["solana"];
5671
7363
  let solanaAddress;
7364
+ let solanaPrivateKey;
7365
+ let solanaDerivationPath;
5672
7366
  let minaAddress;
5673
7367
  try {
5674
- const millChainKeys = await deriveMillKeys({
7368
+ const chainKeys = await deriveMillKeys({
5675
7369
  mnemonic,
5676
- chains: ["solana", "mina"],
5677
- accountIndex: ACCOUNT_INDEX_MILL
7370
+ chains,
7371
+ accountIndex: derivationIndex
5678
7372
  });
5679
- if (millChainKeys.solana) {
5680
- solanaAddress = base58Encode(millChainKeys.solana.publicKey);
7373
+ if (chainKeys.solana) {
7374
+ solanaAddress = base58Encode(chainKeys.solana.publicKey);
7375
+ solanaPrivateKey = chainKeys.solana.privateKey;
7376
+ solanaDerivationPath = chainKeys.solana.path;
7377
+ }
7378
+ if (chainKeys.mina && type === "mill") {
7379
+ minaAddress = chainKeys.mina.publicKey;
7380
+ }
7381
+ } catch (err) {
7382
+ const errMsg = err instanceof Error ? err.message : String(err);
7383
+ console.warn(
7384
+ `[WalletManager] deriveMillKeys failed for type=${type} accountIndex=${derivationIndex}: ${errMsg} \u2014 Solana/Mina addresses omitted`
7385
+ );
7386
+ }
7387
+ return {
7388
+ ...baseKeys,
7389
+ solanaAddress,
7390
+ solanaPrivateKey,
7391
+ solanaDerivationPath,
7392
+ minaAddress
7393
+ };
7394
+ } finally {
7395
+ if (seed) seed.fill(0);
7396
+ }
7397
+ }
7398
+ // ── Private-key accessors (epic-49 credit funding) ───────────────────────
7399
+ /**
7400
+ * Returns the EVM private key for a node as a 64-char lowercase hex string.
7401
+ * Throws when the wallet is locked. Callers MUST treat the returned string
7402
+ * as sensitive (no logging, no persisting). The underlying Uint8Array is
7403
+ * still owned by WalletManager and will be zeroed by `lock()`.
7404
+ */
7405
+ getEvmPrivateKeyHex(nodeType) {
7406
+ const keys = this.getNodeKeys(nodeType);
7407
+ return bytesToHex(keys.evmPrivateKey);
7408
+ }
7409
+ /**
7410
+ * Returns the Solana Ed25519 private key seed for a node as a 64-char
7411
+ * lowercase hex string (32 raw seed bytes). Throws when the wallet is
7412
+ * locked or when the Solana key was not derived for this node type.
7413
+ */
7414
+ getSolanaPrivateKeyHex(nodeType) {
7415
+ const keys = this.getNodeKeys(nodeType);
7416
+ if (!keys.solanaPrivateKey) {
7417
+ throw new Error(
7418
+ `Solana private key not available for node '${nodeType}'`
7419
+ );
7420
+ }
7421
+ return bytesToHex(keys.solanaPrivateKey);
7422
+ }
7423
+ /**
7424
+ * Returns the Arweave RSA JWK for a node. Throws when the wallet is locked
7425
+ * or when AR derivation has not yet been triggered for this node type.
7426
+ *
7427
+ * Callers MUST `await ensureArweaveKey(nodeType)` first the first time per
7428
+ * unlock — RSA-4096 derivation is 5–30s and is therefore not done eagerly
7429
+ * at `fromMnemonic`/`generate` time. After the first `ensureArweaveKey`
7430
+ * the JWK is cached on the in-memory state until `lock()`.
7431
+ */
7432
+ getArweaveJwk(nodeType) {
7433
+ const keys = this.getNodeKeys(nodeType);
7434
+ if (!keys.arweaveJwk) {
7435
+ throw new Error(
7436
+ `Arweave JWK not yet derived for node '${nodeType}'. Call ensureArweaveKey('${nodeType}') first (note: derivation takes 5\u201330s).`
7437
+ );
7438
+ }
7439
+ return keys.arweaveJwk;
7440
+ }
7441
+ /**
7442
+ * Lazily derive the Arweave RSA-4096 JWK for a node type and cache it on
7443
+ * the in-memory wallet state. Subsequent calls (within the same unlocked
7444
+ * session) return the cached result without re-deriving.
7445
+ *
7446
+ * Only meaningful for node types that participate in the Arweave credit
7447
+ * flow — `dvm` (account 2) in the current Townhouse layout. Calling on
7448
+ * `town` or `mill` will derive a valid AR key at the corresponding
7449
+ * account index but those keys are not used by any current code path.
7450
+ *
7451
+ * Throws if the wallet is locked or RSA derivation fails (unsupported
7452
+ * platform, etc.). On success the result is also reflected in subsequent
7453
+ * `getAllKeys()` calls (arweaveAddress + arweaveDerivationPath fields).
7454
+ */
7455
+ async ensureArweaveKey(nodeType, password) {
7456
+ if (!this.state) {
7457
+ throw new Error(
7458
+ "Wallet not initialized. Call generate() or fromMnemonic() first."
7459
+ );
7460
+ }
7461
+ const existing = this.state.keys[nodeType].arweaveJwk;
7462
+ if (existing) return existing;
7463
+ const accountIndex = NODE_ACCOUNT_INDEX[nodeType];
7464
+ const path = `m/44'/472'/${accountIndex}'/0/0`;
7465
+ let seed;
7466
+ let subSeed;
7467
+ try {
7468
+ seed = mnemonicToSeedSync(this.state.mnemonic);
7469
+ const hdKey = HDKey.fromMasterSeed(seed).derive(path);
7470
+ if (!hdKey.privateKey) {
7471
+ throw new Error(`Arweave sub-seed missing at ${path}`);
7472
+ }
7473
+ subSeed = new Uint8Array(hdKey.privateKey);
7474
+ const fingerprint = createHash("sha256").update(subSeed).digest("base64url");
7475
+ if (password) {
7476
+ const cachePath = arweaveCachePath(this.config.encryptedPath);
7477
+ const result = await readArweaveJwkFromCache(
7478
+ cachePath,
7479
+ nodeType,
7480
+ password,
7481
+ fingerprint
7482
+ );
7483
+ if (result.status === "hit") {
7484
+ this.state.keys[nodeType].arweaveJwk = result.jwk;
7485
+ this.state.keys[nodeType].arweaveAddress = result.entry.arweaveAddress;
7486
+ this.state.keys[nodeType].arweaveDerivationPath = path;
7487
+ return result.jwk;
7488
+ }
7489
+ if (result.status === "stale") {
7490
+ console.warn(
7491
+ `[WalletManager] Arweave JWK cache for ${nodeType} was written from a different mnemonic (cached address ${result.cachedAddress.slice(0, 12)}\u2026). Discarding and re-deriving.`
7492
+ );
7493
+ await deleteArweaveJwkFromCache(cachePath, nodeType);
7494
+ }
7495
+ }
7496
+ const ar = await deriveArweaveKey(seed, accountIndex);
7497
+ if (!this.state) {
7498
+ zeroArweaveJwk(ar.jwk);
7499
+ throw new Error(
7500
+ "Wallet was locked during Arweave key derivation. Discarding derived key."
7501
+ );
7502
+ }
7503
+ this.state.keys[nodeType].arweaveJwk = ar.jwk;
7504
+ this.state.keys[nodeType].arweaveAddress = ar.address;
7505
+ this.state.keys[nodeType].arweaveDerivationPath = ar.path;
7506
+ if (password) {
7507
+ try {
7508
+ const cachePath = arweaveCachePath(this.config.encryptedPath);
7509
+ await writeArweaveJwkToCache(
7510
+ cachePath,
7511
+ nodeType,
7512
+ ar.jwk,
7513
+ password,
7514
+ fingerprint,
7515
+ ar.address
7516
+ );
7517
+ } catch (err) {
7518
+ const msg = err instanceof Error ? err.message : String(err);
7519
+ console.warn(
7520
+ `[WalletManager] Failed to write Arweave JWK cache (non-fatal): ${msg}`
7521
+ );
5681
7522
  }
5682
- if (millChainKeys.mina) {
5683
- minaAddress = millChainKeys.mina.publicKey;
7523
+ }
7524
+ return ar.jwk;
7525
+ } finally {
7526
+ if (seed) seed.fill(0);
7527
+ if (subSeed) subSeed.fill(0);
7528
+ }
7529
+ }
7530
+ /**
7531
+ * Derive keys for all node types from a mnemonic.
7532
+ */
7533
+ async deriveAllKeys(mnemonic) {
7534
+ let seed;
7535
+ try {
7536
+ seed = mnemonicToSeedSync(mnemonic);
7537
+ const chainExtras = {
7538
+ town: {},
7539
+ mill: {},
7540
+ dvm: {}
7541
+ };
7542
+ const types = ["town", "mill", "dvm"];
7543
+ for (const nodeType of types) {
7544
+ const accountIndex = NODE_ACCOUNT_INDEX[nodeType];
7545
+ const chains = nodeType === "mill" ? ["solana", "mina"] : ["solana"];
7546
+ try {
7547
+ const chainKeys = await deriveMillKeys({
7548
+ mnemonic,
7549
+ chains,
7550
+ accountIndex
7551
+ });
7552
+ if (chainKeys.solana) {
7553
+ chainExtras[nodeType].solanaAddress = base58Encode(
7554
+ chainKeys.solana.publicKey
7555
+ );
7556
+ chainExtras[nodeType].solanaPrivateKey = chainKeys.solana.privateKey;
7557
+ chainExtras[nodeType].solanaDerivationPath = chainKeys.solana.path;
7558
+ }
7559
+ if (nodeType === "mill" && chainKeys.mina) {
7560
+ chainExtras[nodeType].minaAddress = chainKeys.mina.publicKey;
7561
+ }
7562
+ } catch (err) {
7563
+ const errMsg = err instanceof Error ? err.message : String(err);
7564
+ console.warn(
7565
+ `[WalletManager] deriveMillKeys failed for ${nodeType} (accountIndex=${accountIndex}): ${errMsg} \u2014 chain addresses omitted`
7566
+ );
5684
7567
  }
5685
- } catch {
5686
7568
  }
5687
7569
  const keys = {
5688
- town: this.deriveNodeKeys(seed, "town"),
5689
- mill: { ...millBaseKeys, solanaAddress, minaAddress },
5690
- dvm: this.deriveNodeKeys(seed, "dvm")
7570
+ town: { ...this.deriveNodeKeys(seed, "town"), ...chainExtras.town },
7571
+ mill: { ...this.deriveNodeKeys(seed, "mill"), ...chainExtras.mill },
7572
+ dvm: { ...this.deriveNodeKeys(seed, "dvm"), ...chainExtras.dvm }
5691
7573
  };
5692
- return { keys };
7574
+ return { keys, mnemonic };
5693
7575
  } finally {
5694
7576
  if (seed) seed.fill(0);
5695
7577
  }
5696
7578
  }
5697
7579
  /**
5698
7580
  * Derive Nostr + EVM keys for a specific node type.
7581
+ * Accepts an optional `accountIndex` to override the default per-type index.
7582
+ * When omitted, uses `NODE_ACCOUNT_INDEX[nodeType]` (existing behavior).
5699
7583
  */
5700
- deriveNodeKeys(seed, nodeType) {
5701
- const accountIndex = NODE_ACCOUNT_INDEX[nodeType];
5702
- const nostrPath = `m/44'/1237'/${accountIndex}'/0/0`;
7584
+ deriveNodeKeys(seed, nodeType, accountIndex) {
7585
+ const idx = accountIndex ?? NODE_ACCOUNT_INDEX[nodeType];
7586
+ const nostrPath = `m/44'/1237'/${idx}'/0/0`;
5703
7587
  const nostrHdKey = HDKey.fromMasterSeed(seed).derive(nostrPath);
5704
7588
  if (!nostrHdKey.privateKey) {
5705
7589
  throw new Error(`Nostr private key missing at ${nostrPath}`);
5706
7590
  }
5707
7591
  const nostrSecretKey = new Uint8Array(nostrHdKey.privateKey);
5708
7592
  const nostrPubkey = getPublicKey(nostrSecretKey);
5709
- const evmPath = `m/44'/60'/${accountIndex}'/0/0`;
7593
+ const evmPath = `m/44'/60'/${idx}'/0/0`;
5710
7594
  const evmHdKey = HDKey.fromMasterSeed(seed).derive(evmPath);
5711
7595
  if (!evmHdKey.privateKey) {
5712
7596
  throw new Error(`EVM private key missing at ${evmPath}`);
@@ -5723,6 +7607,54 @@ var WalletManager = class {
5723
7607
  };
5724
7608
  }
5725
7609
  };
7610
+ async function deriveArweaveKey(seed, accountIndex) {
7611
+ const path = `m/44'/472'/${accountIndex}'/0/0`;
7612
+ const hdKey = HDKey.fromMasterSeed(seed).derive(path);
7613
+ if (!hdKey.privateKey) {
7614
+ throw new Error(`Arweave sub-seed missing at ${path}`);
7615
+ }
7616
+ const subSeed = new Uint8Array(hdKey.privateKey);
7617
+ const { rsaPrivateKeyPemFromSeed } = await import("./rsa-from-seed-VMNLNDZM.js");
7618
+ let pemPrivateKey;
7619
+ try {
7620
+ pemPrivateKey = await rsaPrivateKeyPemFromSeed(subSeed);
7621
+ } finally {
7622
+ subSeed.fill(0);
7623
+ }
7624
+ const keyObject = createPrivateKey({
7625
+ key: pemPrivateKey,
7626
+ format: "pem",
7627
+ type: "pkcs1"
7628
+ });
7629
+ const rawJwk = keyObject.export({ format: "jwk" });
7630
+ if (rawJwk.kty !== "RSA" || !rawJwk.n || !rawJwk.e || !rawJwk.d || !rawJwk.p || !rawJwk.q || !rawJwk.dp || !rawJwk.dq || !rawJwk.qi) {
7631
+ throw new Error(
7632
+ `Arweave JWK conversion produced unexpected shape (kty=${String(rawJwk.kty)}, has-private=${Boolean(rawJwk.d)})`
7633
+ );
7634
+ }
7635
+ const jwk = {
7636
+ kty: "RSA",
7637
+ e: rawJwk.e,
7638
+ n: rawJwk.n,
7639
+ d: rawJwk.d,
7640
+ p: rawJwk.p,
7641
+ q: rawJwk.q,
7642
+ dp: rawJwk.dp,
7643
+ dq: rawJwk.dq,
7644
+ qi: rawJwk.qi
7645
+ };
7646
+ const modulusBytes = Buffer.from(jwk.n, "base64url");
7647
+ const address = createHash("sha256").update(modulusBytes).digest("base64url");
7648
+ return { jwk, address, path };
7649
+ }
7650
+ function zeroArweaveJwk(jwk) {
7651
+ jwk.d = "";
7652
+ jwk.p = "";
7653
+ jwk.q = "";
7654
+ jwk.dp = "";
7655
+ jwk.dq = "";
7656
+ jwk.qi = "";
7657
+ }
5726
7658
  function computeEvmAddress(privateKey) {
5727
7659
  const uncompressed = secp256k1.getPublicKey(privateKey, false);
5728
7660
  const hash = keccak_256(uncompressed.slice(1));
@@ -5741,6 +7673,265 @@ function toChecksumAddress(addressHex) {
5741
7673
  return out;
5742
7674
  }
5743
7675
 
7676
+ // src/earnings/aggregator.ts
7677
+ var STUB_DELTAS = { today: "0", month: "0", year: "0" };
7678
+ async function maybeDeltas(deltaComputer, scope, assetCode, currentLifetime, logger) {
7679
+ if (!deltaComputer) return { ...STUB_DELTAS };
7680
+ try {
7681
+ return await deltaComputer({ scope, assetCode, currentLifetime });
7682
+ } catch (err) {
7683
+ logger?.warn(
7684
+ { err, scope, assetCode },
7685
+ "aggregator: deltaComputer rejected \u2014 stubbing deltas to 0 for this asset"
7686
+ );
7687
+ return { ...STUB_DELTAS };
7688
+ }
7689
+ }
7690
+ async function aggregateEarnings(input) {
7691
+ let earnings;
7692
+ try {
7693
+ earnings = await input.connectorAdmin.getEarnings();
7694
+ } catch (err) {
7695
+ input.logger?.warn(
7696
+ { err },
7697
+ "aggregator: connectorAdmin.getEarnings failed \u2014 returning status=connector_unavailable"
7698
+ );
7699
+ return {
7700
+ status: "connector_unavailable",
7701
+ apex: { routingFees: {} },
7702
+ peers: [],
7703
+ recentClaims: [],
7704
+ eventsRelayed: 0,
7705
+ uptimeSeconds: 0
7706
+ };
7707
+ }
7708
+ const buildRoutingFees = async () => {
7709
+ const out = {};
7710
+ await Promise.all(
7711
+ earnings.connectorFees.map(async (fee) => {
7712
+ const deltas = await maybeDeltas(
7713
+ input.deltaComputer,
7714
+ "__apex__",
7715
+ fee.assetCode,
7716
+ fee.total,
7717
+ input.logger
7718
+ );
7719
+ out[fee.assetCode] = { lifetime: fee.total, ...deltas };
7720
+ })
7721
+ );
7722
+ return out;
7723
+ };
7724
+ const buildPeers = async () => Promise.all(
7725
+ earnings.peers.map(async (peer) => {
7726
+ const type = input.peerTypeResolver.resolvePeerType(peer.peerId);
7727
+ const byAsset = {};
7728
+ await Promise.all(
7729
+ peer.byAsset.map(async (a) => {
7730
+ const deltas = await maybeDeltas(
7731
+ input.deltaComputer,
7732
+ peer.peerId,
7733
+ a.assetCode,
7734
+ a.claimsReceivedTotal,
7735
+ input.logger
7736
+ );
7737
+ byAsset[a.assetCode] = {
7738
+ lifetime: a.claimsReceivedTotal,
7739
+ ...deltas
7740
+ };
7741
+ })
7742
+ );
7743
+ const lastClaimAt = peer.byAsset.reduce((acc, a) => {
7744
+ const v = a.lastClaimAt;
7745
+ if (!v) return acc;
7746
+ const vMs = Date.parse(v);
7747
+ if (!Number.isFinite(vMs)) return acc;
7748
+ if (acc === null) return v;
7749
+ const accMs = Date.parse(acc);
7750
+ if (!Number.isFinite(accMs)) return v;
7751
+ return vMs > accMs ? v : acc;
7752
+ }, null);
7753
+ return { id: peer.peerId, type, byAsset, lastClaimAt };
7754
+ })
7755
+ );
7756
+ const metricsPromise = input.connectorAdmin.getMetrics().catch((err) => {
7757
+ input.logger?.warn(
7758
+ { err },
7759
+ "aggregator: getMetrics failed \u2014 eventsRelayed/uptimeSeconds defaulting to 0"
7760
+ );
7761
+ return null;
7762
+ });
7763
+ const [routingFees, peers, metricsResult] = await Promise.all([
7764
+ buildRoutingFees(),
7765
+ buildPeers(),
7766
+ metricsPromise
7767
+ ]);
7768
+ const clampInt = (n) => Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
7769
+ let eventsRelayed = 0;
7770
+ if (metricsResult) {
7771
+ if (metricsResult.peers.length === 0) {
7772
+ eventsRelayed = clampInt(metricsResult.aggregate.packetsForwarded) + clampInt(metricsResult.aggregate.packetsLocallyDelivered ?? 0);
7773
+ } else {
7774
+ eventsRelayed = metricsResult.peers.reduce(
7775
+ (sum, p) => sum + clampInt(p.packetsForwarded) + clampInt(p.packetsLocallyDelivered ?? 0),
7776
+ 0
7777
+ );
7778
+ }
7779
+ }
7780
+ const uptimeSeconds = clampInt(metricsResult?.uptimeSeconds ?? 0);
7781
+ return {
7782
+ status: "ok",
7783
+ apex: { routingFees },
7784
+ peers,
7785
+ recentClaims: earnings.recentClaims,
7786
+ eventsRelayed,
7787
+ uptimeSeconds
7788
+ };
7789
+ }
7790
+
7791
+ // src/earnings/snapshot-reader.ts
7792
+ import { createReadStream } from "fs";
7793
+ import { createInterface } from "readline";
7794
+ function utcDayBoundary(ref) {
7795
+ const d = new Date(ref);
7796
+ d.setUTCHours(0, 0, 0, 0);
7797
+ return d.toISOString();
7798
+ }
7799
+ function utcMonthBoundary(ref) {
7800
+ const d = new Date(ref);
7801
+ d.setUTCDate(1);
7802
+ d.setUTCHours(0, 0, 0, 0);
7803
+ return d.toISOString();
7804
+ }
7805
+ function utcYearBoundary(ref) {
7806
+ const d = new Date(ref);
7807
+ d.setUTCMonth(0, 1);
7808
+ d.setUTCHours(0, 0, 0, 0);
7809
+ return d.toISOString();
7810
+ }
7811
+ async function readSnapshotMap(snapshotPath, nowMs) {
7812
+ const map = /* @__PURE__ */ new Map();
7813
+ let stream;
7814
+ try {
7815
+ stream = createReadStream(snapshotPath, { encoding: "utf-8" });
7816
+ } catch {
7817
+ return map;
7818
+ }
7819
+ let streamFailed = false;
7820
+ await new Promise((resolve2) => {
7821
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
7822
+ rl.on("line", (line) => {
7823
+ if (!line.trim()) return;
7824
+ let entry;
7825
+ try {
7826
+ entry = JSON.parse(line);
7827
+ } catch {
7828
+ return;
7829
+ }
7830
+ if (!isSnapshotEntry2(entry)) return;
7831
+ const tsMs = Date.parse(entry.ts);
7832
+ if (!Number.isFinite(tsMs)) return;
7833
+ if (tsMs > nowMs) return;
7834
+ const key = `${entry.peerId}\0${entry.assetCode}`;
7835
+ let arr = map.get(key);
7836
+ if (!arr) {
7837
+ arr = [];
7838
+ map.set(key, arr);
7839
+ }
7840
+ arr.push(entry);
7841
+ });
7842
+ rl.on("close", resolve2);
7843
+ rl.on("error", () => {
7844
+ streamFailed = true;
7845
+ resolve2();
7846
+ });
7847
+ stream.on("error", () => {
7848
+ streamFailed = true;
7849
+ rl.close();
7850
+ resolve2();
7851
+ });
7852
+ });
7853
+ if (streamFailed) return /* @__PURE__ */ new Map();
7854
+ return map;
7855
+ }
7856
+ function findBestMatch(entries, boundaryMs) {
7857
+ if (!entries || entries.length === 0) return null;
7858
+ let best = null;
7859
+ let bestMs = -Infinity;
7860
+ for (const e of entries) {
7861
+ const eMs = Date.parse(e.ts);
7862
+ if (!Number.isFinite(eMs)) continue;
7863
+ if (eMs <= boundaryMs && eMs > bestMs) {
7864
+ best = e;
7865
+ bestMs = eMs;
7866
+ }
7867
+ }
7868
+ return best;
7869
+ }
7870
+ function createDeltaComputer(opts) {
7871
+ return async ({ scope, assetCode, currentLifetime }) => {
7872
+ const ref = (opts.now ?? (() => /* @__PURE__ */ new Date()))();
7873
+ const nowMs = ref.getTime();
7874
+ if (!Number.isFinite(nowMs)) {
7875
+ return { today: "0", month: "0", year: "0" };
7876
+ }
7877
+ const dayMs = Date.parse(utcDayBoundary(ref));
7878
+ const monthMs = Date.parse(utcMonthBoundary(ref));
7879
+ const yearMs = Date.parse(utcYearBoundary(ref));
7880
+ const map = await readSnapshotMap(opts.snapshotPath, nowMs);
7881
+ const key = `${scope}\0${assetCode}`;
7882
+ const entries = map.get(key);
7883
+ const daySnap = findBestMatch(entries, dayMs);
7884
+ const monthSnap = findBestMatch(entries, monthMs);
7885
+ const yearSnap = findBestMatch(entries, yearMs);
7886
+ let cur;
7887
+ try {
7888
+ cur = BigInt(currentLifetime);
7889
+ } catch {
7890
+ return { today: "0", month: "0", year: "0" };
7891
+ }
7892
+ const subOrZero = (snap) => {
7893
+ if (!snap) return "0";
7894
+ try {
7895
+ const base = BigInt(snap.claimsReceivedTotal);
7896
+ if (base < 0n) return "0";
7897
+ const diff = cur - base;
7898
+ return diff < 0n ? "0" : diff.toString();
7899
+ } catch {
7900
+ return "0";
7901
+ }
7902
+ };
7903
+ return {
7904
+ today: subOrZero(daySnap),
7905
+ month: subOrZero(monthSnap),
7906
+ year: subOrZero(yearSnap)
7907
+ };
7908
+ };
7909
+ }
7910
+ function isSnapshotEntry2(v) {
7911
+ if (typeof v !== "object" || v === null) return false;
7912
+ const e = v;
7913
+ return typeof e["ts"] === "string" && typeof e["peerId"] === "string" && typeof e["assetCode"] === "string" && typeof e["claimsReceivedTotal"] === "string";
7914
+ }
7915
+
7916
+ // src/registry/peer-type-resolver.ts
7917
+ var PeerTypeResolver = class {
7918
+ map;
7919
+ constructor(yaml) {
7920
+ this.map = new Map(yaml.entries.map((e) => [e.peerId, e.type]));
7921
+ }
7922
+ /**
7923
+ * Resolve a connector `peerId` to its operator-declared node type.
7924
+ * Returns `'external'` for unknown peerIds (legitimate non-Townhouse
7925
+ * peers running through the same connector).
7926
+ */
7927
+ resolvePeerType(peerId) {
7928
+ return this.map.get(peerId) ?? "external";
7929
+ }
7930
+ };
7931
+
7932
+ // src/api/server.ts
7933
+ import { join as join8, dirname as dirname11 } from "path";
7934
+
5744
7935
  // ../../node_modules/.pnpm/ws@8.19.0/node_modules/ws/wrapper.mjs
5745
7936
  var import_stream = __toESM(require_stream(), 1);
5746
7937
  var import_receiver = __toESM(require_receiver(), 1);
@@ -5752,6 +7943,7 @@ var import_websocket_server = __toESM(require_websocket_server(), 1);
5752
7943
  import Fastify from "fastify";
5753
7944
  import cors from "@fastify/cors";
5754
7945
  import websocket from "@fastify/websocket";
7946
+ import { createRequire as nodeCreateRequire } from "module";
5755
7947
 
5756
7948
  // src/api/cors.ts
5757
7949
  var ALLOWED_ORIGINS = ["localhost", "127.0.0.1", "[::1]", "::1"];
@@ -5781,6 +7973,20 @@ function buildCorsOptions() {
5781
7973
  }
5782
7974
 
5783
7975
  // src/api/build-app.ts
7976
+ var STARTED_AT = (/* @__PURE__ */ new Date()).toISOString();
7977
+ var _localRequire = nodeCreateRequire(import.meta.url);
7978
+ function _loadPackageJson() {
7979
+ for (const rel of ["../package.json", "../../package.json"]) {
7980
+ try {
7981
+ return _localRequire(rel);
7982
+ } catch {
7983
+ }
7984
+ }
7985
+ throw new Error(
7986
+ "build-app.ts: could not resolve package.json from '../package.json' or '../../package.json'. Bundle layout may have changed \u2014 update the resolution ladder."
7987
+ );
7988
+ }
7989
+ var _pkgVersion = _loadPackageJson()["version"];
5784
7990
  var LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"];
5785
7991
  async function buildFastifyApp(opts = {}) {
5786
7992
  const bindHost = opts.bindHost ?? "127.0.0.1";
@@ -5806,7 +8012,21 @@ async function buildFastifyApp(opts = {}) {
5806
8012
  "res.body.mnemonic",
5807
8013
  "mnemonic",
5808
8014
  "password",
5809
- "password_confirm"
8015
+ "password_confirm",
8016
+ // Story 46.2: secret-bearing fields introduced by node lifecycle
8017
+ // routes. These never appear in request/response bodies (they go
8018
+ // to subprocess env), but defense-in-depth covers them at every
8019
+ // path Pino might log a stray object (error objects, debug dumps).
8020
+ "nostrSecretKey",
8021
+ "evmPrivateKey",
8022
+ "TOWN_SECRET_KEY",
8023
+ "MILL_SECRET_KEY",
8024
+ "DVM_SECRET_KEY",
8025
+ "TOWN_SETTLEMENT_PRIVATE_KEY",
8026
+ "MILL_SETTLEMENT_PRIVATE_KEY",
8027
+ "DVM_SETTLEMENT_PRIVATE_KEY",
8028
+ "MILL_MNEMONIC",
8029
+ "TOWNHOUSE_WALLET_PASSWORD"
5810
8030
  ],
5811
8031
  censor: "[REDACTED]"
5812
8032
  }
@@ -5845,6 +8065,12 @@ async function buildFastifyApp(opts = {}) {
5845
8065
  });
5846
8066
  await app.register(cors, buildCorsOptions());
5847
8067
  await app.register(websocket);
8068
+ app.get("/health", async () => ({
8069
+ status: "healthy",
8070
+ uptime: Math.floor(process.uptime()),
8071
+ startedAt: STARTED_AT,
8072
+ version: _pkgVersion
8073
+ }));
5848
8074
  return app;
5849
8075
  }
5850
8076
 
@@ -7131,6 +9357,15 @@ function acquireConfigMutex() {
7131
9357
  function releaseConfigMutex() {
7132
9358
  isMutating = false;
7133
9359
  }
9360
+ var isNodeLifecycleRunning = false;
9361
+ function acquireNodeLifecycleMutex() {
9362
+ if (isNodeLifecycleRunning) return false;
9363
+ isNodeLifecycleRunning = true;
9364
+ return true;
9365
+ }
9366
+ function releaseNodeLifecycleMutex() {
9367
+ isNodeLifecycleRunning = false;
9368
+ }
7134
9369
 
7135
9370
  // src/api/routes/nodes-patch.ts
7136
9371
  var patchBodySchema = {
@@ -7191,61 +9426,773 @@ function registerConfigPatchRoutes(app, deps) {
7191
9426
  type
7192
9427
  });
7193
9428
  }
7194
- const existingKindPricing = nodeConfig.kindPricing ?? void 0;
7195
- const mergedKindPricing = body.kindPricing !== void 0 ? { ...existingKindPricing ?? {}, ...body.kindPricing } : existingKindPricing;
7196
- const mergedConfig = {
7197
- ...currentConfig,
7198
- nodes: {
7199
- ...currentConfig.nodes,
7200
- [type]: {
7201
- ...nodeConfig,
7202
- ...body,
7203
- ...mergedKindPricing !== void 0 ? { kindPricing: mergedKindPricing } : {}
7204
- }
9429
+ const existingKindPricing = nodeConfig.kindPricing ?? void 0;
9430
+ const mergedKindPricing = body.kindPricing !== void 0 ? { ...existingKindPricing ?? {}, ...body.kindPricing } : existingKindPricing;
9431
+ const mergedConfig = {
9432
+ ...currentConfig,
9433
+ nodes: {
9434
+ ...currentConfig.nodes,
9435
+ [type]: {
9436
+ ...nodeConfig,
9437
+ ...body,
9438
+ ...mergedKindPricing !== void 0 ? { kindPricing: mergedKindPricing } : {}
9439
+ }
9440
+ }
9441
+ };
9442
+ try {
9443
+ validateConfig(mergedConfig);
9444
+ } catch (validationError) {
9445
+ return reply.status(400).send({
9446
+ error: "config_validation_error",
9447
+ message: validationError instanceof Error ? validationError.message : "Invalid configuration"
9448
+ });
9449
+ }
9450
+ await saveConfig(deps.configPath, mergedConfig);
9451
+ deps.config.nodes = mergedConfig.nodes;
9452
+ const nodeType = type;
9453
+ const oldEnabled = nodeConfig.enabled;
9454
+ const newEnabled = body.enabled !== void 0 ? body.enabled : oldEnabled;
9455
+ if (oldEnabled !== newEnabled) {
9456
+ if (newEnabled) {
9457
+ await deps.orchestrator.addNode(nodeType);
9458
+ } else {
9459
+ await deps.orchestrator.removeNode(nodeType);
9460
+ }
9461
+ }
9462
+ if (body.feePerEvent !== void 0 || body.feeBasisPoints !== void 0 || body.feePerJob !== void 0 || body.kindPricing !== void 0) {
9463
+ const activeTypes = Object.entries(mergedConfig.nodes).filter(([, config]) => config.enabled).map(([type2]) => type2);
9464
+ await deps.orchestrator.regenerateConnectorConfig(activeTypes);
9465
+ }
9466
+ const u = mergedConfig.nodes[type];
9467
+ if (nodeType === "town") {
9468
+ return { enabled: u.enabled, feePerEvent: u.feePerEvent };
9469
+ } else if (nodeType === "mill") {
9470
+ return { enabled: u.enabled, feeBasisPoints: u.feeBasisPoints };
9471
+ } else {
9472
+ return {
9473
+ enabled: u.enabled,
9474
+ feePerJob: u.feePerJob,
9475
+ kindPricing: u.kindPricing
9476
+ };
9477
+ }
9478
+ } finally {
9479
+ releaseConfigMutex();
9480
+ }
9481
+ }
9482
+ );
9483
+ }
9484
+
9485
+ // src/api/routes/nodes-lifecycle.ts
9486
+ import { promises as fs5 } from "fs";
9487
+ import { dirname as dirname8, join as join5 } from "path";
9488
+ import { bytesToHex as bytesToHex2 } from "@noble/hashes/utils";
9489
+ var HEALTH_PORT = {
9490
+ town: TOWN_HEALTH_PORT,
9491
+ mill: MILL_HEALTH_PORT,
9492
+ dvm: DVM_HEALTH_PORT
9493
+ };
9494
+ var ACCOUNT_INDEX = {
9495
+ town: ACCOUNT_INDEX_TOWN,
9496
+ mill: ACCOUNT_INDEX_MILL,
9497
+ dvm: ACCOUNT_INDEX_DVM
9498
+ };
9499
+ var APEX_ILP_ADDRESS = "g.townhouse";
9500
+ function buildMillSwapPairConfig(config) {
9501
+ const fromChain = config.chainProviders?.[0]?.chainId ?? "evm:base:31337";
9502
+ const toChain = "solana:devnet";
9503
+ return {
9504
+ swapPairs: [
9505
+ {
9506
+ from: { assetCode: "USDC", assetScale: 6, chain: fromChain },
9507
+ to: { assetCode: "USDC", assetScale: 6, chain: toChain },
9508
+ rate: "1.0",
9509
+ minAmount: "1000",
9510
+ maxAmount: "1000000000"
9511
+ }
9512
+ ],
9513
+ chains: ["evm", "solana"],
9514
+ // Bootstrap: validateConfig() requires a non-empty channels array for
9515
+ // each distinct pair.to.chain. The zero channelId is a valid-format
9516
+ // sentinel that will never match a real on-chain channel.
9517
+ channels: {
9518
+ [toChain]: [
9519
+ {
9520
+ channelId: "0x" + "0".repeat(64),
9521
+ cumulativeAmount: "0",
9522
+ nonce: "0"
9523
+ }
9524
+ ]
9525
+ },
9526
+ // Zero initial SOL inventory; parsed to 0n by the Mill CLI.
9527
+ inventory: {
9528
+ [toChain]: "0"
9529
+ }
9530
+ };
9531
+ }
9532
+ async function waitForHealthy(url, timeoutMs) {
9533
+ const deadline = Date.now() + timeoutMs;
9534
+ const POLL_INTERVAL_MS = 1e3;
9535
+ const REQUEST_TIMEOUT_MS = 3e3;
9536
+ while (Date.now() < deadline) {
9537
+ const controller = new AbortController();
9538
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
9539
+ try {
9540
+ const res = await fetch(url, { signal: controller.signal });
9541
+ if (res.ok) return;
9542
+ } catch {
9543
+ } finally {
9544
+ clearTimeout(timer);
9545
+ }
9546
+ await new Promise((resolve2) => setTimeout(resolve2, POLL_INTERVAL_MS));
9547
+ }
9548
+ throw new Error(
9549
+ `Health check timeout: ${url} did not return 200 within ${timeoutMs}ms`
9550
+ );
9551
+ }
9552
+ function buildNodeEnv(type, nostrSecretKeyHex, evmPrivateKeyHex, mnemonic, apexEvmAddress) {
9553
+ const evmPrivateKeyHex0x = `0x${evmPrivateKeyHex}`;
9554
+ switch (type) {
9555
+ case "town":
9556
+ return {
9557
+ TOWN_SECRET_KEY: nostrSecretKeyHex,
9558
+ TOWN_SETTLEMENT_PRIVATE_KEY: evmPrivateKeyHex0x,
9559
+ APEX_EVM_ADDRESS: apexEvmAddress
9560
+ };
9561
+ case "mill":
9562
+ return {
9563
+ MILL_SECRET_KEY: nostrSecretKeyHex,
9564
+ MILL_SETTLEMENT_PRIVATE_KEY: evmPrivateKeyHex0x,
9565
+ MILL_MNEMONIC: mnemonic ?? "",
9566
+ APEX_EVM_ADDRESS: apexEvmAddress
9567
+ };
9568
+ case "dvm":
9569
+ return {
9570
+ DVM_SECRET_KEY: nostrSecretKeyHex
9571
+ };
9572
+ }
9573
+ }
9574
+ function registerNodeLifecycleRoutes(app, deps) {
9575
+ app.get("/api/nodes", async (request, reply) => {
9576
+ const homeDir = dirname8(deps.configPath);
9577
+ const nodesYamlPath = join5(homeDir, "nodes.yaml");
9578
+ let yaml;
9579
+ try {
9580
+ yaml = await readNodesYaml(nodesYamlPath);
9581
+ } catch (err) {
9582
+ const errMsg = err instanceof Error ? err.message : String(err);
9583
+ request.log.error(
9584
+ { event: "get_nodes_yaml_error", err: errMsg },
9585
+ "Failed to read nodes.yaml"
9586
+ );
9587
+ return reply.status(500).send({ error: "yaml_read_failed", err: errMsg });
9588
+ }
9589
+ let peers = [];
9590
+ let connectorUnreachable = false;
9591
+ try {
9592
+ peers = await deps.connectorAdmin.getPeers();
9593
+ } catch (err) {
9594
+ connectorUnreachable = true;
9595
+ request.log.warn(
9596
+ { event: "get_nodes_connector_warn", err: String(err) },
9597
+ "connector unreachable during GET /api/nodes \u2014 returning status:unknown"
9598
+ );
9599
+ }
9600
+ const nodes = yaml.entries.map((entry) => {
9601
+ let status;
9602
+ if (connectorUnreachable) {
9603
+ status = "unknown";
9604
+ } else {
9605
+ const peer = peers.find((p) => p.id === entry.peerId);
9606
+ status = peer?.connected ? "connected" : "disconnected";
9607
+ }
9608
+ return {
9609
+ id: entry.id,
9610
+ type: entry.type,
9611
+ peerId: entry.peerId,
9612
+ ilpAddress: entry.ilpAddress,
9613
+ status,
9614
+ enabledAt: entry.enabledAt,
9615
+ lastSeenAt: entry.lastSeenAt
9616
+ };
9617
+ });
9618
+ return reply.status(200).send({ nodes });
9619
+ });
9620
+ app.post(
9621
+ "/api/nodes",
9622
+ {
9623
+ schema: {
9624
+ body: {
9625
+ type: "object",
9626
+ additionalProperties: false,
9627
+ required: ["type"],
9628
+ properties: {
9629
+ type: { type: "string", enum: ["town", "mill", "dvm"] }
9630
+ }
9631
+ }
9632
+ }
9633
+ },
9634
+ async (request, reply) => {
9635
+ if (!acquireNodeLifecycleMutex()) {
9636
+ return reply.status(409).send({ error: "node_lifecycle_in_flight" });
9637
+ }
9638
+ try {
9639
+ const { type } = request.body;
9640
+ const homeDir = dirname8(deps.configPath);
9641
+ const nodesYamlPath = join5(homeDir, "nodes.yaml");
9642
+ const imageManifestPath = join5(homeDir, "image-manifest.json");
9643
+ const millConfigPath = join5(homeDir, "mill.config.json");
9644
+ const yaml = await readNodesYaml(nodesYamlPath);
9645
+ const existing = yaml.entries.find((e) => e.type === type);
9646
+ if (existing) {
9647
+ return reply.status(409).send({
9648
+ error: "node_type_in_use",
9649
+ type,
9650
+ existingId: existing.id
9651
+ });
9652
+ }
9653
+ if (type === "mill" && !process.env["MILL_RELAYS"]?.trim()) {
9654
+ return reply.status(400).send({
9655
+ step: "preflight",
9656
+ err: "MILL_RELAYS is not set or is blank. Export a comma-separated list of relay URLs before provisioning Mill (e.g. export MILL_RELAYS=wss://relay.example.com). See packages/townhouse/README.md."
9657
+ });
9658
+ }
9659
+ const derivationIndex = ACCOUNT_INDEX[type];
9660
+ const id = type;
9661
+ const peerId = type;
9662
+ const ilpAddress = `${APEX_ILP_ADDRESS}.${type}`;
9663
+ const containerName = `${CONTAINER_PREFIX}hs-${type}`;
9664
+ const healthPort = HEALTH_PORT[type];
9665
+ const healthCheckUrl = `http://${containerName}:${healthPort}/health`;
9666
+ const btpUrl = `ws://${CONTAINER_PREFIX}hs-${type}:${NODE_BTP_PORT}`;
9667
+ request.log.info(
9668
+ { event: "node_lifecycle_step", step: "derive-key", type, peerId },
9669
+ "Step 1: deriving node key"
9670
+ );
9671
+ let keys;
9672
+ try {
9673
+ keys = await deps.wallet.deriveNodeKey(type, derivationIndex);
9674
+ } catch (err) {
9675
+ const errMsg = sanitizeErrorMessage(
9676
+ err instanceof Error ? err.message : String(err)
9677
+ );
9678
+ request.log.error(
9679
+ {
9680
+ event: "node_lifecycle_failure",
9681
+ step: "derive-key",
9682
+ err: errMsg
9683
+ },
9684
+ "Step 1 failed: derive-key"
9685
+ );
9686
+ return reply.status(500).send({ step: "derive-key", err: errMsg });
9687
+ }
9688
+ const nostrSecretKeyHex = bytesToHex2(keys.nostrSecretKey);
9689
+ const evmPrivateKeyHex = bytesToHex2(keys.evmPrivateKey);
9690
+ const mnemonicSnapshot = deps.wallet.getMnemonic();
9691
+ if (mnemonicSnapshot === null) {
9692
+ const errMsg = "Wallet locked between step 1 and step 4 \u2014 refusing to start container without mnemonic";
9693
+ request.log.error(
9694
+ {
9695
+ event: "node_lifecycle_failure",
9696
+ step: "derive-key",
9697
+ err: errMsg
9698
+ },
9699
+ "Step 1 post-condition failed: mnemonic gone after derive"
9700
+ );
9701
+ return reply.status(500).send({ step: "derive-key", err: errMsg });
9702
+ }
9703
+ const apexEvmAddress = deps.wallet.getNodeKeys("town").evmAddress;
9704
+ request.log.info(
9705
+ { event: "node_lifecycle_step", step: "pull-image", type, peerId },
9706
+ "Step 2: pulling image"
9707
+ );
9708
+ try {
9709
+ const manifest = await readImageManifest(imageManifestPath);
9710
+ const entry = manifest.images[type];
9711
+ if (isSyntheticDigest(entry.digest)) {
9712
+ return reply.status(400).send({
9713
+ step: "pull-image",
9714
+ err: `Synthetic-digest manifest: image-manifest.json was produced by the connector-publish-smoke workflow for smoke testing only. Fetch a real manifest via 'gh run download' or rerun without --skip-fetch before provisioning nodes.`
9715
+ });
9716
+ }
9717
+ const imageRef = `${entry.name}@${entry.digest}`;
9718
+ await deps.orchestrator.pullImage(imageRef);
9719
+ } catch (err) {
9720
+ const errMsg = sanitizeErrorMessage(
9721
+ err instanceof Error ? err.message : String(err)
9722
+ );
9723
+ request.log.error(
9724
+ {
9725
+ event: "node_lifecycle_failure",
9726
+ step: "pull-image",
9727
+ err: errMsg
9728
+ },
9729
+ "Step 2 failed: pull-image"
9730
+ );
9731
+ return reply.status(502).send({ step: "pull-image", err: errMsg });
9732
+ }
9733
+ request.log.info(
9734
+ { event: "node_lifecycle_step", step: "write-yaml", type, peerId },
9735
+ "Step 3: writing nodes.yaml entry"
9736
+ );
9737
+ const enabledAt = (/* @__PURE__ */ new Date()).toISOString();
9738
+ const newEntry = {
9739
+ id,
9740
+ type,
9741
+ peerId,
9742
+ ilpAddress,
9743
+ derivationIndex,
9744
+ enabledAt,
9745
+ lastSeenAt: null
9746
+ };
9747
+ try {
9748
+ await writeNodesYaml(nodesYamlPath, {
9749
+ entries: [...yaml.entries, newEntry]
9750
+ });
9751
+ } catch (err) {
9752
+ const errMsg = sanitizeErrorMessage(
9753
+ err instanceof Error ? err.message : String(err)
9754
+ );
9755
+ request.log.error(
9756
+ {
9757
+ event: "node_lifecycle_failure",
9758
+ step: "write-yaml",
9759
+ err: errMsg
9760
+ },
9761
+ "Step 3 failed: write-yaml"
9762
+ );
9763
+ return reply.status(500).send({ step: "write-yaml", err: errMsg });
9764
+ }
9765
+ let millConfigWritten = false;
9766
+ if (type === "mill") {
9767
+ try {
9768
+ const defaultMillConfig = JSON.stringify(
9769
+ buildMillSwapPairConfig(deps.config),
9770
+ null,
9771
+ 2
9772
+ );
9773
+ await fs5.mkdir(dirname8(millConfigPath), {
9774
+ recursive: true,
9775
+ mode: 448
9776
+ });
9777
+ await fs5.chmod(dirname8(millConfigPath), 448);
9778
+ await fs5.writeFile(millConfigPath, defaultMillConfig, {
9779
+ encoding: "utf-8",
9780
+ mode: 384
9781
+ });
9782
+ millConfigWritten = true;
9783
+ } catch (err) {
9784
+ const errMsg = sanitizeErrorMessage(
9785
+ err instanceof Error ? err.message : String(err)
9786
+ );
9787
+ request.log.error(
9788
+ {
9789
+ event: "node_lifecycle_failure",
9790
+ step: "write-mill-config",
9791
+ err: errMsg
9792
+ },
9793
+ "Step 3b failed: write mill.config.json"
9794
+ );
9795
+ const rollbackMillError = await safeRollbackMillConfig(
9796
+ millConfigPath,
9797
+ request
9798
+ );
9799
+ const rollbackYamlError = await safeRollbackYaml(
9800
+ nodesYamlPath,
9801
+ peerId,
9802
+ request
9803
+ );
9804
+ const rollbackError = combineRollbackErrors(
9805
+ rollbackMillError,
9806
+ rollbackYamlError
9807
+ );
9808
+ return reply.status(500).send({ step: "write-mill-config", err: errMsg, rollbackError });
9809
+ }
9810
+ }
9811
+ request.log.info(
9812
+ {
9813
+ event: "node_lifecycle_step",
9814
+ step: "start-container",
9815
+ type,
9816
+ peerId
9817
+ },
9818
+ "Step 4: starting container via compose"
9819
+ );
9820
+ const nodeEnv = buildNodeEnv(
9821
+ type,
9822
+ nostrSecretKeyHex,
9823
+ evmPrivateKeyHex,
9824
+ mnemonicSnapshot,
9825
+ apexEvmAddress
9826
+ );
9827
+ try {
9828
+ await deps.orchestrator.startNodeViaCompose(type, nodeEnv);
9829
+ } catch (err) {
9830
+ const errMsg = sanitizeErrorMessage(
9831
+ err instanceof Error ? err.message : String(err)
9832
+ );
9833
+ request.log.error(
9834
+ {
9835
+ event: "node_lifecycle_failure",
9836
+ step: "start-container",
9837
+ err: errMsg
9838
+ },
9839
+ "Step 4 failed: start-container"
9840
+ );
9841
+ const rollbackError = await safeRollbackYaml(
9842
+ nodesYamlPath,
9843
+ peerId,
9844
+ request
9845
+ );
9846
+ let rollbackMillError;
9847
+ if (millConfigWritten) {
9848
+ rollbackMillError = await safeRollbackMillConfig(
9849
+ millConfigPath,
9850
+ request
9851
+ );
9852
+ }
9853
+ const combinedRollbackError = combineRollbackErrors(
9854
+ rollbackError,
9855
+ rollbackMillError
9856
+ );
9857
+ return reply.status(502).send({
9858
+ step: "start-container",
9859
+ err: errMsg,
9860
+ rollbackError: combinedRollbackError
9861
+ });
9862
+ }
9863
+ request.log.info(
9864
+ {
9865
+ event: "node_lifecycle_step",
9866
+ step: "healthcheck",
9867
+ type,
9868
+ peerId,
9869
+ healthCheckUrl
9870
+ },
9871
+ "Step 5: waiting for container to become healthy"
9872
+ );
9873
+ try {
9874
+ await waitForHealthy(healthCheckUrl, 6e4);
9875
+ } catch (err) {
9876
+ const errMsg = sanitizeErrorMessage(
9877
+ err instanceof Error ? err.message : String(err)
9878
+ );
9879
+ request.log.error(
9880
+ {
9881
+ event: "node_lifecycle_failure",
9882
+ step: "healthcheck",
9883
+ err: errMsg
9884
+ },
9885
+ "Step 5 failed: healthcheck"
9886
+ );
9887
+ const rollbackYamlError = await safeRollbackYaml(
9888
+ nodesYamlPath,
9889
+ peerId,
9890
+ request
9891
+ );
9892
+ const rollbackStopError = await safeRollbackStop(
9893
+ type,
9894
+ deps.orchestrator,
9895
+ request
9896
+ );
9897
+ let rollbackMillError;
9898
+ if (millConfigWritten) {
9899
+ rollbackMillError = await safeRollbackMillConfig(
9900
+ millConfigPath,
9901
+ request
9902
+ );
7205
9903
  }
7206
- };
9904
+ const combinedRollbackError = combineRollbackErrors(
9905
+ rollbackYamlError,
9906
+ rollbackStopError,
9907
+ rollbackMillError
9908
+ );
9909
+ return reply.status(502).send({
9910
+ step: "healthcheck",
9911
+ err: errMsg,
9912
+ rollbackError: combinedRollbackError
9913
+ });
9914
+ }
9915
+ request.log.info(
9916
+ {
9917
+ event: "node_lifecycle_step",
9918
+ step: "register-peer",
9919
+ type,
9920
+ peerId,
9921
+ ilpAddress
9922
+ },
9923
+ "Step 6: registering peer with connector"
9924
+ );
7207
9925
  try {
7208
- validateConfig(mergedConfig);
7209
- } catch (validationError) {
7210
- return reply.status(400).send({
7211
- error: "config_validation_error",
7212
- message: validationError instanceof Error ? validationError.message : "Invalid configuration"
9926
+ await deps.connectorAdmin.registerPeer({
9927
+ id: peerId,
9928
+ url: btpUrl,
9929
+ authToken: "",
9930
+ routes: [{ prefix: ilpAddress, priority: 0 }],
9931
+ // Force direct (non-SOCKS5) BTP dial for this Docker-sibling
9932
+ // peer. The apex connector runs with `transport.type: socks5`
9933
+ // so the .anyone HS can publish; without this override, every
9934
+ // peer dial gets routed through the anon proxy and fails with
9935
+ // `HostUnreachable` on Docker-internal hostnames. Requires
9936
+ // connector >= 3.6.2 (toon-protocol/connector#70). Discovered
9937
+ // by Story 46.4 live gate run (Finding Q, 2026-05-12).
9938
+ transport: "direct"
9939
+ });
9940
+ } catch (err) {
9941
+ const errMsg = sanitizeErrorMessage(
9942
+ err instanceof Error ? err.message : String(err)
9943
+ );
9944
+ request.log.error(
9945
+ {
9946
+ event: "node_lifecycle_failure",
9947
+ step: "register-peer",
9948
+ err: errMsg
9949
+ },
9950
+ "Step 6 failed: register-peer"
9951
+ );
9952
+ const rollbackYamlError = await safeRollbackYaml(
9953
+ nodesYamlPath,
9954
+ peerId,
9955
+ request
9956
+ );
9957
+ const rollbackStopError = await safeRollbackStop(
9958
+ type,
9959
+ deps.orchestrator,
9960
+ request
9961
+ );
9962
+ let rollbackMillError;
9963
+ if (millConfigWritten) {
9964
+ rollbackMillError = await safeRollbackMillConfig(
9965
+ millConfigPath,
9966
+ request
9967
+ );
9968
+ }
9969
+ const combinedRollbackError = combineRollbackErrors(
9970
+ rollbackYamlError,
9971
+ rollbackStopError,
9972
+ rollbackMillError
9973
+ );
9974
+ return reply.status(502).send({
9975
+ step: "register-peer",
9976
+ err: errMsg,
9977
+ rollbackError: combinedRollbackError
7213
9978
  });
7214
9979
  }
7215
- await saveConfig(deps.configPath, mergedConfig);
7216
- deps.config.nodes = mergedConfig.nodes;
7217
- const nodeType = type;
7218
- const oldEnabled = nodeConfig.enabled;
7219
- const newEnabled = body.enabled !== void 0 ? body.enabled : oldEnabled;
7220
- if (oldEnabled !== newEnabled) {
7221
- if (newEnabled) {
7222
- await deps.orchestrator.addNode(nodeType);
7223
- } else {
7224
- await deps.orchestrator.removeNode(nodeType);
9980
+ request.log.info(
9981
+ { event: "node_lifecycle_success", type, peerId, ilpAddress },
9982
+ "Node provisioned successfully"
9983
+ );
9984
+ return reply.status(201).send({
9985
+ id,
9986
+ type,
9987
+ peerId,
9988
+ ilpAddress,
9989
+ hsRoute: ilpAddress,
9990
+ healthCheckUrl
9991
+ });
9992
+ } finally {
9993
+ releaseNodeLifecycleMutex();
9994
+ }
9995
+ }
9996
+ );
9997
+ app.delete(
9998
+ "/api/nodes/:id",
9999
+ {
10000
+ schema: {
10001
+ params: {
10002
+ type: "object",
10003
+ required: ["id"],
10004
+ properties: {
10005
+ id: {
10006
+ type: "string",
10007
+ minLength: 1,
10008
+ maxLength: 64,
10009
+ pattern: "^[a-z][a-z0-9-]*$"
10010
+ }
7225
10011
  }
7226
10012
  }
7227
- if (body.feePerEvent !== void 0 || body.feeBasisPoints !== void 0 || body.feePerJob !== void 0 || body.kindPricing !== void 0) {
7228
- const activeTypes = Object.entries(mergedConfig.nodes).filter(([, config]) => config.enabled).map(([type2]) => type2);
7229
- await deps.orchestrator.regenerateConnectorConfig(activeTypes);
10013
+ }
10014
+ },
10015
+ async (request, reply) => {
10016
+ if (!acquireNodeLifecycleMutex()) {
10017
+ return reply.status(409).send({ error: "node_lifecycle_in_flight" });
10018
+ }
10019
+ try {
10020
+ const { id } = request.params;
10021
+ const homeDir = dirname8(deps.configPath);
10022
+ const nodesYamlPath = join5(homeDir, "nodes.yaml");
10023
+ const millConfigPath = join5(homeDir, "mill.config.json");
10024
+ const yaml = await readNodesYaml(nodesYamlPath);
10025
+ const entry = yaml.entries.find((e) => e.id === id);
10026
+ if (!entry) {
10027
+ return reply.status(404).send({ error: "unknown_node", id });
10028
+ }
10029
+ request.log.info(
10030
+ {
10031
+ event: "node_lifecycle_step",
10032
+ step: "deregister-peer",
10033
+ type: entry.type,
10034
+ peerId: entry.peerId
10035
+ },
10036
+ "DELETE step 1: deregistering peer from connector"
10037
+ );
10038
+ try {
10039
+ await deps.connectorAdmin.removePeer(entry.peerId);
10040
+ } catch (err) {
10041
+ const errMsg = sanitizeErrorMessage(
10042
+ err instanceof Error ? err.message : String(err)
10043
+ );
10044
+ request.log.error(
10045
+ {
10046
+ event: "node_lifecycle_failure",
10047
+ step: "deregister-peer",
10048
+ err: errMsg
10049
+ },
10050
+ "DELETE step 1 failed: deregister-peer"
10051
+ );
10052
+ return reply.status(502).send({ step: "deregister-peer", err: errMsg });
7230
10053
  }
7231
- const u = mergedConfig.nodes[type];
7232
- if (nodeType === "town") {
7233
- return { enabled: u.enabled, feePerEvent: u.feePerEvent };
7234
- } else if (nodeType === "mill") {
7235
- return { enabled: u.enabled, feeBasisPoints: u.feeBasisPoints };
7236
- } else {
7237
- return {
7238
- enabled: u.enabled,
7239
- feePerJob: u.feePerJob,
7240
- kindPricing: u.kindPricing
7241
- };
10054
+ request.log.info(
10055
+ {
10056
+ event: "node_lifecycle_step",
10057
+ step: "stop-container",
10058
+ type: entry.type
10059
+ },
10060
+ "DELETE step 2: stopping container"
10061
+ );
10062
+ try {
10063
+ await deps.orchestrator.stopNodeViaCompose(entry.type);
10064
+ } catch (err) {
10065
+ const errMsg = sanitizeErrorMessage(
10066
+ err instanceof Error ? err.message : String(err)
10067
+ );
10068
+ request.log.error(
10069
+ {
10070
+ event: "node_lifecycle_failure",
10071
+ step: "stop-container",
10072
+ err: errMsg
10073
+ },
10074
+ "DELETE step 2 failed: stop-container"
10075
+ );
10076
+ return reply.status(502).send({ step: "stop-container", err: errMsg });
10077
+ }
10078
+ request.log.info(
10079
+ {
10080
+ event: "node_lifecycle_step",
10081
+ step: "remove-yaml",
10082
+ type: entry.type
10083
+ },
10084
+ "DELETE step 3: removing nodes.yaml entry"
10085
+ );
10086
+ try {
10087
+ await writeNodesYaml(nodesYamlPath, {
10088
+ entries: yaml.entries.filter((e) => e.id !== id)
10089
+ });
10090
+ } catch (err) {
10091
+ const errMsg = sanitizeErrorMessage(
10092
+ err instanceof Error ? err.message : String(err)
10093
+ );
10094
+ request.log.error(
10095
+ {
10096
+ event: "node_lifecycle_failure",
10097
+ step: "remove-yaml",
10098
+ err: errMsg
10099
+ },
10100
+ "DELETE step 3 failed: remove-yaml"
10101
+ );
10102
+ return reply.status(500).send({ step: "remove-yaml", err: errMsg });
10103
+ }
10104
+ if (entry.type === "mill") {
10105
+ await fs5.rm(millConfigPath, { force: true });
7242
10106
  }
10107
+ request.log.info(
10108
+ { event: "node_lifecycle_deleted", id, type: entry.type },
10109
+ "Node deprovisioned successfully"
10110
+ );
10111
+ return reply.status(200).send({ id, type: entry.type });
7243
10112
  } finally {
7244
- releaseConfigMutex();
10113
+ releaseNodeLifecycleMutex();
7245
10114
  }
7246
10115
  }
7247
10116
  );
7248
10117
  }
10118
+ async function safeRollbackYaml(nodesYamlPath, addedPeerId, request) {
10119
+ try {
10120
+ const current = await readNodesYaml(nodesYamlPath);
10121
+ const filtered = current.entries.filter((e) => e.peerId !== addedPeerId);
10122
+ await writeNodesYaml(nodesYamlPath, { entries: filtered });
10123
+ return void 0;
10124
+ } catch (err) {
10125
+ const errMsg = sanitizeErrorMessage(
10126
+ err instanceof Error ? err.message : String(err)
10127
+ );
10128
+ request.log.error(
10129
+ {
10130
+ event: "node_lifecycle_rollback_failure",
10131
+ step: "write-yaml",
10132
+ err: errMsg
10133
+ },
10134
+ "Rollback: failed to remove yaml entry \u2014 operator may need to hand-edit nodes.yaml"
10135
+ );
10136
+ return `write-yaml: ${errMsg}`;
10137
+ }
10138
+ }
10139
+ async function safeRollbackMillConfig(millConfigPath, request) {
10140
+ try {
10141
+ await fs5.rm(millConfigPath, { force: true });
10142
+ return void 0;
10143
+ } catch (err) {
10144
+ const errMsg = sanitizeErrorMessage(
10145
+ err instanceof Error ? err.message : String(err)
10146
+ );
10147
+ request.log.error(
10148
+ {
10149
+ event: "node_lifecycle_rollback_failure",
10150
+ step: "remove-mill-config",
10151
+ err: errMsg
10152
+ },
10153
+ "Rollback: failed to remove mill.config.json"
10154
+ );
10155
+ return `remove-mill-config: ${errMsg}`;
10156
+ }
10157
+ }
10158
+ async function safeRollbackStop(type, orchestrator, request) {
10159
+ try {
10160
+ await orchestrator.stopNodeViaCompose(type);
10161
+ return void 0;
10162
+ } catch (err) {
10163
+ const errMsg = sanitizeErrorMessage(
10164
+ err instanceof Error ? err.message : String(err)
10165
+ );
10166
+ request.log.error(
10167
+ {
10168
+ event: "node_lifecycle_rollback_failure",
10169
+ step: "stop-container",
10170
+ err: errMsg
10171
+ },
10172
+ "Rollback: failed to stop container \u2014 operator may need to docker rm by hand"
10173
+ );
10174
+ return `stop-container: ${errMsg}`;
10175
+ }
10176
+ }
10177
+ function combineRollbackErrors(...errors) {
10178
+ const present = errors.filter((e) => e !== void 0);
10179
+ if (present.length === 0) return void 0;
10180
+ return present.join("; ");
10181
+ }
10182
+ var SECRET_KEYS = [
10183
+ "TOWN_SECRET_KEY",
10184
+ "MILL_SECRET_KEY",
10185
+ "DVM_SECRET_KEY",
10186
+ "TOWN_SETTLEMENT_PRIVATE_KEY",
10187
+ "MILL_SETTLEMENT_PRIVATE_KEY",
10188
+ "DVM_SETTLEMENT_PRIVATE_KEY",
10189
+ "MILL_MNEMONIC",
10190
+ "TOWNHOUSE_WALLET_PASSWORD"
10191
+ ];
10192
+ var REDACT_RE = new RegExp(`(${SECRET_KEYS.join("|")})=[^\\s"'\\n\\r]+`, "g");
10193
+ function sanitizeErrorMessage(msg) {
10194
+ return msg.replace(REDACT_RE, "$1=[REDACTED]");
10195
+ }
7249
10196
 
7250
10197
  // src/api/routes/metrics-ws.ts
7251
10198
  import { decode as decodeToon } from "@toon-format/toon";
@@ -7528,8 +10475,8 @@ function registerMetricsWsRoutes(app, deps) {
7528
10475
  }
7529
10476
 
7530
10477
  // src/api/routes/wizard.ts
7531
- import { existsSync, unlinkSync, chmodSync } from "fs";
7532
- import { dirname as dirname3, join as join3 } from "path";
10478
+ import { existsSync as existsSync4, unlinkSync, chmodSync as chmodSync3 } from "fs";
10479
+ import { dirname as dirname9, join as join6 } from "path";
7533
10480
  import { generateMnemonic as generateMnemonic2, validateMnemonic as validateMnemonic2 } from "@scure/bip39";
7534
10481
  import { wordlist as wordlist2 } from "@scure/bip39/wordlists/english.js";
7535
10482
  var PROGRESS_BUFFER_MAX = 200;
@@ -7550,8 +10497,8 @@ function isAllowedWsOrigin(origin) {
7550
10497
  }
7551
10498
  function registerWizardRoutes(app, deps, state, onInit) {
7552
10499
  app.get("/wizard/state", async (_request, reply) => {
7553
- const configExists = existsSync(deps.configPath);
7554
- const walletExists = existsSync(deps.walletPath);
10500
+ const configExists = existsSync4(deps.configPath);
10501
+ const walletExists = existsSync4(deps.walletPath);
7555
10502
  const containersRunning = state.mode === "normal";
7556
10503
  return reply.status(200).send({
7557
10504
  config_exists: configExists,
@@ -7672,13 +10619,13 @@ function registerWizardRoutes(app, deps, state, onInit) {
7672
10619
  message: 'transport.mode must be "direct" or "ator".'
7673
10620
  });
7674
10621
  }
7675
- if (existsSync(deps.walletPath)) {
10622
+ if (existsSync4(deps.walletPath)) {
7676
10623
  return reply.status(409).send({
7677
10624
  code: "wallet_already_exists",
7678
10625
  message: `A wallet already exists at ${deps.walletPath}. Delete it first.`
7679
10626
  });
7680
10627
  }
7681
- if (existsSync(deps.configPath)) {
10628
+ if (existsSync4(deps.configPath)) {
7682
10629
  return reply.status(409).send({
7683
10630
  code: "config_already_exists",
7684
10631
  message: `A config already exists at ${deps.configPath}. Delete it first.`
@@ -7691,7 +10638,7 @@ function registerWizardRoutes(app, deps, state, onInit) {
7691
10638
  const encrypted = encryptWallet(cleanMnemonic, password);
7692
10639
  await saveWallet(deps.walletPath, encrypted);
7693
10640
  try {
7694
- chmodSync(deps.walletPath, 384);
10641
+ chmodSync3(deps.walletPath, 384);
7695
10642
  } catch {
7696
10643
  }
7697
10644
  let config;
@@ -7792,8 +10739,8 @@ function registerWizardRoutes(app, deps, state, onInit) {
7792
10739
  }
7793
10740
  function buildConfigFromRequest(request, configPath) {
7794
10741
  const config = getDefaultConfig();
7795
- const configDir = dirname3(configPath);
7796
- config.wallet.encrypted_path = join3(configDir, "wallet.enc");
10742
+ const configDir = dirname9(configPath);
10743
+ config.wallet.encrypted_path = join6(configDir, "wallet.enc");
7797
10744
  config.nodes.town.enabled = request.nodes.town.enabled;
7798
10745
  if (request.nodes.town.enabled && request.nodes.town.feePerEvent !== void 0) {
7799
10746
  config.nodes.town.feePerEvent = request.nodes.town.feePerEvent;
@@ -8014,9 +10961,480 @@ function registerTransportRoutes(app, deps, opts = {}) {
8014
10961
  );
8015
10962
  }
8016
10963
 
10964
+ // src/api/routes/earnings.ts
10965
+ import { dirname as dirname10, join as join7 } from "path";
10966
+
10967
+ // src/api/schemas/earnings.ts
10968
+ var DECIMAL_STRING_PATTERN = "^-?\\d+$";
10969
+ var perAssetSchema = {
10970
+ type: "object",
10971
+ properties: {
10972
+ lifetime: { type: "string", pattern: DECIMAL_STRING_PATTERN },
10973
+ today: { type: "string", pattern: DECIMAL_STRING_PATTERN },
10974
+ month: { type: "string", pattern: DECIMAL_STRING_PATTERN },
10975
+ year: { type: "string", pattern: DECIMAL_STRING_PATTERN }
10976
+ },
10977
+ required: ["lifetime", "today", "month", "year"]
10978
+ // Open to future connector-derived fields per D2 decision (2026-05-13).
10979
+ };
10980
+ var routingFeesSchema = {
10981
+ type: "object",
10982
+ additionalProperties: perAssetSchema
10983
+ };
10984
+ var peerSchema = {
10985
+ type: "object",
10986
+ properties: {
10987
+ id: { type: "string" },
10988
+ type: {
10989
+ type: "string",
10990
+ enum: ["town", "mill", "dvm", "external"]
10991
+ },
10992
+ byAsset: routingFeesSchema,
10993
+ lastClaimAt: {
10994
+ oneOf: [{ type: "string", format: "date-time" }, { type: "null" }]
10995
+ }
10996
+ },
10997
+ required: ["id", "type", "byAsset", "lastClaimAt"],
10998
+ additionalProperties: false
10999
+ // Townhouse owns the peer shape.
11000
+ };
11001
+ var recentClaimSchema = {
11002
+ type: "object",
11003
+ properties: {
11004
+ peerId: { type: "string" },
11005
+ assetCode: { type: "string" },
11006
+ assetScale: { type: "integer", minimum: 0 },
11007
+ amount: { type: "string", pattern: DECIMAL_STRING_PATTERN },
11008
+ direction: { type: "string", enum: ["inbound", "outbound"] },
11009
+ at: { type: "string", format: "date-time" }
11010
+ },
11011
+ required: [
11012
+ "peerId",
11013
+ "assetCode",
11014
+ "assetScale",
11015
+ "amount",
11016
+ "direction",
11017
+ "at"
11018
+ ]
11019
+ // Open to future connector-shipped fields per D2 decision (2026-05-13).
11020
+ };
11021
+ var earningsResponseSchema = {
11022
+ response: {
11023
+ 200: {
11024
+ type: "object",
11025
+ properties: {
11026
+ status: { type: "string", enum: ["ok", "connector_unavailable"] },
11027
+ apex: {
11028
+ type: "object",
11029
+ properties: {
11030
+ routingFees: routingFeesSchema
11031
+ },
11032
+ required: ["routingFees"],
11033
+ additionalProperties: false
11034
+ },
11035
+ peers: {
11036
+ type: "array",
11037
+ items: peerSchema
11038
+ },
11039
+ recentClaims: {
11040
+ type: "array",
11041
+ items: recentClaimSchema
11042
+ },
11043
+ eventsRelayed: { type: "integer", minimum: 0 },
11044
+ uptimeSeconds: { type: "integer", minimum: 0 }
11045
+ },
11046
+ required: [
11047
+ "status",
11048
+ "apex",
11049
+ "peers",
11050
+ "recentClaims",
11051
+ "eventsRelayed",
11052
+ "uptimeSeconds"
11053
+ ],
11054
+ additionalProperties: false
11055
+ }
11056
+ }
11057
+ };
11058
+
11059
+ // src/api/routes/earnings.ts
11060
+ function resolveNodesYamlPath(deps) {
11061
+ return join7(dirname10(deps.configPath), "nodes.yaml");
11062
+ }
11063
+ function resolveSnapshotPath(deps) {
11064
+ return join7(dirname10(deps.configPath), "earnings-snapshots.jsonl");
11065
+ }
11066
+ function registerEarningsRoutes(app, deps) {
11067
+ app.get(
11068
+ "/api/earnings",
11069
+ { schema: earningsResponseSchema },
11070
+ async (request, reply) => {
11071
+ let yaml;
11072
+ try {
11073
+ yaml = await readNodesYaml(resolveNodesYamlPath(deps));
11074
+ } catch (err) {
11075
+ request.log.error(
11076
+ { err: err instanceof Error ? err.message : String(err) },
11077
+ "earnings: nodes.yaml read/validate failed"
11078
+ );
11079
+ return reply.status(500).send({ error: "nodes_yaml_invalid" });
11080
+ }
11081
+ const peerTypeResolver = new PeerTypeResolver(yaml);
11082
+ const deltaComputer = createDeltaComputer({
11083
+ snapshotPath: resolveSnapshotPath(deps)
11084
+ });
11085
+ return aggregateEarnings({
11086
+ connectorAdmin: deps.connectorAdmin,
11087
+ peerTypeResolver,
11088
+ deltaComputer,
11089
+ logger: request.log
11090
+ });
11091
+ }
11092
+ );
11093
+ }
11094
+
11095
+ // src/api/routes/logs.ts
11096
+ import Docker from "dockerode";
11097
+
11098
+ // src/docker/log-tail.ts
11099
+ var LOG_SERVICES = [
11100
+ "town",
11101
+ "mill",
11102
+ "dvm",
11103
+ "connector"
11104
+ ];
11105
+ function stripDockerFrame(chunk) {
11106
+ if (chunk.length < 8) return chunk;
11107
+ const streamType = chunk[0];
11108
+ if (streamType !== 1 && streamType !== 2 || chunk[1] !== 0 || chunk[2] !== 0 || chunk[3] !== 0) {
11109
+ return chunk;
11110
+ }
11111
+ const out = [];
11112
+ let offset = 0;
11113
+ while (offset + 8 <= chunk.length) {
11114
+ const st = chunk[offset];
11115
+ if (st !== 1 && st !== 2 || chunk[offset + 1] !== 0 || chunk[offset + 2] !== 0 || chunk[offset + 3] !== 0) {
11116
+ out.push(chunk.subarray(offset));
11117
+ return Buffer.concat(out);
11118
+ }
11119
+ const size = chunk.readUInt32BE(offset + 4);
11120
+ const start = offset + 8;
11121
+ const end = start + size;
11122
+ if (end > chunk.length) {
11123
+ out.push(chunk.subarray(start));
11124
+ return Buffer.concat(out);
11125
+ }
11126
+ out.push(chunk.subarray(start, end));
11127
+ offset = end;
11128
+ }
11129
+ return Buffer.concat(out);
11130
+ }
11131
+ function parseLogLine(line, service) {
11132
+ const trimmed = line.replace(/\r$/, "").trim();
11133
+ if (!trimmed) return null;
11134
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
11135
+ try {
11136
+ const obj = JSON.parse(trimmed);
11137
+ const level = pinoLevelToLevel(obj["level"]);
11138
+ const ts = pickTimestamp(obj["time"] ?? obj["ts"] ?? obj["@timestamp"]);
11139
+ const msg = pickMsg(obj["msg"] ?? obj["message"] ?? obj["text"]) ?? trimmed;
11140
+ return { ts, service, level, msg, raw: trimmed };
11141
+ } catch {
11142
+ }
11143
+ }
11144
+ const levelMatch = trimmed.match(
11145
+ /^\s*\[?(DEBUG|INFO|WARN|WARNING|ERROR|ERR|FATAL)\]?[:\s]+(.*)$/i
11146
+ );
11147
+ if (levelMatch && levelMatch[1] !== void 0) {
11148
+ const lvl = levelMatch[1].toUpperCase();
11149
+ const msg = levelMatch[2] ?? "";
11150
+ return {
11151
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
11152
+ service,
11153
+ level: textLevelToLevel(lvl),
11154
+ msg,
11155
+ raw: trimmed
11156
+ };
11157
+ }
11158
+ return {
11159
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
11160
+ service,
11161
+ level: "info",
11162
+ msg: trimmed,
11163
+ raw: trimmed
11164
+ };
11165
+ }
11166
+ function pinoLevelToLevel(raw) {
11167
+ if (typeof raw === "number") {
11168
+ if (raw >= 50) return "error";
11169
+ if (raw >= 40) return "warn";
11170
+ if (raw >= 30) return "info";
11171
+ return "debug";
11172
+ }
11173
+ if (typeof raw === "string") {
11174
+ return textLevelToLevel(raw.toUpperCase());
11175
+ }
11176
+ return "info";
11177
+ }
11178
+ function textLevelToLevel(upper) {
11179
+ switch (upper) {
11180
+ case "DEBUG":
11181
+ case "TRACE":
11182
+ return "debug";
11183
+ case "WARN":
11184
+ case "WARNING":
11185
+ return "warn";
11186
+ case "ERR":
11187
+ case "ERROR":
11188
+ case "FATAL":
11189
+ case "CRITICAL":
11190
+ return "error";
11191
+ case "INFO":
11192
+ default:
11193
+ return "info";
11194
+ }
11195
+ }
11196
+ function pickTimestamp(value) {
11197
+ if (typeof value === "number" && Number.isFinite(value)) {
11198
+ return new Date(value).toISOString();
11199
+ }
11200
+ if (typeof value === "string") {
11201
+ const d = new Date(value);
11202
+ if (!Number.isNaN(d.getTime())) return d.toISOString();
11203
+ }
11204
+ return (/* @__PURE__ */ new Date()).toISOString();
11205
+ }
11206
+ function pickMsg(value) {
11207
+ if (typeof value === "string") return value;
11208
+ if (value == null) return null;
11209
+ try {
11210
+ return JSON.stringify(value);
11211
+ } catch {
11212
+ return String(value);
11213
+ }
11214
+ }
11215
+ var LineSplitter = class {
11216
+ buffer = "";
11217
+ push(chunk) {
11218
+ this.buffer += stripDockerFrame(chunk).toString("utf8");
11219
+ const lines = this.buffer.split("\n");
11220
+ this.buffer = lines.pop() ?? "";
11221
+ return lines;
11222
+ }
11223
+ flush() {
11224
+ if (!this.buffer) return [];
11225
+ const out = [this.buffer];
11226
+ this.buffer = "";
11227
+ return out;
11228
+ }
11229
+ };
11230
+ function serviceFromContainerName(name) {
11231
+ const clean = name.replace(/^\//, "");
11232
+ if (!clean.startsWith(CONTAINER_PREFIX)) return null;
11233
+ const suffix = clean.slice(CONTAINER_PREFIX.length);
11234
+ for (const svc of LOG_SERVICES) {
11235
+ if (suffix === svc || suffix.startsWith(`${svc}-`) || suffix.includes(`-${svc}-`) || suffix.endsWith(`-${svc}`)) {
11236
+ return svc;
11237
+ }
11238
+ }
11239
+ return null;
11240
+ }
11241
+ async function* tailContainerLogs(docker, containerName, service, opts = {}) {
11242
+ const tail = opts.tail ?? 50;
11243
+ const container = docker.getContainer(containerName);
11244
+ const stream = await container.logs({
11245
+ follow: true,
11246
+ stdout: true,
11247
+ stderr: true,
11248
+ tail,
11249
+ timestamps: false
11250
+ });
11251
+ const splitter = new LineSplitter();
11252
+ const queue = [];
11253
+ let waiter = null;
11254
+ let done = false;
11255
+ let err = null;
11256
+ function wake() {
11257
+ if (waiter) {
11258
+ const w = waiter;
11259
+ waiter = null;
11260
+ w();
11261
+ }
11262
+ }
11263
+ stream.on("data", (chunk) => {
11264
+ for (const line of splitter.push(chunk)) {
11265
+ const evt = parseLogLine(line, service);
11266
+ if (evt) queue.push(evt);
11267
+ }
11268
+ wake();
11269
+ });
11270
+ stream.on("end", () => {
11271
+ for (const line of splitter.flush()) {
11272
+ const evt = parseLogLine(line, service);
11273
+ if (evt) queue.push(evt);
11274
+ }
11275
+ done = true;
11276
+ wake();
11277
+ });
11278
+ stream.on("error", (e) => {
11279
+ err = e;
11280
+ done = true;
11281
+ wake();
11282
+ });
11283
+ if (opts.signal) {
11284
+ if (opts.signal.aborted) {
11285
+ try {
11286
+ stream.destroy();
11287
+ } catch {
11288
+ }
11289
+ done = true;
11290
+ } else {
11291
+ opts.signal.addEventListener(
11292
+ "abort",
11293
+ () => {
11294
+ try {
11295
+ stream.destroy();
11296
+ } catch {
11297
+ }
11298
+ done = true;
11299
+ wake();
11300
+ },
11301
+ { once: true }
11302
+ );
11303
+ }
11304
+ }
11305
+ while (true) {
11306
+ const next = queue.shift();
11307
+ if (next !== void 0) {
11308
+ yield next;
11309
+ continue;
11310
+ }
11311
+ if (done) break;
11312
+ await new Promise((resolve2) => {
11313
+ waiter = resolve2;
11314
+ });
11315
+ }
11316
+ if (err) throw err;
11317
+ }
11318
+
11319
+ // src/api/routes/logs.ts
11320
+ var HEARTBEAT_INTERVAL_MS2 = 15e3;
11321
+ async function listTownhouseContainers(docker) {
11322
+ const containers = await docker.listContainers({ all: false });
11323
+ const out = [];
11324
+ for (const c of containers) {
11325
+ for (const rawName of c.Names) {
11326
+ const name = rawName.startsWith("/") ? rawName.slice(1) : rawName;
11327
+ const service = serviceFromContainerName(name);
11328
+ if (service) {
11329
+ out.push({ name, service });
11330
+ break;
11331
+ }
11332
+ }
11333
+ }
11334
+ return out;
11335
+ }
11336
+ function registerLogsRoutes(app, _deps, opts = {}) {
11337
+ const docker = opts.docker ?? new Docker();
11338
+ const tailFn = opts.tailFn ?? tailContainerLogs;
11339
+ app.get("/api/logs/stream", async (request, reply) => {
11340
+ await streamLogs(request, reply, docker, tailFn);
11341
+ });
11342
+ }
11343
+ async function streamLogs(request, reply, docker, tailFn) {
11344
+ const raw = reply.raw;
11345
+ raw.statusCode = 200;
11346
+ raw.setHeader("Content-Type", "text/event-stream");
11347
+ raw.setHeader("Cache-Control", "no-cache, no-transform");
11348
+ raw.setHeader("Connection", "keep-alive");
11349
+ raw.setHeader("X-Accel-Buffering", "no");
11350
+ raw.flushHeaders?.();
11351
+ const controller = new AbortController();
11352
+ const heartbeat = setInterval(() => {
11353
+ if (raw.writableEnded) return;
11354
+ try {
11355
+ raw.write(`: heartbeat ${Date.now()}
11356
+
11357
+ `);
11358
+ } catch {
11359
+ }
11360
+ }, HEARTBEAT_INTERVAL_MS2);
11361
+ function teardown() {
11362
+ clearInterval(heartbeat);
11363
+ controller.abort();
11364
+ }
11365
+ request.raw.on("close", teardown);
11366
+ request.raw.on("error", teardown);
11367
+ let containers;
11368
+ try {
11369
+ containers = await listTownhouseContainers(docker);
11370
+ } catch (err) {
11371
+ const msg = err instanceof Error ? err.message : String(err);
11372
+ writeEvent(raw, {
11373
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
11374
+ service: "connector",
11375
+ level: "error",
11376
+ msg: `log-tail: docker unavailable (${msg})`
11377
+ });
11378
+ teardown();
11379
+ raw.end();
11380
+ return;
11381
+ }
11382
+ if (containers.length === 0) {
11383
+ writeEvent(raw, {
11384
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
11385
+ service: "connector",
11386
+ level: "warn",
11387
+ msg: "log-tail: no townhouse containers running"
11388
+ });
11389
+ }
11390
+ const tasks = containers.map(async (c) => {
11391
+ try {
11392
+ for await (const evt of tailFn(docker, c.name, c.service, {
11393
+ signal: controller.signal,
11394
+ tail: 50
11395
+ })) {
11396
+ if (raw.writableEnded) break;
11397
+ writeEvent(raw, evt);
11398
+ }
11399
+ } catch (err) {
11400
+ if (controller.signal.aborted) return;
11401
+ const msg = err instanceof Error ? err.message : String(err);
11402
+ writeEvent(raw, {
11403
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
11404
+ service: c.service,
11405
+ level: "error",
11406
+ msg: `log-tail: ${c.name} stream error (${msg})`
11407
+ });
11408
+ }
11409
+ });
11410
+ await Promise.allSettled(tasks);
11411
+ teardown();
11412
+ if (!raw.writableEnded) {
11413
+ raw.end();
11414
+ }
11415
+ }
11416
+ function writeEvent(raw, evt) {
11417
+ if (raw.writableEnded) return;
11418
+ try {
11419
+ raw.write(`data: ${JSON.stringify(evt)}
11420
+
11421
+ `);
11422
+ } catch {
11423
+ }
11424
+ }
11425
+
8017
11426
  // src/api/server.ts
8018
11427
  async function createApiServer(deps) {
8019
11428
  const { config, logger } = deps;
11429
+ const snapshotPath = join8(
11430
+ dirname11(deps.configPath),
11431
+ "earnings-snapshots.jsonl"
11432
+ );
11433
+ const snapshotWriter = new SnapshotWriter({
11434
+ connectorAdmin: deps.connectorAdmin,
11435
+ snapshotPath,
11436
+ logger
11437
+ });
8020
11438
  const app = await buildFastifyApp({
8021
11439
  logger: logger ?? true,
8022
11440
  bindHost: config.api.host ?? "127.0.0.1"
@@ -8036,9 +11454,17 @@ async function createApiServer(deps) {
8036
11454
  registerWalletRevealRoutes(app, deps);
8037
11455
  registerWalletWithdrawRoutes(app, deps);
8038
11456
  registerConfigPatchRoutes(app, deps);
11457
+ registerNodeLifecycleRoutes(app, deps);
11458
+ registerEarningsRoutes(app, deps);
11459
+ registerLogsRoutes(app, deps);
8039
11460
  registerMetricsWsRoutes(app, deps);
11461
+ snapshotWriter.start();
8040
11462
  const CLOSE_TIMEOUT_MS2 = 5e3;
8041
11463
  async function close() {
11464
+ try {
11465
+ snapshotWriter.stop();
11466
+ } catch {
11467
+ }
8042
11468
  try {
8043
11469
  deps.transportProbe.stop();
8044
11470
  } catch {
@@ -8055,7 +11481,7 @@ async function createApiServer(deps) {
8055
11481
  openSockets.clear();
8056
11482
  await Promise.race([
8057
11483
  app.close(),
8058
- new Promise((resolve) => setTimeout(resolve, CLOSE_TIMEOUT_MS2))
11484
+ new Promise((resolve2) => setTimeout(resolve2, CLOSE_TIMEOUT_MS2))
8059
11485
  ]);
8060
11486
  }
8061
11487
  return { app, close };
@@ -8246,7 +11672,7 @@ async function createWizardApiServer(initialDeps) {
8246
11672
  state.progressSockets.clear();
8247
11673
  await Promise.race([
8248
11674
  app.close(),
8249
- new Promise((resolve) => setTimeout(resolve, CLOSE_TIMEOUT_MS))
11675
+ new Promise((resolve2) => setTimeout(resolve2, CLOSE_TIMEOUT_MS))
8250
11676
  ]);
8251
11677
  }
8252
11678
  return { app, close };
@@ -8260,15 +11686,38 @@ export {
8260
11686
  saveConfig,
8261
11687
  DEFAULT_ATOR_PROXY,
8262
11688
  ConnectorConfigGenerator,
8263
- DockerOrchestrator,
8264
11689
  ConnectorAdminClient,
11690
+ OrchestratorError,
11691
+ DockerOrchestrator,
8265
11692
  TransportProbe,
11693
+ writeHsConnectorConfig,
11694
+ ComposeLoaderError,
11695
+ loadComposeTemplate,
11696
+ materializeComposeTemplate,
11697
+ NodesYamlEntrySchema,
11698
+ NodesYamlSchema,
11699
+ readNodesYaml,
11700
+ writeNodesYaml,
11701
+ BootReconciler,
11702
+ SnapshotWriter,
8266
11703
  saveWallet,
8267
11704
  loadWallet,
8268
11705
  encryptWallet,
8269
11706
  decryptWallet,
11707
+ ImageManifestSchema,
11708
+ isSyntheticDigest,
11709
+ readImageManifest,
8270
11710
  WalletManager,
11711
+ aggregateEarnings,
11712
+ utcDayBoundary,
11713
+ utcMonthBoundary,
11714
+ utcYearBoundary,
11715
+ createDeltaComputer,
11716
+ PeerTypeResolver,
11717
+ LOG_SERVICES,
11718
+ serviceFromContainerName,
11719
+ tailContainerLogs,
8271
11720
  createApiServer,
8272
11721
  createWizardApiServer
8273
11722
  };
8274
- //# sourceMappingURL=chunk-IB6TNCUQ.js.map
11723
+ //# sourceMappingURL=chunk-4WCMVIO4.js.map