@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.js CHANGED
@@ -88,6 +88,9 @@ var init_dns_verifier = __esm({
88
88
  }
89
89
  });
90
90
 
91
+ // src/sdk/agent.ts
92
+ import { join as join4 } from "path";
93
+
91
94
  // src/protocol/index.ts
92
95
  var protocol_exports = {};
93
96
  __export(protocol_exports, {
@@ -98,6 +101,7 @@ __export(protocol_exports, {
98
101
  InvalidContactCardError: () => InvalidContactCardError,
99
102
  InvalidEnvelopeError: () => InvalidEnvelopeError,
100
103
  KeyPinningError: () => KeyPinningError,
104
+ MAX_ENVELOPE_AGE: () => MAX_ENVELOPE_AGE,
101
105
  MAX_ENVELOPE_SIZE: () => MAX_ENVELOPE_SIZE,
102
106
  MessageType: () => MessageType,
103
107
  SignatureError: () => SignatureError,
@@ -137,6 +141,7 @@ __export(protocol_exports, {
137
141
  // src/protocol/types.ts
138
142
  var UAM_VERSION = "0.1";
139
143
  var MAX_ENVELOPE_SIZE = 65536;
144
+ var MAX_ENVELOPE_AGE = 300;
140
145
  var MessageType = /* @__PURE__ */ ((MessageType2) => {
141
146
  MessageType2["MESSAGE"] = "message";
142
147
  MessageType2["HANDSHAKE_REQUEST"] = "handshake.request";
@@ -661,10 +666,16 @@ function verifyContactCard(card) {
661
666
  verifySignature(canonicalize(signable), card.signature, vk);
662
667
  }
663
668
 
669
+ // src/version.ts
670
+ var SDK_VERSION = "0.2.1";
671
+
664
672
  // src/sdk/config.ts
665
673
  import { homedir } from "os";
666
674
  import { join } from "path";
667
675
  var DEFAULT_RELAY_URL = "https://relay.youam.network";
676
+ function trimRelayUrl(url) {
677
+ return url.replace(/\/+$/, "");
678
+ }
668
679
  var VALID_POLICIES = /* @__PURE__ */ new Set([
669
680
  "auto-accept",
670
681
  "approval-required",
@@ -681,9 +692,13 @@ var SDKConfig = class {
681
692
  transportType;
682
693
  trustPolicy;
683
694
  relayDomain;
695
+ /** The domain the caller chose, if any; null when only derived. */
696
+ explicitRelayDomain;
684
697
  constructor(options) {
685
698
  this.name = options.name;
686
- this.relayUrl = options.relayUrl ?? process.env["UAM_RELAY_URL"] ?? DEFAULT_RELAY_URL;
699
+ this.relayUrl = trimRelayUrl(
700
+ options.relayUrl ?? process.env["UAM_RELAY_URL"] ?? DEFAULT_RELAY_URL
701
+ );
687
702
  if (options.relayWsUrl) {
688
703
  this.relayWsUrl = options.relayWsUrl;
689
704
  } else {
@@ -694,6 +709,7 @@ var SDKConfig = class {
694
709
  this.relayWsUrl = wsUrl;
695
710
  }
696
711
  const envDomain = process.env["UAM_RELAY_DOMAIN"];
712
+ this.explicitRelayDomain = envDomain || options.relayDomain || null;
697
713
  if (envDomain) {
698
714
  this.relayDomain = envDomain;
699
715
  } else if (options.relayDomain) {
@@ -727,6 +743,27 @@ var SDKConfig = class {
727
743
  }
728
744
  };
729
745
 
746
+ // src/sdk/freshness.ts
747
+ var RELAY_META_FEATURE = "relay_meta";
748
+ var STORED_MAIL_MAX_AGE = 7 * 86400;
749
+ var ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
750
+ function relayVouches(raw, signedAt, now, relayDomain) {
751
+ const meta = raw["relay_meta"];
752
+ if (typeof meta !== "object" || meta === null || Array.isArray(meta)) {
753
+ return false;
754
+ }
755
+ const stamp = meta;
756
+ if (stamp["v"] !== 1 || stamp["relay"] !== relayDomain) return false;
757
+ if (stamp["stored"] !== true) return false;
758
+ const received = stamp["received_at"];
759
+ if (typeof received !== "string" || !ISO_TIME.test(received)) return false;
760
+ const receivedAt = Date.parse(received);
761
+ if (Number.isNaN(receivedAt)) return false;
762
+ const window = MAX_ENVELOPE_AGE * 1e3;
763
+ const age = now - signedAt;
764
+ return Math.abs(receivedAt - signedAt) <= window && age >= 0 && age <= STORED_MAIL_MAX_AGE * 1e3 + window;
765
+ }
766
+
730
767
  // src/sdk/key-manager.ts
731
768
  import {
732
769
  chmodSync,
@@ -803,17 +840,54 @@ var KeyManager = class {
803
840
  writeFileSync(tokenPath, token);
804
841
  this._setPermissions(tokenPath);
805
842
  }
843
+ /**
844
+ * Store the address `relayUrl` gave this agent.
845
+ */
846
+ saveAddress(name, address, relayUrl) {
847
+ mkdirSync(this._keyDir, { recursive: true });
848
+ writeFileSync(
849
+ join2(this._keyDir, `${name}.address`),
850
+ JSON.stringify({ address, relay_url: relayUrl })
851
+ );
852
+ }
853
+ /**
854
+ * Load the address saved for `relayUrl`.
855
+ *
856
+ * Returns null if there is none: the file is missing, unreadable or
857
+ * malformed, or it belongs to another relay.
858
+ */
859
+ loadAddress(name, relayUrl) {
860
+ try {
861
+ const path = join2(this._keyDir, `${name}.address`);
862
+ const saved = JSON.parse(readFileSync(path, "utf-8"));
863
+ const address = saved?.address;
864
+ const savedRelay = saved?.relay_url;
865
+ if (typeof address !== "string" || typeof savedRelay !== "string") {
866
+ return null;
867
+ }
868
+ if (trimRelayUrl(savedRelay) !== trimRelayUrl(relayUrl)) return null;
869
+ parseAddress(address);
870
+ return address;
871
+ } catch {
872
+ return null;
873
+ }
874
+ }
806
875
  /**
807
876
  * Load a previously saved token, or return null.
808
877
  *
809
878
  * Checks UAM_TOKEN env var first, then file-based storage,
810
- * then legacy .api_key files for backward compatibility.
879
+ * then legacy .api_key files for backward compatibility. With `relayUrl`,
880
+ * a saved token that belongs to another relay counts as none, so it is
881
+ * only offered to the relay that issued it.
811
882
  */
812
- loadToken(name) {
883
+ loadToken(name, relayUrl) {
813
884
  const envToken = process.env["UAM_TOKEN"];
814
885
  if (envToken) {
815
886
  return envToken.trim();
816
887
  }
888
+ if (relayUrl !== void 0 && this._savedForAnotherRelay(name, relayUrl)) {
889
+ return null;
890
+ }
817
891
  const tokenPath = join2(this._keyDir, `${name}.token`);
818
892
  if (existsSync(tokenPath)) {
819
893
  return readFileSync(tokenPath, "utf-8").trim();
@@ -824,6 +898,20 @@ var KeyManager = class {
824
898
  }
825
899
  return null;
826
900
  }
901
+ /**
902
+ * True if the saved address says this agent's files are another relay's.
903
+ * A missing or damaged file says nothing.
904
+ */
905
+ _savedForAnotherRelay(name, relayUrl) {
906
+ try {
907
+ const path = join2(this._keyDir, `${name}.address`);
908
+ const saved = JSON.parse(readFileSync(path, "utf-8"));
909
+ const savedRelay = saved?.relay_url;
910
+ return typeof savedRelay === "string" && trimRelayUrl(savedRelay) !== trimRelayUrl(relayUrl);
911
+ } catch {
912
+ return false;
913
+ }
914
+ }
827
915
  /** Set file permissions to 0o600 (owner read/write only). */
828
916
  _setPermissions(path) {
829
917
  if (platform() !== "win32") {
@@ -851,6 +939,20 @@ var KeyManager = class {
851
939
  import { mkdirSync as mkdirSync2 } from "fs";
852
940
  import { join as join3, dirname } from "path";
853
941
  import Database from "better-sqlite3";
942
+
943
+ // src/sdk/log.ts
944
+ var log = {
945
+ warn(message) {
946
+ console.warn(`[uam] ${message}`);
947
+ },
948
+ info(message) {
949
+ if (process.env["UAM_DEBUG"]) {
950
+ console.info(`[uam] ${message}`);
951
+ }
952
+ }
953
+ };
954
+
955
+ // src/sdk/contact-book.ts
854
956
  var ContactBook = class {
855
957
  _dbPath;
856
958
  _db = null;
@@ -971,11 +1073,30 @@ var ContactBook = class {
971
1073
  }
972
1074
  /**
973
1075
  * Add or update a contact (upsert).
1076
+ *
1077
+ * Refuses to replace the key of a contact whose trust_state is "pinned"
1078
+ * or "verified" with a different key, unless `force: true` is passed (for
1079
+ * a deliberate key rotation, which is logged). Matches the Python SDK's
1080
+ * ContactBook.add_contact.
1081
+ *
1082
+ * @throws {KeyPinningError} If a pinned/verified contact's key would be
1083
+ * replaced by a different key without `force`.
974
1084
  */
975
1085
  addContact(address, publicKey, options) {
976
1086
  if (this._db === null) {
977
1087
  throw new Error("ContactBook not open. Call open() first.");
978
1088
  }
1089
+ const existing = this._db.prepare("SELECT public_key, trust_state FROM contacts WHERE address = ?").get(address);
1090
+ if (existing !== void 0 && (existing.trust_state === "pinned" || existing.trust_state === "verified") && existing.public_key !== publicKey) {
1091
+ if (!options?.force) {
1092
+ throw new KeyPinningError(
1093
+ `Refusing to overwrite ${existing.trust_state} contact ${JSON.stringify(address)}: stored public key does not match the new key. Pass force: true to rotate.`
1094
+ );
1095
+ }
1096
+ log.warn(
1097
+ `Force-replacing the key of ${existing.trust_state} contact ${address} (key rotation)`
1098
+ );
1099
+ }
979
1100
  const displayName = options?.displayName ?? null;
980
1101
  const trustState = options?.trustState ?? "trusted";
981
1102
  const trustSource = options?.trustSource ?? null;
@@ -1148,6 +1269,18 @@ var ContactBook = class {
1148
1269
  };
1149
1270
 
1150
1271
  // src/sdk/handshake.ts
1272
+ function bindCardToEnvelope(card, envelope, senderVk) {
1273
+ if (card.address !== envelope.fromAddress) {
1274
+ throw new InvalidEnvelopeError(
1275
+ `Contact card address ${JSON.stringify(card.address)} does not match envelope sender ${JSON.stringify(envelope.fromAddress)}`
1276
+ );
1277
+ }
1278
+ if (card.publicKey !== serializeVerifyKey(senderVk)) {
1279
+ throw new InvalidEnvelopeError(
1280
+ "Contact card public_key does not match envelope sender's verify key"
1281
+ );
1282
+ }
1283
+ }
1151
1284
  var HandshakeManager = class {
1152
1285
  _contactBook;
1153
1286
  _trustPolicy;
@@ -1190,7 +1323,7 @@ var HandshakeManager = class {
1190
1323
  if (envelope.type === "handshake.request" /* HANDSHAKE_REQUEST */) {
1191
1324
  await this._handleRequest(agent, envelope, senderVk);
1192
1325
  } else if (envelope.type === "handshake.accept" /* HANDSHAKE_ACCEPT */) {
1193
- await this._handleAccept(envelope, senderVk);
1326
+ await this._handleAccept(agent, envelope, senderVk);
1194
1327
  } else if (envelope.type === "handshake.deny" /* HANDSHAKE_DENY */) {
1195
1328
  }
1196
1329
  return null;
@@ -1208,6 +1341,7 @@ var HandshakeManager = class {
1208
1341
  );
1209
1342
  const card = contactCardFromDict(cardDict);
1210
1343
  verifyContactCard(card);
1344
+ bindCardToEnvelope(card, envelope, senderVk);
1211
1345
  if (this._trustPolicy === "auto-accept") {
1212
1346
  this._contactBook.addContact(card.address, card.publicKey, {
1213
1347
  displayName: card.displayName,
@@ -1226,8 +1360,46 @@ var HandshakeManager = class {
1226
1360
  }
1227
1361
  /**
1228
1362
  * Process a handshake.accept: store the sender as pinned (TOFU).
1363
+ *
1364
+ * Only honoured when:
1365
+ * 1. we previously sent this address a handshake.request
1366
+ * (trust_state === "handshake-sent"); anything else is an unsolicited
1367
+ * accept and is dropped. This also means an accept can never
1368
+ * downgrade a trusted/verified contact to pinned;
1369
+ * 2. the payload decrypts with our key and the sender's key, and parses
1370
+ * as JSON. Otherwise it is dropped without pinning;
1371
+ * 3. an embedded contact_card, if present, is well-formed, correctly
1372
+ * self-signed, and bound to the envelope sender (throws otherwise).
1229
1373
  */
1230
- async _handleAccept(envelope, senderVk) {
1374
+ async _handleAccept(agent, envelope, senderVk) {
1375
+ const prior = this._contactBook.getTrustState(envelope.fromAddress);
1376
+ if (prior !== "handshake-sent") {
1377
+ log.warn(
1378
+ `Ignoring unsolicited handshake.accept from ${envelope.fromAddress} (trust_state=${JSON.stringify(prior)})`
1379
+ );
1380
+ return;
1381
+ }
1382
+ let payload;
1383
+ try {
1384
+ const plaintext = decryptPayload(
1385
+ envelope.payload,
1386
+ agent._keyManager.signingKey,
1387
+ senderVk
1388
+ );
1389
+ payload = JSON.parse(new TextDecoder().decode(plaintext));
1390
+ } catch {
1391
+ log.warn(
1392
+ `Dropping handshake.accept ${envelope.messageId} from ${envelope.fromAddress}: payload could not be decrypted or parsed`
1393
+ );
1394
+ return;
1395
+ }
1396
+ if (payload !== null && typeof payload === "object" && "contact_card" in payload) {
1397
+ const card = contactCardFromDict(
1398
+ payload["contact_card"]
1399
+ );
1400
+ verifyContactCard(card);
1401
+ bindCardToEnvelope(card, envelope, senderVk);
1402
+ }
1231
1403
  const senderPkStr = serializeVerifyKey(senderVk);
1232
1404
  this._contactBook.addContact(envelope.fromAddress, senderPkStr, {
1233
1405
  trustState: "pinned"
@@ -1284,6 +1456,10 @@ var HandshakeManager = class {
1284
1456
  }
1285
1457
  };
1286
1458
 
1459
+ // src/sdk/resolver-base.ts
1460
+ var AddressResolver = class {
1461
+ };
1462
+
1287
1463
  // src/sdk/tier3.ts
1288
1464
  import { createPublicClient, http, defineChain } from "viem";
1289
1465
  var DEFAULT_RPC_URL = "https://sepolia.base.org";
@@ -1400,8 +1576,6 @@ var Tier3Resolver = class extends AddressResolver {
1400
1576
 
1401
1577
  // src/sdk/resolver.ts
1402
1578
  init_dns_verifier();
1403
- var AddressResolver = class {
1404
- };
1405
1579
  var Tier1Resolver = class extends AddressResolver {
1406
1580
  async resolvePublicKey(address, _token, relayUrl) {
1407
1581
  const resp = await fetch(
@@ -1438,13 +1612,13 @@ var Tier2Resolver = class extends AddressResolver {
1438
1612
  }
1439
1613
  };
1440
1614
  var SmartResolver = class extends AddressResolver {
1441
- _relayDomain;
1615
+ relayDomain;
1442
1616
  _tier1;
1443
1617
  _tier2;
1444
1618
  _tier3;
1445
1619
  constructor(relayDomain, tier3Config) {
1446
1620
  super();
1447
- this._relayDomain = relayDomain;
1621
+ this.relayDomain = relayDomain;
1448
1622
  this._tier1 = new Tier1Resolver();
1449
1623
  this._tier2 = new Tier2Resolver();
1450
1624
  this._tier3 = tier3Config ? new Tier3Resolver(tier3Config) : null;
@@ -1452,7 +1626,7 @@ var SmartResolver = class extends AddressResolver {
1452
1626
  async resolvePublicKey(address, token, relayUrl) {
1453
1627
  const parsed = parseAddress(address);
1454
1628
  const domain = parsed.domain;
1455
- if (domain === this._relayDomain) {
1629
+ if (domain === this.relayDomain) {
1456
1630
  return this._tier1.resolvePublicKey(address, token, relayUrl);
1457
1631
  }
1458
1632
  if (domain.includes(".")) {
@@ -1531,8 +1705,10 @@ import WebSocket from "ws";
1531
1705
  var BASE_DELAY = 1;
1532
1706
  var MAX_DELAY = 60;
1533
1707
  var JITTER_RANGE = 1;
1708
+ var SEND_SETTLE_SECONDS = 5;
1534
1709
  var WebSocketTransport = class extends TransportBase {
1535
1710
  _url;
1711
+ _token;
1536
1712
  _onMessage;
1537
1713
  _ws = null;
1538
1714
  _pending = [];
@@ -1541,9 +1717,16 @@ var WebSocketTransport = class extends TransportBase {
1541
1717
  _connectReject = null;
1542
1718
  _running = false;
1543
1719
  _reconnectTimeout = null;
1720
+ /** Envelopes sent that the relay hasn't answered yet. */
1721
+ _unanswered = 0;
1722
+ _settling = null;
1723
+ _settled = null;
1724
+ /** @internal How long a close waits, in ms. Replaceable in tests. */
1725
+ _settleMs = SEND_SETTLE_SECONDS * 1e3;
1544
1726
  constructor(wsUrl, token, onMessage) {
1545
1727
  super();
1546
- this._url = `${wsUrl}?token=${token}`;
1728
+ this._url = wsUrl;
1729
+ this._token = token;
1547
1730
  this._onMessage = onMessage ?? null;
1548
1731
  }
1549
1732
  async connect() {
@@ -1564,7 +1747,14 @@ var WebSocketTransport = class extends TransportBase {
1564
1747
  };
1565
1748
  });
1566
1749
  }
1750
+ /**
1751
+ * Close the connection, once the relay has answered what was sent.
1752
+ *
1753
+ * The relay stops reading a socket when it can't write an ack to it, so
1754
+ * closing straight after send() loses every frame but the first.
1755
+ */
1567
1756
  async disconnect() {
1757
+ await this._allAnswered();
1568
1758
  this._running = false;
1569
1759
  if (this._reconnectTimeout !== null) {
1570
1760
  clearTimeout(this._reconnectTimeout);
@@ -1582,6 +1772,7 @@ var WebSocketTransport = class extends TransportBase {
1582
1772
  throw new Error("WebSocket not connected");
1583
1773
  }
1584
1774
  this._ws.send(JSON.stringify(envelope));
1775
+ this._unanswered += 1;
1585
1776
  }
1586
1777
  async receive(limit = 50) {
1587
1778
  const result = this._pending.slice(0, limit);
@@ -1591,11 +1782,32 @@ var WebSocketTransport = class extends TransportBase {
1591
1782
  async listen(callback) {
1592
1783
  this._onMessage = callback;
1593
1784
  }
1785
+ /** Resolves when nothing is unanswered, or after _settleMs regardless. */
1786
+ _allAnswered() {
1787
+ if (this._unanswered === 0) return Promise.resolve();
1788
+ this._settling ??= new Promise((resolve) => {
1789
+ const timeout = setTimeout(() => {
1790
+ log.warn(`Closing with ${this._unanswered} sends unanswered`);
1791
+ this._settled?.();
1792
+ }, this._settleMs);
1793
+ this._settled = () => {
1794
+ clearTimeout(timeout);
1795
+ this._settling = null;
1796
+ this._settled = null;
1797
+ resolve();
1798
+ };
1799
+ });
1800
+ return this._settling;
1801
+ }
1802
+ _answered() {
1803
+ this._unanswered = Math.max(0, this._unanswered - 1);
1804
+ if (this._unanswered === 0) this._settled?.();
1805
+ }
1594
1806
  _startConnectionLoop() {
1595
1807
  let attempt = 0;
1596
1808
  const tryConnect = () => {
1597
1809
  if (!this._running) return;
1598
- const ws = new WebSocket(this._url);
1810
+ const ws = new WebSocket(this._url, [`bearer.${this._token}`]);
1599
1811
  this._ws = ws;
1600
1812
  ws.on("open", () => {
1601
1813
  this._connected = true;
@@ -1616,6 +1828,8 @@ var WebSocketTransport = class extends TransportBase {
1616
1828
  ws.on("close", () => {
1617
1829
  this._connected = false;
1618
1830
  this._ws = null;
1831
+ this._unanswered = 0;
1832
+ this._settled?.();
1619
1833
  if (!this._running) return;
1620
1834
  attempt += 1;
1621
1835
  const delay = Math.min(BASE_DELAY * Math.pow(2, attempt), MAX_DELAY);
@@ -1632,19 +1846,21 @@ var WebSocketTransport = class extends TransportBase {
1632
1846
  }
1633
1847
  _handleMessage(msg) {
1634
1848
  const msgType = msg["type"];
1635
- if (msgType === "ping") {
1636
- if (this._ws && this._ws.readyState === WebSocket.OPEN) {
1637
- this._ws.send(JSON.stringify({ type: "pong" }));
1638
- }
1639
- } else if (msgType === "ack") {
1640
- } else if (msgType === "error" || "error" in msg) {
1641
- } else if ("uam_version" in msg) {
1849
+ if ("uam_version" in msg) {
1642
1850
  if (this._onMessage) {
1643
1851
  this._onMessage(msg).catch(() => {
1644
1852
  });
1645
1853
  } else {
1646
1854
  this._pending.push(msg);
1647
1855
  }
1856
+ } else if (msgType === "ping") {
1857
+ if (this._ws && this._ws.readyState === WebSocket.OPEN) {
1858
+ this._ws.send(JSON.stringify({ type: "pong" }));
1859
+ }
1860
+ } else if (msgType === "ack") {
1861
+ this._answered();
1862
+ } else if (msgType === "error" || "error" in msg) {
1863
+ this._answered();
1648
1864
  }
1649
1865
  }
1650
1866
  };
@@ -1658,7 +1874,239 @@ function createTransport(config, token, address, onMessage) {
1658
1874
  }
1659
1875
  }
1660
1876
 
1877
+ // src/sdk/replay-cache.ts
1878
+ import { mkdirSync as mkdirSync3 } from "fs";
1879
+ import { dirname as dirname2 } from "path";
1880
+ import { setTimeout as sleep } from "timers/promises";
1881
+ import Database2 from "better-sqlite3";
1882
+ var RETENTION_SECONDS = STORED_MAIL_MAX_AGE + 2 * MAX_ENVELOPE_AGE;
1883
+ var PURGE_INTERVAL_MS = 6e4;
1884
+ var WAL_ATTEMPTS = 100;
1885
+ var WAL_RETRY_MS = 100;
1886
+ var SCHEMA = `
1887
+ CREATE TABLE IF NOT EXISTS seen_envelopes (
1888
+ from_address TEXT NOT NULL,
1889
+ message_id TEXT NOT NULL,
1890
+ seen_at REAL NOT NULL,
1891
+ PRIMARY KEY (from_address, message_id)
1892
+ ) WITHOUT ROWID;
1893
+ CREATE INDEX IF NOT EXISTS seen_envelopes_seen_at ON seen_envelopes (seen_at);
1894
+ CREATE TABLE IF NOT EXISTS replay_meta (
1895
+ key TEXT PRIMARY KEY,
1896
+ value TEXT NOT NULL
1897
+ );
1898
+ `;
1899
+ function isLocked(err) {
1900
+ const code = err.code;
1901
+ return typeof code === "string" && (code.startsWith("SQLITE_BUSY") || code.startsWith("SQLITE_LOCKED"));
1902
+ }
1903
+ async function enableWal(db) {
1904
+ for (let attempt = 1; ; attempt += 1) {
1905
+ try {
1906
+ db.pragma("journal_mode = WAL");
1907
+ return;
1908
+ } catch (err) {
1909
+ if (attempt >= WAL_ATTEMPTS || !isLocked(err)) throw err;
1910
+ await sleep(WAL_RETRY_MS);
1911
+ }
1912
+ }
1913
+ }
1914
+ var DEFAULT_REPLAY_CACHE_CAPACITY = 1e6;
1915
+ var EnvelopeReplayCache = class {
1916
+ capacity;
1917
+ ttlMs;
1918
+ _now;
1919
+ // Map iteration order is insertion order: first key = least recently used.
1920
+ _seen = /* @__PURE__ */ new Map();
1921
+ constructor(options) {
1922
+ const capacity = options?.capacity ?? 1e5;
1923
+ if (!Number.isInteger(capacity) || capacity < 1) {
1924
+ throw new RangeError(
1925
+ `Replay cache capacity must be a positive integer, got ${capacity}`
1926
+ );
1927
+ }
1928
+ this.capacity = capacity;
1929
+ this.ttlMs = options?.ttlMs ?? 3e5;
1930
+ this._now = options?.now ?? (() => Date.now());
1931
+ }
1932
+ get size() {
1933
+ return this._seen.size;
1934
+ }
1935
+ /** True if this pair was seen within the TTL. Records nothing. */
1936
+ has(fromAddress, messageId) {
1937
+ const seenAt = this._seen.get(this._key(fromAddress, messageId));
1938
+ return seenAt !== void 0 && this._now() - seenAt < this.ttlMs;
1939
+ }
1940
+ /**
1941
+ * Return true if this (fromAddress, messageId) pair was already seen
1942
+ * within the TTL (a replay). Otherwise record it and return false.
1943
+ */
1944
+ seenOrRecord(fromAddress, messageId) {
1945
+ const key = this._key(fromAddress, messageId);
1946
+ const now = this._now();
1947
+ const seenAt = this._seen.get(key);
1948
+ if (seenAt !== void 0 && now - seenAt < this.ttlMs) {
1949
+ this._seen.delete(key);
1950
+ this._seen.set(key, seenAt);
1951
+ return true;
1952
+ }
1953
+ this._seen.delete(key);
1954
+ this._seen.set(key, now);
1955
+ while (this._seen.size > this.capacity) {
1956
+ const oldest = this._seen.keys().next().value;
1957
+ this._seen.delete(oldest);
1958
+ }
1959
+ return false;
1960
+ }
1961
+ _key(fromAddress, messageId) {
1962
+ return JSON.stringify([fromAddress, messageId]);
1963
+ }
1964
+ };
1965
+ var PersistentReplayCache = class {
1966
+ capacity;
1967
+ _path;
1968
+ _now;
1969
+ _db = null;
1970
+ _opening = null;
1971
+ _count = 0;
1972
+ _lastPurge = 0;
1973
+ _createdAt = 0;
1974
+ // Used only while the file is full, for envelopes inside MAX_ENVELOPE_AGE.
1975
+ // One can be signed that far ahead of us and stay acceptable until that far
1976
+ // behind, so its id must last both.
1977
+ _overflow;
1978
+ /**
1979
+ * @param path - The SQLite file
1980
+ * @param options.now - The clock, in ms since the epoch
1981
+ */
1982
+ constructor(path, options) {
1983
+ const capacity = options?.capacity ?? DEFAULT_REPLAY_CACHE_CAPACITY;
1984
+ if (!Number.isInteger(capacity) || capacity < 1) {
1985
+ throw new RangeError(
1986
+ `Replay cache capacity must be a positive integer, got ${capacity}`
1987
+ );
1988
+ }
1989
+ this.capacity = capacity;
1990
+ this._path = path;
1991
+ this._now = options?.now ?? (() => Date.now());
1992
+ this._overflow = new EnvelopeReplayCache({
1993
+ capacity: 1e4,
1994
+ ttlMs: 2 * MAX_ENVELOPE_AGE * 1e3,
1995
+ now: this._now
1996
+ });
1997
+ }
1998
+ /** Open the file now, rather than on first use. */
1999
+ async open() {
2000
+ await this._open();
2001
+ }
2002
+ /**
2003
+ * Return true if the envelope must be dropped as a replay. Otherwise record
2004
+ * the pair and return false.
2005
+ *
2006
+ * @param signedAt - The envelope's signed time, in ms since the epoch. It
2007
+ * matters only when the cache is full.
2008
+ */
2009
+ async seenOrRecord(fromAddress, messageId, signedAt) {
2010
+ const db = await this._open();
2011
+ const now = this._now();
2012
+ if (now - this._lastPurge >= PURGE_INTERVAL_MS) this._purge(db);
2013
+ if (this._overflow.has(fromAddress, messageId)) return true;
2014
+ if (this._count < this.capacity) {
2015
+ const { changes } = db.prepare(
2016
+ "INSERT OR IGNORE INTO seen_envelopes (from_address, message_id, seen_at) VALUES (?, ?, ?)"
2017
+ ).run(fromAddress, messageId, now / 1e3);
2018
+ if (changes === 0) return true;
2019
+ this._count += 1;
2020
+ return false;
2021
+ }
2022
+ const known = db.prepare(
2023
+ "SELECT 1 FROM seen_envelopes WHERE from_address = ? AND message_id = ?"
2024
+ ).get(fromAddress, messageId);
2025
+ if (known !== void 0) return true;
2026
+ if (Math.abs(now - signedAt) <= MAX_ENVELOPE_AGE * 1e3) {
2027
+ return this._overflow.seenOrRecord(fromAddress, messageId);
2028
+ }
2029
+ log.warn(
2030
+ `Replay cache full (${this._count} ids); refused envelope ${messageId} from ${fromAddress} signed ${Math.round((now - signedAt) / 1e3)}s ago`
2031
+ );
2032
+ return true;
2033
+ }
2034
+ /** When this cache file was first created, in ms since the epoch. */
2035
+ async createdAt() {
2036
+ await this._open();
2037
+ return this._createdAt;
2038
+ }
2039
+ /** True if an envelope signed at `signedAt` (ms) predates this cache. */
2040
+ async signedBeforeCache(signedAt) {
2041
+ return signedAt < await this.createdAt();
2042
+ }
2043
+ /** Number of ids currently held on disk. */
2044
+ async count() {
2045
+ const db = await this._open();
2046
+ return this._countRows(db);
2047
+ }
2048
+ /** Close the file; the next call opens it again. */
2049
+ async close() {
2050
+ await this._opening?.catch(() => void 0);
2051
+ this._db?.close();
2052
+ this._db = null;
2053
+ }
2054
+ _open() {
2055
+ if (this._db !== null) return Promise.resolve(this._db);
2056
+ this._opening ??= this._connect().finally(() => {
2057
+ this._opening = null;
2058
+ });
2059
+ return this._opening;
2060
+ }
2061
+ async _connect() {
2062
+ mkdirSync3(dirname2(this._path), { recursive: true });
2063
+ const db = new Database2(this._path, { timeout: 3e4 });
2064
+ try {
2065
+ await enableWal(db);
2066
+ db.exec(SCHEMA);
2067
+ db.prepare(
2068
+ "INSERT OR IGNORE INTO replay_meta (key, value) VALUES ('created_at', ?)"
2069
+ ).run(String(this._now() / 1e3));
2070
+ const row = db.prepare("SELECT value FROM replay_meta WHERE key = 'created_at'").get();
2071
+ this._createdAt = Number(row.value) * 1e3;
2072
+ this._purge(db);
2073
+ } catch (err) {
2074
+ db.close();
2075
+ throw err;
2076
+ }
2077
+ this._db = db;
2078
+ return db;
2079
+ }
2080
+ _purge(db) {
2081
+ const now = this._now();
2082
+ db.prepare("DELETE FROM seen_envelopes WHERE seen_at < ?").run(
2083
+ now / 1e3 - RETENTION_SECONDS
2084
+ );
2085
+ this._count = this._countRows(db);
2086
+ this._lastPurge = now;
2087
+ }
2088
+ _countRows(db) {
2089
+ const row = db.prepare("SELECT COUNT(*) AS n FROM seen_envelopes").get();
2090
+ return row.n;
2091
+ }
2092
+ };
2093
+
1661
2094
  // src/sdk/agent.ts
2095
+ var RELAY_NO_MEMORY_MS = 6e4;
2096
+ function replayCacheCapacityFromEnv() {
2097
+ const raw = process.env["UAM_REPLAY_CACHE_CAPACITY"];
2098
+ if (raw === void 0 || raw === "") {
2099
+ return DEFAULT_REPLAY_CACHE_CAPACITY;
2100
+ }
2101
+ const n = Number(raw);
2102
+ if (!Number.isInteger(n) || n < 1) {
2103
+ log.warn(
2104
+ `Ignoring invalid UAM_REPLAY_CACHE_CAPACITY=${JSON.stringify(raw)}; using ${DEFAULT_REPLAY_CACHE_CAPACITY}`
2105
+ );
2106
+ return DEFAULT_REPLAY_CACHE_CAPACITY;
2107
+ }
2108
+ return n;
2109
+ }
1662
2110
  var Agent = class {
1663
2111
  /** @internal */
1664
2112
  _config;
@@ -1675,6 +2123,23 @@ var Agent = class {
1675
2123
  _contactBook;
1676
2124
  /** @internal */
1677
2125
  _handshake;
2126
+ /**
2127
+ * @internal Wall clock in ms since the epoch, used by the inbound
2128
+ * freshness/expiry checks and the replay cache. Replaceable in tests.
2129
+ */
2130
+ _clock = () => Date.now();
2131
+ /**
2132
+ * @internal Inbound replay cache, keyed by (from address, message id).
2133
+ * Kept on disk under the data dir, so it outlives restarts and is shared by
2134
+ * every process using it; capacity from UAM_REPLAY_CACHE_CAPACITY.
2135
+ */
2136
+ _replayCache;
2137
+ /** Whether our relay has said it writes relay_meta. */
2138
+ _relayStamps = false;
2139
+ /** Until when, by _monotonic, to take the relay's last no as still true. */
2140
+ _relayNoUntil = 0;
2141
+ /** @internal Monotonic clock in ms. Replaceable in tests. */
2142
+ _monotonic = () => performance.now();
1678
2143
  /**
1679
2144
  * Create an Agent. No I/O happens here -- call connect() to initialize.
1680
2145
  */
@@ -1696,6 +2161,13 @@ var Agent = class {
1696
2161
  this._contactBook,
1697
2162
  this._config.trustPolicy
1698
2163
  );
2164
+ this._replayCache = new PersistentReplayCache(
2165
+ join4(this._config.dataDir, "replay", "replay.db"),
2166
+ {
2167
+ capacity: replayCacheCapacityFromEnv(),
2168
+ now: () => this._clock()
2169
+ }
2170
+ );
1699
2171
  }
1700
2172
  // -- Properties ----------------------------------------------------------
1701
2173
  /** The agent's full UAM address (e.g., 'myagent::youam.network'). */
@@ -1709,7 +2181,7 @@ var Agent = class {
1709
2181
  }
1710
2182
  /** UAM SDK version. */
1711
2183
  get version() {
1712
- return "0.1.0";
2184
+ return SDK_VERSION;
1713
2185
  }
1714
2186
  /** The agent's public key (base64-encoded Ed25519 verify key). */
1715
2187
  get publicKey() {
@@ -1746,10 +2218,19 @@ var Agent = class {
1746
2218
  }
1747
2219
  await sodiumReady;
1748
2220
  this._keyManager.loadOrGenerate(this._config.name);
1749
- const storedToken = this._keyManager.loadToken(this._config.name);
2221
+ try {
2222
+ await this._replayCache.open();
2223
+ } catch (err) {
2224
+ const reason = err instanceof Error ? err.message : String(err);
2225
+ log.warn(`Could not open the replay cache: ${reason}`);
2226
+ }
2227
+ const storedToken = this._keyManager.loadToken(
2228
+ this._config.name,
2229
+ this._config.relayUrl
2230
+ );
1750
2231
  if (storedToken) {
1751
2232
  this._token = storedToken;
1752
- this._address = `${this._config.name}::${this._config.relayDomain}`;
2233
+ this._useAddress(await this._storedAddress());
1753
2234
  } else if (this._autoRegister) {
1754
2235
  await this._registerWithRelay();
1755
2236
  } else {
@@ -1772,6 +2253,7 @@ var Agent = class {
1772
2253
  */
1773
2254
  async close() {
1774
2255
  this._contactBook.close();
2256
+ await this._replayCache.close();
1775
2257
  if (this._transport) {
1776
2258
  await this._transport.disconnect();
1777
2259
  }
@@ -1792,7 +2274,8 @@ var Agent = class {
1792
2274
  }
1793
2275
  }
1794
2276
  const recipientVk = await this._resolvePublicKey(toAddress);
1795
- if (!this._contactBook.isKnown(toAddress)) {
2277
+ const trustState = this._contactBook.getTrustState(toAddress);
2278
+ if (trustState !== "pinned" && trustState !== "verified" && trustState !== "trusted" && trustState !== "handshake-sent") {
1796
2279
  await this._initiateHandshake(toAddress, recipientVk);
1797
2280
  }
1798
2281
  const envelope = await createEnvelope(
@@ -1823,7 +2306,10 @@ var Agent = class {
1823
2306
  * Retrieve, decrypt, and verify pending messages.
1824
2307
  *
1825
2308
  * Returns a list of ReceivedMessage data objects.
1826
- * Silently drops messages with invalid signatures or decryption errors.
2309
+ * Drops messages with invalid signatures or decryption errors. A message
2310
+ * that fails processing for any other reason (malformed envelope, bad
2311
+ * handshake payload, ...) is logged and dropped; it never aborts the
2312
+ * rest of the batch.
1827
2313
  */
1828
2314
  async inbox(limit = 50) {
1829
2315
  await this._ensureConnected();
@@ -1831,7 +2317,15 @@ var Agent = class {
1831
2317
  const rawMessages = await this._transport.receive(limit);
1832
2318
  const result = [];
1833
2319
  for (const raw of rawMessages) {
1834
- const msg = await this._processInbound(raw);
2320
+ let msg;
2321
+ try {
2322
+ msg = await this._processInbound(raw);
2323
+ } catch (err) {
2324
+ log.warn(
2325
+ `Dropping inbound message ${String(raw["message_id"])} from ${String(raw["from"])}: ${err instanceof Error ? err.message : String(err)}`
2326
+ );
2327
+ continue;
2328
+ }
1835
2329
  if (msg !== null) {
1836
2330
  result.push(msg);
1837
2331
  await this._sendReadReceipt(msg);
@@ -1861,13 +2355,14 @@ var Agent = class {
1861
2355
  const cardDict = JSON.parse(entry.contactCard);
1862
2356
  const card = contactCardFromDict(cardDict);
1863
2357
  verifyContactCard(card);
2358
+ const senderVk = deserializeVerifyKey(card.publicKey);
2359
+ bindCardToEnvelope(card, { fromAddress: address }, senderVk);
1864
2360
  this._contactBook.addContact(card.address, card.publicKey, {
1865
2361
  displayName: card.displayName,
1866
2362
  trustState: "trusted",
1867
2363
  trustSource: "explicit-approval"
1868
2364
  });
1869
2365
  this._contactBook.removePending(address);
1870
- const senderVk = deserializeVerifyKey(card.publicKey);
1871
2366
  await this._handshake._sendAccept(this, address, senderVk);
1872
2367
  }
1873
2368
  /**
@@ -2004,6 +2499,10 @@ var Agent = class {
2004
2499
  /**
2005
2500
  * Resolve a recipient's public key, checking contact book first.
2006
2501
  *
2502
+ * Pure lookup: never writes to the contact book. The contact row is
2503
+ * created by _initiateHandshake (as "handshake-sent") once the request
2504
+ * has gone out, giving the lifecycle no row -> handshake-sent -> pinned.
2505
+ *
2007
2506
  * TOFU-01: Known contacts always use stored key (zero network I/O).
2008
2507
  * TOFU-03: Race-condition double-check after network resolve.
2009
2508
  * Raises KeyPinningError if a pinned contact's key doesn't match.
@@ -2027,9 +2526,6 @@ var Agent = class {
2027
2526
  );
2028
2527
  }
2029
2528
  }
2030
- this._contactBook.addContact(toAddress, resolvedPkStr, {
2031
- trustState: "provisional"
2032
- });
2033
2529
  return deserializeVerifyKey(resolvedPkStr);
2034
2530
  }
2035
2531
  async _initiateHandshake(toAddress, recipientVk) {
@@ -2076,6 +2572,9 @@ var Agent = class {
2076
2572
  }
2077
2573
  throw err;
2078
2574
  }
2575
+ if (!await this._passesInboundChecks(envelope, raw)) {
2576
+ return null;
2577
+ }
2079
2578
  if (envelope.type === "handshake.request" /* HANDSHAKE_REQUEST */ || envelope.type === "handshake.accept" /* HANDSHAKE_ACCEPT */ || envelope.type === "handshake.deny" /* HANDSHAKE_DENY */) {
2080
2579
  return this._handshake.handleInbound(this, envelope, senderVk);
2081
2580
  }
@@ -2110,6 +2609,102 @@ var Agent = class {
2110
2609
  verified: true
2111
2610
  });
2112
2611
  }
2612
+ /**
2613
+ * Recipient, freshness, expiry and replay checks for a signature-verified
2614
+ * inbound envelope. Returns false (and logs) when it must be dropped.
2615
+ * Matches the Python SDK's _process_inbound checks.
2616
+ */
2617
+ async _passesInboundChecks(envelope, raw) {
2618
+ const id = envelope.messageId;
2619
+ const from = envelope.fromAddress;
2620
+ if (this._address !== null && envelope.toAddress !== this._address) {
2621
+ log.warn(
2622
+ `Dropped envelope ${id} from ${from} addressed to ${envelope.toAddress} (we are ${this._address})`
2623
+ );
2624
+ return false;
2625
+ }
2626
+ const now = this._clock();
2627
+ const ts = Date.parse(envelope.timestamp);
2628
+ if (Number.isNaN(ts)) {
2629
+ log.warn(
2630
+ `Dropped envelope ${id} from ${from} with malformed timestamp ` + JSON.stringify(envelope.timestamp)
2631
+ );
2632
+ return false;
2633
+ }
2634
+ const ageSeconds = Math.abs(now - ts) / 1e3;
2635
+ if (ageSeconds > MAX_ENVELOPE_AGE && !await this._wasStored(raw, envelope.toAddress, ts, now)) {
2636
+ log.info(
2637
+ `Dropped stale envelope ${id} from ${from} (age=${Math.round(ageSeconds)}s > ${MAX_ENVELOPE_AGE}s)`
2638
+ );
2639
+ return false;
2640
+ }
2641
+ if (envelope.expires !== void 0 && envelope.expires !== null) {
2642
+ const exp = Date.parse(envelope.expires);
2643
+ if (Number.isNaN(exp)) {
2644
+ log.warn(
2645
+ `Dropped envelope ${id} from ${from} with malformed expires ` + JSON.stringify(envelope.expires)
2646
+ );
2647
+ return false;
2648
+ }
2649
+ if (now > exp) {
2650
+ log.info(
2651
+ `Dropped expired envelope ${id} from ${from} (expires=${envelope.expires})`
2652
+ );
2653
+ return false;
2654
+ }
2655
+ }
2656
+ if (await this._replayCache.seenOrRecord(from, id, ts)) {
2657
+ log.info(`Dropped replayed envelope ${id} from ${from}`);
2658
+ return false;
2659
+ }
2660
+ return true;
2661
+ }
2662
+ /** True if a message too old to be live is stored mail, not a replay. */
2663
+ async _wasStored(raw, toAddress, signedAt, now) {
2664
+ const relayDomain = toAddress.split("::").pop();
2665
+ if (!relayVouches(raw, signedAt, now, relayDomain)) return false;
2666
+ if (await this._replayCache.signedBeforeCache(signedAt)) return false;
2667
+ return this._relayStampsMail();
2668
+ }
2669
+ /**
2670
+ * True if our relay writes relay_meta, so a sender can't have.
2671
+ *
2672
+ * Asked only when a stale message carries a stamp. A yes is remembered for
2673
+ * good, a no for RELAY_NO_MEMORY_MS: a relay can be upgraded while this
2674
+ * agent runs. A failed lookup isn't remembered, because the relay has
2675
+ * already marked this mail delivered and the next message should get a
2676
+ * real answer.
2677
+ */
2678
+ async _relayStampsMail() {
2679
+ if (this._relayStamps) return true;
2680
+ if (this._monotonic() < this._relayNoUntil) return false;
2681
+ try {
2682
+ const features = await this._fetchRelayFeatures();
2683
+ this._relayStamps = features.includes(RELAY_META_FEATURE);
2684
+ if (!this._relayStamps) {
2685
+ this._relayNoUntil = this._monotonic() + RELAY_NO_MEMORY_MS;
2686
+ }
2687
+ } catch (err) {
2688
+ const reason = err instanceof Error ? err.message : String(err);
2689
+ log.warn(`Cannot read relay features: ${reason}`);
2690
+ }
2691
+ return this._relayStamps;
2692
+ }
2693
+ /** The relay's features; a body that doesn't list any is an empty list. */
2694
+ async _fetchRelayFeatures() {
2695
+ const resp = await fetch(`${this._config.relayUrl}/api/v1/versions`, {
2696
+ signal: AbortSignal.timeout(1e4)
2697
+ });
2698
+ if (!resp.ok) throw new UAMError(`Relay answered ${resp.status}`);
2699
+ const body = await resp.text();
2700
+ let data;
2701
+ try {
2702
+ data = JSON.parse(body);
2703
+ } catch {
2704
+ return [];
2705
+ }
2706
+ return Array.isArray(data?.features) ? data.features : [];
2707
+ }
2113
2708
  /**
2114
2709
  * Sweep expired pending handshakes and send receipt.failed (HAND-03).
2115
2710
  */
@@ -2162,7 +2757,8 @@ var Agent = class {
2162
2757
  "receipt.read" /* RECEIPT_READ */,
2163
2758
  new TextEncoder().encode(receiptPayload),
2164
2759
  this._keyManager.signingKey,
2165
- senderVk
2760
+ senderVk,
2761
+ { replyTo: msg.messageId }
2166
2762
  );
2167
2763
  const wire = toWireDict(envelope);
2168
2764
  await this._transport.send(wire);
@@ -2172,26 +2768,25 @@ var Agent = class {
2172
2768
  /**
2173
2769
  * Register with the relay server.
2174
2770
  *
2175
- * Calls POST /api/v1/register with agent name and public key.
2176
- * Stores the returned API key on disk.
2771
+ * Calls POST /api/v1/register with agent name and public key. If the
2772
+ * address exists (409), repeats it with a proof that this agent holds the
2773
+ * key. Stores the returned API key on disk.
2177
2774
  */
2178
2775
  async _registerWithRelay() {
2179
- const publicKeyStr = serializeVerifyKey(this._keyManager.verifyKey);
2180
- const resp = await fetch(
2181
- `${this._config.relayUrl}/api/v1/register`,
2182
- {
2183
- method: "POST",
2184
- headers: { "Content-Type": "application/json" },
2185
- body: JSON.stringify({
2186
- agent_name: this._config.name,
2187
- public_key: publicKeyStr
2188
- })
2189
- }
2190
- );
2776
+ const body = {
2777
+ agent_name: this._config.name,
2778
+ public_key: serializeVerifyKey(this._keyManager.verifyKey)
2779
+ };
2780
+ let resp = await this._postRegistration(body);
2191
2781
  if (resp.status === 409) {
2192
- throw new UAMError(
2193
- `Address already registered with a different key: ${this._config.name}::${this._config.relayDomain}`
2194
- );
2782
+ const address2 = await this._addressOnRelay();
2783
+ resp = await this._postRegistration({
2784
+ ...body,
2785
+ proof: this._registrationProof(address2)
2786
+ });
2787
+ if (resp.status === 409 || resp.status === 403) {
2788
+ throw this._ownershipNotProven(address2);
2789
+ }
2195
2790
  }
2196
2791
  if (!resp.ok) {
2197
2792
  const text = await resp.text();
@@ -2200,19 +2795,152 @@ var Agent = class {
2200
2795
  );
2201
2796
  }
2202
2797
  const data = await resp.json();
2203
- this._address = data["address"];
2798
+ const address = data["address"];
2799
+ this._useAddress(address);
2204
2800
  this._token = data["token"];
2205
- this._keyManager.saveToken(this._config.name, this._token);
2801
+ const { name } = this._config;
2802
+ const token = this._token;
2803
+ this._tryToSave("token", () => this._keyManager.saveToken(name, token));
2804
+ this._saveAddress(address);
2805
+ }
2806
+ /** This agent's address on the relay, for signing a proof. */
2807
+ async _addressOnRelay() {
2808
+ const { name, explicitRelayDomain, relayDomain, relayUrl } = this._config;
2809
+ if (explicitRelayDomain !== null) return `${name}::${explicitRelayDomain}`;
2810
+ const domain = await this._fetchRelayDomain();
2811
+ if (domain === null) return `${name}::${relayDomain}`;
2812
+ if (!this._relayMayUse(domain)) {
2813
+ throw new UAMError(
2814
+ `The relay at ${relayUrl} says its domain is ${domain}. Pass the domain option as ${JSON.stringify(domain)} if that is right.`
2815
+ );
2816
+ }
2817
+ return `${name}::${domain}`;
2818
+ }
2819
+ /**
2820
+ * True if the relay's URL fits `domain`, so a proof can be signed for it.
2821
+ *
2822
+ * A proof names an address on one relay, so it is only signed for a domain
2823
+ * this URL belongs to. A relay on this machine may use any domain.
2824
+ */
2825
+ _relayMayUse(domain) {
2826
+ let host;
2827
+ try {
2828
+ host = new URL(this._config.relayUrl).hostname.toLowerCase();
2829
+ } catch {
2830
+ return false;
2831
+ }
2832
+ const wanted = domain.toLowerCase();
2833
+ return host === wanted || host.endsWith(`.${wanted}`) || host === "localhost" || host === "[::1]" || /^127\.\d+\.\d+\.\d+$/.test(host);
2834
+ }
2835
+ _postRegistration(body) {
2836
+ return fetch(`${this._config.relayUrl}/api/v1/register`, {
2837
+ method: "POST",
2838
+ headers: { "Content-Type": "application/json" },
2839
+ body: JSON.stringify(body)
2840
+ });
2841
+ }
2842
+ /**
2843
+ * Sign a statement that this agent holds the key for `address`.
2844
+ *
2845
+ * The signed string must stay byte-identical to the Python SDK's.
2846
+ */
2847
+ _registrationProof(address) {
2848
+ const timestamp = utcTimestamp();
2849
+ const signature = signMessage(
2850
+ new TextEncoder().encode(`uam-register-v1
2851
+ ${address}
2852
+ ${timestamp}`),
2853
+ this._keyManager.signingKey
2854
+ );
2855
+ return { timestamp, signature };
2856
+ }
2857
+ _ownershipNotProven(address) {
2858
+ return new UAMError(
2859
+ `Address already registered, and this key could not prove ownership: ` + address
2860
+ );
2861
+ }
2862
+ /** Remember `address` for this relay, if the key folder allows it. */
2863
+ _saveAddress(address) {
2864
+ const { name, relayUrl } = this._config;
2865
+ this._tryToSave(
2866
+ "address",
2867
+ () => this._keyManager.saveAddress(name, address, relayUrl)
2868
+ );
2869
+ }
2870
+ /** Saving is best effort: a key folder we can't write doesn't stop us. */
2871
+ _tryToSave(what, save) {
2872
+ try {
2873
+ save();
2874
+ } catch (err) {
2875
+ const reason = err instanceof Error ? err.message : String(err);
2876
+ log.warn(`Could not save the ${what}: ${reason}`);
2877
+ }
2878
+ }
2879
+ /** Take `address` as ours; its domain is this relay's real domain. */
2880
+ _useAddress(address) {
2881
+ this._address = address;
2882
+ const domain = parseAddress(address).domain;
2883
+ this._config.relayDomain = domain;
2884
+ if (this._resolver instanceof SmartResolver) {
2885
+ this._resolver.relayDomain = domain;
2886
+ }
2887
+ }
2888
+ /**
2889
+ * The address the stored token belongs to.
2890
+ *
2891
+ * An explicit domain settles it. Otherwise the relay chooses the domain,
2892
+ * and it need not be the relay's hostname: use the address saved for this
2893
+ * relay, or ask the relay once and keep the answer.
2894
+ */
2895
+ async _storedAddress() {
2896
+ const { name, explicitRelayDomain } = this._config;
2897
+ if (explicitRelayDomain !== null) return `${name}::${explicitRelayDomain}`;
2898
+ const saved = this._keyManager.loadAddress(name, this._config.relayUrl);
2899
+ if (saved) return saved;
2900
+ const domain = await this._fetchRelayDomain();
2901
+ if (domain === null) return `${name}::${this._config.relayDomain}`;
2902
+ const address = `${name}::${domain}`;
2903
+ this._saveAddress(address);
2904
+ return address;
2905
+ }
2906
+ /** Ask the relay for its domain; null if it cannot say. */
2907
+ async _fetchRelayDomain() {
2908
+ const url = `${this._config.relayUrl}/.well-known/uam-relay.json`;
2909
+ try {
2910
+ const resp = await fetch(url, { signal: AbortSignal.timeout(1e4) });
2911
+ if (!resp.ok) return null;
2912
+ const data = await resp.json();
2913
+ const domain = data["relay_domain"];
2914
+ if (typeof domain !== "string" || domain === "") return null;
2915
+ return parseAddress(`${this._config.name}::${domain}`).domain;
2916
+ } catch {
2917
+ return null;
2918
+ }
2206
2919
  }
2207
2920
  };
2208
2921
 
2209
2922
  // src/sdk/webhook-verify.ts
2210
2923
  import { createHmac, timingSafeEqual } from "crypto";
2924
+ function verifyWebhookSignature(payload, signatureHeader, secret) {
2925
+ if (!signatureHeader.startsWith("sha256=")) {
2926
+ return false;
2927
+ }
2928
+ const expected = createHmac("sha256", secret).update(payload).digest("hex");
2929
+ const received = signatureHeader.slice("sha256=".length);
2930
+ const expectedBuf = Buffer.from(expected, "utf-8");
2931
+ const receivedBuf = Buffer.from(received, "utf-8");
2932
+ if (expectedBuf.length !== receivedBuf.length) {
2933
+ return false;
2934
+ }
2935
+ return timingSafeEqual(expectedBuf, receivedBuf);
2936
+ }
2211
2937
 
2212
2938
  // src/sdk/index.ts
2213
2939
  init_dns_verifier();
2214
2940
  export {
2215
2941
  Agent,
2216
- protocol_exports as protocol
2942
+ ContactBook,
2943
+ protocol_exports as protocol,
2944
+ verifyWebhookSignature
2217
2945
  };
2218
2946
  //# sourceMappingURL=index.js.map