vibedate 0.5.0 → 0.7.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.
@@ -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,301 @@ 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
+
1148
+ // src/room.ts
1149
+ import { createHash as createHash3 } from "crypto";
1150
+ var ROOM_TOPIC_PREFIX = "vibedate-room:";
1151
+ function roomTopic(name) {
1152
+ return createHash3("sha256").update(`${ROOM_TOPIC_PREFIX}${name}`, "utf8").digest();
1153
+ }
1154
+ async function startRoom(opts) {
1155
+ const topic = roomTopic(opts.room);
1156
+ const entries = /* @__PURE__ */ new Map();
1157
+ const memberHellos = /* @__PURE__ */ new Map();
1158
+ const messageCbs = /* @__PURE__ */ new Set();
1159
+ const rosterCbs = /* @__PURE__ */ new Set();
1160
+ const signalCbs = /* @__PURE__ */ new Set();
1161
+ const fireRoster = () => {
1162
+ const snapshot = [...memberHellos.values()];
1163
+ for (const cb of rosterCbs) cb(snapshot);
1164
+ };
1165
+ let discovery;
1166
+ const ready = startDiscovery({
1167
+ hello: opts.hello,
1168
+ // ONE topic — the room's own. Rooms are intentionally cross-league, so
1169
+ // every member of the room is accepted regardless of advertised league.
1170
+ topics: [topic],
1171
+ acceptLeague: () => true,
1172
+ ...opts.isBlocked === void 0 ? {} : { isBlocked: opts.isBlocked },
1173
+ ...opts.bootstrap === void 0 ? {} : { bootstrap: opts.bootstrap },
1174
+ ...opts.stateDir === void 0 ? {} : { stateDir: opts.stateDir },
1175
+ ...opts.notify === void 0 ? {} : { notify: opts.notify },
1176
+ onLink: (link) => {
1177
+ const handle = link.hello.handle;
1178
+ entries.set(handle, { hello: link.hello, link });
1179
+ memberHellos.set(handle, link.hello);
1180
+ fireRoster();
1181
+ link.onMessage((m) => {
1182
+ for (const cb of messageCbs) cb({ from: handle, ...m });
1183
+ });
1184
+ link.onSignal((frame) => {
1185
+ for (const cb of signalCbs) cb(handle, frame);
1186
+ });
1187
+ link.onClose(() => {
1188
+ const cur = entries.get(handle);
1189
+ if (cur && cur.link === link) {
1190
+ entries.delete(handle);
1191
+ memberHellos.delete(handle);
1192
+ fireRoster();
1193
+ }
1194
+ });
1195
+ }
1196
+ }).then((s) => {
1197
+ discovery = s;
1198
+ return s.ready;
1199
+ });
1200
+ const session = {
1201
+ room: opts.room,
1202
+ topic,
1203
+ hello: opts.hello,
1204
+ members: memberHellos,
1205
+ ready,
1206
+ broadcast(text) {
1207
+ const reached = [];
1208
+ for (const [handle, entry] of entries) {
1209
+ entry.link.send(text);
1210
+ reached.push(handle);
1211
+ }
1212
+ return reached;
1213
+ },
1214
+ onMessage(cb) {
1215
+ messageCbs.add(cb);
1216
+ },
1217
+ onRoster(cb) {
1218
+ rosterCbs.add(cb);
1219
+ },
1220
+ onSignal(cb) {
1221
+ signalCbs.add(cb);
1222
+ },
1223
+ sendSignal(handle, frame) {
1224
+ entries.get(handle)?.link.sendSignal(frame);
1225
+ },
1226
+ linkFor(handle) {
1227
+ return entries.get(handle)?.link;
1228
+ },
1229
+ async close() {
1230
+ await ready.catch(() => void 0);
1231
+ for (const entry of entries.values()) {
1232
+ try {
1233
+ entry.link.close();
1234
+ } catch {
1235
+ }
1236
+ }
1237
+ entries.clear();
1238
+ memberHellos.clear();
1239
+ if (discovery !== void 0) await discovery.close();
1240
+ }
1241
+ };
1242
+ return session;
1243
+ }
1244
+
912
1245
  // src/index.ts
913
1246
  var LEAGUES = [
914
1247
  { name: "1M", min: 1e6, max: 4999999 },
@@ -1067,6 +1400,7 @@ export {
1067
1400
  signHelloClaims,
1068
1401
  verifyHelloClaims,
1069
1402
  classifyHelloIdentity,
1403
+ loadOrCreateNostrKey,
1070
1404
  sanitizePeerText,
1071
1405
  TOPIC_PREFIX,
1072
1406
  leagueTopic,
@@ -1077,6 +1411,15 @@ export {
1077
1411
  recordPeer,
1078
1412
  recordPeerMessage,
1079
1413
  startDiscovery,
1414
+ DEFAULT_RELAYS,
1415
+ VIBEDATE_MESSAGE_KIND,
1416
+ VIBEDATE_PRESENCE_KIND,
1417
+ conversationTag,
1418
+ createNostrRelayLink,
1419
+ createNostrPoolTransport,
1420
+ ROOM_TOPIC_PREFIX,
1421
+ roomTopic,
1422
+ startRoom,
1080
1423
  LEAGUES,
1081
1424
  BELOW_LEAGUE,
1082
1425
  league,
@@ -2,7 +2,7 @@ import {
2
2
  CANDIDATES,
3
3
  loadProfile,
4
4
  matches
5
- } from "./chunk-KKWP4DLY.js";
5
+ } from "./chunk-HDHJLZZG.js";
6
6
 
7
7
  // src/mcp.ts
8
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
package/dist/cli.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /** Recognized top-level commands, plus the synthetic help/version. */
3
- type Command = 'connect' | 'matches' | 'discover' | 'open' | 'live' | 'find' | 'handle' | 'block' | 'unblock' | 'blocklist' | 'daemon' | 'mcp' | 'help' | 'version' | null;
3
+ type Command = 'connect' | 'matches' | 'discover' | 'open' | 'live' | 'find' | 'handle' | 'block' | 'unblock' | 'blocklist' | 'daemon' | 'mcp' | 'room' | 'help' | 'version' | null;
4
4
  interface ParsedArgs {
5
5
  readonly command: Command;
6
6
  /** Port for `open --port`; undefined means "let the OS pick". */
@@ -17,6 +17,14 @@ 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;
26
+ /** `room <name>` positional, or `--room <name>`: join/create a named room. */
27
+ readonly room: string | undefined;
20
28
  }
21
29
  /**
22
30
  * Parse argv (the slice AFTER the program name) into a command + options.