vibedate 0.5.0 → 0.6.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.
@@ -2,7 +2,7 @@ import {
2
2
  CANDIDATES,
3
3
  loadProfile,
4
4
  matches
5
- } from "./chunk-KKWP4DLY.js";
5
+ } from "./chunk-VZXPYU2C.js";
6
6
 
7
7
  // src/mcp.ts
8
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -388,6 +388,44 @@ function classifyHelloIdentity(hello) {
388
388
  if (hello.nonce === void 0 || hello.sig === void 0) return "drop";
389
389
  return verifyHelloClaims(hello, { pubkey: hello.pubkey, nonce: hello.nonce, sig: hello.sig }) ? "verified" : "drop";
390
390
  }
391
+ var NOSTR_FILE = "nostr.json";
392
+ function nostrPath(dir) {
393
+ return path2.join(dir, NOSTR_FILE);
394
+ }
395
+ function isStoredNostrKey(data) {
396
+ if (typeof data !== "object" || data === null) return false;
397
+ const r = data;
398
+ return typeof r["sk"] === "string" && /^[0-9a-f]{64}$/.test(r["sk"]) && typeof r["pubkey"] === "string" && /^[0-9a-f]{64}$/.test(r["pubkey"]) && typeof r["createdAt"] === "string";
399
+ }
400
+ async function loadOrCreateNostrKey(dir = defaultStateDir()) {
401
+ try {
402
+ const raw = readFileSync2(nostrPath(dir), "utf8");
403
+ const data = JSON.parse(raw);
404
+ if (isStoredNostrKey(data)) {
405
+ try {
406
+ chmodSync(nostrPath(dir), 384);
407
+ } catch {
408
+ }
409
+ return { sk: Buffer.from(data.sk, "hex"), pubkey: data.pubkey };
410
+ }
411
+ } catch {
412
+ }
413
+ const { generateSecretKey, getPublicKey } = await import("nostr-tools");
414
+ const sk = generateSecretKey();
415
+ const pubkey = getPublicKey(sk);
416
+ const skHex = Buffer.from(sk).toString("hex");
417
+ mkdirSync2(dir, { recursive: true });
418
+ writeFileSync2(
419
+ nostrPath(dir),
420
+ JSON.stringify({ sk: skHex, pubkey, createdAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2) + "\n",
421
+ { encoding: "utf8", mode: 384 }
422
+ );
423
+ try {
424
+ chmodSync(nostrPath(dir), 384);
425
+ } catch {
426
+ }
427
+ return { sk, pubkey };
428
+ }
391
429
 
392
430
  // src/link.ts
393
431
  import { randomUUID as randomUUID2 } from "crypto";
@@ -909,6 +947,204 @@ async function startDiscovery(opts) {
909
947
  };
910
948
  }
911
949
 
950
+ // src/relay.ts
951
+ import { createHash as createHash2 } from "crypto";
952
+ var DEFAULT_RELAYS = [
953
+ "wss://relay.damus.io",
954
+ "wss://nostr.mom",
955
+ "wss://relay.snort.social"
956
+ ];
957
+ var VIBEDATE_MESSAGE_KIND = 4;
958
+ var VIBEDATE_PRESENCE_KIND = 30078;
959
+ var PRESENCE_D_TAG = "vibedating";
960
+ var PRESENCE_MARKER = JSON.stringify({ app: "vibedating", v: 1 });
961
+ var ED25519_TAG = "ed25519";
962
+ function conversationTag(myEd25519Hex, peerEd25519Hex) {
963
+ const lo = myEd25519Hex < peerEd25519Hex ? myEd25519Hex : peerEd25519Hex;
964
+ const hi = myEd25519Hex < peerEd25519Hex ? peerEd25519Hex : myEd25519Hex;
965
+ return createHash2("sha256").update(`vibedate:nostr:${lo}:${hi}`, "utf8").digest("hex");
966
+ }
967
+ var nostrPromise;
968
+ function nostr() {
969
+ if (!nostrPromise) nostrPromise = import("nostr-tools");
970
+ return nostrPromise;
971
+ }
972
+ function tagValue(event, name) {
973
+ for (const tag of event.tags) {
974
+ if (tag[0] === name && typeof tag[1] === "string") return tag[1];
975
+ }
976
+ return void 0;
977
+ }
978
+ async function createNostrRelayLink(opts) {
979
+ const { finalizeEvent, nip04 } = await nostr();
980
+ const { myNostr, myEd25519Hex, peerEd25519Hex, hello, transport } = opts;
981
+ const convTag = conversationTag(myEd25519Hex, peerEd25519Hex);
982
+ const messageCbs = /* @__PURE__ */ new Set();
983
+ const closeCbs = /* @__PURE__ */ new Set();
984
+ const mediaCbs = /* @__PURE__ */ new Set();
985
+ const signalCbs = /* @__PURE__ */ new Set();
986
+ let closed = false;
987
+ let peerNostrPubkey;
988
+ const pending = [];
989
+ const publish = (template) => {
990
+ let event;
991
+ try {
992
+ event = finalizeEvent(
993
+ { ...template, created_at: Math.floor(Date.now() / 1e3) },
994
+ myNostr.sk
995
+ );
996
+ } catch {
997
+ return;
998
+ }
999
+ try {
1000
+ transport.publish(event);
1001
+ } catch {
1002
+ }
1003
+ };
1004
+ const publishPresence = () => {
1005
+ publish({
1006
+ kind: VIBEDATE_PRESENCE_KIND,
1007
+ content: PRESENCE_MARKER,
1008
+ tags: [
1009
+ ["d", PRESENCE_D_TAG],
1010
+ ["t", convTag],
1011
+ [ED25519_TAG, myEd25519Hex]
1012
+ ]
1013
+ });
1014
+ };
1015
+ const sendEncrypted = (text, recipientPubkey) => {
1016
+ let ciphertext;
1017
+ try {
1018
+ ciphertext = nip04.encrypt(myNostr.sk, recipientPubkey, text);
1019
+ } catch {
1020
+ return;
1021
+ }
1022
+ publish({
1023
+ kind: VIBEDATE_MESSAGE_KIND,
1024
+ content: ciphertext,
1025
+ tags: [
1026
+ ["t", convTag],
1027
+ ["p", recipientPubkey],
1028
+ [ED25519_TAG, myEd25519Hex]
1029
+ ]
1030
+ });
1031
+ };
1032
+ const flushPending = () => {
1033
+ if (peerNostrPubkey === void 0) return;
1034
+ while (pending.length > 0) {
1035
+ const text = pending.shift();
1036
+ if (text !== void 0) sendEncrypted(text, peerNostrPubkey);
1037
+ }
1038
+ };
1039
+ const unsubscribe = transport.subscribe({ "#t": [convTag] }, (event) => {
1040
+ if (closed) return;
1041
+ if (event.pubkey === myNostr.pubkey) return;
1042
+ if (event.kind === VIBEDATE_PRESENCE_KIND) {
1043
+ const claimedEd = tagValue(event, ED25519_TAG);
1044
+ if (claimedEd !== peerEd25519Hex) return;
1045
+ peerNostrPubkey = event.pubkey;
1046
+ flushPending();
1047
+ return;
1048
+ }
1049
+ if (event.kind === VIBEDATE_MESSAGE_KIND) {
1050
+ let plaintext;
1051
+ try {
1052
+ const sender = peerNostrPubkey ?? event.pubkey;
1053
+ plaintext = nip04.decrypt(myNostr.sk, sender, event.content);
1054
+ } catch {
1055
+ return;
1056
+ }
1057
+ peerNostrPubkey = event.pubkey;
1058
+ const msg = { id: event.id, text: plaintext, at: event.created_at * 1e3 };
1059
+ for (const cb of messageCbs) cb(msg);
1060
+ return;
1061
+ }
1062
+ });
1063
+ publishPresence();
1064
+ return {
1065
+ hello,
1066
+ send(text) {
1067
+ if (closed) return;
1068
+ if (peerNostrPubkey === void 0) {
1069
+ pending.push(text);
1070
+ return;
1071
+ }
1072
+ sendEncrypted(text, peerNostrPubkey);
1073
+ },
1074
+ // ponytail: chunked media over the relay (mirror link.ts media frames as
1075
+ // kind-4 payloads). For v0 a relay link carries text only.
1076
+ async sendMedia() {
1077
+ return { id: "", size: 0 };
1078
+ },
1079
+ // ponytail: relay WebRTC signaling (rtc-offer/answer/ice as kind-4 payloads)
1080
+ // so A/V could also fall back through the relay. v0: text only.
1081
+ sendSignal() {
1082
+ },
1083
+ onMessage(cb) {
1084
+ messageCbs.add(cb);
1085
+ },
1086
+ onMedia(cb) {
1087
+ mediaCbs.add(cb);
1088
+ },
1089
+ onSignal(cb) {
1090
+ signalCbs.add(cb);
1091
+ },
1092
+ onClose(cb) {
1093
+ closeCbs.add(cb);
1094
+ },
1095
+ close() {
1096
+ if (closed) return;
1097
+ closed = true;
1098
+ try {
1099
+ unsubscribe();
1100
+ } catch {
1101
+ }
1102
+ try {
1103
+ transport.close();
1104
+ } catch {
1105
+ }
1106
+ }
1107
+ };
1108
+ }
1109
+ async function createNostrPoolTransport(urls = DEFAULT_RELAYS) {
1110
+ const { SimplePool } = await nostr();
1111
+ const pool = new SimplePool();
1112
+ const relays = [...urls];
1113
+ return {
1114
+ publish(event) {
1115
+ try {
1116
+ const results = pool.publish(relays, event);
1117
+ Promise.allSettled(results).catch(() => {
1118
+ });
1119
+ } catch {
1120
+ }
1121
+ },
1122
+ subscribe(filter, onEvent) {
1123
+ const closer = pool.subscribeMany(
1124
+ relays,
1125
+ filter,
1126
+ { onevent: (e) => onEvent(e) }
1127
+ );
1128
+ return () => {
1129
+ try {
1130
+ closer.close();
1131
+ } catch {
1132
+ }
1133
+ };
1134
+ },
1135
+ close() {
1136
+ try {
1137
+ pool.close(relays);
1138
+ } catch {
1139
+ }
1140
+ try {
1141
+ pool.destroy();
1142
+ } catch {
1143
+ }
1144
+ }
1145
+ };
1146
+ }
1147
+
912
1148
  // src/index.ts
913
1149
  var LEAGUES = [
914
1150
  { name: "1M", min: 1e6, max: 4999999 },
@@ -1067,6 +1303,7 @@ export {
1067
1303
  signHelloClaims,
1068
1304
  verifyHelloClaims,
1069
1305
  classifyHelloIdentity,
1306
+ loadOrCreateNostrKey,
1070
1307
  sanitizePeerText,
1071
1308
  TOPIC_PREFIX,
1072
1309
  leagueTopic,
@@ -1077,6 +1314,12 @@ export {
1077
1314
  recordPeer,
1078
1315
  recordPeerMessage,
1079
1316
  startDiscovery,
1317
+ DEFAULT_RELAYS,
1318
+ VIBEDATE_MESSAGE_KIND,
1319
+ VIBEDATE_PRESENCE_KIND,
1320
+ conversationTag,
1321
+ createNostrRelayLink,
1322
+ createNostrPoolTransport,
1080
1323
  LEAGUES,
1081
1324
  BELOW_LEAGUE,
1082
1325
  league,
package/dist/cli.d.ts CHANGED
@@ -17,6 +17,12 @@ interface ParsedArgs {
17
17
  readonly to: string | undefined;
18
18
  /** `live --keep-alive`: stay in the swarm after stdin EOF. Default false. */
19
19
  readonly keepAlive: boolean;
20
+ /**
21
+ * `live --via-relay` / `discover --via-relay`: route through public Nostr relays
22
+ * (NIP-04 e2e) when direct hyperdht hole-punching fails. v0 explicit opt-in;
23
+ * auto-fallback-on-timeout is a follow-up. Default false.
24
+ */
25
+ readonly viaRelay: boolean;
20
26
  }
21
27
  /**
22
28
  * Parse argv (the slice AFTER the program name) into a command + options.
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runMcp
4
- } from "./chunk-I5U3O4RH.js";
4
+ } from "./chunk-JIBC3OHV.js";
5
5
  import {
6
6
  CANDIDATES,
7
7
  DEFAULT_HANDLE,
@@ -12,6 +12,8 @@ import {
12
12
  allLeagueNames,
13
13
  canShareLive,
14
14
  connectProfile,
15
+ createNostrPoolTransport,
16
+ createNostrRelayLink,
15
17
  defaultStateDir,
16
18
  grantLiveConsent,
17
19
  isBlocked,
@@ -22,6 +24,7 @@ import {
22
24
  loadBlocklist,
23
25
  loadHandle,
24
26
  loadOrCreateIdentity,
27
+ loadOrCreateNostrKey,
25
28
  loadPeers,
26
29
  loadProfile,
27
30
  matches,
@@ -35,7 +38,7 @@ import {
35
38
  saveHandle,
36
39
  signHelloClaims,
37
40
  startDiscovery
38
- } from "./chunk-KKWP4DLY.js";
41
+ } from "./chunk-VZXPYU2C.js";
39
42
 
40
43
  // src/cli.ts
41
44
  import readline from "readline";
@@ -2112,7 +2115,7 @@ async function handle(req, res, opts) {
2112
2115
  }
2113
2116
 
2114
2117
  // src/cli.ts
2115
- var VERSION = "0.5.0";
2118
+ var VERSION = "0.6.0";
2116
2119
  function parsePort(raw) {
2117
2120
  const n = Number(raw);
2118
2121
  if (!Number.isInteger(n) || n < 1 || n > 65535) return void 0;
@@ -2127,7 +2130,8 @@ function parseArgs(argv) {
2127
2130
  any: false,
2128
2131
  arg: void 0,
2129
2132
  to: void 0,
2130
- keepAlive: false
2133
+ keepAlive: false,
2134
+ viaRelay: false
2131
2135
  };
2132
2136
  for (let i = 0; i < argv.length; i++) {
2133
2137
  const a = argv[i];
@@ -2140,7 +2144,8 @@ function parseArgs(argv) {
2140
2144
  any: false,
2141
2145
  arg: void 0,
2142
2146
  to: void 0,
2143
- keepAlive: false
2147
+ keepAlive: false,
2148
+ viaRelay: false
2144
2149
  };
2145
2150
  }
2146
2151
  if (a === "--help" || a === "-h") {
@@ -2152,7 +2157,8 @@ function parseArgs(argv) {
2152
2157
  any: false,
2153
2158
  arg: void 0,
2154
2159
  to: void 0,
2155
- keepAlive: false
2160
+ keepAlive: false,
2161
+ viaRelay: false
2156
2162
  };
2157
2163
  }
2158
2164
  if (a === "--live") {
@@ -2163,6 +2169,10 @@ function parseArgs(argv) {
2163
2169
  out = { ...out, keepAlive: true };
2164
2170
  continue;
2165
2171
  }
2172
+ if (a === "--via-relay") {
2173
+ out = { ...out, viaRelay: true };
2174
+ continue;
2175
+ }
2166
2176
  if (a === "--any") {
2167
2177
  out = { ...out, any: true };
2168
2178
  continue;
@@ -2381,7 +2391,7 @@ async function cmdMatches(live) {
2381
2391
  process2.stdout.write("\n");
2382
2392
  return 0;
2383
2393
  }
2384
- async function cmdDiscover(live, any) {
2394
+ async function cmdDiscover(live, any, viaRelay) {
2385
2395
  const profile = loadProfile();
2386
2396
  if (!profile) {
2387
2397
  process2.stderr.write("Not connected yet. Run `vibedating connect` first.\n");
@@ -2400,6 +2410,9 @@ async function cmdDiscover(live, any) {
2400
2410
  `);
2401
2411
  process2.stdout.write(` ${MARKS_LEGEND}
2402
2412
  `);
2413
+ if (viaRelay) {
2414
+ process2.stdout.write(" \u2022 --via-relay: the relay fallback reaches a KNOWN peer over Nostr \u2014 use `live --via-relay --to @handle`\n");
2415
+ }
2403
2416
  const { topics, acceptLeague } = discoveryScope(profile.league, any);
2404
2417
  const session = await startDiscovery({
2405
2418
  hello,
@@ -2489,7 +2502,100 @@ async function cmdOpen(port, any) {
2489
2502
  function shouldKeepAlive(flag, stdinIsTTY) {
2490
2503
  return flag || stdinIsTTY !== true;
2491
2504
  }
2492
- async function cmdLive(dating, any, to, keepAlive) {
2505
+ async function cmdLiveViaRelay(profile, to) {
2506
+ const target = to !== void 0 ? normalizeHandle(to) : null;
2507
+ if (to !== void 0 && target === null) {
2508
+ process2.stderr.write(`invalid target handle: ${to}
2509
+ `);
2510
+ return 1;
2511
+ }
2512
+ if (target === null) {
2513
+ process2.stderr.write(
2514
+ "relay mode (v0) targets one known peer \u2014 use: vibedating live --via-relay --to <@handle>\n(discover the peer first with `vibedating discover --live`, then reach them over the relay).\n"
2515
+ );
2516
+ return 1;
2517
+ }
2518
+ const peer = loadPeers().find((p) => sameHandle(p.handle, target) && typeof p.pubkey === "string");
2519
+ if (peer === void 0 || peer.pubkey === void 0) {
2520
+ process2.stderr.write(
2521
+ `${target} is not a known peer with an identity pubkey. Run \`vibedating discover --live\` first to meet them over the DHT, then retry --via-relay.
2522
+ `
2523
+ );
2524
+ return 1;
2525
+ }
2526
+ const identity = loadOrCreateIdentity();
2527
+ process2.stdout.write("\n");
2528
+ process2.stdout.write(` ${LIVE_NOTICE}
2529
+ `);
2530
+ process2.stdout.write(
2531
+ ` routing e2e chat to ${sanitizePeerText(peer.handle)} through public Nostr relays (direct hole-punch fallback)
2532
+ `
2533
+ );
2534
+ process2.stdout.write(" \u2022 the relay stores only ciphertext \u2014 it cannot read your chat\n");
2535
+ const myNostr = await loadOrCreateNostrKey();
2536
+ const transport = await createNostrPoolTransport();
2537
+ const link = await createNostrRelayLink({
2538
+ myNostr,
2539
+ myEd25519Hex: identity.publicKeyHex,
2540
+ peerEd25519Hex: peer.pubkey,
2541
+ hello: peer,
2542
+ transport
2543
+ });
2544
+ const pairing = createPairing();
2545
+ pairing.onMatch((l) => {
2546
+ if (l === void 0) {
2547
+ process2.stdout.write(" \xB7 relay peer gone\n");
2548
+ return;
2549
+ }
2550
+ const { qual } = peerDirection(profile.league, l.hello.league);
2551
+ process2.stdout.write(
2552
+ ` \xB7 relayed to ${sanitizePeerText(l.hello.handle)} (${l.hello.league}${qual} \xB7 ${l.hello.harness}) ${usageMark(l.hello)}${idMark(l.hello)}
2553
+ `
2554
+ );
2555
+ l.onMessage((m) => {
2556
+ process2.stdout.write(` <${sanitizePeerText(l.hello.handle)}> ${sanitizePeerText(m.text)}
2557
+ `);
2558
+ });
2559
+ });
2560
+ pairing.add(link);
2561
+ process2.stdout.write(" type to chat \xB7 /quit\n");
2562
+ process2.stdout.write(" (Ctrl+C to stop)\n\n");
2563
+ const rl = readline.createInterface({ input: process2.stdin, terminal: false });
2564
+ const stop = () => {
2565
+ rl.close();
2566
+ };
2567
+ process2.once("SIGINT", stop);
2568
+ process2.once("SIGTERM", stop);
2569
+ let quitRequested = false;
2570
+ for await (const line of rl) {
2571
+ const text = line.trim();
2572
+ if (text === "/quit") {
2573
+ quitRequested = true;
2574
+ break;
2575
+ }
2576
+ if (text === "/next") {
2577
+ process2.stdout.write(" \xB7 relay mode targets one peer \u2014 /next re-announces presence\n");
2578
+ continue;
2579
+ }
2580
+ if (text === "") continue;
2581
+ const cur2 = pairing.current();
2582
+ if (cur2 !== void 0) {
2583
+ cur2.send(text);
2584
+ } else {
2585
+ process2.stdout.write(" \xB7 no relay peer yet \u2014 waiting for the presence exchange\u2026\n");
2586
+ }
2587
+ }
2588
+ process2.removeListener("SIGINT", stop);
2589
+ process2.removeListener("SIGTERM", stop);
2590
+ void quitRequested;
2591
+ const cur = pairing.current();
2592
+ if (cur !== void 0) cur.close();
2593
+ process2.stdout.write("\n closing relay link\u2026\n");
2594
+ link.close();
2595
+ process2.stdout.write("\n");
2596
+ return 0;
2597
+ }
2598
+ async function cmdLive(dating, any, to, keepAlive, viaRelay) {
2493
2599
  const profile = loadProfile();
2494
2600
  if (!profile) {
2495
2601
  process2.stderr.write("Not connected yet. Run `vibedating connect` first.\n");
@@ -2506,6 +2612,7 @@ async function cmdLive(dating, any, to, keepAlive) {
2506
2612
  process2.stderr.write("Could not enable live discovery. Try `vibedating discover --live`.\n");
2507
2613
  return 1;
2508
2614
  }
2615
+ if (viaRelay) return cmdLiveViaRelay(profile, to);
2509
2616
  const hello = buildHello(profile);
2510
2617
  process2.stdout.write("\n");
2511
2618
  process2.stdout.write(` ${LIVE_NOTICE}
@@ -2846,10 +2953,11 @@ Usage:
2846
2953
  vibedating connect Read your usage, compute + print your league
2847
2954
  vibedating matches [--live] List candidates in your league (live peers if any)
2848
2955
  vibedating discover [--live] [--any] Find live peers over the DHT (your league + adjacent; --any = everyone)
2849
- vibedating live [--dating] [--any] [--to @handle] [--keep-alive] Live chat (your league
2956
+ vibedating live [--dating] [--any] [--to @handle] [--keep-alive] [--via-relay] Live chat (your league
2850
2957
  + adjacent; --any = everyone; /next or --dating pick;
2851
2958
  --to targets one peer; --keep-alive survives stdin EOF \u2014
2852
- automatic when stdin is not a TTY)
2959
+ automatic when stdin is not a TTY; --via-relay routes e2e chat
2960
+ through public Nostr relays when direct hole-punching fails)
2853
2961
  vibedating find <@handle> [--any] Search the DHT for one specific handle (\u2605 highlights a match)
2854
2962
  vibedating handle [@name] Print or set your handle (persisted; a leading '@' is optional)
2855
2963
  vibedating block <@handle> Block a handle \u2014 their hello is dropped (never recorded/paired)
@@ -2912,11 +3020,11 @@ async function main(argv) {
2912
3020
  case "matches":
2913
3021
  return cmdMatches(parsed.live);
2914
3022
  case "discover":
2915
- return cmdDiscover(parsed.live, parsed.any);
3023
+ return cmdDiscover(parsed.live, parsed.any, parsed.viaRelay);
2916
3024
  case "open":
2917
3025
  return cmdOpen(parsed.port, parsed.any);
2918
3026
  case "live":
2919
- return cmdLive(parsed.dating, parsed.any, parsed.to, parsed.keepAlive);
3027
+ return cmdLive(parsed.dating, parsed.any, parsed.to, parsed.keepAlive, parsed.viaRelay);
2920
3028
  case "find":
2921
3029
  return cmdFind(parsed.arg, parsed.any);
2922
3030
  case "mcp":
package/dist/index.d.ts CHANGED
@@ -238,6 +238,119 @@ interface DiscoverySession {
238
238
  */
239
239
  declare function startDiscovery(opts: DiscoveryOptions): Promise<DiscoverySession>;
240
240
 
241
+ /**
242
+ * A Nostr event, reduced to the fields this module touches. Structurally
243
+ * compatible with `nostr-tools`'s `Event`, so a finalized/verified event can be
244
+ * published and a relay-delivered event can be received without conversion.
245
+ */
246
+ interface RelayEvent {
247
+ readonly kind: number;
248
+ /** Sender's secp256k1 (Nostr) pubkey, hex — public; the relay sees it. */
249
+ readonly pubkey: string;
250
+ /** Ciphertext for kind-4 DMs; a presence marker for the presence kind. */
251
+ readonly content: string;
252
+ readonly tags: readonly (readonly string[])[];
253
+ readonly created_at: number;
254
+ readonly id: string;
255
+ readonly sig: string;
256
+ }
257
+ /** A subscription filter: kinds + a `#t` conversation-tag match. */
258
+ interface RelayFilter {
259
+ readonly kinds?: readonly number[];
260
+ readonly '#t'?: readonly string[];
261
+ }
262
+ /**
263
+ * The injection point between the relay link and the wire. The production
264
+ * implementation is {@link createNostrPoolTransport} (real wss:// relays via
265
+ * `nostr-tools` `SimplePool`); tests pass an in-memory fan-out. A real relay
266
+ * STORES events and replays matching stored events to new subscribers (that
267
+ * store-and-replay semantics is what makes the presence handshake work across
268
+ * arrival order) — a faithful mock must do the same.
269
+ */
270
+ interface RelayTransport {
271
+ /** Publish a signed event to the relay(s). Fire-and-forget / best-effort. */
272
+ publish(event: RelayEvent): void;
273
+ /**
274
+ * Subscribe to events matching `filter`. Returns an unsubscribe. A faithful
275
+ * relay replays matching STORED events to a brand-new subscriber before any
276
+ * future ones, so the presence handshake is order-independent.
277
+ */
278
+ subscribe(filter: RelayFilter, onEvent: (event: RelayEvent) => void): () => void;
279
+ /** Close all relay connections. Idempotent. */
280
+ close(): void;
281
+ }
282
+ /**
283
+ * Default public Nostr relays (the public commons — we host none of them).
284
+ * Overridable via {@link createNostrPoolTransport}. These are well-known,
285
+ * generally-open relays; any reachable public relay works.
286
+ */
287
+ declare const DEFAULT_RELAYS: readonly string[];
288
+ /** Kind for encrypted DM payloads — the standard NIP-04 sealed-direct-message kind. */
289
+ declare const VIBEDATE_MESSAGE_KIND = 4;
290
+ /**
291
+ * Kind for the presence/rendezvous ping (parameterized-replaceable range). Its
292
+ * content is a constant marker that reveals nothing about the conversation; its
293
+ * `event.pubkey` is the sender's Nostr key (public by definition), and an
294
+ * `ed25519` tag binds it to the sender's identity so the receiver only accepts
295
+ * presence from the peer it expects.
296
+ */
297
+ declare const VIBEDATE_PRESENCE_KIND = 30078;
298
+ /**
299
+ * Derive the deterministic conversation tag two peers rendezvous on, from BOTH
300
+ * ed25519 identity pubkeys (sorted, hashed). Pure: both sides compute it
301
+ * byte-identically from data each already holds (their own identity + the
302
+ * peer's advertised pubkey), so NO extra exchange is needed to agree on the
303
+ * rendezvous point. The hash hides the underlying identities from anyone
304
+ * scanning the relay by tag.
305
+ */
306
+ declare function conversationTag(myEd25519Hex: string, peerEd25519Hex: string): string;
307
+ /**
308
+ * A relay-fallback link with the SAME shape as {@link PeerLink}, so callers can
309
+ * drop it in wherever a direct P2P link is used (pairing, the `live` chat loop).
310
+ *
311
+ * Implemented in v0: `send`/`onMessage`/`onClose`/`close` + `.hello` (text chat
312
+ * over NIP-04). `sendMedia`/`sendSignal`/`onMedia`/`onSignal` are accepted as
313
+ * no-ops so the link is type-interchangeable with a direct PeerLink — chunked
314
+ * media + WebRTC signaling over the relay are `// ponytail:` follow-ups (a relay
315
+ * link is a fallback for peers who can't connect at all, where plain text chat
316
+ * is the priority).
317
+ */
318
+ type NostrRelayLink = PeerLink;
319
+ /** Options for {@link createNostrRelayLink}. */
320
+ interface CreateNostrRelayLinkOptions {
321
+ /** This peer's Nostr secp256k1 keypair (see {@link loadOrCreateNostrKey}). */
322
+ readonly myNostr: {
323
+ readonly sk: Uint8Array;
324
+ readonly pubkey: string;
325
+ };
326
+ /** This peer's ed25519 identity pubkey (hex) — half of the convTag input. */
327
+ readonly myEd25519Hex: string;
328
+ /** The REMOTE peer's ed25519 identity pubkey (hex) — the other convTag half. */
329
+ readonly peerEd25519Hex: string;
330
+ /** The remote peer's identity, surfaced as the link's `.hello`. */
331
+ readonly hello: PeerHello;
332
+ /** The injected relay transport (real pool OR an in-memory mock). */
333
+ readonly transport: RelayTransport;
334
+ }
335
+ /**
336
+ * Build a {@link NostrRelayLink} over the injected {@link RelayTransport}. The
337
+ * link immediately subscribes to the conversation tag and publishes its presence
338
+ * so the peer can learn this side's Nostr pubkey (the bootstrap for NIP-04).
339
+ * Resolves once subscribed + presence published. Async only because
340
+ * `nostr-tools` (NIP-04 + event signing) is lazy-imported on first use.
341
+ */
342
+ declare function createNostrRelayLink(opts: CreateNostrRelayLinkOptions): Promise<NostrRelayLink>;
343
+ /**
344
+ * Build a real {@link RelayTransport} over public Nostr relays via
345
+ * `nostr-tools`'s `SimplePool`. `nostr-tools` (and its WebSocket stack) is
346
+ * imported lazily here. Async because constructing the pool may need the
347
+ * lazy module load. The returned transport:
348
+ * - `publish` is fire-and-forget (publishes to all relays, awaits none);
349
+ * - `subscribe` mirrors `SimplePool.subscribeMany` and returns an unsub;
350
+ * - `close` tears the pool down.
351
+ */
352
+ declare function createNostrPoolTransport(urls?: readonly string[]): Promise<RelayTransport>;
353
+
241
354
  /**
242
355
  * Persistent ed25519 identity — binds a handle to a keypair so a peer cannot
243
356
  * impersonate it.
@@ -307,6 +420,28 @@ type IdentityVerdict = 'verified' | 'legacy' | 'drop';
307
420
  * caller (discovery) turns 'drop' into "never recorded, never paired".
308
421
  */
309
422
  declare function classifyHelloIdentity(hello: HelloClaims & Partial<IdentityProof>): IdentityVerdict;
423
+ /**
424
+ * A separate secp256k1 keypair for the Nostr relay fallback (see relay.ts).
425
+ * Kept DISTINCT from the ed25519 {@link Identity} on purpose: NIP-04 encryption
426
+ * requires a secp256k1 key, and reusing or deriving the ed25519 key for that
427
+ * would cross two unrelated cryptographic curves. The private key is persisted
428
+ * at `<dir>/nostr.json` (mode 0600) and never leaves the file; only the
429
+ * secp256k1 public key (hex) is ever published on a relay.
430
+ */
431
+ interface NostrKey {
432
+ /** 32-byte secp256k1 secret key — the NIP-04 decrypt/encrypt key. Never sent. */
433
+ readonly sk: Uint8Array;
434
+ /** secp256k1 public key, hex (64 chars) — safe to publish; the relay sees it. */
435
+ readonly pubkey: string;
436
+ }
437
+ /**
438
+ * Load the persistent secp256k1 Nostr key, generating + storing it (mode 0600)
439
+ * on first use. A missing or corrupt file is (re)generated — never throws on
440
+ * disk content. Idempotent across runs: same file → same key. `nostr-tools` is
441
+ * imported LAZILY (only when a key must be generated) so non-relay commands
442
+ * never pay for the secp256k1 stack.
443
+ */
444
+ declare function loadOrCreateNostrKey(dir?: string): Promise<NostrKey>;
310
445
 
311
446
  /**
312
447
  * A usage league (volume bucket). `max` is inclusive; the top tier is open-ended
@@ -423,4 +558,4 @@ declare const CANDIDATES: readonly Candidate[];
423
558
  */
424
559
  declare function matches(myLeague: string, candidates?: readonly Candidate[]): Candidate[];
425
560
 
426
- export { BELOW_LEAGUE, CANDIDATES, type Candidate, DEMO_TOTAL_TOKENS, type DiscoveryOptions, type DiscoverySession, type HelloClaims, type Identity, type IdentityProof, type IdentityVerdict, LEAGUES, LIVE_NOTICE, type League, type LocalUsageSnapshot, type PeerHello, type StoredPeer, TOKENS_ENV, TOPIC_PREFIX, allLeagueNames, canonicalHelloClaims, classifyHelloIdentity, league, leagueIndex, leagueTopic, leaguesWithin, loadOrCreateIdentity, loadPeers, matches, parseHandshake, parseTokensEnv, readUsage, recordPeer, recordPeerMessage, serializeHandshake, signHelloClaims, startDiscovery, verifyHelloClaims };
561
+ export { BELOW_LEAGUE, CANDIDATES, type Candidate, type CreateNostrRelayLinkOptions, DEFAULT_RELAYS, DEMO_TOTAL_TOKENS, type DiscoveryOptions, type DiscoverySession, type HelloClaims, type Identity, type IdentityProof, type IdentityVerdict, LEAGUES, LIVE_NOTICE, type League, type LocalUsageSnapshot, type NostrKey, type NostrRelayLink, type PeerHello, type RelayEvent, type RelayFilter, type RelayTransport, type StoredPeer, TOKENS_ENV, TOPIC_PREFIX, VIBEDATE_MESSAGE_KIND, VIBEDATE_PRESENCE_KIND, allLeagueNames, canonicalHelloClaims, classifyHelloIdentity, conversationTag, createNostrPoolTransport, createNostrRelayLink, league, leagueIndex, leagueTopic, leaguesWithin, loadOrCreateIdentity, loadOrCreateNostrKey, loadPeers, matches, parseHandshake, parseTokensEnv, readUsage, recordPeer, recordPeerMessage, serializeHandshake, signHelloClaims, startDiscovery, verifyHelloClaims };
package/dist/index.js CHANGED
@@ -1,20 +1,27 @@
1
1
  import {
2
2
  BELOW_LEAGUE,
3
3
  CANDIDATES,
4
+ DEFAULT_RELAYS,
4
5
  DEMO_TOTAL_TOKENS,
5
6
  LEAGUES,
6
7
  LIVE_NOTICE,
7
8
  TOKENS_ENV,
8
9
  TOPIC_PREFIX,
10
+ VIBEDATE_MESSAGE_KIND,
11
+ VIBEDATE_PRESENCE_KIND,
9
12
  allLeagueNames,
10
13
  canonicalHelloClaims,
11
14
  classifyHelloIdentity,
15
+ conversationTag,
12
16
  createConsentLedger,
17
+ createNostrPoolTransport,
18
+ createNostrRelayLink,
13
19
  league,
14
20
  leagueIndex,
15
21
  leagueTopic,
16
22
  leaguesWithin,
17
23
  loadOrCreateIdentity,
24
+ loadOrCreateNostrKey,
18
25
  loadPeers,
19
26
  matches,
20
27
  parseHandshake,
@@ -26,24 +33,31 @@ import {
26
33
  signHelloClaims,
27
34
  startDiscovery,
28
35
  verifyHelloClaims
29
- } from "./chunk-KKWP4DLY.js";
36
+ } from "./chunk-VZXPYU2C.js";
30
37
  export {
31
38
  BELOW_LEAGUE,
32
39
  CANDIDATES,
40
+ DEFAULT_RELAYS,
33
41
  DEMO_TOTAL_TOKENS,
34
42
  LEAGUES,
35
43
  LIVE_NOTICE,
36
44
  TOKENS_ENV,
37
45
  TOPIC_PREFIX,
46
+ VIBEDATE_MESSAGE_KIND,
47
+ VIBEDATE_PRESENCE_KIND,
38
48
  allLeagueNames,
39
49
  canonicalHelloClaims,
40
50
  classifyHelloIdentity,
51
+ conversationTag,
41
52
  createConsentLedger,
53
+ createNostrPoolTransport,
54
+ createNostrRelayLink,
42
55
  league,
43
56
  leagueIndex,
44
57
  leagueTopic,
45
58
  leaguesWithin,
46
59
  loadOrCreateIdentity,
60
+ loadOrCreateNostrKey,
47
61
  loadPeers,
48
62
  matches,
49
63
  parseHandshake,
package/dist/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  runMcp
3
- } from "./chunk-I5U3O4RH.js";
4
- import "./chunk-KKWP4DLY.js";
3
+ } from "./chunk-JIBC3OHV.js";
4
+ import "./chunk-VZXPYU2C.js";
5
5
  export {
6
6
  runMcp
7
7
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibedate",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Dating by tokens — matched by usage league across agentic CLIs. Raw usage stays local; only your league is shared. CLI + local web app + MCP. Local-first.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,8 @@
30
30
  "dependencies": {
31
31
  "@modelcontextprotocol/sdk": "^1.0.0",
32
32
  "@pooriaarab/vibe-core": "^0.3.0",
33
- "hyperswarm": "^4.17.0"
33
+ "hyperswarm": "^4.17.0",
34
+ "nostr-tools": "^2.24.1"
34
35
  },
35
36
  "devDependencies": {
36
37
  "@types/node": "^20.11.0",