@youam/sdk 0.2.0 → 0.2.1

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.
@@ -114,14 +114,18 @@ var init_dns_verifier = __esm({
114
114
  // src/cli/index.ts
115
115
  var import_commander = require("commander");
116
116
 
117
+ // src/version.ts
118
+ var SDK_VERSION = "0.2.1";
119
+
117
120
  // src/cli/commands/init.ts
118
121
  var import_node_os5 = require("os");
119
- var import_node_fs5 = require("fs");
120
- var import_node_path5 = require("path");
122
+ var import_node_fs6 = require("fs");
123
+ var import_node_path7 = require("path");
121
124
 
122
125
  // src/protocol/types.ts
123
126
  var UAM_VERSION = "0.1";
124
127
  var MAX_ENVELOPE_SIZE = 65536;
128
+ var MAX_ENVELOPE_AGE = 300;
125
129
  function b64Encode(data) {
126
130
  return Buffer.from(data).toString("base64url");
127
131
  }
@@ -636,6 +640,9 @@ function verifyContactCard(card) {
636
640
  var import_node_os = require("os");
637
641
  var import_node_path = require("path");
638
642
  var DEFAULT_RELAY_URL = "https://relay.youam.network";
643
+ function trimRelayUrl(url) {
644
+ return url.replace(/\/+$/, "");
645
+ }
639
646
  var VALID_POLICIES = /* @__PURE__ */ new Set([
640
647
  "auto-accept",
641
648
  "approval-required",
@@ -652,9 +659,13 @@ var SDKConfig = class {
652
659
  transportType;
653
660
  trustPolicy;
654
661
  relayDomain;
662
+ /** The domain the caller chose, if any; null when only derived. */
663
+ explicitRelayDomain;
655
664
  constructor(options) {
656
665
  this.name = options.name;
657
- this.relayUrl = options.relayUrl ?? process.env["UAM_RELAY_URL"] ?? DEFAULT_RELAY_URL;
666
+ this.relayUrl = trimRelayUrl(
667
+ options.relayUrl ?? process.env["UAM_RELAY_URL"] ?? DEFAULT_RELAY_URL
668
+ );
658
669
  if (options.relayWsUrl) {
659
670
  this.relayWsUrl = options.relayWsUrl;
660
671
  } else {
@@ -665,6 +676,7 @@ var SDKConfig = class {
665
676
  this.relayWsUrl = wsUrl;
666
677
  }
667
678
  const envDomain = process.env["UAM_RELAY_DOMAIN"];
679
+ this.explicitRelayDomain = envDomain || options.relayDomain || null;
668
680
  if (envDomain) {
669
681
  this.relayDomain = envDomain;
670
682
  } else if (options.relayDomain) {
@@ -768,17 +780,54 @@ var KeyManager = class {
768
780
  (0, import_node_fs.writeFileSync)(tokenPath, token);
769
781
  this._setPermissions(tokenPath);
770
782
  }
783
+ /**
784
+ * Store the address `relayUrl` gave this agent.
785
+ */
786
+ saveAddress(name, address, relayUrl) {
787
+ (0, import_node_fs.mkdirSync)(this._keyDir, { recursive: true });
788
+ (0, import_node_fs.writeFileSync)(
789
+ (0, import_node_path2.join)(this._keyDir, `${name}.address`),
790
+ JSON.stringify({ address, relay_url: relayUrl })
791
+ );
792
+ }
793
+ /**
794
+ * Load the address saved for `relayUrl`.
795
+ *
796
+ * Returns null if there is none: the file is missing, unreadable or
797
+ * malformed, or it belongs to another relay.
798
+ */
799
+ loadAddress(name, relayUrl) {
800
+ try {
801
+ const path = (0, import_node_path2.join)(this._keyDir, `${name}.address`);
802
+ const saved = JSON.parse((0, import_node_fs.readFileSync)(path, "utf-8"));
803
+ const address = saved?.address;
804
+ const savedRelay = saved?.relay_url;
805
+ if (typeof address !== "string" || typeof savedRelay !== "string") {
806
+ return null;
807
+ }
808
+ if (trimRelayUrl(savedRelay) !== trimRelayUrl(relayUrl)) return null;
809
+ parseAddress(address);
810
+ return address;
811
+ } catch {
812
+ return null;
813
+ }
814
+ }
771
815
  /**
772
816
  * Load a previously saved token, or return null.
773
817
  *
774
818
  * Checks UAM_TOKEN env var first, then file-based storage,
775
- * then legacy .api_key files for backward compatibility.
819
+ * then legacy .api_key files for backward compatibility. With `relayUrl`,
820
+ * a saved token that belongs to another relay counts as none, so it is
821
+ * only offered to the relay that issued it.
776
822
  */
777
- loadToken(name) {
823
+ loadToken(name, relayUrl) {
778
824
  const envToken = process.env["UAM_TOKEN"];
779
825
  if (envToken) {
780
826
  return envToken.trim();
781
827
  }
828
+ if (relayUrl !== void 0 && this._savedForAnotherRelay(name, relayUrl)) {
829
+ return null;
830
+ }
782
831
  const tokenPath = (0, import_node_path2.join)(this._keyDir, `${name}.token`);
783
832
  if ((0, import_node_fs2.existsSync)(tokenPath)) {
784
833
  return (0, import_node_fs.readFileSync)(tokenPath, "utf-8").trim();
@@ -789,6 +838,20 @@ var KeyManager = class {
789
838
  }
790
839
  return null;
791
840
  }
841
+ /**
842
+ * True if the saved address says this agent's files are another relay's.
843
+ * A missing or damaged file says nothing.
844
+ */
845
+ _savedForAnotherRelay(name, relayUrl) {
846
+ try {
847
+ const path = (0, import_node_path2.join)(this._keyDir, `${name}.address`);
848
+ const saved = JSON.parse((0, import_node_fs.readFileSync)(path, "utf-8"));
849
+ const savedRelay = saved?.relay_url;
850
+ return typeof savedRelay === "string" && trimRelayUrl(savedRelay) !== trimRelayUrl(relayUrl);
851
+ } catch {
852
+ return false;
853
+ }
854
+ }
792
855
  /** Set file permissions to 0o600 (owner read/write only). */
793
856
  _setPermissions(path) {
794
857
  if ((0, import_node_os3.platform)() !== "win32") {
@@ -812,10 +875,48 @@ var KeyManager = class {
812
875
  }
813
876
  };
814
877
 
878
+ // src/sdk/agent.ts
879
+ var import_node_path5 = require("path");
880
+
881
+ // src/sdk/freshness.ts
882
+ var RELAY_META_FEATURE = "relay_meta";
883
+ var STORED_MAIL_MAX_AGE = 7 * 86400;
884
+ var ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
885
+ function relayVouches(raw, signedAt, now, relayDomain) {
886
+ const meta = raw["relay_meta"];
887
+ if (typeof meta !== "object" || meta === null || Array.isArray(meta)) {
888
+ return false;
889
+ }
890
+ const stamp = meta;
891
+ if (stamp["v"] !== 1 || stamp["relay"] !== relayDomain) return false;
892
+ if (stamp["stored"] !== true) return false;
893
+ const received = stamp["received_at"];
894
+ if (typeof received !== "string" || !ISO_TIME.test(received)) return false;
895
+ const receivedAt = Date.parse(received);
896
+ if (Number.isNaN(receivedAt)) return false;
897
+ const window = MAX_ENVELOPE_AGE * 1e3;
898
+ const age = now - signedAt;
899
+ return Math.abs(receivedAt - signedAt) <= window && age >= 0 && age <= STORED_MAIL_MAX_AGE * 1e3 + window;
900
+ }
901
+
815
902
  // src/sdk/contact-book.ts
816
903
  var import_node_fs3 = require("fs");
817
904
  var import_node_path3 = require("path");
818
905
  var import_better_sqlite3 = __toESM(require("better-sqlite3"), 1);
906
+
907
+ // src/sdk/log.ts
908
+ var log = {
909
+ warn(message) {
910
+ console.warn(`[uam] ${message}`);
911
+ },
912
+ info(message) {
913
+ if (process.env["UAM_DEBUG"]) {
914
+ console.info(`[uam] ${message}`);
915
+ }
916
+ }
917
+ };
918
+
919
+ // src/sdk/contact-book.ts
819
920
  var ContactBook = class {
820
921
  _dbPath;
821
922
  _db = null;
@@ -936,11 +1037,30 @@ var ContactBook = class {
936
1037
  }
937
1038
  /**
938
1039
  * Add or update a contact (upsert).
1040
+ *
1041
+ * Refuses to replace the key of a contact whose trust_state is "pinned"
1042
+ * or "verified" with a different key, unless `force: true` is passed (for
1043
+ * a deliberate key rotation, which is logged). Matches the Python SDK's
1044
+ * ContactBook.add_contact.
1045
+ *
1046
+ * @throws {KeyPinningError} If a pinned/verified contact's key would be
1047
+ * replaced by a different key without `force`.
939
1048
  */
940
1049
  addContact(address, publicKey, options) {
941
1050
  if (this._db === null) {
942
1051
  throw new Error("ContactBook not open. Call open() first.");
943
1052
  }
1053
+ const existing = this._db.prepare("SELECT public_key, trust_state FROM contacts WHERE address = ?").get(address);
1054
+ if (existing !== void 0 && (existing.trust_state === "pinned" || existing.trust_state === "verified") && existing.public_key !== publicKey) {
1055
+ if (!options?.force) {
1056
+ throw new KeyPinningError(
1057
+ `Refusing to overwrite ${existing.trust_state} contact ${JSON.stringify(address)}: stored public key does not match the new key. Pass force: true to rotate.`
1058
+ );
1059
+ }
1060
+ log.warn(
1061
+ `Force-replacing the key of ${existing.trust_state} contact ${address} (key rotation)`
1062
+ );
1063
+ }
944
1064
  const displayName = options?.displayName ?? null;
945
1065
  const trustState = options?.trustState ?? "trusted";
946
1066
  const trustSource = options?.trustSource ?? null;
@@ -1113,6 +1233,18 @@ var ContactBook = class {
1113
1233
  };
1114
1234
 
1115
1235
  // src/sdk/handshake.ts
1236
+ function bindCardToEnvelope(card, envelope, senderVk) {
1237
+ if (card.address !== envelope.fromAddress) {
1238
+ throw new InvalidEnvelopeError(
1239
+ `Contact card address ${JSON.stringify(card.address)} does not match envelope sender ${JSON.stringify(envelope.fromAddress)}`
1240
+ );
1241
+ }
1242
+ if (card.publicKey !== serializeVerifyKey(senderVk)) {
1243
+ throw new InvalidEnvelopeError(
1244
+ "Contact card public_key does not match envelope sender's verify key"
1245
+ );
1246
+ }
1247
+ }
1116
1248
  var HandshakeManager = class {
1117
1249
  _contactBook;
1118
1250
  _trustPolicy;
@@ -1155,7 +1287,7 @@ var HandshakeManager = class {
1155
1287
  if (envelope.type === "handshake.request" /* HANDSHAKE_REQUEST */) {
1156
1288
  await this._handleRequest(agent, envelope, senderVk);
1157
1289
  } else if (envelope.type === "handshake.accept" /* HANDSHAKE_ACCEPT */) {
1158
- await this._handleAccept(envelope, senderVk);
1290
+ await this._handleAccept(agent, envelope, senderVk);
1159
1291
  } else if (envelope.type === "handshake.deny" /* HANDSHAKE_DENY */) {
1160
1292
  }
1161
1293
  return null;
@@ -1173,6 +1305,7 @@ var HandshakeManager = class {
1173
1305
  );
1174
1306
  const card = contactCardFromDict(cardDict);
1175
1307
  verifyContactCard(card);
1308
+ bindCardToEnvelope(card, envelope, senderVk);
1176
1309
  if (this._trustPolicy === "auto-accept") {
1177
1310
  this._contactBook.addContact(card.address, card.publicKey, {
1178
1311
  displayName: card.displayName,
@@ -1191,8 +1324,46 @@ var HandshakeManager = class {
1191
1324
  }
1192
1325
  /**
1193
1326
  * Process a handshake.accept: store the sender as pinned (TOFU).
1327
+ *
1328
+ * Only honoured when:
1329
+ * 1. we previously sent this address a handshake.request
1330
+ * (trust_state === "handshake-sent"); anything else is an unsolicited
1331
+ * accept and is dropped. This also means an accept can never
1332
+ * downgrade a trusted/verified contact to pinned;
1333
+ * 2. the payload decrypts with our key and the sender's key, and parses
1334
+ * as JSON. Otherwise it is dropped without pinning;
1335
+ * 3. an embedded contact_card, if present, is well-formed, correctly
1336
+ * self-signed, and bound to the envelope sender (throws otherwise).
1194
1337
  */
1195
- async _handleAccept(envelope, senderVk) {
1338
+ async _handleAccept(agent, envelope, senderVk) {
1339
+ const prior = this._contactBook.getTrustState(envelope.fromAddress);
1340
+ if (prior !== "handshake-sent") {
1341
+ log.warn(
1342
+ `Ignoring unsolicited handshake.accept from ${envelope.fromAddress} (trust_state=${JSON.stringify(prior)})`
1343
+ );
1344
+ return;
1345
+ }
1346
+ let payload;
1347
+ try {
1348
+ const plaintext = decryptPayload(
1349
+ envelope.payload,
1350
+ agent._keyManager.signingKey,
1351
+ senderVk
1352
+ );
1353
+ payload = JSON.parse(new TextDecoder().decode(plaintext));
1354
+ } catch {
1355
+ log.warn(
1356
+ `Dropping handshake.accept ${envelope.messageId} from ${envelope.fromAddress}: payload could not be decrypted or parsed`
1357
+ );
1358
+ return;
1359
+ }
1360
+ if (payload !== null && typeof payload === "object" && "contact_card" in payload) {
1361
+ const card = contactCardFromDict(
1362
+ payload["contact_card"]
1363
+ );
1364
+ verifyContactCard(card);
1365
+ bindCardToEnvelope(card, envelope, senderVk);
1366
+ }
1196
1367
  const senderPkStr = serializeVerifyKey(senderVk);
1197
1368
  this._contactBook.addContact(envelope.fromAddress, senderPkStr, {
1198
1369
  trustState: "pinned"
@@ -1249,6 +1420,10 @@ var HandshakeManager = class {
1249
1420
  }
1250
1421
  };
1251
1422
 
1423
+ // src/sdk/resolver-base.ts
1424
+ var AddressResolver = class {
1425
+ };
1426
+
1252
1427
  // src/sdk/tier3.ts
1253
1428
  var import_viem = require("viem");
1254
1429
  var DEFAULT_RPC_URL = "https://sepolia.base.org";
@@ -1365,8 +1540,6 @@ var Tier3Resolver = class extends AddressResolver {
1365
1540
 
1366
1541
  // src/sdk/resolver.ts
1367
1542
  init_dns_verifier();
1368
- var AddressResolver = class {
1369
- };
1370
1543
  var Tier1Resolver = class extends AddressResolver {
1371
1544
  async resolvePublicKey(address, _token, relayUrl) {
1372
1545
  const resp = await fetch(
@@ -1403,13 +1576,13 @@ var Tier2Resolver = class extends AddressResolver {
1403
1576
  }
1404
1577
  };
1405
1578
  var SmartResolver = class extends AddressResolver {
1406
- _relayDomain;
1579
+ relayDomain;
1407
1580
  _tier1;
1408
1581
  _tier2;
1409
1582
  _tier3;
1410
1583
  constructor(relayDomain, tier3Config) {
1411
1584
  super();
1412
- this._relayDomain = relayDomain;
1585
+ this.relayDomain = relayDomain;
1413
1586
  this._tier1 = new Tier1Resolver();
1414
1587
  this._tier2 = new Tier2Resolver();
1415
1588
  this._tier3 = tier3Config ? new Tier3Resolver(tier3Config) : null;
@@ -1417,7 +1590,7 @@ var SmartResolver = class extends AddressResolver {
1417
1590
  async resolvePublicKey(address, token, relayUrl) {
1418
1591
  const parsed = parseAddress(address);
1419
1592
  const domain = parsed.domain;
1420
- if (domain === this._relayDomain) {
1593
+ if (domain === this.relayDomain) {
1421
1594
  return this._tier1.resolvePublicKey(address, token, relayUrl);
1422
1595
  }
1423
1596
  if (domain.includes(".")) {
@@ -1496,8 +1669,10 @@ var import_ws = __toESM(require("ws"), 1);
1496
1669
  var BASE_DELAY = 1;
1497
1670
  var MAX_DELAY = 60;
1498
1671
  var JITTER_RANGE = 1;
1672
+ var SEND_SETTLE_SECONDS = 5;
1499
1673
  var WebSocketTransport = class extends TransportBase {
1500
1674
  _url;
1675
+ _token;
1501
1676
  _onMessage;
1502
1677
  _ws = null;
1503
1678
  _pending = [];
@@ -1506,9 +1681,16 @@ var WebSocketTransport = class extends TransportBase {
1506
1681
  _connectReject = null;
1507
1682
  _running = false;
1508
1683
  _reconnectTimeout = null;
1684
+ /** Envelopes sent that the relay hasn't answered yet. */
1685
+ _unanswered = 0;
1686
+ _settling = null;
1687
+ _settled = null;
1688
+ /** @internal How long a close waits, in ms. Replaceable in tests. */
1689
+ _settleMs = SEND_SETTLE_SECONDS * 1e3;
1509
1690
  constructor(wsUrl, token, onMessage) {
1510
1691
  super();
1511
- this._url = `${wsUrl}?token=${token}`;
1692
+ this._url = wsUrl;
1693
+ this._token = token;
1512
1694
  this._onMessage = onMessage ?? null;
1513
1695
  }
1514
1696
  async connect() {
@@ -1529,7 +1711,14 @@ var WebSocketTransport = class extends TransportBase {
1529
1711
  };
1530
1712
  });
1531
1713
  }
1714
+ /**
1715
+ * Close the connection, once the relay has answered what was sent.
1716
+ *
1717
+ * The relay stops reading a socket when it can't write an ack to it, so
1718
+ * closing straight after send() loses every frame but the first.
1719
+ */
1532
1720
  async disconnect() {
1721
+ await this._allAnswered();
1533
1722
  this._running = false;
1534
1723
  if (this._reconnectTimeout !== null) {
1535
1724
  clearTimeout(this._reconnectTimeout);
@@ -1547,6 +1736,7 @@ var WebSocketTransport = class extends TransportBase {
1547
1736
  throw new Error("WebSocket not connected");
1548
1737
  }
1549
1738
  this._ws.send(JSON.stringify(envelope));
1739
+ this._unanswered += 1;
1550
1740
  }
1551
1741
  async receive(limit = 50) {
1552
1742
  const result = this._pending.slice(0, limit);
@@ -1556,11 +1746,32 @@ var WebSocketTransport = class extends TransportBase {
1556
1746
  async listen(callback) {
1557
1747
  this._onMessage = callback;
1558
1748
  }
1749
+ /** Resolves when nothing is unanswered, or after _settleMs regardless. */
1750
+ _allAnswered() {
1751
+ if (this._unanswered === 0) return Promise.resolve();
1752
+ this._settling ??= new Promise((resolve) => {
1753
+ const timeout = setTimeout(() => {
1754
+ log.warn(`Closing with ${this._unanswered} sends unanswered`);
1755
+ this._settled?.();
1756
+ }, this._settleMs);
1757
+ this._settled = () => {
1758
+ clearTimeout(timeout);
1759
+ this._settling = null;
1760
+ this._settled = null;
1761
+ resolve();
1762
+ };
1763
+ });
1764
+ return this._settling;
1765
+ }
1766
+ _answered() {
1767
+ this._unanswered = Math.max(0, this._unanswered - 1);
1768
+ if (this._unanswered === 0) this._settled?.();
1769
+ }
1559
1770
  _startConnectionLoop() {
1560
1771
  let attempt = 0;
1561
1772
  const tryConnect = () => {
1562
1773
  if (!this._running) return;
1563
- const ws = new import_ws.default(this._url);
1774
+ const ws = new import_ws.default(this._url, [`bearer.${this._token}`]);
1564
1775
  this._ws = ws;
1565
1776
  ws.on("open", () => {
1566
1777
  this._connected = true;
@@ -1581,6 +1792,8 @@ var WebSocketTransport = class extends TransportBase {
1581
1792
  ws.on("close", () => {
1582
1793
  this._connected = false;
1583
1794
  this._ws = null;
1795
+ this._unanswered = 0;
1796
+ this._settled?.();
1584
1797
  if (!this._running) return;
1585
1798
  attempt += 1;
1586
1799
  const delay = Math.min(BASE_DELAY * Math.pow(2, attempt), MAX_DELAY);
@@ -1597,19 +1810,21 @@ var WebSocketTransport = class extends TransportBase {
1597
1810
  }
1598
1811
  _handleMessage(msg) {
1599
1812
  const msgType = msg["type"];
1600
- if (msgType === "ping") {
1601
- if (this._ws && this._ws.readyState === import_ws.default.OPEN) {
1602
- this._ws.send(JSON.stringify({ type: "pong" }));
1603
- }
1604
- } else if (msgType === "ack") {
1605
- } else if (msgType === "error" || "error" in msg) {
1606
- } else if ("uam_version" in msg) {
1813
+ if ("uam_version" in msg) {
1607
1814
  if (this._onMessage) {
1608
1815
  this._onMessage(msg).catch(() => {
1609
1816
  });
1610
1817
  } else {
1611
1818
  this._pending.push(msg);
1612
1819
  }
1820
+ } else if (msgType === "ping") {
1821
+ if (this._ws && this._ws.readyState === import_ws.default.OPEN) {
1822
+ this._ws.send(JSON.stringify({ type: "pong" }));
1823
+ }
1824
+ } else if (msgType === "ack") {
1825
+ this._answered();
1826
+ } else if (msgType === "error" || "error" in msg) {
1827
+ this._answered();
1613
1828
  }
1614
1829
  }
1615
1830
  };
@@ -1623,7 +1838,239 @@ function createTransport(config, token, address, onMessage) {
1623
1838
  }
1624
1839
  }
1625
1840
 
1841
+ // src/sdk/replay-cache.ts
1842
+ var import_node_fs4 = require("fs");
1843
+ var import_node_path4 = require("path");
1844
+ var import_promises2 = require("timers/promises");
1845
+ var import_better_sqlite32 = __toESM(require("better-sqlite3"), 1);
1846
+ var RETENTION_SECONDS = STORED_MAIL_MAX_AGE + 2 * MAX_ENVELOPE_AGE;
1847
+ var PURGE_INTERVAL_MS = 6e4;
1848
+ var WAL_ATTEMPTS = 100;
1849
+ var WAL_RETRY_MS = 100;
1850
+ var SCHEMA = `
1851
+ CREATE TABLE IF NOT EXISTS seen_envelopes (
1852
+ from_address TEXT NOT NULL,
1853
+ message_id TEXT NOT NULL,
1854
+ seen_at REAL NOT NULL,
1855
+ PRIMARY KEY (from_address, message_id)
1856
+ ) WITHOUT ROWID;
1857
+ CREATE INDEX IF NOT EXISTS seen_envelopes_seen_at ON seen_envelopes (seen_at);
1858
+ CREATE TABLE IF NOT EXISTS replay_meta (
1859
+ key TEXT PRIMARY KEY,
1860
+ value TEXT NOT NULL
1861
+ );
1862
+ `;
1863
+ function isLocked(err) {
1864
+ const code = err.code;
1865
+ return typeof code === "string" && (code.startsWith("SQLITE_BUSY") || code.startsWith("SQLITE_LOCKED"));
1866
+ }
1867
+ async function enableWal(db) {
1868
+ for (let attempt = 1; ; attempt += 1) {
1869
+ try {
1870
+ db.pragma("journal_mode = WAL");
1871
+ return;
1872
+ } catch (err) {
1873
+ if (attempt >= WAL_ATTEMPTS || !isLocked(err)) throw err;
1874
+ await (0, import_promises2.setTimeout)(WAL_RETRY_MS);
1875
+ }
1876
+ }
1877
+ }
1878
+ var DEFAULT_REPLAY_CACHE_CAPACITY = 1e6;
1879
+ var EnvelopeReplayCache = class {
1880
+ capacity;
1881
+ ttlMs;
1882
+ _now;
1883
+ // Map iteration order is insertion order: first key = least recently used.
1884
+ _seen = /* @__PURE__ */ new Map();
1885
+ constructor(options) {
1886
+ const capacity = options?.capacity ?? 1e5;
1887
+ if (!Number.isInteger(capacity) || capacity < 1) {
1888
+ throw new RangeError(
1889
+ `Replay cache capacity must be a positive integer, got ${capacity}`
1890
+ );
1891
+ }
1892
+ this.capacity = capacity;
1893
+ this.ttlMs = options?.ttlMs ?? 3e5;
1894
+ this._now = options?.now ?? (() => Date.now());
1895
+ }
1896
+ get size() {
1897
+ return this._seen.size;
1898
+ }
1899
+ /** True if this pair was seen within the TTL. Records nothing. */
1900
+ has(fromAddress, messageId) {
1901
+ const seenAt = this._seen.get(this._key(fromAddress, messageId));
1902
+ return seenAt !== void 0 && this._now() - seenAt < this.ttlMs;
1903
+ }
1904
+ /**
1905
+ * Return true if this (fromAddress, messageId) pair was already seen
1906
+ * within the TTL (a replay). Otherwise record it and return false.
1907
+ */
1908
+ seenOrRecord(fromAddress, messageId) {
1909
+ const key = this._key(fromAddress, messageId);
1910
+ const now = this._now();
1911
+ const seenAt = this._seen.get(key);
1912
+ if (seenAt !== void 0 && now - seenAt < this.ttlMs) {
1913
+ this._seen.delete(key);
1914
+ this._seen.set(key, seenAt);
1915
+ return true;
1916
+ }
1917
+ this._seen.delete(key);
1918
+ this._seen.set(key, now);
1919
+ while (this._seen.size > this.capacity) {
1920
+ const oldest = this._seen.keys().next().value;
1921
+ this._seen.delete(oldest);
1922
+ }
1923
+ return false;
1924
+ }
1925
+ _key(fromAddress, messageId) {
1926
+ return JSON.stringify([fromAddress, messageId]);
1927
+ }
1928
+ };
1929
+ var PersistentReplayCache = class {
1930
+ capacity;
1931
+ _path;
1932
+ _now;
1933
+ _db = null;
1934
+ _opening = null;
1935
+ _count = 0;
1936
+ _lastPurge = 0;
1937
+ _createdAt = 0;
1938
+ // Used only while the file is full, for envelopes inside MAX_ENVELOPE_AGE.
1939
+ // One can be signed that far ahead of us and stay acceptable until that far
1940
+ // behind, so its id must last both.
1941
+ _overflow;
1942
+ /**
1943
+ * @param path - The SQLite file
1944
+ * @param options.now - The clock, in ms since the epoch
1945
+ */
1946
+ constructor(path, options) {
1947
+ const capacity = options?.capacity ?? DEFAULT_REPLAY_CACHE_CAPACITY;
1948
+ if (!Number.isInteger(capacity) || capacity < 1) {
1949
+ throw new RangeError(
1950
+ `Replay cache capacity must be a positive integer, got ${capacity}`
1951
+ );
1952
+ }
1953
+ this.capacity = capacity;
1954
+ this._path = path;
1955
+ this._now = options?.now ?? (() => Date.now());
1956
+ this._overflow = new EnvelopeReplayCache({
1957
+ capacity: 1e4,
1958
+ ttlMs: 2 * MAX_ENVELOPE_AGE * 1e3,
1959
+ now: this._now
1960
+ });
1961
+ }
1962
+ /** Open the file now, rather than on first use. */
1963
+ async open() {
1964
+ await this._open();
1965
+ }
1966
+ /**
1967
+ * Return true if the envelope must be dropped as a replay. Otherwise record
1968
+ * the pair and return false.
1969
+ *
1970
+ * @param signedAt - The envelope's signed time, in ms since the epoch. It
1971
+ * matters only when the cache is full.
1972
+ */
1973
+ async seenOrRecord(fromAddress, messageId, signedAt) {
1974
+ const db = await this._open();
1975
+ const now = this._now();
1976
+ if (now - this._lastPurge >= PURGE_INTERVAL_MS) this._purge(db);
1977
+ if (this._overflow.has(fromAddress, messageId)) return true;
1978
+ if (this._count < this.capacity) {
1979
+ const { changes } = db.prepare(
1980
+ "INSERT OR IGNORE INTO seen_envelopes (from_address, message_id, seen_at) VALUES (?, ?, ?)"
1981
+ ).run(fromAddress, messageId, now / 1e3);
1982
+ if (changes === 0) return true;
1983
+ this._count += 1;
1984
+ return false;
1985
+ }
1986
+ const known = db.prepare(
1987
+ "SELECT 1 FROM seen_envelopes WHERE from_address = ? AND message_id = ?"
1988
+ ).get(fromAddress, messageId);
1989
+ if (known !== void 0) return true;
1990
+ if (Math.abs(now - signedAt) <= MAX_ENVELOPE_AGE * 1e3) {
1991
+ return this._overflow.seenOrRecord(fromAddress, messageId);
1992
+ }
1993
+ log.warn(
1994
+ `Replay cache full (${this._count} ids); refused envelope ${messageId} from ${fromAddress} signed ${Math.round((now - signedAt) / 1e3)}s ago`
1995
+ );
1996
+ return true;
1997
+ }
1998
+ /** When this cache file was first created, in ms since the epoch. */
1999
+ async createdAt() {
2000
+ await this._open();
2001
+ return this._createdAt;
2002
+ }
2003
+ /** True if an envelope signed at `signedAt` (ms) predates this cache. */
2004
+ async signedBeforeCache(signedAt) {
2005
+ return signedAt < await this.createdAt();
2006
+ }
2007
+ /** Number of ids currently held on disk. */
2008
+ async count() {
2009
+ const db = await this._open();
2010
+ return this._countRows(db);
2011
+ }
2012
+ /** Close the file; the next call opens it again. */
2013
+ async close() {
2014
+ await this._opening?.catch(() => void 0);
2015
+ this._db?.close();
2016
+ this._db = null;
2017
+ }
2018
+ _open() {
2019
+ if (this._db !== null) return Promise.resolve(this._db);
2020
+ this._opening ??= this._connect().finally(() => {
2021
+ this._opening = null;
2022
+ });
2023
+ return this._opening;
2024
+ }
2025
+ async _connect() {
2026
+ (0, import_node_fs4.mkdirSync)((0, import_node_path4.dirname)(this._path), { recursive: true });
2027
+ const db = new import_better_sqlite32.default(this._path, { timeout: 3e4 });
2028
+ try {
2029
+ await enableWal(db);
2030
+ db.exec(SCHEMA);
2031
+ db.prepare(
2032
+ "INSERT OR IGNORE INTO replay_meta (key, value) VALUES ('created_at', ?)"
2033
+ ).run(String(this._now() / 1e3));
2034
+ const row = db.prepare("SELECT value FROM replay_meta WHERE key = 'created_at'").get();
2035
+ this._createdAt = Number(row.value) * 1e3;
2036
+ this._purge(db);
2037
+ } catch (err) {
2038
+ db.close();
2039
+ throw err;
2040
+ }
2041
+ this._db = db;
2042
+ return db;
2043
+ }
2044
+ _purge(db) {
2045
+ const now = this._now();
2046
+ db.prepare("DELETE FROM seen_envelopes WHERE seen_at < ?").run(
2047
+ now / 1e3 - RETENTION_SECONDS
2048
+ );
2049
+ this._count = this._countRows(db);
2050
+ this._lastPurge = now;
2051
+ }
2052
+ _countRows(db) {
2053
+ const row = db.prepare("SELECT COUNT(*) AS n FROM seen_envelopes").get();
2054
+ return row.n;
2055
+ }
2056
+ };
2057
+
1626
2058
  // src/sdk/agent.ts
2059
+ var RELAY_NO_MEMORY_MS = 6e4;
2060
+ function replayCacheCapacityFromEnv() {
2061
+ const raw = process.env["UAM_REPLAY_CACHE_CAPACITY"];
2062
+ if (raw === void 0 || raw === "") {
2063
+ return DEFAULT_REPLAY_CACHE_CAPACITY;
2064
+ }
2065
+ const n = Number(raw);
2066
+ if (!Number.isInteger(n) || n < 1) {
2067
+ log.warn(
2068
+ `Ignoring invalid UAM_REPLAY_CACHE_CAPACITY=${JSON.stringify(raw)}; using ${DEFAULT_REPLAY_CACHE_CAPACITY}`
2069
+ );
2070
+ return DEFAULT_REPLAY_CACHE_CAPACITY;
2071
+ }
2072
+ return n;
2073
+ }
1627
2074
  var Agent = class {
1628
2075
  /** @internal */
1629
2076
  _config;
@@ -1640,6 +2087,23 @@ var Agent = class {
1640
2087
  _contactBook;
1641
2088
  /** @internal */
1642
2089
  _handshake;
2090
+ /**
2091
+ * @internal Wall clock in ms since the epoch, used by the inbound
2092
+ * freshness/expiry checks and the replay cache. Replaceable in tests.
2093
+ */
2094
+ _clock = () => Date.now();
2095
+ /**
2096
+ * @internal Inbound replay cache, keyed by (from address, message id).
2097
+ * Kept on disk under the data dir, so it outlives restarts and is shared by
2098
+ * every process using it; capacity from UAM_REPLAY_CACHE_CAPACITY.
2099
+ */
2100
+ _replayCache;
2101
+ /** Whether our relay has said it writes relay_meta. */
2102
+ _relayStamps = false;
2103
+ /** Until when, by _monotonic, to take the relay's last no as still true. */
2104
+ _relayNoUntil = 0;
2105
+ /** @internal Monotonic clock in ms. Replaceable in tests. */
2106
+ _monotonic = () => performance.now();
1643
2107
  /**
1644
2108
  * Create an Agent. No I/O happens here -- call connect() to initialize.
1645
2109
  */
@@ -1661,6 +2125,13 @@ var Agent = class {
1661
2125
  this._contactBook,
1662
2126
  this._config.trustPolicy
1663
2127
  );
2128
+ this._replayCache = new PersistentReplayCache(
2129
+ (0, import_node_path5.join)(this._config.dataDir, "replay", "replay.db"),
2130
+ {
2131
+ capacity: replayCacheCapacityFromEnv(),
2132
+ now: () => this._clock()
2133
+ }
2134
+ );
1664
2135
  }
1665
2136
  // -- Properties ----------------------------------------------------------
1666
2137
  /** The agent's full UAM address (e.g., 'myagent::youam.network'). */
@@ -1674,7 +2145,7 @@ var Agent = class {
1674
2145
  }
1675
2146
  /** UAM SDK version. */
1676
2147
  get version() {
1677
- return "0.1.0";
2148
+ return SDK_VERSION;
1678
2149
  }
1679
2150
  /** The agent's public key (base64-encoded Ed25519 verify key). */
1680
2151
  get publicKey() {
@@ -1711,10 +2182,19 @@ var Agent = class {
1711
2182
  }
1712
2183
  await sodiumReady;
1713
2184
  this._keyManager.loadOrGenerate(this._config.name);
1714
- const storedToken = this._keyManager.loadToken(this._config.name);
2185
+ try {
2186
+ await this._replayCache.open();
2187
+ } catch (err) {
2188
+ const reason = err instanceof Error ? err.message : String(err);
2189
+ log.warn(`Could not open the replay cache: ${reason}`);
2190
+ }
2191
+ const storedToken = this._keyManager.loadToken(
2192
+ this._config.name,
2193
+ this._config.relayUrl
2194
+ );
1715
2195
  if (storedToken) {
1716
2196
  this._token = storedToken;
1717
- this._address = `${this._config.name}::${this._config.relayDomain}`;
2197
+ this._useAddress(await this._storedAddress());
1718
2198
  } else if (this._autoRegister) {
1719
2199
  await this._registerWithRelay();
1720
2200
  } else {
@@ -1737,6 +2217,7 @@ var Agent = class {
1737
2217
  */
1738
2218
  async close() {
1739
2219
  this._contactBook.close();
2220
+ await this._replayCache.close();
1740
2221
  if (this._transport) {
1741
2222
  await this._transport.disconnect();
1742
2223
  }
@@ -1757,7 +2238,8 @@ var Agent = class {
1757
2238
  }
1758
2239
  }
1759
2240
  const recipientVk = await this._resolvePublicKey(toAddress);
1760
- if (!this._contactBook.isKnown(toAddress)) {
2241
+ const trustState = this._contactBook.getTrustState(toAddress);
2242
+ if (trustState !== "pinned" && trustState !== "verified" && trustState !== "trusted" && trustState !== "handshake-sent") {
1761
2243
  await this._initiateHandshake(toAddress, recipientVk);
1762
2244
  }
1763
2245
  const envelope = await createEnvelope(
@@ -1788,7 +2270,10 @@ var Agent = class {
1788
2270
  * Retrieve, decrypt, and verify pending messages.
1789
2271
  *
1790
2272
  * Returns a list of ReceivedMessage data objects.
1791
- * Silently drops messages with invalid signatures or decryption errors.
2273
+ * Drops messages with invalid signatures or decryption errors. A message
2274
+ * that fails processing for any other reason (malformed envelope, bad
2275
+ * handshake payload, ...) is logged and dropped; it never aborts the
2276
+ * rest of the batch.
1792
2277
  */
1793
2278
  async inbox(limit = 50) {
1794
2279
  await this._ensureConnected();
@@ -1796,7 +2281,15 @@ var Agent = class {
1796
2281
  const rawMessages = await this._transport.receive(limit);
1797
2282
  const result = [];
1798
2283
  for (const raw of rawMessages) {
1799
- const msg = await this._processInbound(raw);
2284
+ let msg;
2285
+ try {
2286
+ msg = await this._processInbound(raw);
2287
+ } catch (err) {
2288
+ log.warn(
2289
+ `Dropping inbound message ${String(raw["message_id"])} from ${String(raw["from"])}: ${err instanceof Error ? err.message : String(err)}`
2290
+ );
2291
+ continue;
2292
+ }
1800
2293
  if (msg !== null) {
1801
2294
  result.push(msg);
1802
2295
  await this._sendReadReceipt(msg);
@@ -1826,13 +2319,14 @@ var Agent = class {
1826
2319
  const cardDict = JSON.parse(entry.contactCard);
1827
2320
  const card = contactCardFromDict(cardDict);
1828
2321
  verifyContactCard(card);
2322
+ const senderVk = deserializeVerifyKey(card.publicKey);
2323
+ bindCardToEnvelope(card, { fromAddress: address }, senderVk);
1829
2324
  this._contactBook.addContact(card.address, card.publicKey, {
1830
2325
  displayName: card.displayName,
1831
2326
  trustState: "trusted",
1832
2327
  trustSource: "explicit-approval"
1833
2328
  });
1834
2329
  this._contactBook.removePending(address);
1835
- const senderVk = deserializeVerifyKey(card.publicKey);
1836
2330
  await this._handshake._sendAccept(this, address, senderVk);
1837
2331
  }
1838
2332
  /**
@@ -1969,6 +2463,10 @@ var Agent = class {
1969
2463
  /**
1970
2464
  * Resolve a recipient's public key, checking contact book first.
1971
2465
  *
2466
+ * Pure lookup: never writes to the contact book. The contact row is
2467
+ * created by _initiateHandshake (as "handshake-sent") once the request
2468
+ * has gone out, giving the lifecycle no row -> handshake-sent -> pinned.
2469
+ *
1972
2470
  * TOFU-01: Known contacts always use stored key (zero network I/O).
1973
2471
  * TOFU-03: Race-condition double-check after network resolve.
1974
2472
  * Raises KeyPinningError if a pinned contact's key doesn't match.
@@ -1992,9 +2490,6 @@ var Agent = class {
1992
2490
  );
1993
2491
  }
1994
2492
  }
1995
- this._contactBook.addContact(toAddress, resolvedPkStr, {
1996
- trustState: "provisional"
1997
- });
1998
2493
  return deserializeVerifyKey(resolvedPkStr);
1999
2494
  }
2000
2495
  async _initiateHandshake(toAddress, recipientVk) {
@@ -2041,6 +2536,9 @@ var Agent = class {
2041
2536
  }
2042
2537
  throw err;
2043
2538
  }
2539
+ if (!await this._passesInboundChecks(envelope, raw)) {
2540
+ return null;
2541
+ }
2044
2542
  if (envelope.type === "handshake.request" /* HANDSHAKE_REQUEST */ || envelope.type === "handshake.accept" /* HANDSHAKE_ACCEPT */ || envelope.type === "handshake.deny" /* HANDSHAKE_DENY */) {
2045
2543
  return this._handshake.handleInbound(this, envelope, senderVk);
2046
2544
  }
@@ -2075,6 +2573,102 @@ var Agent = class {
2075
2573
  verified: true
2076
2574
  });
2077
2575
  }
2576
+ /**
2577
+ * Recipient, freshness, expiry and replay checks for a signature-verified
2578
+ * inbound envelope. Returns false (and logs) when it must be dropped.
2579
+ * Matches the Python SDK's _process_inbound checks.
2580
+ */
2581
+ async _passesInboundChecks(envelope, raw) {
2582
+ const id = envelope.messageId;
2583
+ const from = envelope.fromAddress;
2584
+ if (this._address !== null && envelope.toAddress !== this._address) {
2585
+ log.warn(
2586
+ `Dropped envelope ${id} from ${from} addressed to ${envelope.toAddress} (we are ${this._address})`
2587
+ );
2588
+ return false;
2589
+ }
2590
+ const now = this._clock();
2591
+ const ts = Date.parse(envelope.timestamp);
2592
+ if (Number.isNaN(ts)) {
2593
+ log.warn(
2594
+ `Dropped envelope ${id} from ${from} with malformed timestamp ` + JSON.stringify(envelope.timestamp)
2595
+ );
2596
+ return false;
2597
+ }
2598
+ const ageSeconds = Math.abs(now - ts) / 1e3;
2599
+ if (ageSeconds > MAX_ENVELOPE_AGE && !await this._wasStored(raw, envelope.toAddress, ts, now)) {
2600
+ log.info(
2601
+ `Dropped stale envelope ${id} from ${from} (age=${Math.round(ageSeconds)}s > ${MAX_ENVELOPE_AGE}s)`
2602
+ );
2603
+ return false;
2604
+ }
2605
+ if (envelope.expires !== void 0 && envelope.expires !== null) {
2606
+ const exp = Date.parse(envelope.expires);
2607
+ if (Number.isNaN(exp)) {
2608
+ log.warn(
2609
+ `Dropped envelope ${id} from ${from} with malformed expires ` + JSON.stringify(envelope.expires)
2610
+ );
2611
+ return false;
2612
+ }
2613
+ if (now > exp) {
2614
+ log.info(
2615
+ `Dropped expired envelope ${id} from ${from} (expires=${envelope.expires})`
2616
+ );
2617
+ return false;
2618
+ }
2619
+ }
2620
+ if (await this._replayCache.seenOrRecord(from, id, ts)) {
2621
+ log.info(`Dropped replayed envelope ${id} from ${from}`);
2622
+ return false;
2623
+ }
2624
+ return true;
2625
+ }
2626
+ /** True if a message too old to be live is stored mail, not a replay. */
2627
+ async _wasStored(raw, toAddress, signedAt, now) {
2628
+ const relayDomain = toAddress.split("::").pop();
2629
+ if (!relayVouches(raw, signedAt, now, relayDomain)) return false;
2630
+ if (await this._replayCache.signedBeforeCache(signedAt)) return false;
2631
+ return this._relayStampsMail();
2632
+ }
2633
+ /**
2634
+ * True if our relay writes relay_meta, so a sender can't have.
2635
+ *
2636
+ * Asked only when a stale message carries a stamp. A yes is remembered for
2637
+ * good, a no for RELAY_NO_MEMORY_MS: a relay can be upgraded while this
2638
+ * agent runs. A failed lookup isn't remembered, because the relay has
2639
+ * already marked this mail delivered and the next message should get a
2640
+ * real answer.
2641
+ */
2642
+ async _relayStampsMail() {
2643
+ if (this._relayStamps) return true;
2644
+ if (this._monotonic() < this._relayNoUntil) return false;
2645
+ try {
2646
+ const features = await this._fetchRelayFeatures();
2647
+ this._relayStamps = features.includes(RELAY_META_FEATURE);
2648
+ if (!this._relayStamps) {
2649
+ this._relayNoUntil = this._monotonic() + RELAY_NO_MEMORY_MS;
2650
+ }
2651
+ } catch (err) {
2652
+ const reason = err instanceof Error ? err.message : String(err);
2653
+ log.warn(`Cannot read relay features: ${reason}`);
2654
+ }
2655
+ return this._relayStamps;
2656
+ }
2657
+ /** The relay's features; a body that doesn't list any is an empty list. */
2658
+ async _fetchRelayFeatures() {
2659
+ const resp = await fetch(`${this._config.relayUrl}/api/v1/versions`, {
2660
+ signal: AbortSignal.timeout(1e4)
2661
+ });
2662
+ if (!resp.ok) throw new UAMError(`Relay answered ${resp.status}`);
2663
+ const body = await resp.text();
2664
+ let data;
2665
+ try {
2666
+ data = JSON.parse(body);
2667
+ } catch {
2668
+ return [];
2669
+ }
2670
+ return Array.isArray(data?.features) ? data.features : [];
2671
+ }
2078
2672
  /**
2079
2673
  * Sweep expired pending handshakes and send receipt.failed (HAND-03).
2080
2674
  */
@@ -2127,7 +2721,8 @@ var Agent = class {
2127
2721
  "receipt.read" /* RECEIPT_READ */,
2128
2722
  new TextEncoder().encode(receiptPayload),
2129
2723
  this._keyManager.signingKey,
2130
- senderVk
2724
+ senderVk,
2725
+ { replyTo: msg.messageId }
2131
2726
  );
2132
2727
  const wire = toWireDict(envelope);
2133
2728
  await this._transport.send(wire);
@@ -2137,26 +2732,25 @@ var Agent = class {
2137
2732
  /**
2138
2733
  * Register with the relay server.
2139
2734
  *
2140
- * Calls POST /api/v1/register with agent name and public key.
2141
- * Stores the returned API key on disk.
2735
+ * Calls POST /api/v1/register with agent name and public key. If the
2736
+ * address exists (409), repeats it with a proof that this agent holds the
2737
+ * key. Stores the returned API key on disk.
2142
2738
  */
2143
2739
  async _registerWithRelay() {
2144
- const publicKeyStr = serializeVerifyKey(this._keyManager.verifyKey);
2145
- const resp = await fetch(
2146
- `${this._config.relayUrl}/api/v1/register`,
2147
- {
2148
- method: "POST",
2149
- headers: { "Content-Type": "application/json" },
2150
- body: JSON.stringify({
2151
- agent_name: this._config.name,
2152
- public_key: publicKeyStr
2153
- })
2154
- }
2155
- );
2740
+ const body = {
2741
+ agent_name: this._config.name,
2742
+ public_key: serializeVerifyKey(this._keyManager.verifyKey)
2743
+ };
2744
+ let resp = await this._postRegistration(body);
2156
2745
  if (resp.status === 409) {
2157
- throw new UAMError(
2158
- `Address already registered with a different key: ${this._config.name}::${this._config.relayDomain}`
2159
- );
2746
+ const address2 = await this._addressOnRelay();
2747
+ resp = await this._postRegistration({
2748
+ ...body,
2749
+ proof: this._registrationProof(address2)
2750
+ });
2751
+ if (resp.status === 409 || resp.status === 403) {
2752
+ throw this._ownershipNotProven(address2);
2753
+ }
2160
2754
  }
2161
2755
  if (!resp.ok) {
2162
2756
  const text = await resp.text();
@@ -2165,22 +2759,140 @@ var Agent = class {
2165
2759
  );
2166
2760
  }
2167
2761
  const data = await resp.json();
2168
- this._address = data["address"];
2762
+ const address = data["address"];
2763
+ this._useAddress(address);
2169
2764
  this._token = data["token"];
2170
- this._keyManager.saveToken(this._config.name, this._token);
2765
+ const { name } = this._config;
2766
+ const token = this._token;
2767
+ this._tryToSave("token", () => this._keyManager.saveToken(name, token));
2768
+ this._saveAddress(address);
2769
+ }
2770
+ /** This agent's address on the relay, for signing a proof. */
2771
+ async _addressOnRelay() {
2772
+ const { name, explicitRelayDomain, relayDomain, relayUrl } = this._config;
2773
+ if (explicitRelayDomain !== null) return `${name}::${explicitRelayDomain}`;
2774
+ const domain = await this._fetchRelayDomain();
2775
+ if (domain === null) return `${name}::${relayDomain}`;
2776
+ if (!this._relayMayUse(domain)) {
2777
+ throw new UAMError(
2778
+ `The relay at ${relayUrl} says its domain is ${domain}. Pass the domain option as ${JSON.stringify(domain)} if that is right.`
2779
+ );
2780
+ }
2781
+ return `${name}::${domain}`;
2782
+ }
2783
+ /**
2784
+ * True if the relay's URL fits `domain`, so a proof can be signed for it.
2785
+ *
2786
+ * A proof names an address on one relay, so it is only signed for a domain
2787
+ * this URL belongs to. A relay on this machine may use any domain.
2788
+ */
2789
+ _relayMayUse(domain) {
2790
+ let host;
2791
+ try {
2792
+ host = new URL(this._config.relayUrl).hostname.toLowerCase();
2793
+ } catch {
2794
+ return false;
2795
+ }
2796
+ const wanted = domain.toLowerCase();
2797
+ return host === wanted || host.endsWith(`.${wanted}`) || host === "localhost" || host === "[::1]" || /^127\.\d+\.\d+\.\d+$/.test(host);
2798
+ }
2799
+ _postRegistration(body) {
2800
+ return fetch(`${this._config.relayUrl}/api/v1/register`, {
2801
+ method: "POST",
2802
+ headers: { "Content-Type": "application/json" },
2803
+ body: JSON.stringify(body)
2804
+ });
2805
+ }
2806
+ /**
2807
+ * Sign a statement that this agent holds the key for `address`.
2808
+ *
2809
+ * The signed string must stay byte-identical to the Python SDK's.
2810
+ */
2811
+ _registrationProof(address) {
2812
+ const timestamp = utcTimestamp();
2813
+ const signature = signMessage(
2814
+ new TextEncoder().encode(`uam-register-v1
2815
+ ${address}
2816
+ ${timestamp}`),
2817
+ this._keyManager.signingKey
2818
+ );
2819
+ return { timestamp, signature };
2820
+ }
2821
+ _ownershipNotProven(address) {
2822
+ return new UAMError(
2823
+ `Address already registered, and this key could not prove ownership: ` + address
2824
+ );
2825
+ }
2826
+ /** Remember `address` for this relay, if the key folder allows it. */
2827
+ _saveAddress(address) {
2828
+ const { name, relayUrl } = this._config;
2829
+ this._tryToSave(
2830
+ "address",
2831
+ () => this._keyManager.saveAddress(name, address, relayUrl)
2832
+ );
2833
+ }
2834
+ /** Saving is best effort: a key folder we can't write doesn't stop us. */
2835
+ _tryToSave(what, save) {
2836
+ try {
2837
+ save();
2838
+ } catch (err) {
2839
+ const reason = err instanceof Error ? err.message : String(err);
2840
+ log.warn(`Could not save the ${what}: ${reason}`);
2841
+ }
2842
+ }
2843
+ /** Take `address` as ours; its domain is this relay's real domain. */
2844
+ _useAddress(address) {
2845
+ this._address = address;
2846
+ const domain = parseAddress(address).domain;
2847
+ this._config.relayDomain = domain;
2848
+ if (this._resolver instanceof SmartResolver) {
2849
+ this._resolver.relayDomain = domain;
2850
+ }
2851
+ }
2852
+ /**
2853
+ * The address the stored token belongs to.
2854
+ *
2855
+ * An explicit domain settles it. Otherwise the relay chooses the domain,
2856
+ * and it need not be the relay's hostname: use the address saved for this
2857
+ * relay, or ask the relay once and keep the answer.
2858
+ */
2859
+ async _storedAddress() {
2860
+ const { name, explicitRelayDomain } = this._config;
2861
+ if (explicitRelayDomain !== null) return `${name}::${explicitRelayDomain}`;
2862
+ const saved = this._keyManager.loadAddress(name, this._config.relayUrl);
2863
+ if (saved) return saved;
2864
+ const domain = await this._fetchRelayDomain();
2865
+ if (domain === null) return `${name}::${this._config.relayDomain}`;
2866
+ const address = `${name}::${domain}`;
2867
+ this._saveAddress(address);
2868
+ return address;
2869
+ }
2870
+ /** Ask the relay for its domain; null if it cannot say. */
2871
+ async _fetchRelayDomain() {
2872
+ const url = `${this._config.relayUrl}/.well-known/uam-relay.json`;
2873
+ try {
2874
+ const resp = await fetch(url, { signal: AbortSignal.timeout(1e4) });
2875
+ if (!resp.ok) return null;
2876
+ const data = await resp.json();
2877
+ const domain = data["relay_domain"];
2878
+ if (typeof domain !== "string" || domain === "") return null;
2879
+ return parseAddress(`${this._config.name}::${domain}`).domain;
2880
+ } catch {
2881
+ return null;
2882
+ }
2171
2883
  }
2172
2884
  };
2173
2885
 
2174
2886
  // src/cli/helpers.ts
2175
- var import_node_fs4 = require("fs");
2176
- var import_node_path4 = require("path");
2887
+ var import_node_fs5 = require("fs");
2888
+ var import_node_path6 = require("path");
2177
2889
  var import_node_os4 = require("os");
2178
2890
  function findAgentName(keyDir) {
2179
- const dir = keyDir ?? (0, import_node_path4.join)(process.env["UAM_HOME"] ?? (0, import_node_path4.join)((0, import_node_os4.homedir)(), ".uam"), "keys");
2180
- if (!(0, import_node_fs4.existsSync)(dir)) {
2891
+ const dir = keyDir ?? (0, import_node_path6.join)(process.env["UAM_HOME"] ?? (0, import_node_path6.join)((0, import_node_os4.homedir)(), ".uam"), "keys");
2892
+ if (!(0, import_node_fs5.existsSync)(dir)) {
2181
2893
  return null;
2182
2894
  }
2183
- const keyFiles = (0, import_node_fs4.readdirSync)(dir).filter((f) => f.endsWith(".key")).sort();
2895
+ const keyFiles = (0, import_node_fs5.readdirSync)(dir).filter((f) => f.endsWith(".key")).sort();
2184
2896
  if (keyFiles.length === 0) {
2185
2897
  return null;
2186
2898
  }
@@ -2200,10 +2912,10 @@ async function initCommand(options) {
2200
2912
  try {
2201
2913
  const cfg = new SDKConfig({ name: agentName, relayUrl: options.relay });
2202
2914
  const km = new KeyManager(cfg.keyDir);
2203
- const keyPath = (0, import_node_path5.join)(cfg.keyDir, `${agentName}.key`);
2204
- if ((0, import_node_fs5.existsSync)(keyPath)) {
2915
+ const keyPath = (0, import_node_path7.join)(cfg.keyDir, `${agentName}.key`);
2916
+ if ((0, import_node_fs6.existsSync)(keyPath)) {
2205
2917
  km.loadOrGenerate(agentName);
2206
- const address2 = `${agentName}::${cfg.relayDomain}`;
2918
+ const address2 = km.loadAddress(agentName, cfg.relayUrl) ?? `${agentName}::${cfg.relayDomain}`;
2207
2919
  const fp2 = publicKeyFingerprint(km.verifyKey);
2208
2920
  console.log(`Agent already initialized: ${address2}`);
2209
2921
  console.log(`Fingerprint: ${fp2}`);
@@ -2284,22 +2996,22 @@ async function cardCommand(options) {
2284
2996
  }
2285
2997
 
2286
2998
  // src/cli/commands/whoami.ts
2287
- var import_node_fs6 = require("fs");
2288
- var import_node_path6 = require("path");
2999
+ var import_node_fs7 = require("fs");
3000
+ var import_node_path8 = require("path");
2289
3001
  async function whoamiCommand(options) {
2290
3002
  const agentName = options.name ?? findAgentName();
2291
3003
  if (!agentName) {
2292
3004
  cliError("No agent initialized. Run `uam init` first.");
2293
3005
  }
2294
3006
  const cfg = new SDKConfig({ name: agentName });
2295
- const keyPath = (0, import_node_path6.join)(cfg.keyDir, `${agentName}.key`);
2296
- if (!(0, import_node_fs6.existsSync)(keyPath)) {
3007
+ const keyPath = (0, import_node_path8.join)(cfg.keyDir, `${agentName}.key`);
3008
+ if (!(0, import_node_fs7.existsSync)(keyPath)) {
2297
3009
  cliError("No agent initialized. Run `uam init` first.");
2298
3010
  }
2299
3011
  await sodiumReady;
2300
3012
  const km = new KeyManager(cfg.keyDir);
2301
3013
  km.loadOrGenerate(agentName);
2302
- const address = `${agentName}::${cfg.relayDomain}`;
3014
+ const address = km.loadAddress(agentName, cfg.relayUrl) ?? `${agentName}::${cfg.relayDomain}`;
2303
3015
  const fp = publicKeyFingerprint(km.verifyKey);
2304
3016
  console.log(`Address: ${address}`);
2305
3017
  console.log(`Fingerprint: ${fp}`);
@@ -2557,7 +3269,7 @@ async function verifyDomainCommand(domain, options) {
2557
3269
 
2558
3270
  // src/cli/index.ts
2559
3271
  var program = new import_commander.Command();
2560
- program.name("uam").description("Universal Agent Messaging CLI").version("0.1.0");
3272
+ program.name("uam").description("Universal Agent Messaging CLI").version(SDK_VERSION);
2561
3273
  program.option("-n, --name <name>", "Agent name (auto-detected from ~/.uam/keys/)");
2562
3274
  program.command("init").description("Initialize a new agent: generate keys and register with relay").option("-n, --name <name>", "Agent name").option("-r, --relay <url>", "Relay URL (default: relay.youam.network)").action(async (opts) => {
2563
3275
  const globalName = program.opts().name;