@youam/sdk 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -60,11 +60,11 @@ function extractPublicKey(tags) {
60
60
  }
61
61
  return null;
62
62
  }
63
- async function queryUamTxt(domain, timeout) {
63
+ async function queryUamTxt(domain2, timeout) {
64
64
  const controller = new AbortController();
65
65
  const timer = timeout != null ? setTimeout(() => controller.abort(), timeout) : void 0;
66
66
  try {
67
- const records = await (0, import_promises.resolveTxt)(`_uam.${domain}`);
67
+ const records = await (0, import_promises.resolveTxt)(`_uam.${domain2}`);
68
68
  const results = [];
69
69
  for (const parts of records) {
70
70
  const txtValue = parts.join("");
@@ -79,8 +79,8 @@ async function queryUamTxt(domain, timeout) {
79
79
  if (timer != null) clearTimeout(timer);
80
80
  }
81
81
  }
82
- async function resolveKeyViaHttps(agentName, domain, timeout) {
83
- const url = `https://${domain}/.well-known/uam.json`;
82
+ async function resolveKeyViaHttps(agentName, domain2, timeout) {
83
+ const url = `https://${domain2}/.well-known/uam.json`;
84
84
  try {
85
85
  const resp = await fetch(url, {
86
86
  signal: AbortSignal.timeout(timeout ?? 1e4)
@@ -114,14 +114,18 @@ var init_dns_verifier = __esm({
114
114
  // src/cli/index.ts
115
115
  var import_commander = require("commander");
116
116
 
117
+ // src/version.ts
118
+ var SDK_VERSION = "0.4.0";
119
+
117
120
  // src/cli/commands/init.ts
118
121
  var import_node_os5 = require("os");
119
- var import_node_fs5 = require("fs");
120
- var import_node_path5 = require("path");
122
+ var import_node_fs6 = require("fs");
123
+ var import_node_path7 = require("path");
121
124
 
122
125
  // src/protocol/types.ts
123
126
  var UAM_VERSION = "0.1";
124
127
  var MAX_ENVELOPE_SIZE = 65536;
128
+ var MAX_ENVELOPE_AGE = 300;
125
129
  function b64Encode(data) {
126
130
  return Buffer.from(data).toString("base64url");
127
131
  }
@@ -195,6 +199,18 @@ var KeyPinningError = class _KeyPinningError extends UAMError {
195
199
  Object.setPrototypeOf(this, _KeyPinningError.prototype);
196
200
  }
197
201
  };
202
+ var SendRefusedError = class extends UAMError {
203
+ messageId;
204
+ code;
205
+ detail;
206
+ constructor(messageId, code, detail) {
207
+ super(`Relay refused message ${messageId}: [${code}] ${detail ?? ""}`.trimEnd());
208
+ this.name = "SendRefusedError";
209
+ this.messageId = messageId;
210
+ this.code = code;
211
+ this.detail = detail;
212
+ }
213
+ };
198
214
 
199
215
  // src/protocol/address.ts
200
216
  var ADDRESS_RE = /^(?<agent>[a-z0-9][a-z0-9_-]{0,62}[a-z0-9]|[a-z0-9])::(?<domain>[a-z0-9](?:[a-z0-9.-]{0,253}[a-z0-9])?)$/;
@@ -321,11 +337,11 @@ function encryptPayload(plaintext, senderSigningKey, recipientVerifyKey) {
321
337
  try {
322
338
  const senderCurve = import_libsodium_wrappers.default.crypto_sign_ed25519_sk_to_curve25519(senderSigningKey);
323
339
  const recipientCurve = import_libsodium_wrappers.default.crypto_sign_ed25519_pk_to_curve25519(recipientVerifyKey);
324
- const nonce = import_libsodium_wrappers.default.randombytes_buf(import_libsodium_wrappers.default.crypto_box_NONCEBYTES);
325
- const encrypted = import_libsodium_wrappers.default.crypto_box_easy(plaintext, nonce, recipientCurve, senderCurve);
326
- const combined = new Uint8Array(nonce.length + encrypted.length);
327
- combined.set(nonce, 0);
328
- combined.set(encrypted, nonce.length);
340
+ const nonce2 = import_libsodium_wrappers.default.randombytes_buf(import_libsodium_wrappers.default.crypto_box_NONCEBYTES);
341
+ const encrypted = import_libsodium_wrappers.default.crypto_box_easy(plaintext, nonce2, recipientCurve, senderCurve);
342
+ const combined = new Uint8Array(nonce2.length + encrypted.length);
343
+ combined.set(nonce2, 0);
344
+ combined.set(encrypted, nonce2.length);
329
345
  return b64Encode(combined);
330
346
  } catch (err) {
331
347
  if (err instanceof EncryptionError) throw err;
@@ -335,11 +351,11 @@ function encryptPayload(plaintext, senderSigningKey, recipientVerifyKey) {
335
351
  function decryptPayload(ciphertextB64, recipientSigningKey, senderVerifyKey) {
336
352
  try {
337
353
  const combined = b64Decode(ciphertextB64);
338
- const nonce = combined.slice(0, import_libsodium_wrappers.default.crypto_box_NONCEBYTES);
354
+ const nonce2 = combined.slice(0, import_libsodium_wrappers.default.crypto_box_NONCEBYTES);
339
355
  const ciphertext = combined.slice(import_libsodium_wrappers.default.crypto_box_NONCEBYTES);
340
356
  const recipientCurve = import_libsodium_wrappers.default.crypto_sign_ed25519_sk_to_curve25519(recipientSigningKey);
341
357
  const senderCurve = import_libsodium_wrappers.default.crypto_sign_ed25519_pk_to_curve25519(senderVerifyKey);
342
- return import_libsodium_wrappers.default.crypto_box_open_easy(ciphertext, nonce, senderCurve, recipientCurve);
358
+ return import_libsodium_wrappers.default.crypto_box_open_easy(ciphertext, nonce2, senderCurve, recipientCurve);
343
359
  } catch (err) {
344
360
  if (err instanceof DecryptionError) throw err;
345
361
  throw new DecryptionError(err instanceof Error ? err.message : String(err));
@@ -447,7 +463,7 @@ async function createEnvelope(fromAddress, toAddress, messageType, payloadPlaint
447
463
  parseAddress(fromAddress);
448
464
  parseAddress(toAddress);
449
465
  const messageId = (0, import_node_crypto2.randomUUID)();
450
- const nonce = generateNonce();
466
+ const nonce2 = generateNonce();
451
467
  const timestamp = utcTimestamp();
452
468
  let encryptedPayload;
453
469
  if (typeValue === "handshake.request" /* HANDSHAKE_REQUEST */) {
@@ -466,7 +482,7 @@ async function createEnvelope(fromAddress, toAddress, messageType, payloadPlaint
466
482
  toAddress,
467
483
  timestamp,
468
484
  type: typeValue,
469
- nonce,
485
+ nonce: nonce2,
470
486
  payload: encryptedPayload,
471
487
  signature: "",
472
488
  threadId: options?.threadId,
@@ -485,7 +501,7 @@ async function createEnvelope(fromAddress, toAddress, messageType, payloadPlaint
485
501
  toAddress,
486
502
  timestamp,
487
503
  type: typeValue,
488
- nonce,
504
+ nonce: nonce2,
489
505
  payload: encryptedPayload,
490
506
  signature,
491
507
  threadId: options?.threadId,
@@ -577,8 +593,8 @@ function contactCardFromDict(d, options) {
577
593
  }
578
594
  return card;
579
595
  }
580
- async function createContactCard(address, displayName, relay, signingKey, options) {
581
- parseAddress(address);
596
+ async function createContactCard(address2, displayName, relay, signingKey, options) {
597
+ parseAddress(address2);
582
598
  const verifyKey = signingKey.slice(32);
583
599
  const pubKeyB64 = serializeVerifyKey(verifyKey);
584
600
  const fp = publicKeyFingerprint(verifyKey);
@@ -588,7 +604,7 @@ async function createContactCard(address, displayName, relay, signingKey, option
588
604
  ];
589
605
  const tempCard = {
590
606
  version: UAM_VERSION,
591
- address,
607
+ address: address2,
592
608
  displayName,
593
609
  description: options?.description,
594
610
  system: options?.system,
@@ -605,7 +621,7 @@ async function createContactCard(address, displayName, relay, signingKey, option
605
621
  const signature = signMessage(canonicalize(signable), signingKey);
606
622
  return Object.freeze({
607
623
  version: UAM_VERSION,
608
- address,
624
+ address: address2,
609
625
  displayName,
610
626
  description: options?.description,
611
627
  system: options?.system,
@@ -632,10 +648,163 @@ function verifyContactCard(card) {
632
648
  verifySignature(canonicalize(signable), card.signature, vk);
633
649
  }
634
650
 
651
+ // src/protocol/signed.ts
652
+ var import_node_crypto3 = require("crypto");
653
+
654
+ // src/protocol/didkey.ts
655
+ var B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
656
+ var PREFIX = {
657
+ ed25519: new Uint8Array([237, 1]),
658
+ p256: new Uint8Array([128, 36])
659
+ };
660
+ var LENGTH = { ed25519: 32, p256: 33 };
661
+ var InvalidDidKeyError = class extends UAMError {
662
+ constructor(message) {
663
+ super(message);
664
+ this.name = "InvalidDidKeyError";
665
+ }
666
+ };
667
+ function b58decode(text) {
668
+ let n = 0n;
669
+ for (const c of text) {
670
+ const i = B58.indexOf(c);
671
+ if (i < 0) throw new InvalidDidKeyError(`not base58btc: ${JSON.stringify(c)}`);
672
+ n = n * 58n + BigInt(i);
673
+ }
674
+ const body = [];
675
+ while (n > 0n) {
676
+ body.unshift(Number(n & 0xffn));
677
+ n >>= 8n;
678
+ }
679
+ let zeros = 0;
680
+ while (zeros < text.length && text[zeros] === "1") zeros += 1;
681
+ return new Uint8Array([...new Array(zeros).fill(0), ...body]);
682
+ }
683
+ function checkKey(kind, key) {
684
+ if (key.length !== LENGTH[kind]) {
685
+ throw new InvalidDidKeyError(`${kind} key must be ${LENGTH[kind]} bytes`);
686
+ }
687
+ if (kind === "p256" && key[0] !== 2 && key[0] !== 3) {
688
+ throw new InvalidDidKeyError("P-256 key must be a compressed point");
689
+ }
690
+ }
691
+ function decodeDidKey(did2) {
692
+ if (typeof did2 !== "string" || !did2.startsWith("did:key:z")) {
693
+ throw new InvalidDidKeyError("not a did:key with base58btc multibase");
694
+ }
695
+ const raw = b58decode(did2.slice("did:key:z".length));
696
+ for (const kind of Object.keys(PREFIX)) {
697
+ const prefix = PREFIX[kind];
698
+ if (raw.length >= prefix.length && prefix.every((b, i) => raw[i] === b)) {
699
+ const key = raw.slice(prefix.length);
700
+ checkKey(kind, key);
701
+ return { kind, key };
702
+ }
703
+ }
704
+ throw new InvalidDidKeyError("unsupported key type");
705
+ }
706
+
707
+ // src/protocol/signed.ts
708
+ var MAX_INT = 2 ** 53 - 1;
709
+ var SignedObjectError = class extends UAMError {
710
+ constructor(message) {
711
+ super(message);
712
+ this.name = "SignedObjectError";
713
+ }
714
+ };
715
+ var has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
716
+ function isPlainObject(value) {
717
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
718
+ const proto = Object.getPrototypeOf(value);
719
+ return proto === null || proto === Object.prototype;
720
+ }
721
+ var HASH_RE = /^[A-Za-z0-9_-]{43}$/;
722
+ var OWNER_ID_RE = /^[A-Za-z0-9_-]{22}$/;
723
+ var NONCE_RE = /^[A-Za-z0-9_-]{16,64}$/;
724
+ var SECRET_RE = /^[A-Za-z0-9_-]{22}$/;
725
+ var DOMAIN_RE = /^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
726
+ function fail(path, why) {
727
+ throw new SignedObjectError(`${path}: ${why}`);
728
+ }
729
+ var matching = (re, what) => (v, p) => {
730
+ if (typeof v !== "string" || !re.test(v)) fail(p, `must be ${what}`);
731
+ };
732
+ var hash = matching(HASH_RE, "a hash (43 base64url characters)");
733
+ var ownerId = matching(OWNER_ID_RE, "an owner id (22 base64url characters)");
734
+ var nonce = matching(NONCE_RE, "a nonce (16-64 base64url characters)");
735
+ var secret = matching(SECRET_RE, "a secret (22 base64url characters)");
736
+ var domain = matching(DOMAIN_RE, "a lowercase domain");
737
+ var constant = (expected) => (v, p) => {
738
+ if (v !== expected) fail(p, `must be ${JSON.stringify(expected)}`);
739
+ };
740
+ var oneOf = (...options) => (v, p) => {
741
+ if (typeof v !== "string" || !options.includes(v)) fail(p, `must be one of ${options.join(", ")}`);
742
+ };
743
+ var address = (v, p) => {
744
+ if (typeof v !== "string") fail(p, "must be an address");
745
+ let full;
746
+ try {
747
+ full = parseAddress(v).full;
748
+ } catch {
749
+ return fail(p, "must be an address");
750
+ }
751
+ if (full !== v) fail(p, "must be an address in canonical (lowercase) form");
752
+ };
753
+ var did = (kind) => (v, p) => {
754
+ let found;
755
+ try {
756
+ found = decodeDidKey(v).kind;
757
+ } catch (err) {
758
+ return fail(p, err instanceof Error ? err.message : String(err));
759
+ }
760
+ if (found !== kind) fail(p, `must be a ${kind} did:key`);
761
+ };
762
+ var list = (item, minLen = 1, maxLen = 64) => (v, p) => {
763
+ if (!Array.isArray(v) || v.length < minLen || v.length > maxLen) {
764
+ fail(p, `must be a list of ${minLen}-${maxLen} items`);
765
+ }
766
+ v.forEach((entry, i) => item(entry, `${p}[${i}]`));
767
+ };
768
+ var object = (required, optional = {}) => (v, p) => {
769
+ if (!isPlainObject(v)) fail(p, "must be an object");
770
+ for (const key of Object.keys(v)) {
771
+ if (!has(required, key) && !has(optional, key)) fail(`${p}.${key}`, "unknown member");
772
+ }
773
+ for (const [key, member] of Object.entries(required)) {
774
+ if (!has(v, key)) fail(`${p}.${key}`, "missing");
775
+ member(v[key], `${p}.${key}`);
776
+ }
777
+ for (const [key, member] of Object.entries(optional)) {
778
+ if (has(v, key)) member(v[key], `${p}.${key}`);
779
+ }
780
+ };
781
+ var location = (v, p) => {
782
+ if (v !== "shared") address(v, p);
783
+ };
784
+ var access = object({ actions: list(oneOf("message"), 1, 1), locations: list(location) });
785
+ var ownerRef = object({ relay: domain, ownerId, keysHash: hash });
786
+ var ownerRefNoKeys = object({ relay: domain, ownerId });
787
+ var passkey = did("p256");
788
+ var agentKey = did("ed25519");
789
+ var keysOwner = object({ relay: domain }, { ownerId });
790
+ var DAY = 24 * 60 * 60 * 1e3;
791
+ var SUBJECTS = {
792
+ owner: object({ kind: constant("owner"), owner: ownerRef }),
793
+ invitee: object({ kind: constant("invitee"), invite: hash }),
794
+ agent: object({ kind: constant("agent"), id: agentKey, address })
795
+ };
796
+
797
+ // src/protocol/webauthn.ts
798
+ var import_node_crypto4 = require("crypto");
799
+ var import_p256 = require("@noble/curves/p256");
800
+
635
801
  // src/sdk/config.ts
636
802
  var import_node_os = require("os");
637
803
  var import_node_path = require("path");
638
804
  var DEFAULT_RELAY_URL = "https://relay.youam.network";
805
+ function trimRelayUrl(url) {
806
+ return url.replace(/\/+$/, "");
807
+ }
639
808
  var VALID_POLICIES = /* @__PURE__ */ new Set([
640
809
  "auto-accept",
641
810
  "approval-required",
@@ -652,9 +821,13 @@ var SDKConfig = class {
652
821
  transportType;
653
822
  trustPolicy;
654
823
  relayDomain;
824
+ /** The domain the caller chose, if any; null when only derived. */
825
+ explicitRelayDomain;
655
826
  constructor(options) {
656
827
  this.name = options.name;
657
- this.relayUrl = options.relayUrl ?? process.env["UAM_RELAY_URL"] ?? DEFAULT_RELAY_URL;
828
+ this.relayUrl = trimRelayUrl(
829
+ options.relayUrl ?? process.env["UAM_RELAY_URL"] ?? DEFAULT_RELAY_URL
830
+ );
658
831
  if (options.relayWsUrl) {
659
832
  this.relayWsUrl = options.relayWsUrl;
660
833
  } else {
@@ -665,6 +838,7 @@ var SDKConfig = class {
665
838
  this.relayWsUrl = wsUrl;
666
839
  }
667
840
  const envDomain = process.env["UAM_RELAY_DOMAIN"];
841
+ this.explicitRelayDomain = envDomain || options.relayDomain || null;
668
842
  if (envDomain) {
669
843
  this.relayDomain = envDomain;
670
844
  } else if (options.relayDomain) {
@@ -768,17 +942,54 @@ var KeyManager = class {
768
942
  (0, import_node_fs.writeFileSync)(tokenPath, token);
769
943
  this._setPermissions(tokenPath);
770
944
  }
945
+ /**
946
+ * Store the address `relayUrl` gave this agent.
947
+ */
948
+ saveAddress(name, address2, relayUrl) {
949
+ (0, import_node_fs.mkdirSync)(this._keyDir, { recursive: true });
950
+ (0, import_node_fs.writeFileSync)(
951
+ (0, import_node_path2.join)(this._keyDir, `${name}.address`),
952
+ JSON.stringify({ address: address2, relay_url: relayUrl })
953
+ );
954
+ }
955
+ /**
956
+ * Load the address saved for `relayUrl`.
957
+ *
958
+ * Returns null if there is none: the file is missing, unreadable or
959
+ * malformed, or it belongs to another relay.
960
+ */
961
+ loadAddress(name, relayUrl) {
962
+ try {
963
+ const path = (0, import_node_path2.join)(this._keyDir, `${name}.address`);
964
+ const saved = JSON.parse((0, import_node_fs.readFileSync)(path, "utf-8"));
965
+ const address2 = saved?.address;
966
+ const savedRelay = saved?.relay_url;
967
+ if (typeof address2 !== "string" || typeof savedRelay !== "string") {
968
+ return null;
969
+ }
970
+ if (trimRelayUrl(savedRelay) !== trimRelayUrl(relayUrl)) return null;
971
+ parseAddress(address2);
972
+ return address2;
973
+ } catch {
974
+ return null;
975
+ }
976
+ }
771
977
  /**
772
978
  * Load a previously saved token, or return null.
773
979
  *
774
980
  * Checks UAM_TOKEN env var first, then file-based storage,
775
- * then legacy .api_key files for backward compatibility.
981
+ * then legacy .api_key files for backward compatibility. With `relayUrl`,
982
+ * a saved token that belongs to another relay counts as none, so it is
983
+ * only offered to the relay that issued it.
776
984
  */
777
- loadToken(name) {
985
+ loadToken(name, relayUrl) {
778
986
  const envToken = process.env["UAM_TOKEN"];
779
987
  if (envToken) {
780
988
  return envToken.trim();
781
989
  }
990
+ if (relayUrl !== void 0 && this._savedForAnotherRelay(name, relayUrl)) {
991
+ return null;
992
+ }
782
993
  const tokenPath = (0, import_node_path2.join)(this._keyDir, `${name}.token`);
783
994
  if ((0, import_node_fs2.existsSync)(tokenPath)) {
784
995
  return (0, import_node_fs.readFileSync)(tokenPath, "utf-8").trim();
@@ -789,6 +1000,20 @@ var KeyManager = class {
789
1000
  }
790
1001
  return null;
791
1002
  }
1003
+ /**
1004
+ * True if the saved address says this agent's files are another relay's.
1005
+ * A missing or damaged file says nothing.
1006
+ */
1007
+ _savedForAnotherRelay(name, relayUrl) {
1008
+ try {
1009
+ const path = (0, import_node_path2.join)(this._keyDir, `${name}.address`);
1010
+ const saved = JSON.parse((0, import_node_fs.readFileSync)(path, "utf-8"));
1011
+ const savedRelay = saved?.relay_url;
1012
+ return typeof savedRelay === "string" && trimRelayUrl(savedRelay) !== trimRelayUrl(relayUrl);
1013
+ } catch {
1014
+ return false;
1015
+ }
1016
+ }
792
1017
  /** Set file permissions to 0o600 (owner read/write only). */
793
1018
  _setPermissions(path) {
794
1019
  if ((0, import_node_os3.platform)() !== "win32") {
@@ -812,10 +1037,73 @@ var KeyManager = class {
812
1037
  }
813
1038
  };
814
1039
 
1040
+ // src/sdk/agent.ts
1041
+ var import_node_path5 = require("path");
1042
+
1043
+ // src/sdk/freshness.ts
1044
+ var RELAY_META_FEATURE = "relay_meta";
1045
+ var STORED_MAIL_MAX_AGE = 7 * 86400;
1046
+ var ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
1047
+ function relayVouches(raw, signedAt, now, relayDomain) {
1048
+ const meta = raw["relay_meta"];
1049
+ if (typeof meta !== "object" || meta === null || Array.isArray(meta)) {
1050
+ return false;
1051
+ }
1052
+ const stamp = meta;
1053
+ if (stamp["v"] !== 1 || stamp["relay"] !== relayDomain) return false;
1054
+ if (stamp["stored"] !== true) return false;
1055
+ const received = stamp["received_at"];
1056
+ if (typeof received !== "string" || !ISO_TIME.test(received)) return false;
1057
+ const receivedAt = Date.parse(received);
1058
+ if (Number.isNaN(receivedAt)) return false;
1059
+ const window = MAX_ENVELOPE_AGE * 1e3;
1060
+ const age = now - signedAt;
1061
+ return Math.abs(receivedAt - signedAt) <= window && age >= 0 && age <= STORED_MAIL_MAX_AGE * 1e3 + window;
1062
+ }
1063
+
1064
+ // src/sdk/message.ts
1065
+ var SEND_STATUSES = [
1066
+ "delivered",
1067
+ "stored",
1068
+ "relayed",
1069
+ "queued",
1070
+ "duplicate",
1071
+ "dropped",
1072
+ "pending",
1073
+ "accepted",
1074
+ "unknown"
1075
+ ];
1076
+ function sendStatus(answer) {
1077
+ if (answer === null) return "unknown";
1078
+ const status = answer["status"];
1079
+ if (typeof status === "string" && SEND_STATUSES.includes(status) && status !== "accepted" && status !== "unknown") {
1080
+ return status;
1081
+ }
1082
+ return "accepted";
1083
+ }
1084
+ var PENDING_NOTE = "Not delivered: {to} only accepts messages from approved contacts. Do not resend it.";
1085
+ function pendingNote(to) {
1086
+ return PENDING_NOTE.replace("{to}", to);
1087
+ }
1088
+
815
1089
  // src/sdk/contact-book.ts
816
1090
  var import_node_fs3 = require("fs");
817
1091
  var import_node_path3 = require("path");
818
1092
  var import_better_sqlite3 = __toESM(require("better-sqlite3"), 1);
1093
+
1094
+ // src/sdk/log.ts
1095
+ var log = {
1096
+ warn(message) {
1097
+ console.warn(`[uam] ${message}`);
1098
+ },
1099
+ info(message) {
1100
+ if (process.env["UAM_DEBUG"]) {
1101
+ console.info(`[uam] ${message}`);
1102
+ }
1103
+ }
1104
+ };
1105
+
1106
+ // src/sdk/contact-book.ts
819
1107
  var ContactBook = class {
820
1108
  _dbPath;
821
1109
  _db = null;
@@ -895,36 +1183,36 @@ var ContactBook = class {
895
1183
  /**
896
1184
  * Check if an address is in the contact book (in-memory, no I/O).
897
1185
  */
898
- isKnown(address) {
899
- return this._knownAddresses.has(address);
1186
+ isKnown(address2) {
1187
+ return this._knownAddresses.has(address2);
900
1188
  }
901
1189
  /**
902
1190
  * Check if an address matches any blocked pattern (O(1) set lookup).
903
1191
  */
904
- isBlocked(address) {
905
- if (this._blockedExact.has(address)) {
1192
+ isBlocked(address2) {
1193
+ if (this._blockedExact.has(address2)) {
906
1194
  return true;
907
1195
  }
908
- if (address.includes("::")) {
909
- const domain = address.split("::")[1];
910
- return this._blockedDomains.has(domain);
1196
+ if (address2.includes("::")) {
1197
+ const domain2 = address2.split("::")[1];
1198
+ return this._blockedDomains.has(domain2);
911
1199
  }
912
1200
  return false;
913
1201
  }
914
1202
  /**
915
1203
  * Look up the public key for a known contact.
916
1204
  */
917
- getPublicKey(address) {
1205
+ getPublicKey(address2) {
918
1206
  if (this._db === null) return null;
919
- const row = this._db.prepare("SELECT public_key FROM contacts WHERE address = ?").get(address);
1207
+ const row = this._db.prepare("SELECT public_key FROM contacts WHERE address = ?").get(address2);
920
1208
  return row?.public_key ?? null;
921
1209
  }
922
1210
  /**
923
1211
  * Return the relay URLs for a known contact (CARD-04).
924
1212
  */
925
- getRelayUrls(address) {
1213
+ getRelayUrls(address2) {
926
1214
  if (this._db === null) return null;
927
- const row = this._db.prepare("SELECT relay, relays_json FROM contacts WHERE address = ?").get(address);
1215
+ const row = this._db.prepare("SELECT relay, relays_json FROM contacts WHERE address = ?").get(address2);
928
1216
  if (!row) return null;
929
1217
  if (row.relays_json !== null) {
930
1218
  return JSON.parse(row.relays_json);
@@ -936,11 +1224,30 @@ var ContactBook = class {
936
1224
  }
937
1225
  /**
938
1226
  * Add or update a contact (upsert).
1227
+ *
1228
+ * Refuses to replace the key of a contact whose trust_state is "pinned"
1229
+ * or "verified" with a different key, unless `force: true` is passed (for
1230
+ * a deliberate key rotation, which is logged). Matches the Python SDK's
1231
+ * ContactBook.add_contact.
1232
+ *
1233
+ * @throws {KeyPinningError} If a pinned/verified contact's key would be
1234
+ * replaced by a different key without `force`.
939
1235
  */
940
- addContact(address, publicKey, options) {
1236
+ addContact(address2, publicKey, options) {
941
1237
  if (this._db === null) {
942
1238
  throw new Error("ContactBook not open. Call open() first.");
943
1239
  }
1240
+ const existing = this._db.prepare("SELECT public_key, trust_state FROM contacts WHERE address = ?").get(address2);
1241
+ if (existing !== void 0 && (existing.trust_state === "pinned" || existing.trust_state === "verified") && existing.public_key !== publicKey) {
1242
+ if (!options?.force) {
1243
+ throw new KeyPinningError(
1244
+ `Refusing to overwrite ${existing.trust_state} contact ${JSON.stringify(address2)}: stored public key does not match the new key. Pass force: true to rotate.`
1245
+ );
1246
+ }
1247
+ log.warn(
1248
+ `Force-replacing the key of ${existing.trust_state} contact ${address2} (key rotation)`
1249
+ );
1250
+ }
944
1251
  const displayName = options?.displayName ?? null;
945
1252
  const trustState = options?.trustState ?? "trusted";
946
1253
  const trustSource = options?.trustSource ?? null;
@@ -957,8 +1264,8 @@ var ContactBook = class {
957
1264
  relay = COALESCE(excluded.relay, contacts.relay),
958
1265
  relays_json = COALESCE(excluded.relays_json, contacts.relays_json),
959
1266
  last_seen = datetime('now')`
960
- ).run(address, publicKey, displayName, trustState, trustSource, relay, relaysJson);
961
- this._knownAddresses.add(address);
1267
+ ).run(address2, publicKey, displayName, trustState, trustSource, relay, relaysJson);
1268
+ this._knownAddresses.add(address2);
962
1269
  }
963
1270
  /**
964
1271
  * Return all contacts sorted by last_seen DESC.
@@ -979,13 +1286,13 @@ var ContactBook = class {
979
1286
  /**
980
1287
  * Store a pending handshake request.
981
1288
  */
982
- addPending(address, contactCardJson) {
1289
+ addPending(address2, contactCardJson) {
983
1290
  if (this._db === null) {
984
1291
  throw new Error("ContactBook not open. Call open() first.");
985
1292
  }
986
1293
  this._db.prepare(
987
1294
  `INSERT OR REPLACE INTO pending_handshakes (address, contact_card) VALUES (?, ?)`
988
- ).run(address, contactCardJson);
1295
+ ).run(address2, contactCardJson);
989
1296
  }
990
1297
  /**
991
1298
  * Retrieve all pending handshake requests.
@@ -1004,9 +1311,9 @@ var ContactBook = class {
1004
1311
  /**
1005
1312
  * Remove a pending handshake request.
1006
1313
  */
1007
- removePending(address) {
1314
+ removePending(address2) {
1008
1315
  if (this._db === null) return;
1009
- this._db.prepare("DELETE FROM pending_handshakes WHERE address = ?").run(address);
1316
+ this._db.prepare("DELETE FROM pending_handshakes WHERE address = ?").run(address2);
1010
1317
  }
1011
1318
  /**
1012
1319
  * Return pending handshakes older than N days.
@@ -1026,31 +1333,31 @@ var ContactBook = class {
1026
1333
  /**
1027
1334
  * Return the trust_state for an address, or null if unknown.
1028
1335
  */
1029
- getTrustState(address) {
1336
+ getTrustState(address2) {
1030
1337
  if (this._db === null) return null;
1031
- const row = this._db.prepare("SELECT trust_state FROM contacts WHERE address = ?").get(address);
1338
+ const row = this._db.prepare("SELECT trust_state FROM contacts WHERE address = ?").get(address2);
1032
1339
  return row?.trust_state ?? null;
1033
1340
  }
1034
1341
  /**
1035
1342
  * Set the pinned_at timestamp and trust_state='pinned' for a contact (TOFU).
1036
1343
  */
1037
- setPinnedAt(address) {
1344
+ setPinnedAt(address2) {
1038
1345
  if (this._db === null) {
1039
1346
  throw new Error("ContactBook not open. Call open() first.");
1040
1347
  }
1041
1348
  this._db.prepare(
1042
1349
  "UPDATE contacts SET pinned_at = datetime('now'), trust_state = 'pinned' WHERE address = ?"
1043
- ).run(address);
1350
+ ).run(address2);
1044
1351
  }
1045
1352
  /**
1046
1353
  * Remove a contact by address. Returns true if a contact was deleted.
1047
1354
  */
1048
- removeContact(address) {
1355
+ removeContact(address2) {
1049
1356
  if (this._db === null) {
1050
1357
  throw new Error("ContactBook not open. Call open() first.");
1051
1358
  }
1052
- const result = this._db.prepare("DELETE FROM contacts WHERE address = ?").run(address);
1053
- this._knownAddresses.delete(address);
1359
+ const result = this._db.prepare("DELETE FROM contacts WHERE address = ?").run(address2);
1360
+ this._knownAddresses.delete(address2);
1054
1361
  return result.changes > 0;
1055
1362
  }
1056
1363
  /**
@@ -1058,8 +1365,8 @@ var ContactBook = class {
1058
1365
  *
1059
1366
  * Convenience helper for inbound message filtering (CARD-05, TOFU-02).
1060
1367
  */
1061
- isTrustedOrVerified(address) {
1062
- const state = this.getTrustState(address);
1368
+ isTrustedOrVerified(address2) {
1369
+ const state = this.getTrustState(address2);
1063
1370
  return state === "trusted" || state === "verified" || state === "pinned";
1064
1371
  }
1065
1372
  /**
@@ -1113,6 +1420,18 @@ var ContactBook = class {
1113
1420
  };
1114
1421
 
1115
1422
  // src/sdk/handshake.ts
1423
+ function bindCardToEnvelope(card, envelope, senderVk) {
1424
+ if (card.address !== envelope.fromAddress) {
1425
+ throw new InvalidEnvelopeError(
1426
+ `Contact card address ${JSON.stringify(card.address)} does not match envelope sender ${JSON.stringify(envelope.fromAddress)}`
1427
+ );
1428
+ }
1429
+ if (card.publicKey !== serializeVerifyKey(senderVk)) {
1430
+ throw new InvalidEnvelopeError(
1431
+ "Contact card public_key does not match envelope sender's verify key"
1432
+ );
1433
+ }
1434
+ }
1116
1435
  var HandshakeManager = class {
1117
1436
  _contactBook;
1118
1437
  _trustPolicy;
@@ -1155,7 +1474,7 @@ var HandshakeManager = class {
1155
1474
  if (envelope.type === "handshake.request" /* HANDSHAKE_REQUEST */) {
1156
1475
  await this._handleRequest(agent, envelope, senderVk);
1157
1476
  } else if (envelope.type === "handshake.accept" /* HANDSHAKE_ACCEPT */) {
1158
- await this._handleAccept(envelope, senderVk);
1477
+ await this._handleAccept(agent, envelope, senderVk);
1159
1478
  } else if (envelope.type === "handshake.deny" /* HANDSHAKE_DENY */) {
1160
1479
  }
1161
1480
  return null;
@@ -1173,6 +1492,7 @@ var HandshakeManager = class {
1173
1492
  );
1174
1493
  const card = contactCardFromDict(cardDict);
1175
1494
  verifyContactCard(card);
1495
+ bindCardToEnvelope(card, envelope, senderVk);
1176
1496
  if (this._trustPolicy === "auto-accept") {
1177
1497
  this._contactBook.addContact(card.address, card.publicKey, {
1178
1498
  displayName: card.displayName,
@@ -1191,8 +1511,46 @@ var HandshakeManager = class {
1191
1511
  }
1192
1512
  /**
1193
1513
  * Process a handshake.accept: store the sender as pinned (TOFU).
1514
+ *
1515
+ * Only honoured when:
1516
+ * 1. we previously sent this address a handshake.request
1517
+ * (trust_state === "handshake-sent"); anything else is an unsolicited
1518
+ * accept and is dropped. This also means an accept can never
1519
+ * downgrade a trusted/verified contact to pinned;
1520
+ * 2. the payload decrypts with our key and the sender's key, and parses
1521
+ * as JSON. Otherwise it is dropped without pinning;
1522
+ * 3. an embedded contact_card, if present, is well-formed, correctly
1523
+ * self-signed, and bound to the envelope sender (throws otherwise).
1194
1524
  */
1195
- async _handleAccept(envelope, senderVk) {
1525
+ async _handleAccept(agent, envelope, senderVk) {
1526
+ const prior = this._contactBook.getTrustState(envelope.fromAddress);
1527
+ if (prior !== "handshake-sent") {
1528
+ log.warn(
1529
+ `Ignoring unsolicited handshake.accept from ${envelope.fromAddress} (trust_state=${JSON.stringify(prior)})`
1530
+ );
1531
+ return;
1532
+ }
1533
+ let payload;
1534
+ try {
1535
+ const plaintext = decryptPayload(
1536
+ envelope.payload,
1537
+ agent._keyManager.signingKey,
1538
+ senderVk
1539
+ );
1540
+ payload = JSON.parse(new TextDecoder().decode(plaintext));
1541
+ } catch {
1542
+ log.warn(
1543
+ `Dropping handshake.accept ${envelope.messageId} from ${envelope.fromAddress}: payload could not be decrypted or parsed`
1544
+ );
1545
+ return;
1546
+ }
1547
+ if (payload !== null && typeof payload === "object" && "contact_card" in payload) {
1548
+ const card = contactCardFromDict(
1549
+ payload["contact_card"]
1550
+ );
1551
+ verifyContactCard(card);
1552
+ bindCardToEnvelope(card, envelope, senderVk);
1553
+ }
1196
1554
  const senderPkStr = serializeVerifyKey(senderVk);
1197
1555
  this._contactBook.addContact(envelope.fromAddress, senderPkStr, {
1198
1556
  trustState: "pinned"
@@ -1249,6 +1607,10 @@ var HandshakeManager = class {
1249
1607
  }
1250
1608
  };
1251
1609
 
1610
+ // src/sdk/resolver-base.ts
1611
+ var AddressResolver = class {
1612
+ };
1613
+
1252
1614
  // src/sdk/tier3.ts
1253
1615
  var import_viem = require("viem");
1254
1616
  var DEFAULT_RPC_URL = "https://sepolia.base.org";
@@ -1301,8 +1663,8 @@ var Tier3Resolver = class extends AddressResolver {
1301
1663
  transport: (0, import_viem.http)(rpcUrl)
1302
1664
  });
1303
1665
  }
1304
- async resolvePublicKey(address, _token, _relayUrl) {
1305
- const parsed = parseAddress(address);
1666
+ async resolvePublicKey(address2, _token, _relayUrl) {
1667
+ const parsed = parseAddress(address2);
1306
1668
  const name = parsed.domain;
1307
1669
  const cached = this._cache.get(name);
1308
1670
  if (cached && cached.expiresAt > Date.now()) {
@@ -1365,15 +1727,13 @@ var Tier3Resolver = class extends AddressResolver {
1365
1727
 
1366
1728
  // src/sdk/resolver.ts
1367
1729
  init_dns_verifier();
1368
- var AddressResolver = class {
1369
- };
1370
1730
  var Tier1Resolver = class extends AddressResolver {
1371
- async resolvePublicKey(address, _token, relayUrl) {
1731
+ async resolvePublicKey(address2, _token, relayUrl) {
1372
1732
  const resp = await fetch(
1373
- `${relayUrl}/api/v1/agents/${address}/public-key`
1733
+ `${relayUrl}/api/v1/agents/${address2}/public-key`
1374
1734
  );
1375
1735
  if (resp.status === 404) {
1376
- throw new UAMError(`Agent not found: ${address}`);
1736
+ throw new UAMError(`Agent not found: ${address2}`);
1377
1737
  }
1378
1738
  if (!resp.ok) {
1379
1739
  throw new UAMError(
@@ -1385,8 +1745,8 @@ var Tier1Resolver = class extends AddressResolver {
1385
1745
  }
1386
1746
  };
1387
1747
  var Tier2Resolver = class extends AddressResolver {
1388
- async resolvePublicKey(address, _token, _relayUrl) {
1389
- const parsed = parseAddress(address);
1748
+ async resolvePublicKey(address2, _token, _relayUrl) {
1749
+ const parsed = parseAddress(address2);
1390
1750
  const txtRecords = await queryUamTxt(parsed.domain);
1391
1751
  for (const txt of txtRecords) {
1392
1752
  const tags = parseUamTxt(txt);
@@ -1398,37 +1758,37 @@ var Tier2Resolver = class extends AddressResolver {
1398
1758
  const httpsKey = await resolveKeyViaHttps(parsed.agent, parsed.domain);
1399
1759
  if (httpsKey) return httpsKey;
1400
1760
  throw new UAMError(
1401
- `Could not resolve public key for ${address} via DNS or HTTPS`
1761
+ `Could not resolve public key for ${address2} via DNS or HTTPS`
1402
1762
  );
1403
1763
  }
1404
1764
  };
1405
1765
  var SmartResolver = class extends AddressResolver {
1406
- _relayDomain;
1766
+ relayDomain;
1407
1767
  _tier1;
1408
1768
  _tier2;
1409
1769
  _tier3;
1410
1770
  constructor(relayDomain, tier3Config) {
1411
1771
  super();
1412
- this._relayDomain = relayDomain;
1772
+ this.relayDomain = relayDomain;
1413
1773
  this._tier1 = new Tier1Resolver();
1414
1774
  this._tier2 = new Tier2Resolver();
1415
1775
  this._tier3 = tier3Config ? new Tier3Resolver(tier3Config) : null;
1416
1776
  }
1417
- async resolvePublicKey(address, token, relayUrl) {
1418
- const parsed = parseAddress(address);
1419
- const domain = parsed.domain;
1420
- if (domain === this._relayDomain) {
1421
- return this._tier1.resolvePublicKey(address, token, relayUrl);
1777
+ async resolvePublicKey(address2, token, relayUrl) {
1778
+ const parsed = parseAddress(address2);
1779
+ const domain2 = parsed.domain;
1780
+ if (domain2 === this.relayDomain) {
1781
+ return this._tier1.resolvePublicKey(address2, token, relayUrl);
1422
1782
  }
1423
- if (domain.includes(".")) {
1424
- return this._tier2.resolvePublicKey(address, token, relayUrl);
1783
+ if (domain2.includes(".")) {
1784
+ return this._tier2.resolvePublicKey(address2, token, relayUrl);
1425
1785
  }
1426
1786
  if (!this._tier3) {
1427
1787
  throw new UAMError(
1428
- `Tier 3 resolution requires contract config. Cannot resolve dot-free domain: '${domain}'`
1788
+ `Tier 3 resolution requires contract config. Cannot resolve dot-free domain: '${domain2}'`
1429
1789
  );
1430
1790
  }
1431
- return this._tier3.resolvePublicKey(address, token, relayUrl);
1791
+ return this._tier3.resolvePublicKey(address2, token, relayUrl);
1432
1792
  }
1433
1793
  };
1434
1794
 
@@ -1437,15 +1797,23 @@ var TransportBase = class {
1437
1797
  };
1438
1798
 
1439
1799
  // src/sdk/transport/http.ts
1800
+ async function readAnswer(resp) {
1801
+ try {
1802
+ const body = await resp.json();
1803
+ return body !== null && typeof body === "object" && !Array.isArray(body) ? body : {};
1804
+ } catch {
1805
+ return {};
1806
+ }
1807
+ }
1440
1808
  var HTTPTransport = class extends TransportBase {
1441
1809
  _relayUrl;
1442
1810
  _token;
1443
1811
  _address;
1444
- constructor(relayUrl, token, address) {
1812
+ constructor(relayUrl, token, address2) {
1445
1813
  super();
1446
1814
  this._relayUrl = relayUrl.replace(/\/+$/, "");
1447
1815
  this._token = token;
1448
- this._address = address;
1816
+ this._address = address2;
1449
1817
  }
1450
1818
  async connect() {
1451
1819
  }
@@ -1465,6 +1833,7 @@ var HTTPTransport = class extends TransportBase {
1465
1833
  `HTTP send failed: ${resp.status} ${resp.statusText}`
1466
1834
  );
1467
1835
  }
1836
+ return readAnswer(resp);
1468
1837
  }
1469
1838
  async receive(limit = 50) {
1470
1839
  const url = new URL(
@@ -1496,8 +1865,11 @@ var import_ws = __toESM(require("ws"), 1);
1496
1865
  var BASE_DELAY = 1;
1497
1866
  var MAX_DELAY = 60;
1498
1867
  var JITTER_RANGE = 1;
1868
+ var SEND_SETTLE_SECONDS = 5;
1869
+ var ACK_TIMEOUT_SECONDS = 5;
1499
1870
  var WebSocketTransport = class extends TransportBase {
1500
1871
  _url;
1872
+ _token;
1501
1873
  _onMessage;
1502
1874
  _ws = null;
1503
1875
  _pending = [];
@@ -1506,9 +1878,20 @@ var WebSocketTransport = class extends TransportBase {
1506
1878
  _connectReject = null;
1507
1879
  _running = false;
1508
1880
  _reconnectTimeout = null;
1881
+ /** Envelopes sent that the relay hasn't answered yet. */
1882
+ _unanswered = 0;
1883
+ _settling = null;
1884
+ _settled = null;
1885
+ /** Sends waiting for the relay's answer, by message_id. */
1886
+ _waiting = /* @__PURE__ */ new Map();
1887
+ /** @internal How long a close waits, in ms. Replaceable in tests. */
1888
+ _settleMs = SEND_SETTLE_SECONDS * 1e3;
1889
+ /** @internal How long send() waits for an answer, in ms. Replaceable in tests. */
1890
+ _ackMs = ACK_TIMEOUT_SECONDS * 1e3;
1509
1891
  constructor(wsUrl, token, onMessage) {
1510
1892
  super();
1511
- this._url = `${wsUrl}?token=${token}`;
1893
+ this._url = wsUrl;
1894
+ this._token = token;
1512
1895
  this._onMessage = onMessage ?? null;
1513
1896
  }
1514
1897
  async connect() {
@@ -1529,7 +1912,14 @@ var WebSocketTransport = class extends TransportBase {
1529
1912
  };
1530
1913
  });
1531
1914
  }
1915
+ /**
1916
+ * Close the connection, once the relay has answered what was sent.
1917
+ *
1918
+ * The relay stops reading a socket when it can't write an ack to it, so
1919
+ * closing straight after send() loses every frame but the first.
1920
+ */
1532
1921
  async disconnect() {
1922
+ await this._allAnswered();
1533
1923
  this._running = false;
1534
1924
  if (this._reconnectTimeout !== null) {
1535
1925
  clearTimeout(this._reconnectTimeout);
@@ -1541,12 +1931,42 @@ var WebSocketTransport = class extends TransportBase {
1541
1931
  this._ws = null;
1542
1932
  }
1543
1933
  this._connected = false;
1934
+ this._abandonWaiting();
1544
1935
  }
1936
+ /**
1937
+ * Send an envelope and wait for the relay's answer.
1938
+ *
1939
+ * Resolves with the ack that names the message, or null when none came
1940
+ * within _ackMs or the socket went: the message may still have arrived.
1941
+ * Rejects with SendRefusedError when the relay refuses it by message_id
1942
+ * (relays before 1.1 don't name it, so their refusals end as null).
1943
+ */
1545
1944
  async send(envelope) {
1546
1945
  if (!this._ws || this._ws.readyState !== import_ws.default.OPEN) {
1547
1946
  throw new Error("WebSocket not connected");
1548
1947
  }
1948
+ const messageId = envelope["message_id"];
1949
+ let answer = null;
1950
+ let timer;
1951
+ if (typeof messageId === "string" && !this._waiting.has(messageId)) {
1952
+ answer = new Promise((resolve, reject) => {
1953
+ this._waiting.set(messageId, { resolve, reject });
1954
+ timer = setTimeout(() => {
1955
+ log.warn(
1956
+ `No answer from the relay for ${messageId} within ${this._ackMs / 1e3}s`
1957
+ );
1958
+ this._settle(messageId, null);
1959
+ }, this._ackMs);
1960
+ });
1961
+ }
1549
1962
  this._ws.send(JSON.stringify(envelope));
1963
+ this._unanswered += 1;
1964
+ if (answer === null) return null;
1965
+ try {
1966
+ return await answer;
1967
+ } finally {
1968
+ clearTimeout(timer);
1969
+ }
1550
1970
  }
1551
1971
  async receive(limit = 50) {
1552
1972
  const result = this._pending.slice(0, limit);
@@ -1556,11 +1976,47 @@ var WebSocketTransport = class extends TransportBase {
1556
1976
  async listen(callback) {
1557
1977
  this._onMessage = callback;
1558
1978
  }
1979
+ /** Resolves when nothing is unanswered, or after _settleMs regardless. */
1980
+ _allAnswered() {
1981
+ if (this._unanswered === 0) return Promise.resolve();
1982
+ this._settling ??= new Promise((resolve) => {
1983
+ const timeout = setTimeout(() => {
1984
+ log.warn(`Closing with ${this._unanswered} sends unanswered`);
1985
+ this._settled?.();
1986
+ }, this._settleMs);
1987
+ this._settled = () => {
1988
+ clearTimeout(timeout);
1989
+ this._settling = null;
1990
+ this._settled = null;
1991
+ resolve();
1992
+ };
1993
+ });
1994
+ return this._settling;
1995
+ }
1996
+ /** Hand the relay's answer (or a refusal) to the send waiting for it. */
1997
+ _settle(messageId, answer, error) {
1998
+ if (typeof messageId !== "string") return;
1999
+ const waiting = this._waiting.get(messageId);
2000
+ if (!waiting) return;
2001
+ this._waiting.delete(messageId);
2002
+ if (error) waiting.reject(error);
2003
+ else waiting.resolve(answer);
2004
+ }
2005
+ /** The socket went: no answers will come for what it carried. */
2006
+ _abandonWaiting() {
2007
+ const waiting = [...this._waiting.values()];
2008
+ this._waiting.clear();
2009
+ for (const w of waiting) w.resolve(null);
2010
+ }
2011
+ _answered() {
2012
+ this._unanswered = Math.max(0, this._unanswered - 1);
2013
+ if (this._unanswered === 0) this._settled?.();
2014
+ }
1559
2015
  _startConnectionLoop() {
1560
2016
  let attempt = 0;
1561
2017
  const tryConnect = () => {
1562
2018
  if (!this._running) return;
1563
- const ws = new import_ws.default(this._url);
2019
+ const ws = new import_ws.default(this._url, [`bearer.${this._token}`]);
1564
2020
  this._ws = ws;
1565
2021
  ws.on("open", () => {
1566
2022
  this._connected = true;
@@ -1581,6 +2037,9 @@ var WebSocketTransport = class extends TransportBase {
1581
2037
  ws.on("close", () => {
1582
2038
  this._connected = false;
1583
2039
  this._ws = null;
2040
+ this._unanswered = 0;
2041
+ this._settled?.();
2042
+ this._abandonWaiting();
1584
2043
  if (!this._running) return;
1585
2044
  attempt += 1;
1586
2045
  const delay = Math.min(BASE_DELAY * Math.pow(2, attempt), MAX_DELAY);
@@ -1597,33 +2056,279 @@ var WebSocketTransport = class extends TransportBase {
1597
2056
  }
1598
2057
  _handleMessage(msg) {
1599
2058
  const msgType = msg["type"];
1600
- if (msgType === "ping") {
1601
- if (this._ws && this._ws.readyState === import_ws.default.OPEN) {
1602
- this._ws.send(JSON.stringify({ type: "pong" }));
1603
- }
1604
- } else if (msgType === "ack") {
1605
- } else if (msgType === "error" || "error" in msg) {
1606
- } else if ("uam_version" in msg) {
2059
+ if ("uam_version" in msg) {
1607
2060
  if (this._onMessage) {
1608
2061
  this._onMessage(msg).catch(() => {
1609
2062
  });
1610
2063
  } else {
1611
2064
  this._pending.push(msg);
1612
2065
  }
2066
+ } else if (msgType === "ping") {
2067
+ if (this._ws && this._ws.readyState === import_ws.default.OPEN) {
2068
+ this._ws.send(JSON.stringify({ type: "pong" }));
2069
+ }
2070
+ } else if (msgType === "ack") {
2071
+ this._answered();
2072
+ this._settle(msg["message_id"], msg);
2073
+ } else if (msgType === "relay.notice") {
2074
+ log.warn(
2075
+ `Relay notice: message ${String(msg["message_id"])} is ${String(msg["status"])}`
2076
+ );
2077
+ } else if (msgType === "error" || "error" in msg) {
2078
+ this._answered();
2079
+ const code = String(msg["error"] ?? msg["code"] ?? "error");
2080
+ const detail = typeof msg["detail"] === "string" ? msg["detail"] : void 0;
2081
+ log.warn(`Relay error: [${code}] ${detail ?? ""}`.trimEnd());
2082
+ const messageId = msg["message_id"];
2083
+ if (typeof messageId === "string") {
2084
+ this._settle(messageId, null, new SendRefusedError(messageId, code, detail));
2085
+ }
1613
2086
  }
1614
2087
  }
1615
2088
  };
1616
2089
 
1617
2090
  // src/sdk/transport/index.ts
1618
- function createTransport(config, token, address, onMessage) {
2091
+ function createTransport(config, token, address2, onMessage) {
1619
2092
  if (config.transportType === "http") {
1620
- return new HTTPTransport(config.relayUrl, token, address);
2093
+ return new HTTPTransport(config.relayUrl, token, address2);
1621
2094
  } else {
1622
2095
  return new WebSocketTransport(config.relayWsUrl, token, onMessage);
1623
2096
  }
1624
2097
  }
1625
2098
 
2099
+ // src/sdk/replay-cache.ts
2100
+ var import_node_fs4 = require("fs");
2101
+ var import_node_path4 = require("path");
2102
+ var import_promises2 = require("timers/promises");
2103
+ var import_better_sqlite32 = __toESM(require("better-sqlite3"), 1);
2104
+ var RETENTION_SECONDS = STORED_MAIL_MAX_AGE + 2 * MAX_ENVELOPE_AGE;
2105
+ var PURGE_INTERVAL_MS = 6e4;
2106
+ var WAL_ATTEMPTS = 100;
2107
+ var WAL_RETRY_MS = 100;
2108
+ var SCHEMA = `
2109
+ CREATE TABLE IF NOT EXISTS seen_envelopes (
2110
+ from_address TEXT NOT NULL,
2111
+ message_id TEXT NOT NULL,
2112
+ seen_at REAL NOT NULL,
2113
+ PRIMARY KEY (from_address, message_id)
2114
+ ) WITHOUT ROWID;
2115
+ CREATE INDEX IF NOT EXISTS seen_envelopes_seen_at ON seen_envelopes (seen_at);
2116
+ CREATE TABLE IF NOT EXISTS replay_meta (
2117
+ key TEXT PRIMARY KEY,
2118
+ value TEXT NOT NULL
2119
+ );
2120
+ `;
2121
+ function isLocked(err) {
2122
+ const code = err.code;
2123
+ return typeof code === "string" && (code.startsWith("SQLITE_BUSY") || code.startsWith("SQLITE_LOCKED"));
2124
+ }
2125
+ async function enableWal(db) {
2126
+ for (let attempt = 1; ; attempt += 1) {
2127
+ try {
2128
+ db.pragma("journal_mode = WAL");
2129
+ return;
2130
+ } catch (err) {
2131
+ if (attempt >= WAL_ATTEMPTS || !isLocked(err)) throw err;
2132
+ await (0, import_promises2.setTimeout)(WAL_RETRY_MS);
2133
+ }
2134
+ }
2135
+ }
2136
+ var DEFAULT_REPLAY_CACHE_CAPACITY = 1e6;
2137
+ var EnvelopeReplayCache = class {
2138
+ capacity;
2139
+ ttlMs;
2140
+ _now;
2141
+ // Map iteration order is insertion order: first key = least recently used.
2142
+ _seen = /* @__PURE__ */ new Map();
2143
+ constructor(options) {
2144
+ const capacity = options?.capacity ?? 1e5;
2145
+ if (!Number.isInteger(capacity) || capacity < 1) {
2146
+ throw new RangeError(
2147
+ `Replay cache capacity must be a positive integer, got ${capacity}`
2148
+ );
2149
+ }
2150
+ this.capacity = capacity;
2151
+ this.ttlMs = options?.ttlMs ?? 3e5;
2152
+ this._now = options?.now ?? (() => Date.now());
2153
+ }
2154
+ get size() {
2155
+ return this._seen.size;
2156
+ }
2157
+ /** True if this pair was seen within the TTL. Records nothing. */
2158
+ has(fromAddress, messageId) {
2159
+ const seenAt = this._seen.get(this._key(fromAddress, messageId));
2160
+ return seenAt !== void 0 && this._now() - seenAt < this.ttlMs;
2161
+ }
2162
+ /**
2163
+ * Return true if this (fromAddress, messageId) pair was already seen
2164
+ * within the TTL (a replay). Otherwise record it and return false.
2165
+ */
2166
+ seenOrRecord(fromAddress, messageId) {
2167
+ const key = this._key(fromAddress, messageId);
2168
+ const now = this._now();
2169
+ const seenAt = this._seen.get(key);
2170
+ if (seenAt !== void 0 && now - seenAt < this.ttlMs) {
2171
+ this._seen.delete(key);
2172
+ this._seen.set(key, seenAt);
2173
+ return true;
2174
+ }
2175
+ this._seen.delete(key);
2176
+ this._seen.set(key, now);
2177
+ while (this._seen.size > this.capacity) {
2178
+ const oldest = this._seen.keys().next().value;
2179
+ this._seen.delete(oldest);
2180
+ }
2181
+ return false;
2182
+ }
2183
+ _key(fromAddress, messageId) {
2184
+ return JSON.stringify([fromAddress, messageId]);
2185
+ }
2186
+ };
2187
+ var PersistentReplayCache = class {
2188
+ capacity;
2189
+ _path;
2190
+ _now;
2191
+ _db = null;
2192
+ _opening = null;
2193
+ _count = 0;
2194
+ _lastPurge = 0;
2195
+ _createdAt = 0;
2196
+ // Used only while the file is full, for envelopes inside MAX_ENVELOPE_AGE.
2197
+ // One can be signed that far ahead of us and stay acceptable until that far
2198
+ // behind, so its id must last both.
2199
+ _overflow;
2200
+ /**
2201
+ * @param path - The SQLite file
2202
+ * @param options.now - The clock, in ms since the epoch
2203
+ */
2204
+ constructor(path, options) {
2205
+ const capacity = options?.capacity ?? DEFAULT_REPLAY_CACHE_CAPACITY;
2206
+ if (!Number.isInteger(capacity) || capacity < 1) {
2207
+ throw new RangeError(
2208
+ `Replay cache capacity must be a positive integer, got ${capacity}`
2209
+ );
2210
+ }
2211
+ this.capacity = capacity;
2212
+ this._path = path;
2213
+ this._now = options?.now ?? (() => Date.now());
2214
+ this._overflow = new EnvelopeReplayCache({
2215
+ capacity: 1e4,
2216
+ ttlMs: 2 * MAX_ENVELOPE_AGE * 1e3,
2217
+ now: this._now
2218
+ });
2219
+ }
2220
+ /** Open the file now, rather than on first use. */
2221
+ async open() {
2222
+ await this._open();
2223
+ }
2224
+ /**
2225
+ * Return true if the envelope must be dropped as a replay. Otherwise record
2226
+ * the pair and return false.
2227
+ *
2228
+ * @param signedAt - The envelope's signed time, in ms since the epoch. It
2229
+ * matters only when the cache is full.
2230
+ */
2231
+ async seenOrRecord(fromAddress, messageId, signedAt) {
2232
+ const db = await this._open();
2233
+ const now = this._now();
2234
+ if (now - this._lastPurge >= PURGE_INTERVAL_MS) this._purge(db);
2235
+ if (this._overflow.has(fromAddress, messageId)) return true;
2236
+ if (this._count < this.capacity) {
2237
+ const { changes } = db.prepare(
2238
+ "INSERT OR IGNORE INTO seen_envelopes (from_address, message_id, seen_at) VALUES (?, ?, ?)"
2239
+ ).run(fromAddress, messageId, now / 1e3);
2240
+ if (changes === 0) return true;
2241
+ this._count += 1;
2242
+ return false;
2243
+ }
2244
+ const known = db.prepare(
2245
+ "SELECT 1 FROM seen_envelopes WHERE from_address = ? AND message_id = ?"
2246
+ ).get(fromAddress, messageId);
2247
+ if (known !== void 0) return true;
2248
+ if (Math.abs(now - signedAt) <= MAX_ENVELOPE_AGE * 1e3) {
2249
+ return this._overflow.seenOrRecord(fromAddress, messageId);
2250
+ }
2251
+ log.warn(
2252
+ `Replay cache full (${this._count} ids); refused envelope ${messageId} from ${fromAddress} signed ${Math.round((now - signedAt) / 1e3)}s ago`
2253
+ );
2254
+ return true;
2255
+ }
2256
+ /** When this cache file was first created, in ms since the epoch. */
2257
+ async createdAt() {
2258
+ await this._open();
2259
+ return this._createdAt;
2260
+ }
2261
+ /** True if an envelope signed at `signedAt` (ms) predates this cache. */
2262
+ async signedBeforeCache(signedAt) {
2263
+ return signedAt < await this.createdAt();
2264
+ }
2265
+ /** Number of ids currently held on disk. */
2266
+ async count() {
2267
+ const db = await this._open();
2268
+ return this._countRows(db);
2269
+ }
2270
+ /** Close the file; the next call opens it again. */
2271
+ async close() {
2272
+ await this._opening?.catch(() => void 0);
2273
+ this._db?.close();
2274
+ this._db = null;
2275
+ }
2276
+ _open() {
2277
+ if (this._db !== null) return Promise.resolve(this._db);
2278
+ this._opening ??= this._connect().finally(() => {
2279
+ this._opening = null;
2280
+ });
2281
+ return this._opening;
2282
+ }
2283
+ async _connect() {
2284
+ (0, import_node_fs4.mkdirSync)((0, import_node_path4.dirname)(this._path), { recursive: true });
2285
+ const db = new import_better_sqlite32.default(this._path, { timeout: 3e4 });
2286
+ try {
2287
+ await enableWal(db);
2288
+ db.exec(SCHEMA);
2289
+ db.prepare(
2290
+ "INSERT OR IGNORE INTO replay_meta (key, value) VALUES ('created_at', ?)"
2291
+ ).run(String(this._now() / 1e3));
2292
+ const row = db.prepare("SELECT value FROM replay_meta WHERE key = 'created_at'").get();
2293
+ this._createdAt = Number(row.value) * 1e3;
2294
+ this._purge(db);
2295
+ } catch (err) {
2296
+ db.close();
2297
+ throw err;
2298
+ }
2299
+ this._db = db;
2300
+ return db;
2301
+ }
2302
+ _purge(db) {
2303
+ const now = this._now();
2304
+ db.prepare("DELETE FROM seen_envelopes WHERE seen_at < ?").run(
2305
+ now / 1e3 - RETENTION_SECONDS
2306
+ );
2307
+ this._count = this._countRows(db);
2308
+ this._lastPurge = now;
2309
+ }
2310
+ _countRows(db) {
2311
+ const row = db.prepare("SELECT COUNT(*) AS n FROM seen_envelopes").get();
2312
+ return row.n;
2313
+ }
2314
+ };
2315
+
1626
2316
  // src/sdk/agent.ts
2317
+ var RELAY_NO_MEMORY_MS = 6e4;
2318
+ function replayCacheCapacityFromEnv() {
2319
+ const raw = process.env["UAM_REPLAY_CACHE_CAPACITY"];
2320
+ if (raw === void 0 || raw === "") {
2321
+ return DEFAULT_REPLAY_CACHE_CAPACITY;
2322
+ }
2323
+ const n = Number(raw);
2324
+ if (!Number.isInteger(n) || n < 1) {
2325
+ log.warn(
2326
+ `Ignoring invalid UAM_REPLAY_CACHE_CAPACITY=${JSON.stringify(raw)}; using ${DEFAULT_REPLAY_CACHE_CAPACITY}`
2327
+ );
2328
+ return DEFAULT_REPLAY_CACHE_CAPACITY;
2329
+ }
2330
+ return n;
2331
+ }
1627
2332
  var Agent = class {
1628
2333
  /** @internal */
1629
2334
  _config;
@@ -1640,6 +2345,23 @@ var Agent = class {
1640
2345
  _contactBook;
1641
2346
  /** @internal */
1642
2347
  _handshake;
2348
+ /**
2349
+ * @internal Wall clock in ms since the epoch, used by the inbound
2350
+ * freshness/expiry checks and the replay cache. Replaceable in tests.
2351
+ */
2352
+ _clock = () => Date.now();
2353
+ /**
2354
+ * @internal Inbound replay cache, keyed by (from address, message id).
2355
+ * Kept on disk under the data dir, so it outlives restarts and is shared by
2356
+ * every process using it; capacity from UAM_REPLAY_CACHE_CAPACITY.
2357
+ */
2358
+ _replayCache;
2359
+ /** Whether our relay has said it writes relay_meta. */
2360
+ _relayStamps = false;
2361
+ /** Until when, by _monotonic, to take the relay's last no as still true. */
2362
+ _relayNoUntil = 0;
2363
+ /** @internal Monotonic clock in ms. Replaceable in tests. */
2364
+ _monotonic = () => performance.now();
1643
2365
  /**
1644
2366
  * Create an Agent. No I/O happens here -- call connect() to initialize.
1645
2367
  */
@@ -1661,6 +2383,13 @@ var Agent = class {
1661
2383
  this._contactBook,
1662
2384
  this._config.trustPolicy
1663
2385
  );
2386
+ this._replayCache = new PersistentReplayCache(
2387
+ (0, import_node_path5.join)(this._config.dataDir, "replay", "replay.db"),
2388
+ {
2389
+ capacity: replayCacheCapacityFromEnv(),
2390
+ now: () => this._clock()
2391
+ }
2392
+ );
1664
2393
  }
1665
2394
  // -- Properties ----------------------------------------------------------
1666
2395
  /** The agent's full UAM address (e.g., 'myagent::youam.network'). */
@@ -1674,7 +2403,7 @@ var Agent = class {
1674
2403
  }
1675
2404
  /** UAM SDK version. */
1676
2405
  get version() {
1677
- return "0.1.0";
2406
+ return SDK_VERSION;
1678
2407
  }
1679
2408
  /** The agent's public key (base64-encoded Ed25519 verify key). */
1680
2409
  get publicKey() {
@@ -1711,10 +2440,19 @@ var Agent = class {
1711
2440
  }
1712
2441
  await sodiumReady;
1713
2442
  this._keyManager.loadOrGenerate(this._config.name);
1714
- const storedToken = this._keyManager.loadToken(this._config.name);
2443
+ try {
2444
+ await this._replayCache.open();
2445
+ } catch (err) {
2446
+ const reason = err instanceof Error ? err.message : String(err);
2447
+ log.warn(`Could not open the replay cache: ${reason}`);
2448
+ }
2449
+ const storedToken = this._keyManager.loadToken(
2450
+ this._config.name,
2451
+ this._config.relayUrl
2452
+ );
1715
2453
  if (storedToken) {
1716
2454
  this._token = storedToken;
1717
- this._address = `${this._config.name}::${this._config.relayDomain}`;
2455
+ this._useAddress(await this._storedAddress());
1718
2456
  } else if (this._autoRegister) {
1719
2457
  await this._registerWithRelay();
1720
2458
  } else {
@@ -1737,6 +2475,7 @@ var Agent = class {
1737
2475
  */
1738
2476
  async close() {
1739
2477
  this._contactBook.close();
2478
+ await this._replayCache.close();
1740
2479
  if (this._transport) {
1741
2480
  await this._transport.disconnect();
1742
2481
  }
@@ -1744,7 +2483,12 @@ var Agent = class {
1744
2483
  }
1745
2484
  // -- Messaging -----------------------------------------------------------
1746
2485
  /**
1747
- * Send an encrypted, signed message. Returns the message_id.
2486
+ * Send an encrypted, signed message.
2487
+ *
2488
+ * Returns the message id with what the relay did with it: `status`
2489
+ * "pending" means the recipient only takes approved contacts, and the
2490
+ * message was not kept (don't resend it). `delivered` is true only when
2491
+ * the relay says the recipient has it.
1748
2492
  */
1749
2493
  async send(toAddress, message, options) {
1750
2494
  await this._ensureConnected();
@@ -1757,7 +2501,8 @@ var Agent = class {
1757
2501
  }
1758
2502
  }
1759
2503
  const recipientVk = await this._resolvePublicKey(toAddress);
1760
- if (!this._contactBook.isKnown(toAddress)) {
2504
+ const trustState = this._contactBook.getTrustState(toAddress);
2505
+ if (trustState !== "pinned" && trustState !== "verified" && trustState !== "trusted" && trustState !== "handshake-sent") {
1761
2506
  await this._initiateHandshake(toAddress, recipientVk);
1762
2507
  }
1763
2508
  const envelope = await createEnvelope(
@@ -1775,20 +2520,29 @@ var Agent = class {
1775
2520
  );
1776
2521
  const wire = toWireDict(envelope);
1777
2522
  const relayUrls = await this._getRelayUrls(toAddress);
2523
+ let answer;
1778
2524
  if (relayUrls !== null && relayUrls.length === 1 && relayUrls[0] === this._config.relayUrl) {
1779
- await this._transport.send(wire);
2525
+ answer = await this._transport.send(wire);
1780
2526
  } else if (relayUrls !== null && relayUrls.length > 0) {
1781
- await this._trySendWithFailover(wire, relayUrls);
2527
+ answer = await this._trySendWithFailover(wire, relayUrls);
1782
2528
  } else {
1783
- await this._transport.send(wire);
2529
+ answer = await this._transport.send(wire);
1784
2530
  }
1785
- return envelope.messageId;
2531
+ const status = sendStatus(answer);
2532
+ return {
2533
+ messageId: envelope.messageId,
2534
+ status,
2535
+ delivered: status === "delivered"
2536
+ };
1786
2537
  }
1787
2538
  /**
1788
2539
  * Retrieve, decrypt, and verify pending messages.
1789
2540
  *
1790
2541
  * Returns a list of ReceivedMessage data objects.
1791
- * Silently drops messages with invalid signatures or decryption errors.
2542
+ * Drops messages with invalid signatures or decryption errors. A message
2543
+ * that fails processing for any other reason (malformed envelope, bad
2544
+ * handshake payload, ...) is logged and dropped; it never aborts the
2545
+ * rest of the batch.
1792
2546
  */
1793
2547
  async inbox(limit = 50) {
1794
2548
  await this._ensureConnected();
@@ -1796,7 +2550,15 @@ var Agent = class {
1796
2550
  const rawMessages = await this._transport.receive(limit);
1797
2551
  const result = [];
1798
2552
  for (const raw of rawMessages) {
1799
- const msg = await this._processInbound(raw);
2553
+ let msg;
2554
+ try {
2555
+ msg = await this._processInbound(raw);
2556
+ } catch (err) {
2557
+ log.warn(
2558
+ `Dropping inbound message ${String(raw["message_id"])} from ${String(raw["from"])}: ${err instanceof Error ? err.message : String(err)}`
2559
+ );
2560
+ continue;
2561
+ }
1800
2562
  if (msg !== null) {
1801
2563
  result.push(msg);
1802
2564
  await this._sendReadReceipt(msg);
@@ -1816,42 +2578,43 @@ var Agent = class {
1816
2578
  * Adds the sender to contacts with trust_source='explicit-approval'
1817
2579
  * and sends handshake.accept back.
1818
2580
  */
1819
- async approve(address) {
2581
+ async approve(address2) {
1820
2582
  await this._ensureConnected();
1821
2583
  const pendingList = this._contactBook.getPending();
1822
- const entry = pendingList.find((p) => p.address === address);
2584
+ const entry = pendingList.find((p) => p.address === address2);
1823
2585
  if (!entry) {
1824
- throw new UAMError(`No pending handshake from ${address}`);
2586
+ throw new UAMError(`No pending handshake from ${address2}`);
1825
2587
  }
1826
2588
  const cardDict = JSON.parse(entry.contactCard);
1827
2589
  const card = contactCardFromDict(cardDict);
1828
2590
  verifyContactCard(card);
2591
+ const senderVk = deserializeVerifyKey(card.publicKey);
2592
+ bindCardToEnvelope(card, { fromAddress: address2 }, senderVk);
1829
2593
  this._contactBook.addContact(card.address, card.publicKey, {
1830
2594
  displayName: card.displayName,
1831
2595
  trustState: "trusted",
1832
2596
  trustSource: "explicit-approval"
1833
2597
  });
1834
- this._contactBook.removePending(address);
1835
- const senderVk = deserializeVerifyKey(card.publicKey);
1836
- await this._handshake._sendAccept(this, address, senderVk);
2598
+ this._contactBook.removePending(address2);
2599
+ await this._handshake._sendAccept(this, address2, senderVk);
1837
2600
  }
1838
2601
  /**
1839
2602
  * Deny a pending handshake request.
1840
2603
  *
1841
2604
  * Removes the entry from pending and sends handshake.deny.
1842
2605
  */
1843
- async deny(address) {
2606
+ async deny(address2) {
1844
2607
  await this._ensureConnected();
1845
2608
  const pendingList = this._contactBook.getPending();
1846
- const entry = pendingList.find((p) => p.address === address);
2609
+ const entry = pendingList.find((p) => p.address === address2);
1847
2610
  if (!entry) {
1848
- throw new UAMError(`No pending handshake from ${address}`);
2611
+ throw new UAMError(`No pending handshake from ${address2}`);
1849
2612
  }
1850
2613
  const cardDict = JSON.parse(entry.contactCard);
1851
2614
  const card = contactCardFromDict(cardDict);
1852
2615
  const senderVk = deserializeVerifyKey(card.publicKey);
1853
- this._contactBook.removePending(address);
1854
- await this._handshake._sendDeny(this, address, senderVk);
2616
+ this._contactBook.removePending(address2);
2617
+ await this._handshake._sendDeny(this, address2, senderVk);
1855
2618
  }
1856
2619
  /**
1857
2620
  * Block an address or domain pattern (HAND-04).
@@ -1877,7 +2640,7 @@ var Agent = class {
1877
2640
  *
1878
2641
  * Returns `true` if verified, `false` on timeout.
1879
2642
  */
1880
- async verifyDomain(domain, options) {
2643
+ async verifyDomain(domain2, options) {
1881
2644
  const { generateTxtRecord: generateTxtRecord2 } = await Promise.resolve().then(() => (init_dns_verifier(), dns_verifier_exports));
1882
2645
  await this._ensureConnected();
1883
2646
  const timeout = options?.timeout ?? 3e5;
@@ -1898,7 +2661,7 @@ var Agent = class {
1898
2661
  "Content-Type": "application/json",
1899
2662
  Authorization: `Bearer ${this._token}`
1900
2663
  },
1901
- body: JSON.stringify({ domain }),
2664
+ body: JSON.stringify({ domain: domain2 }),
1902
2665
  signal: AbortSignal.timeout(3e4)
1903
2666
  }
1904
2667
  );
@@ -1951,7 +2714,7 @@ var Agent = class {
1951
2714
  if (!resp.ok) {
1952
2715
  throw new Error(`HTTP ${resp.status}`);
1953
2716
  }
1954
- return;
2717
+ return await readAnswer(resp);
1955
2718
  } catch (err) {
1956
2719
  lastError = err instanceof Error ? err : new Error(String(err));
1957
2720
  }
@@ -1969,6 +2732,10 @@ var Agent = class {
1969
2732
  /**
1970
2733
  * Resolve a recipient's public key, checking contact book first.
1971
2734
  *
2735
+ * Pure lookup: never writes to the contact book. The contact row is
2736
+ * created by _initiateHandshake (as "handshake-sent") once the request
2737
+ * has gone out, giving the lifecycle no row -> handshake-sent -> pinned.
2738
+ *
1972
2739
  * TOFU-01: Known contacts always use stored key (zero network I/O).
1973
2740
  * TOFU-03: Race-condition double-check after network resolve.
1974
2741
  * Raises KeyPinningError if a pinned contact's key doesn't match.
@@ -1992,9 +2759,6 @@ var Agent = class {
1992
2759
  );
1993
2760
  }
1994
2761
  }
1995
- this._contactBook.addContact(toAddress, resolvedPkStr, {
1996
- trustState: "provisional"
1997
- });
1998
2762
  return deserializeVerifyKey(resolvedPkStr);
1999
2763
  }
2000
2764
  async _initiateHandshake(toAddress, recipientVk) {
@@ -2041,6 +2805,9 @@ var Agent = class {
2041
2805
  }
2042
2806
  throw err;
2043
2807
  }
2808
+ if (!await this._passesInboundChecks(envelope, raw)) {
2809
+ return null;
2810
+ }
2044
2811
  if (envelope.type === "handshake.request" /* HANDSHAKE_REQUEST */ || envelope.type === "handshake.accept" /* HANDSHAKE_ACCEPT */ || envelope.type === "handshake.deny" /* HANDSHAKE_DENY */) {
2045
2812
  return this._handshake.handleInbound(this, envelope, senderVk);
2046
2813
  }
@@ -2075,6 +2842,102 @@ var Agent = class {
2075
2842
  verified: true
2076
2843
  });
2077
2844
  }
2845
+ /**
2846
+ * Recipient, freshness, expiry and replay checks for a signature-verified
2847
+ * inbound envelope. Returns false (and logs) when it must be dropped.
2848
+ * Matches the Python SDK's _process_inbound checks.
2849
+ */
2850
+ async _passesInboundChecks(envelope, raw) {
2851
+ const id = envelope.messageId;
2852
+ const from = envelope.fromAddress;
2853
+ if (this._address !== null && envelope.toAddress !== this._address) {
2854
+ log.warn(
2855
+ `Dropped envelope ${id} from ${from} addressed to ${envelope.toAddress} (we are ${this._address})`
2856
+ );
2857
+ return false;
2858
+ }
2859
+ const now = this._clock();
2860
+ const ts = Date.parse(envelope.timestamp);
2861
+ if (Number.isNaN(ts)) {
2862
+ log.warn(
2863
+ `Dropped envelope ${id} from ${from} with malformed timestamp ` + JSON.stringify(envelope.timestamp)
2864
+ );
2865
+ return false;
2866
+ }
2867
+ const ageSeconds = Math.abs(now - ts) / 1e3;
2868
+ if (ageSeconds > MAX_ENVELOPE_AGE && !await this._wasStored(raw, envelope.toAddress, ts, now)) {
2869
+ log.info(
2870
+ `Dropped stale envelope ${id} from ${from} (age=${Math.round(ageSeconds)}s > ${MAX_ENVELOPE_AGE}s)`
2871
+ );
2872
+ return false;
2873
+ }
2874
+ if (envelope.expires !== void 0 && envelope.expires !== null) {
2875
+ const exp = Date.parse(envelope.expires);
2876
+ if (Number.isNaN(exp)) {
2877
+ log.warn(
2878
+ `Dropped envelope ${id} from ${from} with malformed expires ` + JSON.stringify(envelope.expires)
2879
+ );
2880
+ return false;
2881
+ }
2882
+ if (now > exp) {
2883
+ log.info(
2884
+ `Dropped expired envelope ${id} from ${from} (expires=${envelope.expires})`
2885
+ );
2886
+ return false;
2887
+ }
2888
+ }
2889
+ if (await this._replayCache.seenOrRecord(from, id, ts)) {
2890
+ log.info(`Dropped replayed envelope ${id} from ${from}`);
2891
+ return false;
2892
+ }
2893
+ return true;
2894
+ }
2895
+ /** True if a message too old to be live is stored mail, not a replay. */
2896
+ async _wasStored(raw, toAddress, signedAt, now) {
2897
+ const relayDomain = toAddress.split("::").pop();
2898
+ if (!relayVouches(raw, signedAt, now, relayDomain)) return false;
2899
+ if (await this._replayCache.signedBeforeCache(signedAt)) return false;
2900
+ return this._relayStampsMail();
2901
+ }
2902
+ /**
2903
+ * True if our relay writes relay_meta, so a sender can't have.
2904
+ *
2905
+ * Asked only when a stale message carries a stamp. A yes is remembered for
2906
+ * good, a no for RELAY_NO_MEMORY_MS: a relay can be upgraded while this
2907
+ * agent runs. A failed lookup isn't remembered, because the relay has
2908
+ * already marked this mail delivered and the next message should get a
2909
+ * real answer.
2910
+ */
2911
+ async _relayStampsMail() {
2912
+ if (this._relayStamps) return true;
2913
+ if (this._monotonic() < this._relayNoUntil) return false;
2914
+ try {
2915
+ const features = await this._fetchRelayFeatures();
2916
+ this._relayStamps = features.includes(RELAY_META_FEATURE);
2917
+ if (!this._relayStamps) {
2918
+ this._relayNoUntil = this._monotonic() + RELAY_NO_MEMORY_MS;
2919
+ }
2920
+ } catch (err) {
2921
+ const reason = err instanceof Error ? err.message : String(err);
2922
+ log.warn(`Cannot read relay features: ${reason}`);
2923
+ }
2924
+ return this._relayStamps;
2925
+ }
2926
+ /** The relay's features; a body that doesn't list any is an empty list. */
2927
+ async _fetchRelayFeatures() {
2928
+ const resp = await fetch(`${this._config.relayUrl}/api/v1/versions`, {
2929
+ signal: AbortSignal.timeout(1e4)
2930
+ });
2931
+ if (!resp.ok) throw new UAMError(`Relay answered ${resp.status}`);
2932
+ const body = await resp.text();
2933
+ let data;
2934
+ try {
2935
+ data = JSON.parse(body);
2936
+ } catch {
2937
+ return [];
2938
+ }
2939
+ return Array.isArray(data?.features) ? data.features : [];
2940
+ }
2078
2941
  /**
2079
2942
  * Sweep expired pending handshakes and send receipt.failed (HAND-03).
2080
2943
  */
@@ -2127,7 +2990,8 @@ var Agent = class {
2127
2990
  "receipt.read" /* RECEIPT_READ */,
2128
2991
  new TextEncoder().encode(receiptPayload),
2129
2992
  this._keyManager.signingKey,
2130
- senderVk
2993
+ senderVk,
2994
+ { replyTo: msg.messageId }
2131
2995
  );
2132
2996
  const wire = toWireDict(envelope);
2133
2997
  await this._transport.send(wire);
@@ -2137,26 +3001,25 @@ var Agent = class {
2137
3001
  /**
2138
3002
  * Register with the relay server.
2139
3003
  *
2140
- * Calls POST /api/v1/register with agent name and public key.
2141
- * Stores the returned API key on disk.
3004
+ * Calls POST /api/v1/register with agent name and public key. If the
3005
+ * address exists (409), repeats it with a proof that this agent holds the
3006
+ * key. Stores the returned API key on disk.
2142
3007
  */
2143
3008
  async _registerWithRelay() {
2144
- const publicKeyStr = serializeVerifyKey(this._keyManager.verifyKey);
2145
- const resp = await fetch(
2146
- `${this._config.relayUrl}/api/v1/register`,
2147
- {
2148
- method: "POST",
2149
- headers: { "Content-Type": "application/json" },
2150
- body: JSON.stringify({
2151
- agent_name: this._config.name,
2152
- public_key: publicKeyStr
2153
- })
2154
- }
2155
- );
3009
+ const body = {
3010
+ agent_name: this._config.name,
3011
+ public_key: serializeVerifyKey(this._keyManager.verifyKey)
3012
+ };
3013
+ let resp = await this._postRegistration(body);
2156
3014
  if (resp.status === 409) {
2157
- throw new UAMError(
2158
- `Address already registered with a different key: ${this._config.name}::${this._config.relayDomain}`
2159
- );
3015
+ const address3 = await this._addressOnRelay();
3016
+ resp = await this._postRegistration({
3017
+ ...body,
3018
+ proof: this._registrationProof(address3)
3019
+ });
3020
+ if (resp.status === 409 || resp.status === 403) {
3021
+ throw this._ownershipNotProven(address3);
3022
+ }
2160
3023
  }
2161
3024
  if (!resp.ok) {
2162
3025
  const text = await resp.text();
@@ -2165,22 +3028,140 @@ var Agent = class {
2165
3028
  );
2166
3029
  }
2167
3030
  const data = await resp.json();
2168
- this._address = data["address"];
3031
+ const address2 = data["address"];
3032
+ this._useAddress(address2);
2169
3033
  this._token = data["token"];
2170
- this._keyManager.saveToken(this._config.name, this._token);
3034
+ const { name } = this._config;
3035
+ const token = this._token;
3036
+ this._tryToSave("token", () => this._keyManager.saveToken(name, token));
3037
+ this._saveAddress(address2);
3038
+ }
3039
+ /** This agent's address on the relay, for signing a proof. */
3040
+ async _addressOnRelay() {
3041
+ const { name, explicitRelayDomain, relayDomain, relayUrl } = this._config;
3042
+ if (explicitRelayDomain !== null) return `${name}::${explicitRelayDomain}`;
3043
+ const domain2 = await this._fetchRelayDomain();
3044
+ if (domain2 === null) return `${name}::${relayDomain}`;
3045
+ if (!this._relayMayUse(domain2)) {
3046
+ throw new UAMError(
3047
+ `The relay at ${relayUrl} says its domain is ${domain2}. Pass the domain option as ${JSON.stringify(domain2)} if that is right.`
3048
+ );
3049
+ }
3050
+ return `${name}::${domain2}`;
3051
+ }
3052
+ /**
3053
+ * True if the relay's URL fits `domain`, so a proof can be signed for it.
3054
+ *
3055
+ * A proof names an address on one relay, so it is only signed for a domain
3056
+ * this URL belongs to. A relay on this machine may use any domain.
3057
+ */
3058
+ _relayMayUse(domain2) {
3059
+ let host;
3060
+ try {
3061
+ host = new URL(this._config.relayUrl).hostname.toLowerCase();
3062
+ } catch {
3063
+ return false;
3064
+ }
3065
+ const wanted = domain2.toLowerCase();
3066
+ return host === wanted || host.endsWith(`.${wanted}`) || host === "localhost" || host === "[::1]" || /^127\.\d+\.\d+\.\d+$/.test(host);
3067
+ }
3068
+ _postRegistration(body) {
3069
+ return fetch(`${this._config.relayUrl}/api/v1/register`, {
3070
+ method: "POST",
3071
+ headers: { "Content-Type": "application/json" },
3072
+ body: JSON.stringify(body)
3073
+ });
3074
+ }
3075
+ /**
3076
+ * Sign a statement that this agent holds the key for `address`.
3077
+ *
3078
+ * The signed string must stay byte-identical to the Python SDK's.
3079
+ */
3080
+ _registrationProof(address2) {
3081
+ const timestamp = utcTimestamp();
3082
+ const signature = signMessage(
3083
+ new TextEncoder().encode(`uam-register-v1
3084
+ ${address2}
3085
+ ${timestamp}`),
3086
+ this._keyManager.signingKey
3087
+ );
3088
+ return { timestamp, signature };
3089
+ }
3090
+ _ownershipNotProven(address2) {
3091
+ return new UAMError(
3092
+ `Address already registered, and this key could not prove ownership: ` + address2
3093
+ );
3094
+ }
3095
+ /** Remember `address` for this relay, if the key folder allows it. */
3096
+ _saveAddress(address2) {
3097
+ const { name, relayUrl } = this._config;
3098
+ this._tryToSave(
3099
+ "address",
3100
+ () => this._keyManager.saveAddress(name, address2, relayUrl)
3101
+ );
3102
+ }
3103
+ /** Saving is best effort: a key folder we can't write doesn't stop us. */
3104
+ _tryToSave(what, save) {
3105
+ try {
3106
+ save();
3107
+ } catch (err) {
3108
+ const reason = err instanceof Error ? err.message : String(err);
3109
+ log.warn(`Could not save the ${what}: ${reason}`);
3110
+ }
3111
+ }
3112
+ /** Take `address` as ours; its domain is this relay's real domain. */
3113
+ _useAddress(address2) {
3114
+ this._address = address2;
3115
+ const domain2 = parseAddress(address2).domain;
3116
+ this._config.relayDomain = domain2;
3117
+ if (this._resolver instanceof SmartResolver) {
3118
+ this._resolver.relayDomain = domain2;
3119
+ }
3120
+ }
3121
+ /**
3122
+ * The address the stored token belongs to.
3123
+ *
3124
+ * An explicit domain settles it. Otherwise the relay chooses the domain,
3125
+ * and it need not be the relay's hostname: use the address saved for this
3126
+ * relay, or ask the relay once and keep the answer.
3127
+ */
3128
+ async _storedAddress() {
3129
+ const { name, explicitRelayDomain } = this._config;
3130
+ if (explicitRelayDomain !== null) return `${name}::${explicitRelayDomain}`;
3131
+ const saved = this._keyManager.loadAddress(name, this._config.relayUrl);
3132
+ if (saved) return saved;
3133
+ const domain2 = await this._fetchRelayDomain();
3134
+ if (domain2 === null) return `${name}::${this._config.relayDomain}`;
3135
+ const address2 = `${name}::${domain2}`;
3136
+ this._saveAddress(address2);
3137
+ return address2;
3138
+ }
3139
+ /** Ask the relay for its domain; null if it cannot say. */
3140
+ async _fetchRelayDomain() {
3141
+ const url = `${this._config.relayUrl}/.well-known/uam-relay.json`;
3142
+ try {
3143
+ const resp = await fetch(url, { signal: AbortSignal.timeout(1e4) });
3144
+ if (!resp.ok) return null;
3145
+ const data = await resp.json();
3146
+ const domain2 = data["relay_domain"];
3147
+ if (typeof domain2 !== "string" || domain2 === "") return null;
3148
+ return parseAddress(`${this._config.name}::${domain2}`).domain;
3149
+ } catch {
3150
+ return null;
3151
+ }
2171
3152
  }
2172
3153
  };
2173
3154
 
2174
3155
  // src/cli/helpers.ts
2175
- var import_node_fs4 = require("fs");
2176
- var import_node_path4 = require("path");
3156
+ var import_node_fs5 = require("fs");
3157
+ var import_node_path6 = require("path");
2177
3158
  var import_node_os4 = require("os");
2178
3159
  function findAgentName(keyDir) {
2179
- const dir = keyDir ?? (0, import_node_path4.join)(process.env["UAM_HOME"] ?? (0, import_node_path4.join)((0, import_node_os4.homedir)(), ".uam"), "keys");
2180
- if (!(0, import_node_fs4.existsSync)(dir)) {
3160
+ const dir = keyDir ?? (0, import_node_path6.join)(process.env["UAM_HOME"] ?? (0, import_node_path6.join)((0, import_node_os4.homedir)(), ".uam"), "keys");
3161
+ if (!(0, import_node_fs5.existsSync)(dir)) {
2181
3162
  return null;
2182
3163
  }
2183
- const keyFiles = (0, import_node_fs4.readdirSync)(dir).filter((f) => f.endsWith(".key")).sort();
3164
+ const keyFiles = (0, import_node_fs5.readdirSync)(dir).filter((f) => f.endsWith(".key")).sort();
2184
3165
  if (keyFiles.length === 0) {
2185
3166
  return null;
2186
3167
  }
@@ -2200,21 +3181,21 @@ async function initCommand(options) {
2200
3181
  try {
2201
3182
  const cfg = new SDKConfig({ name: agentName, relayUrl: options.relay });
2202
3183
  const km = new KeyManager(cfg.keyDir);
2203
- const keyPath = (0, import_node_path5.join)(cfg.keyDir, `${agentName}.key`);
2204
- if ((0, import_node_fs5.existsSync)(keyPath)) {
3184
+ const keyPath = (0, import_node_path7.join)(cfg.keyDir, `${agentName}.key`);
3185
+ if ((0, import_node_fs6.existsSync)(keyPath)) {
2205
3186
  km.loadOrGenerate(agentName);
2206
- const address2 = `${agentName}::${cfg.relayDomain}`;
3187
+ const address3 = km.loadAddress(agentName, cfg.relayUrl) ?? `${agentName}::${cfg.relayDomain}`;
2207
3188
  const fp2 = publicKeyFingerprint(km.verifyKey);
2208
- console.log(`Agent already initialized: ${address2}`);
3189
+ console.log(`Agent already initialized: ${address3}`);
2209
3190
  console.log(`Fingerprint: ${fp2}`);
2210
3191
  return;
2211
3192
  }
2212
3193
  const agent = new Agent(agentName, { relay: options.relay });
2213
3194
  await agent.connect();
2214
- const address = agent.address;
3195
+ const address2 = agent.address;
2215
3196
  const fp = publicKeyFingerprint(agent._keyManager.verifyKey);
2216
3197
  await agent.close();
2217
- console.log(`Initialized agent: ${address}`);
3198
+ console.log(`Initialized agent: ${address2}`);
2218
3199
  console.log(`Fingerprint: ${fp}`);
2219
3200
  } catch (err) {
2220
3201
  cliError(`Error: ${err instanceof Error ? err.message : String(err)}`);
@@ -2222,7 +3203,7 @@ async function initCommand(options) {
2222
3203
  }
2223
3204
 
2224
3205
  // src/cli/commands/send.ts
2225
- async function sendCommand(address, message, options) {
3206
+ async function sendCommand(address2, message, options) {
2226
3207
  const agentName = options.name ?? findAgentName();
2227
3208
  if (!agentName) {
2228
3209
  cliError("No agent initialized. Run `uam init` first.");
@@ -2230,9 +3211,12 @@ async function sendCommand(address, message, options) {
2230
3211
  try {
2231
3212
  const agent = new Agent(agentName);
2232
3213
  await agent.connect();
2233
- const msgId = await agent.send(address, message);
3214
+ const result = await agent.send(address2, message);
2234
3215
  await agent.close();
2235
- console.log(`Message sent to ${address} (id: ${msgId})`);
3216
+ console.log(`Message to ${address2}: ${result.status} (id: ${result.messageId})`);
3217
+ if (result.status === "pending") {
3218
+ console.log(pendingNote(address2));
3219
+ }
2236
3220
  } catch (err) {
2237
3221
  cliError(`Error: ${err instanceof Error ? err.message : String(err)}`);
2238
3222
  }
@@ -2284,24 +3268,24 @@ async function cardCommand(options) {
2284
3268
  }
2285
3269
 
2286
3270
  // src/cli/commands/whoami.ts
2287
- var import_node_fs6 = require("fs");
2288
- var import_node_path6 = require("path");
3271
+ var import_node_fs7 = require("fs");
3272
+ var import_node_path8 = require("path");
2289
3273
  async function whoamiCommand(options) {
2290
3274
  const agentName = options.name ?? findAgentName();
2291
3275
  if (!agentName) {
2292
3276
  cliError("No agent initialized. Run `uam init` first.");
2293
3277
  }
2294
3278
  const cfg = new SDKConfig({ name: agentName });
2295
- const keyPath = (0, import_node_path6.join)(cfg.keyDir, `${agentName}.key`);
2296
- if (!(0, import_node_fs6.existsSync)(keyPath)) {
3279
+ const keyPath = (0, import_node_path8.join)(cfg.keyDir, `${agentName}.key`);
3280
+ if (!(0, import_node_fs7.existsSync)(keyPath)) {
2297
3281
  cliError("No agent initialized. Run `uam init` first.");
2298
3282
  }
2299
3283
  await sodiumReady;
2300
3284
  const km = new KeyManager(cfg.keyDir);
2301
3285
  km.loadOrGenerate(agentName);
2302
- const address = `${agentName}::${cfg.relayDomain}`;
3286
+ const address2 = km.loadAddress(agentName, cfg.relayUrl) ?? `${agentName}::${cfg.relayDomain}`;
2303
3287
  const fp = publicKeyFingerprint(km.verifyKey);
2304
- console.log(`Address: ${address}`);
3288
+ console.log(`Address: ${address2}`);
2305
3289
  console.log(`Fingerprint: ${fp}`);
2306
3290
  console.log(`Key file: ${keyPath}`);
2307
3291
  }
@@ -2372,7 +3356,7 @@ async function pendingCommand(options) {
2372
3356
  }
2373
3357
 
2374
3358
  // src/cli/commands/approve.ts
2375
- async function approveCommand(address, options) {
3359
+ async function approveCommand(address2, options) {
2376
3360
  const agentName = options.name ?? findAgentName();
2377
3361
  if (!agentName) {
2378
3362
  cliError("No agent initialized. Run `uam init` first.");
@@ -2380,16 +3364,16 @@ async function approveCommand(address, options) {
2380
3364
  try {
2381
3365
  const agent = new Agent(agentName);
2382
3366
  await agent.connect();
2383
- await agent.approve(address);
3367
+ await agent.approve(address2);
2384
3368
  await agent.close();
2385
- console.log(`Approved: ${address}`);
3369
+ console.log(`Approved: ${address2}`);
2386
3370
  } catch (err) {
2387
3371
  cliError(`Error: ${err instanceof Error ? err.message : err}`);
2388
3372
  }
2389
3373
  }
2390
3374
 
2391
3375
  // src/cli/commands/deny.ts
2392
- async function denyCommand(address, options) {
3376
+ async function denyCommand(address2, options) {
2393
3377
  const agentName = options.name ?? findAgentName();
2394
3378
  if (!agentName) {
2395
3379
  cliError("No agent initialized. Run `uam init` first.");
@@ -2397,9 +3381,9 @@ async function denyCommand(address, options) {
2397
3381
  try {
2398
3382
  const agent = new Agent(agentName);
2399
3383
  await agent.connect();
2400
- await agent.deny(address);
3384
+ await agent.deny(address2);
2401
3385
  await agent.close();
2402
- console.log(`Denied: ${address}`);
3386
+ console.log(`Denied: ${address2}`);
2403
3387
  } catch (err) {
2404
3388
  cliError(`Error: ${err instanceof Error ? err.message : err}`);
2405
3389
  }
@@ -2441,21 +3425,21 @@ async function unblockCommand(pattern, options) {
2441
3425
  }
2442
3426
 
2443
3427
  // src/cli/commands/contact.ts
2444
- async function contactFingerprintCommand(address, options) {
3428
+ async function contactFingerprintCommand(address2, options) {
2445
3429
  const agentName = options.name ?? findAgentName();
2446
3430
  const cfg = new SDKConfig({ name: agentName ?? "_probe" });
2447
3431
  const book = new ContactBook(cfg.dataDir);
2448
3432
  try {
2449
3433
  book.open();
2450
- const pkStr = book.getPublicKey(address);
3434
+ const pkStr = book.getPublicKey(address2);
2451
3435
  book.close();
2452
3436
  if (pkStr === null) {
2453
- cliError(`Contact not found: ${address}`);
3437
+ cliError(`Contact not found: ${address2}`);
2454
3438
  }
2455
3439
  const vk = deserializeVerifyKey(pkStr);
2456
3440
  const fp = publicKeyFingerprint(vk);
2457
3441
  const shortFp = fp.slice(0, 16);
2458
- console.log(`Address: ${address}`);
3442
+ console.log(`Address: ${address2}`);
2459
3443
  console.log(`Fingerprint: ${shortFp}`);
2460
3444
  console.log(`Full: ${fp}`);
2461
3445
  } catch (err) {
@@ -2465,43 +3449,43 @@ async function contactFingerprintCommand(address, options) {
2465
3449
  cliError(`Error: ${err instanceof Error ? err.message : err}`);
2466
3450
  }
2467
3451
  }
2468
- async function contactVerifyCommand(address, options) {
3452
+ async function contactVerifyCommand(address2, options) {
2469
3453
  const agentName = options.name ?? findAgentName();
2470
3454
  const cfg = new SDKConfig({ name: agentName ?? "_probe" });
2471
3455
  const book = new ContactBook(cfg.dataDir);
2472
3456
  try {
2473
3457
  book.open();
2474
- const pkStr = book.getPublicKey(address);
3458
+ const pkStr = book.getPublicKey(address2);
2475
3459
  if (pkStr === null) {
2476
3460
  book.close();
2477
- cliError(`Contact not found: ${address}`);
3461
+ cliError(`Contact not found: ${address2}`);
2478
3462
  }
2479
3463
  const vk = deserializeVerifyKey(pkStr);
2480
3464
  const fp = publicKeyFingerprint(vk);
2481
- console.log(`Fingerprint for ${address}: ${fp.slice(0, 16)}`);
2482
- book.addContact(address, pkStr, {
3465
+ console.log(`Fingerprint for ${address2}: ${fp.slice(0, 16)}`);
3466
+ book.addContact(address2, pkStr, {
2483
3467
  trustState: "verified",
2484
3468
  trustSource: "manual-verify"
2485
3469
  });
2486
3470
  book.close();
2487
- console.log(`Contact ${address} verified. Trust state: verified`);
3471
+ console.log(`Contact ${address2} verified. Trust state: verified`);
2488
3472
  } catch (err) {
2489
3473
  cliError(`Error: ${err instanceof Error ? err.message : err}`);
2490
3474
  }
2491
3475
  }
2492
- async function contactRemoveCommand(address, options) {
3476
+ async function contactRemoveCommand(address2, options) {
2493
3477
  const agentName = options.name ?? findAgentName();
2494
3478
  const cfg = new SDKConfig({ name: agentName ?? "_probe" });
2495
3479
  const book = new ContactBook(cfg.dataDir);
2496
3480
  try {
2497
3481
  book.open();
2498
- const removed = book.removeContact(address);
3482
+ const removed = book.removeContact(address2);
2499
3483
  book.close();
2500
3484
  if (!removed) {
2501
- cliError(`Contact not found: ${address}`);
3485
+ cliError(`Contact not found: ${address2}`);
2502
3486
  }
2503
3487
  console.log(
2504
- `Contact ${address} removed. Future messages will re-resolve the public key.`
3488
+ `Contact ${address2} removed. Future messages will re-resolve the public key.`
2505
3489
  );
2506
3490
  } catch (err) {
2507
3491
  cliError(`Error: ${err instanceof Error ? err.message : err}`);
@@ -2510,7 +3494,7 @@ async function contactRemoveCommand(address, options) {
2510
3494
 
2511
3495
  // src/cli/commands/verify-domain.ts
2512
3496
  init_dns_verifier();
2513
- async function verifyDomainCommand(domain, options) {
3497
+ async function verifyDomainCommand(domain2, options) {
2514
3498
  const agentName = options.name ?? findAgentName();
2515
3499
  if (!agentName) {
2516
3500
  cliError("No agent initialized. Run `uam init` first.");
@@ -2523,27 +3507,27 @@ async function verifyDomainCommand(domain, options) {
2523
3507
  const pubkey = agent.publicKey;
2524
3508
  const relayUrl = agent._config.relayUrl;
2525
3509
  const txtValue = generateTxtRecord(pubkey, relayUrl);
2526
- console.log(`Add this DNS TXT record to verify ${domain}:`);
3510
+ console.log(`Add this DNS TXT record to verify ${domain2}:`);
2527
3511
  console.log();
2528
- console.log(` Host: _uam.${domain}`);
3512
+ console.log(` Host: _uam.${domain2}`);
2529
3513
  console.log(` Type: TXT`);
2530
3514
  console.log(` Value: ${txtValue}`);
2531
3515
  console.log();
2532
3516
  console.log(`Or serve this HTTPS fallback:`);
2533
3517
  console.log();
2534
- console.log(` URL: https://${domain}/.well-known/uam.json`);
3518
+ console.log(` URL: https://${domain2}/.well-known/uam.json`);
2535
3519
  console.log();
2536
3520
  console.log(`See documentation for .well-known/uam.json format.`);
2537
3521
  console.log();
2538
3522
  console.log("Polling for verification...");
2539
- const verified = await agent.verifyDomain(domain, {
3523
+ const verified = await agent.verifyDomain(domain2, {
2540
3524
  timeout: timeout * 1e3,
2541
3525
  pollInterval: pollInterval * 1e3
2542
3526
  });
2543
3527
  await agent.close();
2544
3528
  if (verified) {
2545
3529
  console.log(
2546
- `Verified! ${agent.address} is now Tier 2 via ${domain}.`
3530
+ `Domain checked by ${agent.address.split("::")[1] ?? "your relay"}: ${agent.address} is now Tier 2 via ${domain2}.`
2547
3531
  );
2548
3532
  } else {
2549
3533
  console.log(
@@ -2557,7 +3541,7 @@ async function verifyDomainCommand(domain, options) {
2557
3541
 
2558
3542
  // src/cli/index.ts
2559
3543
  var program = new import_commander.Command();
2560
- program.name("uam").description("Universal Agent Messaging CLI").version("0.1.0");
3544
+ program.name("uam").description("Universal Agent Messaging CLI").version(SDK_VERSION);
2561
3545
  program.option("-n, --name <name>", "Agent name (auto-detected from ~/.uam/keys/)");
2562
3546
  program.command("init").description("Initialize a new agent: generate keys and register with relay").option("-n, --name <name>", "Agent name").option("-r, --relay <url>", "Relay URL (default: relay.youam.network)").action(async (opts) => {
2563
3547
  const globalName = program.opts().name;
@@ -2566,9 +3550,9 @@ program.command("init").description("Initialize a new agent: generate keys and r
2566
3550
  relay: opts.relay
2567
3551
  });
2568
3552
  });
2569
- program.command("send <address> <message>").description("Send a message to another agent").action(async (address, message) => {
3553
+ program.command("send <address> <message>").description("Send a message to another agent").action(async (address2, message) => {
2570
3554
  const globalName = program.opts().name;
2571
- await sendCommand(address, message, { name: globalName });
3555
+ await sendCommand(address2, message, { name: globalName });
2572
3556
  });
2573
3557
  program.command("inbox").description("Check your inbox for pending messages").option("-l, --limit <number>", "Max messages to retrieve", "20").action(async (opts) => {
2574
3558
  const globalName = program.opts().name;
@@ -2590,21 +3574,21 @@ program.command("contacts").description("List known contacts from the local cont
2590
3574
  await contactsCommand({ name: globalName });
2591
3575
  });
2592
3576
  var contactCmd = program.command("contact").description("Contact management commands (fingerprint, verify, remove)");
2593
- contactCmd.command("fingerprint <address>").description("Display the fingerprint for a known contact's public key").action(async (address) => {
3577
+ contactCmd.command("fingerprint <address>").description("Display the fingerprint for a known contact's public key").action(async (address2) => {
2594
3578
  const globalName = program.opts().name;
2595
- await contactFingerprintCommand(address, { name: globalName });
3579
+ await contactFingerprintCommand(address2, { name: globalName });
2596
3580
  });
2597
- contactCmd.command("verify <address>").description("Manually verify a contact, upgrading trust state to verified").option("-y, --yes", "Skip confirmation prompt").action(async (address, opts) => {
3581
+ contactCmd.command("verify <address>").description("Manually verify a contact, upgrading trust state to verified").option("-y, --yes", "Skip confirmation prompt").action(async (address2, opts) => {
2598
3582
  const globalName = program.opts().name;
2599
- await contactVerifyCommand(address, { name: globalName, yes: opts.yes });
3583
+ await contactVerifyCommand(address2, { name: globalName, yes: opts.yes });
2600
3584
  });
2601
- contactCmd.command("remove <address>").description("Remove a contact from the contact book").action(async (address) => {
3585
+ contactCmd.command("remove <address>").description("Remove a contact from the contact book").action(async (address2) => {
2602
3586
  const globalName = program.opts().name;
2603
- await contactRemoveCommand(address, { name: globalName });
3587
+ await contactRemoveCommand(address2, { name: globalName });
2604
3588
  });
2605
- program.command("verify-domain <domain>").description("Verify domain ownership for Tier 2 DNS-verified status").option("-t, --timeout <seconds>", "Polling timeout in seconds", "300").option("--poll-interval <seconds>", "Polling interval in seconds", "10").action(async (domain, opts) => {
3589
+ program.command("verify-domain <domain>").description("Verify domain ownership for Tier 2 DNS-verified status").option("-t, --timeout <seconds>", "Polling timeout in seconds", "300").option("--poll-interval <seconds>", "Polling interval in seconds", "10").action(async (domain2, opts) => {
2606
3590
  const globalName = program.opts().name;
2607
- await verifyDomainCommand(domain, {
3591
+ await verifyDomainCommand(domain2, {
2608
3592
  name: globalName,
2609
3593
  timeout: parseInt(opts.timeout, 10),
2610
3594
  pollInterval: parseInt(opts.pollInterval, 10)
@@ -2614,13 +3598,13 @@ program.command("pending").description("List pending handshake requests awaiting
2614
3598
  const globalName = program.opts().name;
2615
3599
  await pendingCommand({ name: globalName });
2616
3600
  });
2617
- program.command("approve <address>").description("Approve a pending handshake request").action(async (address) => {
3601
+ program.command("approve <address>").description("Approve a pending handshake request").action(async (address2) => {
2618
3602
  const globalName = program.opts().name;
2619
- await approveCommand(address, { name: globalName });
3603
+ await approveCommand(address2, { name: globalName });
2620
3604
  });
2621
- program.command("deny <address>").description("Deny a pending handshake request").action(async (address) => {
3605
+ program.command("deny <address>").description("Deny a pending handshake request").action(async (address2) => {
2622
3606
  const globalName = program.opts().name;
2623
- await denyCommand(address, { name: globalName });
3607
+ await denyCommand(address2, { name: globalName });
2624
3608
  });
2625
3609
  program.command("block <pattern>").description("Block an address or domain pattern (e.g., spammer::evil.com or *::evil.com)").action(async (pattern) => {
2626
3610
  const globalName = program.opts().name;