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