@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.
package/dist/index.cjs CHANGED
@@ -115,10 +115,15 @@ var init_dns_verifier = __esm({
115
115
  var index_exports = {};
116
116
  __export(index_exports, {
117
117
  Agent: () => Agent,
118
- protocol: () => protocol_exports
118
+ ContactBook: () => ContactBook,
119
+ protocol: () => protocol_exports,
120
+ verifyWebhookSignature: () => verifyWebhookSignature
119
121
  });
120
122
  module.exports = __toCommonJS(index_exports);
121
123
 
124
+ // src/sdk/agent.ts
125
+ var import_node_path5 = require("path");
126
+
122
127
  // src/protocol/index.ts
123
128
  var protocol_exports = {};
124
129
  __export(protocol_exports, {
@@ -129,6 +134,7 @@ __export(protocol_exports, {
129
134
  InvalidContactCardError: () => InvalidContactCardError,
130
135
  InvalidEnvelopeError: () => InvalidEnvelopeError,
131
136
  KeyPinningError: () => KeyPinningError,
137
+ MAX_ENVELOPE_AGE: () => MAX_ENVELOPE_AGE,
132
138
  MAX_ENVELOPE_SIZE: () => MAX_ENVELOPE_SIZE,
133
139
  MessageType: () => MessageType,
134
140
  SignatureError: () => SignatureError,
@@ -168,6 +174,7 @@ __export(protocol_exports, {
168
174
  // src/protocol/types.ts
169
175
  var UAM_VERSION = "0.1";
170
176
  var MAX_ENVELOPE_SIZE = 65536;
177
+ var MAX_ENVELOPE_AGE = 300;
171
178
  var MessageType = /* @__PURE__ */ ((MessageType2) => {
172
179
  MessageType2["MESSAGE"] = "message";
173
180
  MessageType2["HANDSHAKE_REQUEST"] = "handshake.request";
@@ -692,10 +699,16 @@ function verifyContactCard(card) {
692
699
  verifySignature(canonicalize(signable), card.signature, vk);
693
700
  }
694
701
 
702
+ // src/version.ts
703
+ var SDK_VERSION = "0.2.1";
704
+
695
705
  // src/sdk/config.ts
696
706
  var import_node_os = require("os");
697
707
  var import_node_path = require("path");
698
708
  var DEFAULT_RELAY_URL = "https://relay.youam.network";
709
+ function trimRelayUrl(url) {
710
+ return url.replace(/\/+$/, "");
711
+ }
699
712
  var VALID_POLICIES = /* @__PURE__ */ new Set([
700
713
  "auto-accept",
701
714
  "approval-required",
@@ -712,9 +725,13 @@ var SDKConfig = class {
712
725
  transportType;
713
726
  trustPolicy;
714
727
  relayDomain;
728
+ /** The domain the caller chose, if any; null when only derived. */
729
+ explicitRelayDomain;
715
730
  constructor(options) {
716
731
  this.name = options.name;
717
- this.relayUrl = options.relayUrl ?? process.env["UAM_RELAY_URL"] ?? DEFAULT_RELAY_URL;
732
+ this.relayUrl = trimRelayUrl(
733
+ options.relayUrl ?? process.env["UAM_RELAY_URL"] ?? DEFAULT_RELAY_URL
734
+ );
718
735
  if (options.relayWsUrl) {
719
736
  this.relayWsUrl = options.relayWsUrl;
720
737
  } else {
@@ -725,6 +742,7 @@ var SDKConfig = class {
725
742
  this.relayWsUrl = wsUrl;
726
743
  }
727
744
  const envDomain = process.env["UAM_RELAY_DOMAIN"];
745
+ this.explicitRelayDomain = envDomain || options.relayDomain || null;
728
746
  if (envDomain) {
729
747
  this.relayDomain = envDomain;
730
748
  } else if (options.relayDomain) {
@@ -758,6 +776,27 @@ var SDKConfig = class {
758
776
  }
759
777
  };
760
778
 
779
+ // src/sdk/freshness.ts
780
+ var RELAY_META_FEATURE = "relay_meta";
781
+ var STORED_MAIL_MAX_AGE = 7 * 86400;
782
+ var ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
783
+ function relayVouches(raw, signedAt, now, relayDomain) {
784
+ const meta = raw["relay_meta"];
785
+ if (typeof meta !== "object" || meta === null || Array.isArray(meta)) {
786
+ return false;
787
+ }
788
+ const stamp = meta;
789
+ if (stamp["v"] !== 1 || stamp["relay"] !== relayDomain) return false;
790
+ if (stamp["stored"] !== true) return false;
791
+ const received = stamp["received_at"];
792
+ if (typeof received !== "string" || !ISO_TIME.test(received)) return false;
793
+ const receivedAt = Date.parse(received);
794
+ if (Number.isNaN(receivedAt)) return false;
795
+ const window = MAX_ENVELOPE_AGE * 1e3;
796
+ const age = now - signedAt;
797
+ return Math.abs(receivedAt - signedAt) <= window && age >= 0 && age <= STORED_MAIL_MAX_AGE * 1e3 + window;
798
+ }
799
+
761
800
  // src/sdk/key-manager.ts
762
801
  var import_node_fs = require("fs");
763
802
  var import_node_fs2 = require("fs");
@@ -828,17 +867,54 @@ var KeyManager = class {
828
867
  (0, import_node_fs.writeFileSync)(tokenPath, token);
829
868
  this._setPermissions(tokenPath);
830
869
  }
870
+ /**
871
+ * Store the address `relayUrl` gave this agent.
872
+ */
873
+ saveAddress(name, address, relayUrl) {
874
+ (0, import_node_fs.mkdirSync)(this._keyDir, { recursive: true });
875
+ (0, import_node_fs.writeFileSync)(
876
+ (0, import_node_path2.join)(this._keyDir, `${name}.address`),
877
+ JSON.stringify({ address, relay_url: relayUrl })
878
+ );
879
+ }
880
+ /**
881
+ * Load the address saved for `relayUrl`.
882
+ *
883
+ * Returns null if there is none: the file is missing, unreadable or
884
+ * malformed, or it belongs to another relay.
885
+ */
886
+ loadAddress(name, relayUrl) {
887
+ try {
888
+ const path = (0, import_node_path2.join)(this._keyDir, `${name}.address`);
889
+ const saved = JSON.parse((0, import_node_fs.readFileSync)(path, "utf-8"));
890
+ const address = saved?.address;
891
+ const savedRelay = saved?.relay_url;
892
+ if (typeof address !== "string" || typeof savedRelay !== "string") {
893
+ return null;
894
+ }
895
+ if (trimRelayUrl(savedRelay) !== trimRelayUrl(relayUrl)) return null;
896
+ parseAddress(address);
897
+ return address;
898
+ } catch {
899
+ return null;
900
+ }
901
+ }
831
902
  /**
832
903
  * Load a previously saved token, or return null.
833
904
  *
834
905
  * Checks UAM_TOKEN env var first, then file-based storage,
835
- * then legacy .api_key files for backward compatibility.
906
+ * then legacy .api_key files for backward compatibility. With `relayUrl`,
907
+ * a saved token that belongs to another relay counts as none, so it is
908
+ * only offered to the relay that issued it.
836
909
  */
837
- loadToken(name) {
910
+ loadToken(name, relayUrl) {
838
911
  const envToken = process.env["UAM_TOKEN"];
839
912
  if (envToken) {
840
913
  return envToken.trim();
841
914
  }
915
+ if (relayUrl !== void 0 && this._savedForAnotherRelay(name, relayUrl)) {
916
+ return null;
917
+ }
842
918
  const tokenPath = (0, import_node_path2.join)(this._keyDir, `${name}.token`);
843
919
  if ((0, import_node_fs2.existsSync)(tokenPath)) {
844
920
  return (0, import_node_fs.readFileSync)(tokenPath, "utf-8").trim();
@@ -849,6 +925,20 @@ var KeyManager = class {
849
925
  }
850
926
  return null;
851
927
  }
928
+ /**
929
+ * True if the saved address says this agent's files are another relay's.
930
+ * A missing or damaged file says nothing.
931
+ */
932
+ _savedForAnotherRelay(name, relayUrl) {
933
+ try {
934
+ const path = (0, import_node_path2.join)(this._keyDir, `${name}.address`);
935
+ const saved = JSON.parse((0, import_node_fs.readFileSync)(path, "utf-8"));
936
+ const savedRelay = saved?.relay_url;
937
+ return typeof savedRelay === "string" && trimRelayUrl(savedRelay) !== trimRelayUrl(relayUrl);
938
+ } catch {
939
+ return false;
940
+ }
941
+ }
852
942
  /** Set file permissions to 0o600 (owner read/write only). */
853
943
  _setPermissions(path) {
854
944
  if ((0, import_node_os3.platform)() !== "win32") {
@@ -876,6 +966,20 @@ var KeyManager = class {
876
966
  var import_node_fs3 = require("fs");
877
967
  var import_node_path3 = require("path");
878
968
  var import_better_sqlite3 = __toESM(require("better-sqlite3"), 1);
969
+
970
+ // src/sdk/log.ts
971
+ var log = {
972
+ warn(message) {
973
+ console.warn(`[uam] ${message}`);
974
+ },
975
+ info(message) {
976
+ if (process.env["UAM_DEBUG"]) {
977
+ console.info(`[uam] ${message}`);
978
+ }
979
+ }
980
+ };
981
+
982
+ // src/sdk/contact-book.ts
879
983
  var ContactBook = class {
880
984
  _dbPath;
881
985
  _db = null;
@@ -996,11 +1100,30 @@ var ContactBook = class {
996
1100
  }
997
1101
  /**
998
1102
  * Add or update a contact (upsert).
1103
+ *
1104
+ * Refuses to replace the key of a contact whose trust_state is "pinned"
1105
+ * or "verified" with a different key, unless `force: true` is passed (for
1106
+ * a deliberate key rotation, which is logged). Matches the Python SDK's
1107
+ * ContactBook.add_contact.
1108
+ *
1109
+ * @throws {KeyPinningError} If a pinned/verified contact's key would be
1110
+ * replaced by a different key without `force`.
999
1111
  */
1000
1112
  addContact(address, publicKey, options) {
1001
1113
  if (this._db === null) {
1002
1114
  throw new Error("ContactBook not open. Call open() first.");
1003
1115
  }
1116
+ const existing = this._db.prepare("SELECT public_key, trust_state FROM contacts WHERE address = ?").get(address);
1117
+ if (existing !== void 0 && (existing.trust_state === "pinned" || existing.trust_state === "verified") && existing.public_key !== publicKey) {
1118
+ if (!options?.force) {
1119
+ throw new KeyPinningError(
1120
+ `Refusing to overwrite ${existing.trust_state} contact ${JSON.stringify(address)}: stored public key does not match the new key. Pass force: true to rotate.`
1121
+ );
1122
+ }
1123
+ log.warn(
1124
+ `Force-replacing the key of ${existing.trust_state} contact ${address} (key rotation)`
1125
+ );
1126
+ }
1004
1127
  const displayName = options?.displayName ?? null;
1005
1128
  const trustState = options?.trustState ?? "trusted";
1006
1129
  const trustSource = options?.trustSource ?? null;
@@ -1173,6 +1296,18 @@ var ContactBook = class {
1173
1296
  };
1174
1297
 
1175
1298
  // src/sdk/handshake.ts
1299
+ function bindCardToEnvelope(card, envelope, senderVk) {
1300
+ if (card.address !== envelope.fromAddress) {
1301
+ throw new InvalidEnvelopeError(
1302
+ `Contact card address ${JSON.stringify(card.address)} does not match envelope sender ${JSON.stringify(envelope.fromAddress)}`
1303
+ );
1304
+ }
1305
+ if (card.publicKey !== serializeVerifyKey(senderVk)) {
1306
+ throw new InvalidEnvelopeError(
1307
+ "Contact card public_key does not match envelope sender's verify key"
1308
+ );
1309
+ }
1310
+ }
1176
1311
  var HandshakeManager = class {
1177
1312
  _contactBook;
1178
1313
  _trustPolicy;
@@ -1215,7 +1350,7 @@ var HandshakeManager = class {
1215
1350
  if (envelope.type === "handshake.request" /* HANDSHAKE_REQUEST */) {
1216
1351
  await this._handleRequest(agent, envelope, senderVk);
1217
1352
  } else if (envelope.type === "handshake.accept" /* HANDSHAKE_ACCEPT */) {
1218
- await this._handleAccept(envelope, senderVk);
1353
+ await this._handleAccept(agent, envelope, senderVk);
1219
1354
  } else if (envelope.type === "handshake.deny" /* HANDSHAKE_DENY */) {
1220
1355
  }
1221
1356
  return null;
@@ -1233,6 +1368,7 @@ var HandshakeManager = class {
1233
1368
  );
1234
1369
  const card = contactCardFromDict(cardDict);
1235
1370
  verifyContactCard(card);
1371
+ bindCardToEnvelope(card, envelope, senderVk);
1236
1372
  if (this._trustPolicy === "auto-accept") {
1237
1373
  this._contactBook.addContact(card.address, card.publicKey, {
1238
1374
  displayName: card.displayName,
@@ -1251,8 +1387,46 @@ var HandshakeManager = class {
1251
1387
  }
1252
1388
  /**
1253
1389
  * Process a handshake.accept: store the sender as pinned (TOFU).
1390
+ *
1391
+ * Only honoured when:
1392
+ * 1. we previously sent this address a handshake.request
1393
+ * (trust_state === "handshake-sent"); anything else is an unsolicited
1394
+ * accept and is dropped. This also means an accept can never
1395
+ * downgrade a trusted/verified contact to pinned;
1396
+ * 2. the payload decrypts with our key and the sender's key, and parses
1397
+ * as JSON. Otherwise it is dropped without pinning;
1398
+ * 3. an embedded contact_card, if present, is well-formed, correctly
1399
+ * self-signed, and bound to the envelope sender (throws otherwise).
1254
1400
  */
1255
- async _handleAccept(envelope, senderVk) {
1401
+ async _handleAccept(agent, envelope, senderVk) {
1402
+ const prior = this._contactBook.getTrustState(envelope.fromAddress);
1403
+ if (prior !== "handshake-sent") {
1404
+ log.warn(
1405
+ `Ignoring unsolicited handshake.accept from ${envelope.fromAddress} (trust_state=${JSON.stringify(prior)})`
1406
+ );
1407
+ return;
1408
+ }
1409
+ let payload;
1410
+ try {
1411
+ const plaintext = decryptPayload(
1412
+ envelope.payload,
1413
+ agent._keyManager.signingKey,
1414
+ senderVk
1415
+ );
1416
+ payload = JSON.parse(new TextDecoder().decode(plaintext));
1417
+ } catch {
1418
+ log.warn(
1419
+ `Dropping handshake.accept ${envelope.messageId} from ${envelope.fromAddress}: payload could not be decrypted or parsed`
1420
+ );
1421
+ return;
1422
+ }
1423
+ if (payload !== null && typeof payload === "object" && "contact_card" in payload) {
1424
+ const card = contactCardFromDict(
1425
+ payload["contact_card"]
1426
+ );
1427
+ verifyContactCard(card);
1428
+ bindCardToEnvelope(card, envelope, senderVk);
1429
+ }
1256
1430
  const senderPkStr = serializeVerifyKey(senderVk);
1257
1431
  this._contactBook.addContact(envelope.fromAddress, senderPkStr, {
1258
1432
  trustState: "pinned"
@@ -1309,6 +1483,10 @@ var HandshakeManager = class {
1309
1483
  }
1310
1484
  };
1311
1485
 
1486
+ // src/sdk/resolver-base.ts
1487
+ var AddressResolver = class {
1488
+ };
1489
+
1312
1490
  // src/sdk/tier3.ts
1313
1491
  var import_viem = require("viem");
1314
1492
  var DEFAULT_RPC_URL = "https://sepolia.base.org";
@@ -1425,8 +1603,6 @@ var Tier3Resolver = class extends AddressResolver {
1425
1603
 
1426
1604
  // src/sdk/resolver.ts
1427
1605
  init_dns_verifier();
1428
- var AddressResolver = class {
1429
- };
1430
1606
  var Tier1Resolver = class extends AddressResolver {
1431
1607
  async resolvePublicKey(address, _token, relayUrl) {
1432
1608
  const resp = await fetch(
@@ -1463,13 +1639,13 @@ var Tier2Resolver = class extends AddressResolver {
1463
1639
  }
1464
1640
  };
1465
1641
  var SmartResolver = class extends AddressResolver {
1466
- _relayDomain;
1642
+ relayDomain;
1467
1643
  _tier1;
1468
1644
  _tier2;
1469
1645
  _tier3;
1470
1646
  constructor(relayDomain, tier3Config) {
1471
1647
  super();
1472
- this._relayDomain = relayDomain;
1648
+ this.relayDomain = relayDomain;
1473
1649
  this._tier1 = new Tier1Resolver();
1474
1650
  this._tier2 = new Tier2Resolver();
1475
1651
  this._tier3 = tier3Config ? new Tier3Resolver(tier3Config) : null;
@@ -1477,7 +1653,7 @@ var SmartResolver = class extends AddressResolver {
1477
1653
  async resolvePublicKey(address, token, relayUrl) {
1478
1654
  const parsed = parseAddress(address);
1479
1655
  const domain = parsed.domain;
1480
- if (domain === this._relayDomain) {
1656
+ if (domain === this.relayDomain) {
1481
1657
  return this._tier1.resolvePublicKey(address, token, relayUrl);
1482
1658
  }
1483
1659
  if (domain.includes(".")) {
@@ -1556,8 +1732,10 @@ var import_ws = __toESM(require("ws"), 1);
1556
1732
  var BASE_DELAY = 1;
1557
1733
  var MAX_DELAY = 60;
1558
1734
  var JITTER_RANGE = 1;
1735
+ var SEND_SETTLE_SECONDS = 5;
1559
1736
  var WebSocketTransport = class extends TransportBase {
1560
1737
  _url;
1738
+ _token;
1561
1739
  _onMessage;
1562
1740
  _ws = null;
1563
1741
  _pending = [];
@@ -1566,9 +1744,16 @@ var WebSocketTransport = class extends TransportBase {
1566
1744
  _connectReject = null;
1567
1745
  _running = false;
1568
1746
  _reconnectTimeout = null;
1747
+ /** Envelopes sent that the relay hasn't answered yet. */
1748
+ _unanswered = 0;
1749
+ _settling = null;
1750
+ _settled = null;
1751
+ /** @internal How long a close waits, in ms. Replaceable in tests. */
1752
+ _settleMs = SEND_SETTLE_SECONDS * 1e3;
1569
1753
  constructor(wsUrl, token, onMessage) {
1570
1754
  super();
1571
- this._url = `${wsUrl}?token=${token}`;
1755
+ this._url = wsUrl;
1756
+ this._token = token;
1572
1757
  this._onMessage = onMessage ?? null;
1573
1758
  }
1574
1759
  async connect() {
@@ -1589,7 +1774,14 @@ var WebSocketTransport = class extends TransportBase {
1589
1774
  };
1590
1775
  });
1591
1776
  }
1777
+ /**
1778
+ * Close the connection, once the relay has answered what was sent.
1779
+ *
1780
+ * The relay stops reading a socket when it can't write an ack to it, so
1781
+ * closing straight after send() loses every frame but the first.
1782
+ */
1592
1783
  async disconnect() {
1784
+ await this._allAnswered();
1593
1785
  this._running = false;
1594
1786
  if (this._reconnectTimeout !== null) {
1595
1787
  clearTimeout(this._reconnectTimeout);
@@ -1607,6 +1799,7 @@ var WebSocketTransport = class extends TransportBase {
1607
1799
  throw new Error("WebSocket not connected");
1608
1800
  }
1609
1801
  this._ws.send(JSON.stringify(envelope));
1802
+ this._unanswered += 1;
1610
1803
  }
1611
1804
  async receive(limit = 50) {
1612
1805
  const result = this._pending.slice(0, limit);
@@ -1616,11 +1809,32 @@ var WebSocketTransport = class extends TransportBase {
1616
1809
  async listen(callback) {
1617
1810
  this._onMessage = callback;
1618
1811
  }
1812
+ /** Resolves when nothing is unanswered, or after _settleMs regardless. */
1813
+ _allAnswered() {
1814
+ if (this._unanswered === 0) return Promise.resolve();
1815
+ this._settling ??= new Promise((resolve) => {
1816
+ const timeout = setTimeout(() => {
1817
+ log.warn(`Closing with ${this._unanswered} sends unanswered`);
1818
+ this._settled?.();
1819
+ }, this._settleMs);
1820
+ this._settled = () => {
1821
+ clearTimeout(timeout);
1822
+ this._settling = null;
1823
+ this._settled = null;
1824
+ resolve();
1825
+ };
1826
+ });
1827
+ return this._settling;
1828
+ }
1829
+ _answered() {
1830
+ this._unanswered = Math.max(0, this._unanswered - 1);
1831
+ if (this._unanswered === 0) this._settled?.();
1832
+ }
1619
1833
  _startConnectionLoop() {
1620
1834
  let attempt = 0;
1621
1835
  const tryConnect = () => {
1622
1836
  if (!this._running) return;
1623
- const ws = new import_ws.default(this._url);
1837
+ const ws = new import_ws.default(this._url, [`bearer.${this._token}`]);
1624
1838
  this._ws = ws;
1625
1839
  ws.on("open", () => {
1626
1840
  this._connected = true;
@@ -1641,6 +1855,8 @@ var WebSocketTransport = class extends TransportBase {
1641
1855
  ws.on("close", () => {
1642
1856
  this._connected = false;
1643
1857
  this._ws = null;
1858
+ this._unanswered = 0;
1859
+ this._settled?.();
1644
1860
  if (!this._running) return;
1645
1861
  attempt += 1;
1646
1862
  const delay = Math.min(BASE_DELAY * Math.pow(2, attempt), MAX_DELAY);
@@ -1657,19 +1873,21 @@ var WebSocketTransport = class extends TransportBase {
1657
1873
  }
1658
1874
  _handleMessage(msg) {
1659
1875
  const msgType = msg["type"];
1660
- if (msgType === "ping") {
1661
- if (this._ws && this._ws.readyState === import_ws.default.OPEN) {
1662
- this._ws.send(JSON.stringify({ type: "pong" }));
1663
- }
1664
- } else if (msgType === "ack") {
1665
- } else if (msgType === "error" || "error" in msg) {
1666
- } else if ("uam_version" in msg) {
1876
+ if ("uam_version" in msg) {
1667
1877
  if (this._onMessage) {
1668
1878
  this._onMessage(msg).catch(() => {
1669
1879
  });
1670
1880
  } else {
1671
1881
  this._pending.push(msg);
1672
1882
  }
1883
+ } else if (msgType === "ping") {
1884
+ if (this._ws && this._ws.readyState === import_ws.default.OPEN) {
1885
+ this._ws.send(JSON.stringify({ type: "pong" }));
1886
+ }
1887
+ } else if (msgType === "ack") {
1888
+ this._answered();
1889
+ } else if (msgType === "error" || "error" in msg) {
1890
+ this._answered();
1673
1891
  }
1674
1892
  }
1675
1893
  };
@@ -1683,7 +1901,239 @@ function createTransport(config, token, address, onMessage) {
1683
1901
  }
1684
1902
  }
1685
1903
 
1904
+ // src/sdk/replay-cache.ts
1905
+ var import_node_fs4 = require("fs");
1906
+ var import_node_path4 = require("path");
1907
+ var import_promises2 = require("timers/promises");
1908
+ var import_better_sqlite32 = __toESM(require("better-sqlite3"), 1);
1909
+ var RETENTION_SECONDS = STORED_MAIL_MAX_AGE + 2 * MAX_ENVELOPE_AGE;
1910
+ var PURGE_INTERVAL_MS = 6e4;
1911
+ var WAL_ATTEMPTS = 100;
1912
+ var WAL_RETRY_MS = 100;
1913
+ var SCHEMA = `
1914
+ CREATE TABLE IF NOT EXISTS seen_envelopes (
1915
+ from_address TEXT NOT NULL,
1916
+ message_id TEXT NOT NULL,
1917
+ seen_at REAL NOT NULL,
1918
+ PRIMARY KEY (from_address, message_id)
1919
+ ) WITHOUT ROWID;
1920
+ CREATE INDEX IF NOT EXISTS seen_envelopes_seen_at ON seen_envelopes (seen_at);
1921
+ CREATE TABLE IF NOT EXISTS replay_meta (
1922
+ key TEXT PRIMARY KEY,
1923
+ value TEXT NOT NULL
1924
+ );
1925
+ `;
1926
+ function isLocked(err) {
1927
+ const code = err.code;
1928
+ return typeof code === "string" && (code.startsWith("SQLITE_BUSY") || code.startsWith("SQLITE_LOCKED"));
1929
+ }
1930
+ async function enableWal(db) {
1931
+ for (let attempt = 1; ; attempt += 1) {
1932
+ try {
1933
+ db.pragma("journal_mode = WAL");
1934
+ return;
1935
+ } catch (err) {
1936
+ if (attempt >= WAL_ATTEMPTS || !isLocked(err)) throw err;
1937
+ await (0, import_promises2.setTimeout)(WAL_RETRY_MS);
1938
+ }
1939
+ }
1940
+ }
1941
+ var DEFAULT_REPLAY_CACHE_CAPACITY = 1e6;
1942
+ var EnvelopeReplayCache = class {
1943
+ capacity;
1944
+ ttlMs;
1945
+ _now;
1946
+ // Map iteration order is insertion order: first key = least recently used.
1947
+ _seen = /* @__PURE__ */ new Map();
1948
+ constructor(options) {
1949
+ const capacity = options?.capacity ?? 1e5;
1950
+ if (!Number.isInteger(capacity) || capacity < 1) {
1951
+ throw new RangeError(
1952
+ `Replay cache capacity must be a positive integer, got ${capacity}`
1953
+ );
1954
+ }
1955
+ this.capacity = capacity;
1956
+ this.ttlMs = options?.ttlMs ?? 3e5;
1957
+ this._now = options?.now ?? (() => Date.now());
1958
+ }
1959
+ get size() {
1960
+ return this._seen.size;
1961
+ }
1962
+ /** True if this pair was seen within the TTL. Records nothing. */
1963
+ has(fromAddress, messageId) {
1964
+ const seenAt = this._seen.get(this._key(fromAddress, messageId));
1965
+ return seenAt !== void 0 && this._now() - seenAt < this.ttlMs;
1966
+ }
1967
+ /**
1968
+ * Return true if this (fromAddress, messageId) pair was already seen
1969
+ * within the TTL (a replay). Otherwise record it and return false.
1970
+ */
1971
+ seenOrRecord(fromAddress, messageId) {
1972
+ const key = this._key(fromAddress, messageId);
1973
+ const now = this._now();
1974
+ const seenAt = this._seen.get(key);
1975
+ if (seenAt !== void 0 && now - seenAt < this.ttlMs) {
1976
+ this._seen.delete(key);
1977
+ this._seen.set(key, seenAt);
1978
+ return true;
1979
+ }
1980
+ this._seen.delete(key);
1981
+ this._seen.set(key, now);
1982
+ while (this._seen.size > this.capacity) {
1983
+ const oldest = this._seen.keys().next().value;
1984
+ this._seen.delete(oldest);
1985
+ }
1986
+ return false;
1987
+ }
1988
+ _key(fromAddress, messageId) {
1989
+ return JSON.stringify([fromAddress, messageId]);
1990
+ }
1991
+ };
1992
+ var PersistentReplayCache = class {
1993
+ capacity;
1994
+ _path;
1995
+ _now;
1996
+ _db = null;
1997
+ _opening = null;
1998
+ _count = 0;
1999
+ _lastPurge = 0;
2000
+ _createdAt = 0;
2001
+ // Used only while the file is full, for envelopes inside MAX_ENVELOPE_AGE.
2002
+ // One can be signed that far ahead of us and stay acceptable until that far
2003
+ // behind, so its id must last both.
2004
+ _overflow;
2005
+ /**
2006
+ * @param path - The SQLite file
2007
+ * @param options.now - The clock, in ms since the epoch
2008
+ */
2009
+ constructor(path, options) {
2010
+ const capacity = options?.capacity ?? DEFAULT_REPLAY_CACHE_CAPACITY;
2011
+ if (!Number.isInteger(capacity) || capacity < 1) {
2012
+ throw new RangeError(
2013
+ `Replay cache capacity must be a positive integer, got ${capacity}`
2014
+ );
2015
+ }
2016
+ this.capacity = capacity;
2017
+ this._path = path;
2018
+ this._now = options?.now ?? (() => Date.now());
2019
+ this._overflow = new EnvelopeReplayCache({
2020
+ capacity: 1e4,
2021
+ ttlMs: 2 * MAX_ENVELOPE_AGE * 1e3,
2022
+ now: this._now
2023
+ });
2024
+ }
2025
+ /** Open the file now, rather than on first use. */
2026
+ async open() {
2027
+ await this._open();
2028
+ }
2029
+ /**
2030
+ * Return true if the envelope must be dropped as a replay. Otherwise record
2031
+ * the pair and return false.
2032
+ *
2033
+ * @param signedAt - The envelope's signed time, in ms since the epoch. It
2034
+ * matters only when the cache is full.
2035
+ */
2036
+ async seenOrRecord(fromAddress, messageId, signedAt) {
2037
+ const db = await this._open();
2038
+ const now = this._now();
2039
+ if (now - this._lastPurge >= PURGE_INTERVAL_MS) this._purge(db);
2040
+ if (this._overflow.has(fromAddress, messageId)) return true;
2041
+ if (this._count < this.capacity) {
2042
+ const { changes } = db.prepare(
2043
+ "INSERT OR IGNORE INTO seen_envelopes (from_address, message_id, seen_at) VALUES (?, ?, ?)"
2044
+ ).run(fromAddress, messageId, now / 1e3);
2045
+ if (changes === 0) return true;
2046
+ this._count += 1;
2047
+ return false;
2048
+ }
2049
+ const known = db.prepare(
2050
+ "SELECT 1 FROM seen_envelopes WHERE from_address = ? AND message_id = ?"
2051
+ ).get(fromAddress, messageId);
2052
+ if (known !== void 0) return true;
2053
+ if (Math.abs(now - signedAt) <= MAX_ENVELOPE_AGE * 1e3) {
2054
+ return this._overflow.seenOrRecord(fromAddress, messageId);
2055
+ }
2056
+ log.warn(
2057
+ `Replay cache full (${this._count} ids); refused envelope ${messageId} from ${fromAddress} signed ${Math.round((now - signedAt) / 1e3)}s ago`
2058
+ );
2059
+ return true;
2060
+ }
2061
+ /** When this cache file was first created, in ms since the epoch. */
2062
+ async createdAt() {
2063
+ await this._open();
2064
+ return this._createdAt;
2065
+ }
2066
+ /** True if an envelope signed at `signedAt` (ms) predates this cache. */
2067
+ async signedBeforeCache(signedAt) {
2068
+ return signedAt < await this.createdAt();
2069
+ }
2070
+ /** Number of ids currently held on disk. */
2071
+ async count() {
2072
+ const db = await this._open();
2073
+ return this._countRows(db);
2074
+ }
2075
+ /** Close the file; the next call opens it again. */
2076
+ async close() {
2077
+ await this._opening?.catch(() => void 0);
2078
+ this._db?.close();
2079
+ this._db = null;
2080
+ }
2081
+ _open() {
2082
+ if (this._db !== null) return Promise.resolve(this._db);
2083
+ this._opening ??= this._connect().finally(() => {
2084
+ this._opening = null;
2085
+ });
2086
+ return this._opening;
2087
+ }
2088
+ async _connect() {
2089
+ (0, import_node_fs4.mkdirSync)((0, import_node_path4.dirname)(this._path), { recursive: true });
2090
+ const db = new import_better_sqlite32.default(this._path, { timeout: 3e4 });
2091
+ try {
2092
+ await enableWal(db);
2093
+ db.exec(SCHEMA);
2094
+ db.prepare(
2095
+ "INSERT OR IGNORE INTO replay_meta (key, value) VALUES ('created_at', ?)"
2096
+ ).run(String(this._now() / 1e3));
2097
+ const row = db.prepare("SELECT value FROM replay_meta WHERE key = 'created_at'").get();
2098
+ this._createdAt = Number(row.value) * 1e3;
2099
+ this._purge(db);
2100
+ } catch (err) {
2101
+ db.close();
2102
+ throw err;
2103
+ }
2104
+ this._db = db;
2105
+ return db;
2106
+ }
2107
+ _purge(db) {
2108
+ const now = this._now();
2109
+ db.prepare("DELETE FROM seen_envelopes WHERE seen_at < ?").run(
2110
+ now / 1e3 - RETENTION_SECONDS
2111
+ );
2112
+ this._count = this._countRows(db);
2113
+ this._lastPurge = now;
2114
+ }
2115
+ _countRows(db) {
2116
+ const row = db.prepare("SELECT COUNT(*) AS n FROM seen_envelopes").get();
2117
+ return row.n;
2118
+ }
2119
+ };
2120
+
1686
2121
  // src/sdk/agent.ts
2122
+ var RELAY_NO_MEMORY_MS = 6e4;
2123
+ function replayCacheCapacityFromEnv() {
2124
+ const raw = process.env["UAM_REPLAY_CACHE_CAPACITY"];
2125
+ if (raw === void 0 || raw === "") {
2126
+ return DEFAULT_REPLAY_CACHE_CAPACITY;
2127
+ }
2128
+ const n = Number(raw);
2129
+ if (!Number.isInteger(n) || n < 1) {
2130
+ log.warn(
2131
+ `Ignoring invalid UAM_REPLAY_CACHE_CAPACITY=${JSON.stringify(raw)}; using ${DEFAULT_REPLAY_CACHE_CAPACITY}`
2132
+ );
2133
+ return DEFAULT_REPLAY_CACHE_CAPACITY;
2134
+ }
2135
+ return n;
2136
+ }
1687
2137
  var Agent = class {
1688
2138
  /** @internal */
1689
2139
  _config;
@@ -1700,6 +2150,23 @@ var Agent = class {
1700
2150
  _contactBook;
1701
2151
  /** @internal */
1702
2152
  _handshake;
2153
+ /**
2154
+ * @internal Wall clock in ms since the epoch, used by the inbound
2155
+ * freshness/expiry checks and the replay cache. Replaceable in tests.
2156
+ */
2157
+ _clock = () => Date.now();
2158
+ /**
2159
+ * @internal Inbound replay cache, keyed by (from address, message id).
2160
+ * Kept on disk under the data dir, so it outlives restarts and is shared by
2161
+ * every process using it; capacity from UAM_REPLAY_CACHE_CAPACITY.
2162
+ */
2163
+ _replayCache;
2164
+ /** Whether our relay has said it writes relay_meta. */
2165
+ _relayStamps = false;
2166
+ /** Until when, by _monotonic, to take the relay's last no as still true. */
2167
+ _relayNoUntil = 0;
2168
+ /** @internal Monotonic clock in ms. Replaceable in tests. */
2169
+ _monotonic = () => performance.now();
1703
2170
  /**
1704
2171
  * Create an Agent. No I/O happens here -- call connect() to initialize.
1705
2172
  */
@@ -1721,6 +2188,13 @@ var Agent = class {
1721
2188
  this._contactBook,
1722
2189
  this._config.trustPolicy
1723
2190
  );
2191
+ this._replayCache = new PersistentReplayCache(
2192
+ (0, import_node_path5.join)(this._config.dataDir, "replay", "replay.db"),
2193
+ {
2194
+ capacity: replayCacheCapacityFromEnv(),
2195
+ now: () => this._clock()
2196
+ }
2197
+ );
1724
2198
  }
1725
2199
  // -- Properties ----------------------------------------------------------
1726
2200
  /** The agent's full UAM address (e.g., 'myagent::youam.network'). */
@@ -1734,7 +2208,7 @@ var Agent = class {
1734
2208
  }
1735
2209
  /** UAM SDK version. */
1736
2210
  get version() {
1737
- return "0.1.0";
2211
+ return SDK_VERSION;
1738
2212
  }
1739
2213
  /** The agent's public key (base64-encoded Ed25519 verify key). */
1740
2214
  get publicKey() {
@@ -1771,10 +2245,19 @@ var Agent = class {
1771
2245
  }
1772
2246
  await sodiumReady;
1773
2247
  this._keyManager.loadOrGenerate(this._config.name);
1774
- const storedToken = this._keyManager.loadToken(this._config.name);
2248
+ try {
2249
+ await this._replayCache.open();
2250
+ } catch (err) {
2251
+ const reason = err instanceof Error ? err.message : String(err);
2252
+ log.warn(`Could not open the replay cache: ${reason}`);
2253
+ }
2254
+ const storedToken = this._keyManager.loadToken(
2255
+ this._config.name,
2256
+ this._config.relayUrl
2257
+ );
1775
2258
  if (storedToken) {
1776
2259
  this._token = storedToken;
1777
- this._address = `${this._config.name}::${this._config.relayDomain}`;
2260
+ this._useAddress(await this._storedAddress());
1778
2261
  } else if (this._autoRegister) {
1779
2262
  await this._registerWithRelay();
1780
2263
  } else {
@@ -1797,6 +2280,7 @@ var Agent = class {
1797
2280
  */
1798
2281
  async close() {
1799
2282
  this._contactBook.close();
2283
+ await this._replayCache.close();
1800
2284
  if (this._transport) {
1801
2285
  await this._transport.disconnect();
1802
2286
  }
@@ -1817,7 +2301,8 @@ var Agent = class {
1817
2301
  }
1818
2302
  }
1819
2303
  const recipientVk = await this._resolvePublicKey(toAddress);
1820
- if (!this._contactBook.isKnown(toAddress)) {
2304
+ const trustState = this._contactBook.getTrustState(toAddress);
2305
+ if (trustState !== "pinned" && trustState !== "verified" && trustState !== "trusted" && trustState !== "handshake-sent") {
1821
2306
  await this._initiateHandshake(toAddress, recipientVk);
1822
2307
  }
1823
2308
  const envelope = await createEnvelope(
@@ -1848,7 +2333,10 @@ var Agent = class {
1848
2333
  * Retrieve, decrypt, and verify pending messages.
1849
2334
  *
1850
2335
  * Returns a list of ReceivedMessage data objects.
1851
- * Silently drops messages with invalid signatures or decryption errors.
2336
+ * Drops messages with invalid signatures or decryption errors. A message
2337
+ * that fails processing for any other reason (malformed envelope, bad
2338
+ * handshake payload, ...) is logged and dropped; it never aborts the
2339
+ * rest of the batch.
1852
2340
  */
1853
2341
  async inbox(limit = 50) {
1854
2342
  await this._ensureConnected();
@@ -1856,7 +2344,15 @@ var Agent = class {
1856
2344
  const rawMessages = await this._transport.receive(limit);
1857
2345
  const result = [];
1858
2346
  for (const raw of rawMessages) {
1859
- const msg = await this._processInbound(raw);
2347
+ let msg;
2348
+ try {
2349
+ msg = await this._processInbound(raw);
2350
+ } catch (err) {
2351
+ log.warn(
2352
+ `Dropping inbound message ${String(raw["message_id"])} from ${String(raw["from"])}: ${err instanceof Error ? err.message : String(err)}`
2353
+ );
2354
+ continue;
2355
+ }
1860
2356
  if (msg !== null) {
1861
2357
  result.push(msg);
1862
2358
  await this._sendReadReceipt(msg);
@@ -1886,13 +2382,14 @@ var Agent = class {
1886
2382
  const cardDict = JSON.parse(entry.contactCard);
1887
2383
  const card = contactCardFromDict(cardDict);
1888
2384
  verifyContactCard(card);
2385
+ const senderVk = deserializeVerifyKey(card.publicKey);
2386
+ bindCardToEnvelope(card, { fromAddress: address }, senderVk);
1889
2387
  this._contactBook.addContact(card.address, card.publicKey, {
1890
2388
  displayName: card.displayName,
1891
2389
  trustState: "trusted",
1892
2390
  trustSource: "explicit-approval"
1893
2391
  });
1894
2392
  this._contactBook.removePending(address);
1895
- const senderVk = deserializeVerifyKey(card.publicKey);
1896
2393
  await this._handshake._sendAccept(this, address, senderVk);
1897
2394
  }
1898
2395
  /**
@@ -2029,6 +2526,10 @@ var Agent = class {
2029
2526
  /**
2030
2527
  * Resolve a recipient's public key, checking contact book first.
2031
2528
  *
2529
+ * Pure lookup: never writes to the contact book. The contact row is
2530
+ * created by _initiateHandshake (as "handshake-sent") once the request
2531
+ * has gone out, giving the lifecycle no row -> handshake-sent -> pinned.
2532
+ *
2032
2533
  * TOFU-01: Known contacts always use stored key (zero network I/O).
2033
2534
  * TOFU-03: Race-condition double-check after network resolve.
2034
2535
  * Raises KeyPinningError if a pinned contact's key doesn't match.
@@ -2052,9 +2553,6 @@ var Agent = class {
2052
2553
  );
2053
2554
  }
2054
2555
  }
2055
- this._contactBook.addContact(toAddress, resolvedPkStr, {
2056
- trustState: "provisional"
2057
- });
2058
2556
  return deserializeVerifyKey(resolvedPkStr);
2059
2557
  }
2060
2558
  async _initiateHandshake(toAddress, recipientVk) {
@@ -2101,6 +2599,9 @@ var Agent = class {
2101
2599
  }
2102
2600
  throw err;
2103
2601
  }
2602
+ if (!await this._passesInboundChecks(envelope, raw)) {
2603
+ return null;
2604
+ }
2104
2605
  if (envelope.type === "handshake.request" /* HANDSHAKE_REQUEST */ || envelope.type === "handshake.accept" /* HANDSHAKE_ACCEPT */ || envelope.type === "handshake.deny" /* HANDSHAKE_DENY */) {
2105
2606
  return this._handshake.handleInbound(this, envelope, senderVk);
2106
2607
  }
@@ -2135,6 +2636,102 @@ var Agent = class {
2135
2636
  verified: true
2136
2637
  });
2137
2638
  }
2639
+ /**
2640
+ * Recipient, freshness, expiry and replay checks for a signature-verified
2641
+ * inbound envelope. Returns false (and logs) when it must be dropped.
2642
+ * Matches the Python SDK's _process_inbound checks.
2643
+ */
2644
+ async _passesInboundChecks(envelope, raw) {
2645
+ const id = envelope.messageId;
2646
+ const from = envelope.fromAddress;
2647
+ if (this._address !== null && envelope.toAddress !== this._address) {
2648
+ log.warn(
2649
+ `Dropped envelope ${id} from ${from} addressed to ${envelope.toAddress} (we are ${this._address})`
2650
+ );
2651
+ return false;
2652
+ }
2653
+ const now = this._clock();
2654
+ const ts = Date.parse(envelope.timestamp);
2655
+ if (Number.isNaN(ts)) {
2656
+ log.warn(
2657
+ `Dropped envelope ${id} from ${from} with malformed timestamp ` + JSON.stringify(envelope.timestamp)
2658
+ );
2659
+ return false;
2660
+ }
2661
+ const ageSeconds = Math.abs(now - ts) / 1e3;
2662
+ if (ageSeconds > MAX_ENVELOPE_AGE && !await this._wasStored(raw, envelope.toAddress, ts, now)) {
2663
+ log.info(
2664
+ `Dropped stale envelope ${id} from ${from} (age=${Math.round(ageSeconds)}s > ${MAX_ENVELOPE_AGE}s)`
2665
+ );
2666
+ return false;
2667
+ }
2668
+ if (envelope.expires !== void 0 && envelope.expires !== null) {
2669
+ const exp = Date.parse(envelope.expires);
2670
+ if (Number.isNaN(exp)) {
2671
+ log.warn(
2672
+ `Dropped envelope ${id} from ${from} with malformed expires ` + JSON.stringify(envelope.expires)
2673
+ );
2674
+ return false;
2675
+ }
2676
+ if (now > exp) {
2677
+ log.info(
2678
+ `Dropped expired envelope ${id} from ${from} (expires=${envelope.expires})`
2679
+ );
2680
+ return false;
2681
+ }
2682
+ }
2683
+ if (await this._replayCache.seenOrRecord(from, id, ts)) {
2684
+ log.info(`Dropped replayed envelope ${id} from ${from}`);
2685
+ return false;
2686
+ }
2687
+ return true;
2688
+ }
2689
+ /** True if a message too old to be live is stored mail, not a replay. */
2690
+ async _wasStored(raw, toAddress, signedAt, now) {
2691
+ const relayDomain = toAddress.split("::").pop();
2692
+ if (!relayVouches(raw, signedAt, now, relayDomain)) return false;
2693
+ if (await this._replayCache.signedBeforeCache(signedAt)) return false;
2694
+ return this._relayStampsMail();
2695
+ }
2696
+ /**
2697
+ * True if our relay writes relay_meta, so a sender can't have.
2698
+ *
2699
+ * Asked only when a stale message carries a stamp. A yes is remembered for
2700
+ * good, a no for RELAY_NO_MEMORY_MS: a relay can be upgraded while this
2701
+ * agent runs. A failed lookup isn't remembered, because the relay has
2702
+ * already marked this mail delivered and the next message should get a
2703
+ * real answer.
2704
+ */
2705
+ async _relayStampsMail() {
2706
+ if (this._relayStamps) return true;
2707
+ if (this._monotonic() < this._relayNoUntil) return false;
2708
+ try {
2709
+ const features = await this._fetchRelayFeatures();
2710
+ this._relayStamps = features.includes(RELAY_META_FEATURE);
2711
+ if (!this._relayStamps) {
2712
+ this._relayNoUntil = this._monotonic() + RELAY_NO_MEMORY_MS;
2713
+ }
2714
+ } catch (err) {
2715
+ const reason = err instanceof Error ? err.message : String(err);
2716
+ log.warn(`Cannot read relay features: ${reason}`);
2717
+ }
2718
+ return this._relayStamps;
2719
+ }
2720
+ /** The relay's features; a body that doesn't list any is an empty list. */
2721
+ async _fetchRelayFeatures() {
2722
+ const resp = await fetch(`${this._config.relayUrl}/api/v1/versions`, {
2723
+ signal: AbortSignal.timeout(1e4)
2724
+ });
2725
+ if (!resp.ok) throw new UAMError(`Relay answered ${resp.status}`);
2726
+ const body = await resp.text();
2727
+ let data;
2728
+ try {
2729
+ data = JSON.parse(body);
2730
+ } catch {
2731
+ return [];
2732
+ }
2733
+ return Array.isArray(data?.features) ? data.features : [];
2734
+ }
2138
2735
  /**
2139
2736
  * Sweep expired pending handshakes and send receipt.failed (HAND-03).
2140
2737
  */
@@ -2187,7 +2784,8 @@ var Agent = class {
2187
2784
  "receipt.read" /* RECEIPT_READ */,
2188
2785
  new TextEncoder().encode(receiptPayload),
2189
2786
  this._keyManager.signingKey,
2190
- senderVk
2787
+ senderVk,
2788
+ { replyTo: msg.messageId }
2191
2789
  );
2192
2790
  const wire = toWireDict(envelope);
2193
2791
  await this._transport.send(wire);
@@ -2197,26 +2795,25 @@ var Agent = class {
2197
2795
  /**
2198
2796
  * Register with the relay server.
2199
2797
  *
2200
- * Calls POST /api/v1/register with agent name and public key.
2201
- * Stores the returned API key on disk.
2798
+ * Calls POST /api/v1/register with agent name and public key. If the
2799
+ * address exists (409), repeats it with a proof that this agent holds the
2800
+ * key. Stores the returned API key on disk.
2202
2801
  */
2203
2802
  async _registerWithRelay() {
2204
- const publicKeyStr = serializeVerifyKey(this._keyManager.verifyKey);
2205
- const resp = await fetch(
2206
- `${this._config.relayUrl}/api/v1/register`,
2207
- {
2208
- method: "POST",
2209
- headers: { "Content-Type": "application/json" },
2210
- body: JSON.stringify({
2211
- agent_name: this._config.name,
2212
- public_key: publicKeyStr
2213
- })
2214
- }
2215
- );
2803
+ const body = {
2804
+ agent_name: this._config.name,
2805
+ public_key: serializeVerifyKey(this._keyManager.verifyKey)
2806
+ };
2807
+ let resp = await this._postRegistration(body);
2216
2808
  if (resp.status === 409) {
2217
- throw new UAMError(
2218
- `Address already registered with a different key: ${this._config.name}::${this._config.relayDomain}`
2219
- );
2809
+ const address2 = await this._addressOnRelay();
2810
+ resp = await this._postRegistration({
2811
+ ...body,
2812
+ proof: this._registrationProof(address2)
2813
+ });
2814
+ if (resp.status === 409 || resp.status === 403) {
2815
+ throw this._ownershipNotProven(address2);
2816
+ }
2220
2817
  }
2221
2818
  if (!resp.ok) {
2222
2819
  const text = await resp.text();
@@ -2225,20 +2822,153 @@ var Agent = class {
2225
2822
  );
2226
2823
  }
2227
2824
  const data = await resp.json();
2228
- this._address = data["address"];
2825
+ const address = data["address"];
2826
+ this._useAddress(address);
2229
2827
  this._token = data["token"];
2230
- this._keyManager.saveToken(this._config.name, this._token);
2828
+ const { name } = this._config;
2829
+ const token = this._token;
2830
+ this._tryToSave("token", () => this._keyManager.saveToken(name, token));
2831
+ this._saveAddress(address);
2832
+ }
2833
+ /** This agent's address on the relay, for signing a proof. */
2834
+ async _addressOnRelay() {
2835
+ const { name, explicitRelayDomain, relayDomain, relayUrl } = this._config;
2836
+ if (explicitRelayDomain !== null) return `${name}::${explicitRelayDomain}`;
2837
+ const domain = await this._fetchRelayDomain();
2838
+ if (domain === null) return `${name}::${relayDomain}`;
2839
+ if (!this._relayMayUse(domain)) {
2840
+ throw new UAMError(
2841
+ `The relay at ${relayUrl} says its domain is ${domain}. Pass the domain option as ${JSON.stringify(domain)} if that is right.`
2842
+ );
2843
+ }
2844
+ return `${name}::${domain}`;
2845
+ }
2846
+ /**
2847
+ * True if the relay's URL fits `domain`, so a proof can be signed for it.
2848
+ *
2849
+ * A proof names an address on one relay, so it is only signed for a domain
2850
+ * this URL belongs to. A relay on this machine may use any domain.
2851
+ */
2852
+ _relayMayUse(domain) {
2853
+ let host;
2854
+ try {
2855
+ host = new URL(this._config.relayUrl).hostname.toLowerCase();
2856
+ } catch {
2857
+ return false;
2858
+ }
2859
+ const wanted = domain.toLowerCase();
2860
+ return host === wanted || host.endsWith(`.${wanted}`) || host === "localhost" || host === "[::1]" || /^127\.\d+\.\d+\.\d+$/.test(host);
2861
+ }
2862
+ _postRegistration(body) {
2863
+ return fetch(`${this._config.relayUrl}/api/v1/register`, {
2864
+ method: "POST",
2865
+ headers: { "Content-Type": "application/json" },
2866
+ body: JSON.stringify(body)
2867
+ });
2868
+ }
2869
+ /**
2870
+ * Sign a statement that this agent holds the key for `address`.
2871
+ *
2872
+ * The signed string must stay byte-identical to the Python SDK's.
2873
+ */
2874
+ _registrationProof(address) {
2875
+ const timestamp = utcTimestamp();
2876
+ const signature = signMessage(
2877
+ new TextEncoder().encode(`uam-register-v1
2878
+ ${address}
2879
+ ${timestamp}`),
2880
+ this._keyManager.signingKey
2881
+ );
2882
+ return { timestamp, signature };
2883
+ }
2884
+ _ownershipNotProven(address) {
2885
+ return new UAMError(
2886
+ `Address already registered, and this key could not prove ownership: ` + address
2887
+ );
2888
+ }
2889
+ /** Remember `address` for this relay, if the key folder allows it. */
2890
+ _saveAddress(address) {
2891
+ const { name, relayUrl } = this._config;
2892
+ this._tryToSave(
2893
+ "address",
2894
+ () => this._keyManager.saveAddress(name, address, relayUrl)
2895
+ );
2896
+ }
2897
+ /** Saving is best effort: a key folder we can't write doesn't stop us. */
2898
+ _tryToSave(what, save) {
2899
+ try {
2900
+ save();
2901
+ } catch (err) {
2902
+ const reason = err instanceof Error ? err.message : String(err);
2903
+ log.warn(`Could not save the ${what}: ${reason}`);
2904
+ }
2905
+ }
2906
+ /** Take `address` as ours; its domain is this relay's real domain. */
2907
+ _useAddress(address) {
2908
+ this._address = address;
2909
+ const domain = parseAddress(address).domain;
2910
+ this._config.relayDomain = domain;
2911
+ if (this._resolver instanceof SmartResolver) {
2912
+ this._resolver.relayDomain = domain;
2913
+ }
2914
+ }
2915
+ /**
2916
+ * The address the stored token belongs to.
2917
+ *
2918
+ * An explicit domain settles it. Otherwise the relay chooses the domain,
2919
+ * and it need not be the relay's hostname: use the address saved for this
2920
+ * relay, or ask the relay once and keep the answer.
2921
+ */
2922
+ async _storedAddress() {
2923
+ const { name, explicitRelayDomain } = this._config;
2924
+ if (explicitRelayDomain !== null) return `${name}::${explicitRelayDomain}`;
2925
+ const saved = this._keyManager.loadAddress(name, this._config.relayUrl);
2926
+ if (saved) return saved;
2927
+ const domain = await this._fetchRelayDomain();
2928
+ if (domain === null) return `${name}::${this._config.relayDomain}`;
2929
+ const address = `${name}::${domain}`;
2930
+ this._saveAddress(address);
2931
+ return address;
2932
+ }
2933
+ /** Ask the relay for its domain; null if it cannot say. */
2934
+ async _fetchRelayDomain() {
2935
+ const url = `${this._config.relayUrl}/.well-known/uam-relay.json`;
2936
+ try {
2937
+ const resp = await fetch(url, { signal: AbortSignal.timeout(1e4) });
2938
+ if (!resp.ok) return null;
2939
+ const data = await resp.json();
2940
+ const domain = data["relay_domain"];
2941
+ if (typeof domain !== "string" || domain === "") return null;
2942
+ return parseAddress(`${this._config.name}::${domain}`).domain;
2943
+ } catch {
2944
+ return null;
2945
+ }
2231
2946
  }
2232
2947
  };
2233
2948
 
2234
2949
  // src/sdk/webhook-verify.ts
2235
2950
  var import_node_crypto3 = require("crypto");
2951
+ function verifyWebhookSignature(payload, signatureHeader, secret) {
2952
+ if (!signatureHeader.startsWith("sha256=")) {
2953
+ return false;
2954
+ }
2955
+ const expected = (0, import_node_crypto3.createHmac)("sha256", secret).update(payload).digest("hex");
2956
+ const received = signatureHeader.slice("sha256=".length);
2957
+ const expectedBuf = Buffer.from(expected, "utf-8");
2958
+ const receivedBuf = Buffer.from(received, "utf-8");
2959
+ if (expectedBuf.length !== receivedBuf.length) {
2960
+ return false;
2961
+ }
2962
+ return (0, import_node_crypto3.timingSafeEqual)(expectedBuf, receivedBuf);
2963
+ }
2236
2964
 
2237
2965
  // src/sdk/index.ts
2238
2966
  init_dns_verifier();
2239
2967
  // Annotate the CommonJS export names for ESM import in node:
2240
2968
  0 && (module.exports = {
2241
2969
  Agent,
2242
- protocol
2970
+ ContactBook,
2971
+ protocol,
2972
+ verifyWebhookSignature
2243
2973
  });
2244
2974
  //# sourceMappingURL=index.cjs.map