yakmesh 2.8.2 → 3.0.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.
Files changed (232) hide show
  1. package/CHANGELOG.md +637 -0
  2. package/CONTRIBUTING.md +42 -0
  3. package/Caddyfile +77 -0
  4. package/README.md +119 -29
  5. package/adapters/adapter-mlv-bible/README.md +124 -0
  6. package/adapters/adapter-mlv-bible/index.js +400 -0
  7. package/adapters/chat-mod-adapter.js +532 -0
  8. package/adapters/content-adapter.js +273 -0
  9. package/content/api.js +50 -41
  10. package/content/index.js +2 -2
  11. package/content/store.js +355 -173
  12. package/dashboard/index.html +19 -3
  13. package/database/replication.js +117 -37
  14. package/docs/CRYPTO-AGILITY.md +204 -0
  15. package/docs/MTLS-RESEARCH.md +367 -0
  16. package/docs/NAMCHE-SPEC.md +681 -0
  17. package/docs/PEERQUANTA-YAKMESH-INTEGRATION.md +407 -0
  18. package/docs/PRECISION-DISCLOSURE.md +96 -0
  19. package/docs/README.md +76 -0
  20. package/docs/ROADMAP-2.4.0.md +447 -0
  21. package/docs/ROADMAP-2.5.0.md +244 -0
  22. package/docs/SECURITY-AUDIT-REPORT.md +306 -0
  23. package/docs/SST-INTEGRATION.md +712 -0
  24. package/docs/STEADYWATCH-IMPLEMENTATION.md +303 -0
  25. package/docs/TERNARY-AUDIT-REPORT.md +247 -0
  26. package/docs/TME-FAQ.md +221 -0
  27. package/docs/WHITEPAPER.md +623 -0
  28. package/docs/adapters.html +1001 -0
  29. package/docs/advanced-systems.html +1045 -0
  30. package/docs/annex.html +1046 -0
  31. package/docs/api.html +970 -0
  32. package/docs/business/response-templates.md +160 -0
  33. package/docs/c2c.html +1225 -0
  34. package/docs/cli.html +1332 -0
  35. package/docs/configuration.html +1248 -0
  36. package/docs/darshan.html +1085 -0
  37. package/docs/dharma.html +966 -0
  38. package/docs/docs-bundle.html +1075 -0
  39. package/docs/docs.css +3120 -0
  40. package/docs/docs.js +556 -0
  41. package/docs/doko.html +969 -0
  42. package/docs/geo-proof.html +858 -0
  43. package/docs/getting-started.html +840 -0
  44. package/docs/gumba-tutorial.html +1144 -0
  45. package/docs/gumba.html +1098 -0
  46. package/docs/index.html +914 -0
  47. package/docs/jhilke.html +1312 -0
  48. package/docs/karma.html +1100 -0
  49. package/docs/katha.html +1037 -0
  50. package/docs/lama.html +978 -0
  51. package/docs/mandala.html +1067 -0
  52. package/docs/mani.html +964 -0
  53. package/docs/mantra.html +967 -0
  54. package/docs/mesh.html +1409 -0
  55. package/docs/nakpak.html +869 -0
  56. package/docs/namche.html +928 -0
  57. package/docs/nav-order.json +53 -0
  58. package/docs/prahari.html +1043 -0
  59. package/docs/prism-bash.min.js +1 -0
  60. package/docs/prism-javascript.min.js +1 -0
  61. package/docs/prism-json.min.js +1 -0
  62. package/docs/prism-tomorrow.min.css +1 -0
  63. package/docs/prism.min.js +1 -0
  64. package/docs/privacy.html +699 -0
  65. package/docs/quick-reference.html +1181 -0
  66. package/docs/sakshi.html +1402 -0
  67. package/docs/sandboxing.md +386 -0
  68. package/docs/seva.html +911 -0
  69. package/docs/sherpa.html +871 -0
  70. package/docs/studio.html +860 -0
  71. package/docs/stupa.html +995 -0
  72. package/docs/tailwind.min.css +2 -0
  73. package/docs/tattva.html +1332 -0
  74. package/docs/terms.html +686 -0
  75. package/docs/time-server-deployment.md +166 -0
  76. package/docs/time-sources.html +1392 -0
  77. package/docs/tivra.html +1127 -0
  78. package/docs/trademark-policy.html +686 -0
  79. package/docs/tribhuj.html +1183 -0
  80. package/docs/trust-security.html +1029 -0
  81. package/docs/tutorials/backup-recovery.html +654 -0
  82. package/docs/tutorials/dashboard.html +604 -0
  83. package/docs/tutorials/domain-setup.html +605 -0
  84. package/docs/tutorials/host-website.html +456 -0
  85. package/docs/tutorials/mesh-network.html +505 -0
  86. package/docs/tutorials/mobile-access.html +445 -0
  87. package/docs/tutorials/privacy.html +467 -0
  88. package/docs/tutorials/raspberry-pi.html +600 -0
  89. package/docs/tutorials/security-basics.html +539 -0
  90. package/docs/tutorials/share-files.html +431 -0
  91. package/docs/tutorials/troubleshooting.html +637 -0
  92. package/docs/tutorials/trust-karma.html +419 -0
  93. package/docs/tutorials/yak-protocol.html +456 -0
  94. package/docs/tutorials.html +1034 -0
  95. package/docs/vani.html +1270 -0
  96. package/docs/webserver.html +809 -0
  97. package/docs/yak-protocol.html +940 -0
  98. package/docs/yak-timeserver-design.md +475 -0
  99. package/docs/yakapp.html +1015 -0
  100. package/docs/ypc27.html +1069 -0
  101. package/docs/yurt.html +1344 -0
  102. package/embedded-docs/bundle.js +334 -74
  103. package/gossip/protocol.js +247 -27
  104. package/identity/key-resolver.js +262 -0
  105. package/identity/machine-seed.js +632 -0
  106. package/identity/node-key.js +669 -368
  107. package/identity/tribhuj-ratchet.js +506 -0
  108. package/knowledge-base.js +37 -8
  109. package/launcher/yakmesh.bat +62 -0
  110. package/launcher/yakmesh.sh +70 -0
  111. package/mesh/annex.js +462 -108
  112. package/mesh/beacon-broadcast.js +113 -1
  113. package/mesh/darshan.js +1718 -0
  114. package/mesh/gumba.js +1567 -0
  115. package/mesh/jhilke.js +651 -0
  116. package/mesh/katha.js +1012 -0
  117. package/mesh/nakpak-routing.js +8 -5
  118. package/mesh/network.js +724 -34
  119. package/mesh/pulse-sync.js +4 -1
  120. package/mesh/rate-limiter.js +127 -15
  121. package/mesh/seva.js +526 -0
  122. package/mesh/sherpa-discovery.js +89 -8
  123. package/mesh/sybil-defense.js +19 -5
  124. package/mesh/temporal-encoder.js +4 -3
  125. package/mesh/vani.js +1364 -0
  126. package/mesh/yurt.js +1340 -0
  127. package/models/entropy-sentinel.onnx +0 -0
  128. package/models/karma-trust.onnx +0 -0
  129. package/models/manifest.json +43 -0
  130. package/models/sakshi-anomaly.onnx +0 -0
  131. package/oracle/code-proof-protocol.js +7 -6
  132. package/oracle/codebase-lock.js +257 -28
  133. package/oracle/index.js +74 -15
  134. package/oracle/ma902-snmp.js +678 -0
  135. package/oracle/module-sealer.js +5 -3
  136. package/oracle/network-identity.js +16 -0
  137. package/oracle/packet-checksum.js +201 -0
  138. package/oracle/sst.js +579 -0
  139. package/oracle/ternary-144t.js +714 -0
  140. package/oracle/ternary-ml.js +481 -0
  141. package/oracle/time-api.js +239 -0
  142. package/oracle/time-source.js +137 -47
  143. package/oracle/validation-oracle-hardened.js +1111 -1071
  144. package/oracle/validation-oracle.js +4 -2
  145. package/oracle/ypc27.js +211 -0
  146. package/package.json +20 -3
  147. package/protocol/yak-handler.js +35 -9
  148. package/protocol/yak-protocol.js +28 -13
  149. package/reference/cpp/yakmesh_mceliece_shard.cpp +168 -0
  150. package/reference/cpp/yakmesh_ypc27.cpp +179 -0
  151. package/sbom.json +87 -0
  152. package/scripts/security-audit.mjs +264 -0
  153. package/scripts/update-docs-nav.js +194 -0
  154. package/scripts/update-docs-sidebar.cjs +164 -0
  155. package/security/crypto-config.js +4 -3
  156. package/security/dharma-moderation.js +517 -0
  157. package/security/doko-identity.js +193 -143
  158. package/security/domain-consensus.js +86 -85
  159. package/security/fs-hardening.js +620 -0
  160. package/security/hardware-attestation.js +5 -3
  161. package/security/hybrid-trust.js +227 -87
  162. package/security/karma-rate-limiter.js +692 -0
  163. package/security/khata-protocol.js +22 -21
  164. package/security/khata-trust-integration.js +277 -150
  165. package/security/memory-safety.js +635 -0
  166. package/security/mesh-auth.js +11 -10
  167. package/security/mesh-revocation.js +373 -5
  168. package/security/namche-gateway.js +298 -69
  169. package/security/sakshi.js +460 -3
  170. package/security/sangha.js +770 -0
  171. package/security/secure-config.js +473 -0
  172. package/security/silicon-parity.js +13 -10
  173. package/security/steadywatch.js +1142 -0
  174. package/security/strike-system.js +32 -3
  175. package/security/temporal-signing.js +488 -0
  176. package/security/trit-commitment.js +464 -0
  177. package/server/crypto/annex.js +247 -0
  178. package/server/darshan-api.js +343 -0
  179. package/server/index.js +3259 -362
  180. package/server/komm-api.js +668 -0
  181. package/utils/accel.js +2273 -0
  182. package/utils/ternary-id.js +79 -0
  183. package/utils/verify-worker.js +57 -0
  184. package/webserver/index.js +95 -5
  185. package/assets/yakmesh-logo.png +0 -0
  186. package/assets/yakmesh-logo.svg +0 -80
  187. package/assets/yakmesh-logo2.png +0 -0
  188. package/assets/yakmesh-logo2sm.png +0 -0
  189. package/assets/ymsm.png +0 -0
  190. package/website/assets/silhouettes/adapters.svg +0 -107
  191. package/website/assets/silhouettes/api-endpoints.svg +0 -115
  192. package/website/assets/silhouettes/atomic-clock.svg +0 -83
  193. package/website/assets/silhouettes/base-camp.svg +0 -81
  194. package/website/assets/silhouettes/bridge.svg +0 -69
  195. package/website/assets/silhouettes/docs-bundle.svg +0 -113
  196. package/website/assets/silhouettes/doko-basket.svg +0 -70
  197. package/website/assets/silhouettes/fortress.svg +0 -93
  198. package/website/assets/silhouettes/gateway.svg +0 -54
  199. package/website/assets/silhouettes/gears.svg +0 -93
  200. package/website/assets/silhouettes/globe-satellite.svg +0 -67
  201. package/website/assets/silhouettes/karma-wheel.svg +0 -137
  202. package/website/assets/silhouettes/lama-council.svg +0 -141
  203. package/website/assets/silhouettes/mandala-network.svg +0 -169
  204. package/website/assets/silhouettes/mani-stones.svg +0 -149
  205. package/website/assets/silhouettes/mantra-wheel.svg +0 -116
  206. package/website/assets/silhouettes/mesh-nodes.svg +0 -113
  207. package/website/assets/silhouettes/nakpak.svg +0 -56
  208. package/website/assets/silhouettes/peak-lightning.svg +0 -73
  209. package/website/assets/silhouettes/sherpa.svg +0 -69
  210. package/website/assets/silhouettes/stupa-tower.svg +0 -119
  211. package/website/assets/silhouettes/tattva-eye.svg +0 -78
  212. package/website/assets/silhouettes/terminal.svg +0 -74
  213. package/website/assets/silhouettes/webserver.svg +0 -145
  214. package/website/assets/silhouettes/yak.svg +0 -78
  215. package/website/assets/yakmesh-logo.png +0 -0
  216. package/website/assets/yakmesh-logo.webp +0 -0
  217. package/website/assets/yakmesh-logo128x140.webp +0 -0
  218. package/website/assets/yakmesh-logo2.png +0 -0
  219. package/website/assets/yakmesh-logo2.svg +0 -51
  220. package/website/assets/yakmesh-logo40x44.webp +0 -0
  221. package/website/assets/yakmesh.gif +0 -0
  222. package/website/assets/yakmesh.ico +0 -0
  223. package/website/assets/yakmesh.jpg +0 -0
  224. package/website/assets/yakmesh.pdf +0 -0
  225. package/website/assets/yakmesh.png +0 -0
  226. package/website/assets/yakmesh.svg +0 -70
  227. package/website/assets/yakmesh128.webp +0 -0
  228. package/website/assets/yakmesh32.png +0 -0
  229. package/website/assets/yakmesh32.svg +0 -65
  230. package/website/assets/yakmesh32o.ico +0 -2
  231. package/website/assets/yakmesh32o.svg +0 -65
  232. package/website/assets/yakmesh32o.svgz +0 -0
package/mesh/yurt.js ADDED
@@ -0,0 +1,1340 @@
1
+ /**
2
+ * YURT - YAK Unified Room Tags
3
+ *
4
+ * A decentralized room directory protocol for YakApp chat rooms.
5
+ * Rooms can be discovered through gossip OR accessed directly via yak:// links.
6
+ *
7
+ * ╔═══════════════════════════════════════════════════════════════════════════════╗
8
+ * ║ "Every yurt on the steppe has its own fire. ║
9
+ * ║ Find them by the smoke, or follow the path you know." ║
10
+ * ║ ║
11
+ * ║ Discovery is optional. Direct access always works. ║
12
+ * ╚═══════════════════════════════════════════════════════════════════════════════╝
13
+ *
14
+ * DISCOVERY MODES:
15
+ * 1. DIRECT LINK - yak://hostname/bundleId (works immediately, no gossip needed)
16
+ * 2. WEBSITE EMBED - "Join Chat" button linking to yak:// or https gateway
17
+ * 3. GOSSIP - Browse rooms propagated through the mesh network
18
+ * 4. QR CODE - Scan to join (encodes yak:// URI)
19
+ *
20
+ * SECURITY:
21
+ * - All entries are signed by the hosting node
22
+ * - Joining still requires GUMBA proof (discovery != access)
23
+ * - Nodes can filter/weight entries by reputation
24
+ * - Spam entries die naturally (no re-propagation)
25
+ *
26
+ * URI SCHEME:
27
+ * yak://host:port/bundleId
28
+ * yak://host:port/bundleId?invite=<attestation>
29
+ *
30
+ * Part of the Himalayan Protocol Family:
31
+ * - ANNEX: E2E encrypted channels
32
+ * - DOKO: Identity certificates
33
+ * - GUMBA: Access control (what YURT points to)
34
+ * - YURT: Room discovery (this module)
35
+ *
36
+ * Named after the portable tent homes of Central Asian nomads -
37
+ * visible from afar, welcoming to guests, but yours to control.
38
+ *
39
+ * @module mesh/yurt
40
+ * @license MIT
41
+ * @copyright 2026 YAKMESH™ Contributors
42
+ */
43
+
44
+ import { randomBytes, createHash } from 'crypto';
45
+ import { sha3_256 as _nobleSha3 } from '@noble/hashes/sha3.js';
46
+ import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
47
+ import { ml_dsa65 } from '@noble/post-quantum/ml-dsa.js';
48
+ // ACCEL: Hardware-accelerated crypto
49
+ import { sha3_256, mlDsa65Sign, mlDsa65Verify } from '../utils/accel.js';
50
+ import { createLogger } from '../utils/logger.js';
51
+ import EventEmitter from 'events';
52
+
53
+ const log = createLogger('mesh:yurt');
54
+
55
+ // ═══════════════════════════════════════════════════════════════════════════════
56
+ // CONFIGURATION
57
+ // ═══════════════════════════════════════════════════════════════════════════════
58
+
59
+ export const YURT_CONFIG = Object.freeze({
60
+ // Protocol
61
+ version: 1,
62
+ defaultPort: 8787,
63
+ scheme: 'yak',
64
+
65
+ // Gossip
66
+ maxEntryAge: 7 * 24 * 60 * 60 * 1000, // 7 days max
67
+ refreshInterval: 60 * 60 * 1000, // Refresh own listings hourly
68
+ gossipInterval: 5 * 60 * 1000, // Gossip every 5 minutes
69
+ maxEntriesPerGossip: 50, // Limit per gossip message
70
+ maxDirectorySize: 10000, // Max entries to store
71
+
72
+ // Validation
73
+ maxNameLength: 64,
74
+ maxDescriptionLength: 256,
75
+ maxTagCount: 10,
76
+ maxTagLength: 24,
77
+
78
+ // Entry types
79
+ visibility: {
80
+ PUBLIC: 'public', // Anyone can request to join
81
+ INVITE_ONLY: 'invite-only', // Requires attestation
82
+ UNLISTED: 'unlisted', // Direct link only, no gossip
83
+ },
84
+
85
+ // Message types
86
+ messageTypes: {
87
+ ANNOUNCE: 'yurt:announce', // Publish/update a room listing
88
+ WITHDRAW: 'yurt:withdraw', // Remove a room listing
89
+ GOSSIP: 'yurt:gossip', // Share known listings
90
+ QUERY: 'yurt:query', // Search for rooms
91
+ QUERY_RESPONSE: 'yurt:response', // Search results
92
+ },
93
+ });
94
+
95
+ // ═══════════════════════════════════════════════════════════════════════════════
96
+ // YURT ENTRY - A single room listing
97
+ // ═══════════════════════════════════════════════════════════════════════════════
98
+
99
+ /**
100
+ * YurtEntry - A discoverable room listing
101
+ *
102
+ * "Like smoke rising from a yurt - visible from far away,
103
+ * but entering requires the owner's welcome."
104
+ */
105
+ export class YurtEntry {
106
+ /**
107
+ * Create a new room listing
108
+ */
109
+ constructor(options = {}) {
110
+ // Required
111
+ this.bundleId = options.bundleId;
112
+ this.hostNodeId = options.hostNodeId;
113
+ this.hostEndpoint = options.hostEndpoint;
114
+
115
+ // Metadata
116
+ this.name = options.name || this.bundleId;
117
+ this.description = options.description || '';
118
+ this.visibility = options.visibility || YURT_CONFIG.visibility.PUBLIC;
119
+ this.tags = options.tags || [];
120
+
121
+ // Stats (approximate, gossip-updated)
122
+ this.memberCount = options.memberCount || 0;
123
+ this.messageCount = options.messageCount || 0;
124
+
125
+ // Timestamps
126
+ this.createdAt = options.createdAt || Date.now();
127
+ this.updatedAt = options.updatedAt || Date.now();
128
+ this.lastSeen = options.lastSeen || Date.now();
129
+
130
+ // Signature (set by sign())
131
+ this.signature = options.signature || null;
132
+
133
+ // Entry ID (derived)
134
+ this.entryId = this._computeEntryId();
135
+ }
136
+
137
+ /**
138
+ * Compute unique entry ID from bundleId + hostNodeId
139
+ */
140
+ _computeEntryId() {
141
+ return bytesToHex(sha3_256(utf8ToBytes(`${this.bundleId}:${this.hostNodeId}`)));
142
+ }
143
+
144
+ /**
145
+ * Validate entry fields
146
+ */
147
+ validate() {
148
+ const errors = [];
149
+
150
+ if (!this.bundleId) errors.push('bundleId required');
151
+ if (!this.hostNodeId) errors.push('hostNodeId required');
152
+ if (!this.hostEndpoint) errors.push('hostEndpoint required');
153
+
154
+ const name = this.name || '';
155
+ const description = this.description || '';
156
+ const tags = this.tags || [];
157
+
158
+ if (name.length > YURT_CONFIG.maxNameLength) {
159
+ errors.push(`name exceeds ${YURT_CONFIG.maxNameLength} chars`);
160
+ }
161
+
162
+ if (description.length > YURT_CONFIG.maxDescriptionLength) {
163
+ errors.push(`description exceeds ${YURT_CONFIG.maxDescriptionLength} chars`);
164
+ }
165
+
166
+ if (tags.length > YURT_CONFIG.maxTagCount) {
167
+ errors.push(`too many tags (max ${YURT_CONFIG.maxTagCount})`);
168
+ }
169
+
170
+ for (const tag of tags) {
171
+ if (tag.length > YURT_CONFIG.maxTagLength) {
172
+ errors.push(`tag "${tag}" exceeds ${YURT_CONFIG.maxTagLength} chars`);
173
+ }
174
+ }
175
+
176
+ if (!Object.values(YURT_CONFIG.visibility).includes(this.visibility)) {
177
+ errors.push(`invalid visibility: ${this.visibility}`);
178
+ }
179
+
180
+ return {
181
+ valid: errors.length === 0,
182
+ errors,
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Get the signable payload (deterministic JSON)
188
+ */
189
+ getSignablePayload() {
190
+ return JSON.stringify({
191
+ bundleId: this.bundleId,
192
+ hostNodeId: this.hostNodeId,
193
+ hostEndpoint: this.hostEndpoint,
194
+ name: this.name,
195
+ description: this.description,
196
+ visibility: this.visibility,
197
+ tags: [...this.tags].sort(),
198
+ createdAt: this.createdAt,
199
+ updatedAt: this.updatedAt,
200
+ });
201
+ }
202
+
203
+ /**
204
+ * Sign the entry with host's secret key
205
+ */
206
+ sign(secretKey) {
207
+ const payload = utf8ToBytes(this.getSignablePayload());
208
+ const keyBytes = typeof secretKey === 'string' ? hexToBytes(secretKey) : secretKey;
209
+ const signature = mlDsa65Sign(payload, keyBytes);
210
+ this.signature = bytesToHex(signature);
211
+ return this;
212
+ }
213
+
214
+ /**
215
+ * Verify entry signature
216
+ */
217
+ verify(publicKey) {
218
+ if (!this.signature) return false;
219
+
220
+ try {
221
+ const payload = utf8ToBytes(this.getSignablePayload());
222
+ const signatureBytes = hexToBytes(this.signature);
223
+ const publicKeyBytes = typeof publicKey === 'string'
224
+ ? hexToBytes(publicKey)
225
+ : publicKey;
226
+
227
+ return mlDsa65Verify(signatureBytes, payload, publicKeyBytes);
228
+ } catch (err) {
229
+ log.warn('Signature verification failed', { error: err.message });
230
+ return false;
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Check if entry is expired
236
+ */
237
+ isExpired() {
238
+ return Date.now() - this.lastSeen > YURT_CONFIG.maxEntryAge;
239
+ }
240
+
241
+ /**
242
+ * Update stats (doesn't require re-signing)
243
+ */
244
+ updateStats(memberCount, messageCount) {
245
+ this.memberCount = memberCount;
246
+ this.messageCount = messageCount;
247
+ this.lastSeen = Date.now();
248
+ }
249
+
250
+ /**
251
+ * Generate yak:// URI for this room
252
+ */
253
+ toUri() {
254
+ const url = new URL(`${YURT_CONFIG.scheme}://${this.hostEndpoint}`);
255
+ url.pathname = `/${this.bundleId}`;
256
+ return url.toString();
257
+ }
258
+
259
+ /**
260
+ * Generate shareable invite link with optional attestation
261
+ */
262
+ toInviteUri(attestation = null) {
263
+ let uri = this.toUri();
264
+ if (attestation) {
265
+ uri += `?invite=${encodeURIComponent(JSON.stringify(attestation))}`;
266
+ }
267
+ return uri;
268
+ }
269
+
270
+ /**
271
+ * Export to JSON
272
+ */
273
+ toJSON() {
274
+ return {
275
+ entryId: this.entryId,
276
+ bundleId: this.bundleId,
277
+ hostNodeId: this.hostNodeId,
278
+ hostEndpoint: this.hostEndpoint,
279
+ name: this.name,
280
+ description: this.description,
281
+ visibility: this.visibility,
282
+ tags: this.tags,
283
+ memberCount: this.memberCount,
284
+ messageCount: this.messageCount,
285
+ createdAt: this.createdAt,
286
+ updatedAt: this.updatedAt,
287
+ lastSeen: this.lastSeen,
288
+ signature: this.signature,
289
+ };
290
+ }
291
+
292
+ /**
293
+ * Create from JSON
294
+ */
295
+ static fromJSON(data) {
296
+ return new YurtEntry(data);
297
+ }
298
+ }
299
+
300
+ // ═══════════════════════════════════════════════════════════════════════════════
301
+ // YURT LINK - URI Parser and Direct Access
302
+ // ═══════════════════════════════════════════════════════════════════════════════
303
+
304
+ /**
305
+ * YurtLink - Parse and create yak:// URIs
306
+ *
307
+ * "The path is clear. No map needed. Just follow."
308
+ */
309
+ export class YurtLink {
310
+ /**
311
+ * Parse a yak:// URI
312
+ *
313
+ * Formats:
314
+ * - yak://host/bundleId
315
+ * - yak://host:port/bundleId
316
+ * - yak://host/bundleId?invite=<attestation>
317
+ */
318
+ static parse(uri) {
319
+ try {
320
+ // Handle yak:// scheme (not recognized by URL constructor)
321
+ const normalized = uri.replace(/^yak:\/\//, 'https://');
322
+ const url = new URL(normalized);
323
+
324
+ const bundleId = url.pathname.replace(/^\//, '').split('/')[0];
325
+ const port = url.port || YURT_CONFIG.defaultPort;
326
+ const host = url.hostname;
327
+
328
+ // Parse invite attestation if present
329
+ let invite = null;
330
+ const inviteParam = url.searchParams.get('invite');
331
+ if (inviteParam) {
332
+ try {
333
+ invite = JSON.parse(decodeURIComponent(inviteParam));
334
+ } catch (e) {
335
+ log.warn('Invalid invite parameter in URI');
336
+ }
337
+ }
338
+
339
+ return {
340
+ valid: true,
341
+ scheme: YURT_CONFIG.scheme,
342
+ host,
343
+ port: parseInt(port, 10),
344
+ bundleId,
345
+ endpoint: `${host}:${port}`,
346
+ invite,
347
+ original: uri,
348
+ };
349
+ } catch (err) {
350
+ return {
351
+ valid: false,
352
+ error: err.message,
353
+ original: uri,
354
+ };
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Create a yak:// URI
360
+ */
361
+ static create(host, bundleId, options = {}) {
362
+ const port = options.port || YURT_CONFIG.defaultPort;
363
+ const portSuffix = port === YURT_CONFIG.defaultPort ? '' : `:${port}`;
364
+
365
+ let uri = `${YURT_CONFIG.scheme}://${host}${portSuffix}/${bundleId}`;
366
+
367
+ if (options.invite) {
368
+ uri += `?invite=${encodeURIComponent(JSON.stringify(options.invite))}`;
369
+ }
370
+
371
+ return uri;
372
+ }
373
+
374
+ /**
375
+ * Validate a yak:// URI format
376
+ */
377
+ static isValid(uri) {
378
+ if (!uri || typeof uri !== 'string') return false;
379
+ if (!uri.startsWith('yak://')) return false;
380
+
381
+ const parsed = YurtLink.parse(uri);
382
+ return !!(parsed.valid && parsed.bundleId);
383
+ }
384
+
385
+ /**
386
+ * Convert to HTTPS gateway URL (for browser fallback)
387
+ */
388
+ static toHttpsGateway(uri, gatewayUrl = 'https://yak.to') {
389
+ const parsed = YurtLink.parse(uri);
390
+ if (!parsed.valid) return null;
391
+
392
+ return `${gatewayUrl}/join/${parsed.host}:${parsed.port}/${parsed.bundleId}`;
393
+ }
394
+ }
395
+
396
+ // ═══════════════════════════════════════════════════════════════════════════════
397
+ // YURT DIRECTORY - Local room index
398
+ // ═══════════════════════════════════════════════════════════════════════════════
399
+
400
+ /**
401
+ * YurtDirectory - Local index of known rooms
402
+ *
403
+ * "The traveler's memory of welcoming fires."
404
+ */
405
+ export class YurtDirectory extends EventEmitter {
406
+ constructor(options = {}) {
407
+ super();
408
+
409
+ this.options = options;
410
+
411
+ // All known entries
412
+ this.entries = new Map(); // entryId -> YurtEntry
413
+
414
+ // Indexes for fast lookup
415
+ this.byBundle = new Map(); // bundleId -> entryId
416
+ this.byHost = new Map(); // hostNodeId -> Set<entryId>
417
+ this.byTag = new Map(); // tag -> Set<entryId>
418
+
419
+ // Our own listings
420
+ this.ownListings = new Set(); // entryIds we host
421
+
422
+ // Stats
423
+ this.stats = {
424
+ entriesAdded: 0,
425
+ entriesRemoved: 0,
426
+ entriesExpired: 0,
427
+ };
428
+ }
429
+
430
+ /**
431
+ * Add or update an entry
432
+ */
433
+ add(entry, verifySignature = true, publicKeyLookup = null) {
434
+ // Validate
435
+ const validation = entry.validate();
436
+ if (!validation.valid) {
437
+ return { success: false, errors: validation.errors };
438
+ }
439
+
440
+ // Verify signature if required
441
+ if (verifySignature && entry.signature) {
442
+ if (publicKeyLookup) {
443
+ const publicKey = publicKeyLookup(entry.hostNodeId);
444
+ if (publicKey && !entry.verify(publicKey)) {
445
+ return { success: false, errors: ['signature verification failed'] };
446
+ }
447
+ }
448
+ }
449
+
450
+ // Check size limit
451
+ if (this.entries.size >= YURT_CONFIG.maxDirectorySize && !this.entries.has(entry.entryId)) {
452
+ this._evictOldest();
453
+ }
454
+
455
+ // Remove from old indexes if updating
456
+ if (this.entries.has(entry.entryId)) {
457
+ this._removeFromIndexes(entry.entryId);
458
+ }
459
+
460
+ // Add to directory
461
+ this.entries.set(entry.entryId, entry);
462
+ this._addToIndexes(entry);
463
+
464
+ this.stats.entriesAdded++;
465
+ this.emit('entry:added', entry);
466
+
467
+ log.debug('Entry added', {
468
+ bundleId: entry.bundleId,
469
+ name: entry.name,
470
+ host: entry.hostEndpoint,
471
+ });
472
+
473
+ return { success: true, entryId: entry.entryId };
474
+ }
475
+
476
+ /**
477
+ * Remove an entry
478
+ */
479
+ remove(entryId) {
480
+ const entry = this.entries.get(entryId);
481
+ if (!entry) return false;
482
+
483
+ this._removeFromIndexes(entryId);
484
+ this.entries.delete(entryId);
485
+ this.ownListings.delete(entryId);
486
+
487
+ this.stats.entriesRemoved++;
488
+ this.emit('entry:removed', entry);
489
+
490
+ return true;
491
+ }
492
+
493
+ /**
494
+ * Get entry by ID
495
+ */
496
+ get(entryId) {
497
+ return this.entries.get(entryId) || null;
498
+ }
499
+
500
+ /**
501
+ * Get entry by bundle ID
502
+ */
503
+ getByBundle(bundleId) {
504
+ const entryId = this.byBundle.get(bundleId);
505
+ return entryId ? this.entries.get(entryId) : null;
506
+ }
507
+
508
+ /**
509
+ * Search entries
510
+ */
511
+ search(options = {}) {
512
+ const { query, tags, visibility, limit = 50 } = options;
513
+
514
+ let results = Array.from(this.entries.values());
515
+
516
+ // Filter by visibility
517
+ if (visibility) {
518
+ results = results.filter(e => e.visibility === visibility);
519
+ }
520
+
521
+ // Filter by tags
522
+ if (tags && tags.length > 0) {
523
+ results = results.filter(e =>
524
+ tags.some(tag => e.tags.includes(tag.toLowerCase()))
525
+ );
526
+ }
527
+
528
+ // Filter by text query (searches name, description)
529
+ if (query) {
530
+ const q = query.toLowerCase();
531
+ results = results.filter(e =>
532
+ e.name.toLowerCase().includes(q) ||
533
+ e.description.toLowerCase().includes(q) ||
534
+ e.tags.some(t => t.toLowerCase().includes(q))
535
+ );
536
+ }
537
+
538
+ // Sort by activity (most recent first)
539
+ results.sort((a, b) => b.lastSeen - a.lastSeen);
540
+
541
+ // Apply limit
542
+ return results.slice(0, limit);
543
+ }
544
+
545
+ /**
546
+ * Get entries by host
547
+ */
548
+ getByHost(hostNodeId) {
549
+ const entryIds = this.byHost.get(hostNodeId) || new Set();
550
+ return Array.from(entryIds).map(id => this.entries.get(id)).filter(Boolean);
551
+ }
552
+
553
+ /**
554
+ * Get entries by tag
555
+ */
556
+ getByTag(tag) {
557
+ const entryIds = this.byTag.get(tag.toLowerCase()) || new Set();
558
+ return Array.from(entryIds).map(id => this.entries.get(id)).filter(Boolean);
559
+ }
560
+
561
+ /**
562
+ * List all public entries
563
+ */
564
+ listPublic(limit = 100) {
565
+ return this.search({
566
+ visibility: YURT_CONFIG.visibility.PUBLIC,
567
+ limit,
568
+ });
569
+ }
570
+
571
+ /**
572
+ * Mark an entry as our own listing
573
+ */
574
+ markAsOwn(entryId) {
575
+ if (this.entries.has(entryId)) {
576
+ this.ownListings.add(entryId);
577
+ }
578
+ }
579
+
580
+ /**
581
+ * Get our own listings
582
+ */
583
+ getOwnListings() {
584
+ return Array.from(this.ownListings)
585
+ .map(id => this.entries.get(id))
586
+ .filter(Boolean);
587
+ }
588
+
589
+ /**
590
+ * Cleanup expired entries
591
+ */
592
+ cleanup() {
593
+ let cleaned = 0;
594
+
595
+ for (const [entryId, entry] of this.entries) {
596
+ // Don't expire our own listings
597
+ if (this.ownListings.has(entryId)) continue;
598
+
599
+ if (entry.isExpired()) {
600
+ this.remove(entryId);
601
+ cleaned++;
602
+ this.stats.entriesExpired++;
603
+ }
604
+ }
605
+
606
+ if (cleaned > 0) {
607
+ log.debug('Cleaned expired entries', { count: cleaned });
608
+ }
609
+
610
+ return cleaned;
611
+ }
612
+
613
+ /**
614
+ * Evict oldest entries when at capacity
615
+ */
616
+ _evictOldest() {
617
+ const sorted = Array.from(this.entries.entries())
618
+ .filter(([id]) => !this.ownListings.has(id))
619
+ .sort((a, b) => a[1].lastSeen - b[1].lastSeen);
620
+
621
+ // Evict oldest 10%
622
+ const toEvict = Math.max(1, Math.floor(sorted.length * 0.1));
623
+ for (let i = 0; i < toEvict && i < sorted.length; i++) {
624
+ this.remove(sorted[i][0]);
625
+ }
626
+ }
627
+
628
+ /**
629
+ * Add entry to indexes
630
+ */
631
+ _addToIndexes(entry) {
632
+ this.byBundle.set(entry.bundleId, entry.entryId);
633
+
634
+ if (!this.byHost.has(entry.hostNodeId)) {
635
+ this.byHost.set(entry.hostNodeId, new Set());
636
+ }
637
+ this.byHost.get(entry.hostNodeId).add(entry.entryId);
638
+
639
+ for (const tag of entry.tags) {
640
+ const t = tag.toLowerCase();
641
+ if (!this.byTag.has(t)) {
642
+ this.byTag.set(t, new Set());
643
+ }
644
+ this.byTag.get(t).add(entry.entryId);
645
+ }
646
+ }
647
+
648
+ /**
649
+ * Remove entry from indexes
650
+ */
651
+ _removeFromIndexes(entryId) {
652
+ const entry = this.entries.get(entryId);
653
+ if (!entry) return;
654
+
655
+ this.byBundle.delete(entry.bundleId);
656
+ this.byHost.get(entry.hostNodeId)?.delete(entryId);
657
+
658
+ for (const tag of entry.tags) {
659
+ this.byTag.get(tag.toLowerCase())?.delete(entryId);
660
+ }
661
+ }
662
+
663
+ /**
664
+ * Get directory stats
665
+ */
666
+ getStats() {
667
+ return {
668
+ totalEntries: this.entries.size,
669
+ ownListings: this.ownListings.size,
670
+ uniqueHosts: this.byHost.size,
671
+ uniqueTags: this.byTag.size,
672
+ ...this.stats,
673
+ };
674
+ }
675
+
676
+ /**
677
+ * Export for persistence
678
+ */
679
+ export() {
680
+ return {
681
+ entries: Array.from(this.entries.values()).map(e => e.toJSON()),
682
+ ownListings: Array.from(this.ownListings),
683
+ exportedAt: Date.now(),
684
+ };
685
+ }
686
+
687
+ /**
688
+ * Import from persistence
689
+ */
690
+ import(data) {
691
+ for (const entryData of data.entries) {
692
+ const entry = YurtEntry.fromJSON(entryData);
693
+ this.add(entry, false); // Skip signature verification for persisted data
694
+ }
695
+
696
+ for (const entryId of data.ownListings || []) {
697
+ this.ownListings.add(entryId);
698
+ }
699
+ }
700
+ }
701
+
702
+ // ═══════════════════════════════════════════════════════════════════════════════
703
+ // YURT GOSSIP - Room discovery propagation
704
+ // ═══════════════════════════════════════════════════════════════════════════════
705
+
706
+ /**
707
+ * YurtGossip - Propagate room listings through the mesh
708
+ *
709
+ * "Stories spread around campfires. So do directions to new camps."
710
+ */
711
+ export class YurtGossip extends EventEmitter {
712
+ /**
713
+ * @param {Object} identity - Node identity
714
+ * @param {YurtDirectory} directory - Local directory
715
+ * @param {Object} mesh - Mesh network interface
716
+ */
717
+ constructor(identity, directory, mesh, options = {}) {
718
+ super();
719
+
720
+ this.identity = identity;
721
+ this.directory = directory;
722
+ this.mesh = mesh;
723
+ this.options = options;
724
+ this.keyResolver = options.keyResolver || null;
725
+
726
+ // Track what we've sent to avoid duplicate gossip
727
+ this.sentTo = new Map(); // peerId -> { entryId -> timestamp }
728
+
729
+ // Gossip timers
730
+ this.gossipTimer = null;
731
+ this.refreshTimer = null;
732
+
733
+ // Stats
734
+ this.stats = {
735
+ gossipsSent: 0,
736
+ gossipsReceived: 0,
737
+ entriesReceived: 0,
738
+ entriesForwarded: 0,
739
+ };
740
+
741
+ // Handle incoming gossip
742
+ this._setupHandlers();
743
+ }
744
+
745
+ /**
746
+ * Start gossip timers
747
+ */
748
+ start() {
749
+ // Periodic gossip to peers
750
+ this.gossipTimer = setInterval(() => {
751
+ this._gossipToPeers();
752
+ }, YURT_CONFIG.gossipInterval);
753
+
754
+ // Refresh own listings
755
+ this.refreshTimer = setInterval(() => {
756
+ this._refreshOwnListings();
757
+ }, YURT_CONFIG.refreshInterval);
758
+
759
+ log.info('YURT gossip started');
760
+
761
+ // Initial gossip
762
+ setTimeout(() => this._gossipToPeers(), 5000);
763
+ }
764
+
765
+ /**
766
+ * Stop gossip timers
767
+ */
768
+ stop() {
769
+ if (this.gossipTimer) {
770
+ clearInterval(this.gossipTimer);
771
+ this.gossipTimer = null;
772
+ }
773
+ if (this.refreshTimer) {
774
+ clearInterval(this.refreshTimer);
775
+ this.refreshTimer = null;
776
+ }
777
+ log.info('YURT gossip stopped');
778
+ }
779
+
780
+ /**
781
+ * Announce a room (publish to network)
782
+ */
783
+ announce(entry) {
784
+ // Sign with our identity
785
+ entry.sign(this.identity.identity.secretKey);
786
+
787
+ // Add to directory
788
+ const result = this.directory.add(entry, false);
789
+ if (result.success) {
790
+ this.directory.markAsOwn(entry.entryId);
791
+
792
+ // Broadcast to peers
793
+ this._broadcast({
794
+ type: YURT_CONFIG.messageTypes.ANNOUNCE,
795
+ entry: entry.toJSON(),
796
+ from: this.identity.identity.nodeId,
797
+ timestamp: Date.now(),
798
+ });
799
+
800
+ log.info('Room announced', { name: entry.name, bundleId: entry.bundleId });
801
+ }
802
+
803
+ return result;
804
+ }
805
+
806
+ /**
807
+ * Withdraw a room (remove from network)
808
+ */
809
+ withdraw(bundleId) {
810
+ const entry = this.directory.getByBundle(bundleId);
811
+ if (!entry || !this.directory.ownListings.has(entry.entryId)) {
812
+ return { success: false, error: 'NOT_OWN_LISTING' };
813
+ }
814
+
815
+ // Broadcast withdrawal
816
+ this._broadcast({
817
+ type: YURT_CONFIG.messageTypes.WITHDRAW,
818
+ bundleId,
819
+ hostNodeId: this.identity.identity.nodeId,
820
+ timestamp: Date.now(),
821
+ });
822
+
823
+ this.directory.remove(entry.entryId);
824
+
825
+ log.info('Room withdrawn', { bundleId });
826
+ return { success: true };
827
+ }
828
+
829
+ /**
830
+ * Query the network for rooms
831
+ */
832
+ query(options = {}) {
833
+ const queryId = bytesToHex(randomBytes(16));
834
+
835
+ this._broadcast({
836
+ type: YURT_CONFIG.messageTypes.QUERY,
837
+ queryId,
838
+ query: options.query || null,
839
+ tags: options.tags || [],
840
+ from: this.identity.identity.nodeId,
841
+ timestamp: Date.now(),
842
+ });
843
+
844
+ return queryId;
845
+ }
846
+
847
+ /**
848
+ * Setup message handlers
849
+ */
850
+ _setupHandlers() {
851
+ if (!this.mesh) return;
852
+
853
+ this.mesh.on('message', (message) => {
854
+ if (!message.type?.startsWith('yurt:')) return;
855
+
856
+ switch (message.type) {
857
+ case YURT_CONFIG.messageTypes.ANNOUNCE:
858
+ this._handleAnnounce(message);
859
+ break;
860
+ case YURT_CONFIG.messageTypes.WITHDRAW:
861
+ this._handleWithdraw(message);
862
+ break;
863
+ case YURT_CONFIG.messageTypes.GOSSIP:
864
+ this._handleGossip(message);
865
+ break;
866
+ case YURT_CONFIG.messageTypes.QUERY:
867
+ this._handleQuery(message);
868
+ break;
869
+ case YURT_CONFIG.messageTypes.QUERY_RESPONSE:
870
+ this._handleQueryResponse(message);
871
+ break;
872
+ }
873
+ });
874
+ }
875
+
876
+ /**
877
+ * Handle incoming announcement
878
+ */
879
+ _handleAnnounce(message) {
880
+ const entry = YurtEntry.fromJSON(message.entry);
881
+
882
+ // Don't process our own announcements
883
+ if (entry.hostNodeId === this.identity.identity.nodeId) return;
884
+
885
+ // Verify and add
886
+ const result = this.directory.add(entry, true, (nodeId) => {
887
+ return this._getPublicKey(nodeId);
888
+ });
889
+
890
+ if (result.success) {
891
+ this.stats.entriesReceived++;
892
+ this.emit('entry:discovered', entry);
893
+ }
894
+ }
895
+
896
+ /**
897
+ * Handle withdrawal
898
+ */
899
+ _handleWithdraw(message) {
900
+ const entry = this.directory.getByBundle(message.bundleId);
901
+ if (entry && entry.hostNodeId === message.hostNodeId) {
902
+ this.directory.remove(entry.entryId);
903
+ }
904
+ }
905
+
906
+ /**
907
+ * Handle incoming gossip
908
+ */
909
+ _handleGossip(message) {
910
+ this.stats.gossipsReceived++;
911
+
912
+ for (const entryData of message.entries || []) {
913
+ const entry = YurtEntry.fromJSON(entryData);
914
+
915
+ // Skip our own
916
+ if (entry.hostNodeId === this.identity.identity.nodeId) continue;
917
+
918
+ // Add with verification
919
+ const result = this.directory.add(entry, true, (nodeId) => {
920
+ return this._getPublicKey(nodeId);
921
+ });
922
+
923
+ if (result.success) {
924
+ this.stats.entriesReceived++;
925
+ }
926
+ }
927
+ }
928
+
929
+ /**
930
+ * Handle query
931
+ */
932
+ _handleQuery(message) {
933
+ // Search local directory
934
+ const results = this.directory.search({
935
+ query: message.query,
936
+ tags: message.tags,
937
+ visibility: YURT_CONFIG.visibility.PUBLIC,
938
+ limit: 20,
939
+ });
940
+
941
+ if (results.length === 0) return;
942
+
943
+ // Send response directly to querier
944
+ this.mesh.send(message.from, {
945
+ type: YURT_CONFIG.messageTypes.QUERY_RESPONSE,
946
+ queryId: message.queryId,
947
+ entries: results.map(e => e.toJSON()),
948
+ from: this.identity.identity.nodeId,
949
+ timestamp: Date.now(),
950
+ });
951
+ }
952
+
953
+ /**
954
+ * Handle query response
955
+ */
956
+ _handleQueryResponse(message) {
957
+ this.emit('query:response', {
958
+ queryId: message.queryId,
959
+ entries: message.entries.map(e => YurtEntry.fromJSON(e)),
960
+ from: message.from,
961
+ });
962
+ }
963
+
964
+ /**
965
+ * Gossip to connected peers
966
+ */
967
+ _gossipToPeers() {
968
+ const peers = this.mesh?.getPeers?.() || [];
969
+ if (peers.length === 0) return;
970
+
971
+ // Get entries to gossip (public only)
972
+ const entries = this.directory.listPublic(YURT_CONFIG.maxEntriesPerGossip);
973
+ if (entries.length === 0) return;
974
+
975
+ const now = Date.now();
976
+
977
+ for (const peer of peers) {
978
+ // Check what we've already sent to this peer
979
+ const sent = this.sentTo.get(peer.id) || new Map();
980
+
981
+ // Filter to entries not recently sent
982
+ const toSend = entries.filter(e => {
983
+ const lastSent = sent.get(e.entryId) || 0;
984
+ return now - lastSent > YURT_CONFIG.gossipInterval;
985
+ });
986
+
987
+ if (toSend.length === 0) continue;
988
+
989
+ // Send gossip
990
+ this.mesh.send(peer.id, {
991
+ type: YURT_CONFIG.messageTypes.GOSSIP,
992
+ entries: toSend.map(e => e.toJSON()),
993
+ from: this.identity.identity.nodeId,
994
+ timestamp: now,
995
+ });
996
+
997
+ // Update sent tracking
998
+ for (const entry of toSend) {
999
+ sent.set(entry.entryId, now);
1000
+ }
1001
+ this.sentTo.set(peer.id, sent);
1002
+
1003
+ this.stats.gossipsSent++;
1004
+ this.stats.entriesForwarded += toSend.length;
1005
+ }
1006
+ }
1007
+
1008
+ /**
1009
+ * Refresh our own listings
1010
+ */
1011
+ _refreshOwnListings() {
1012
+ const listings = this.directory.getOwnListings();
1013
+
1014
+ for (const entry of listings) {
1015
+ entry.updatedAt = Date.now();
1016
+ entry.lastSeen = Date.now();
1017
+ entry.sign(this.identity.identity.secretKey);
1018
+
1019
+ this._broadcast({
1020
+ type: YURT_CONFIG.messageTypes.ANNOUNCE,
1021
+ entry: entry.toJSON(),
1022
+ from: this.identity.identity.nodeId,
1023
+ timestamp: Date.now(),
1024
+ });
1025
+ }
1026
+ }
1027
+
1028
+ /**
1029
+ * Broadcast to all peers
1030
+ */
1031
+ _broadcast(message) {
1032
+ const peers = this.mesh?.getPeers?.() || [];
1033
+ for (const peer of peers) {
1034
+ this.mesh.send(peer.id, message);
1035
+ }
1036
+ }
1037
+
1038
+ /**
1039
+ * Get public key for a node — unified resolution cascade
1040
+ *
1041
+ * Resolution order:
1042
+ * 1. Custom publicKeyLookup callback (backwards compat)
1043
+ * 2. KeyResolver (DOKO cache, peers, SHERPA, etc.)
1044
+ */
1045
+ _getPublicKey(nodeId) {
1046
+ // Legacy callback path
1047
+ if (this.options.publicKeyLookup) {
1048
+ const key = this.options.publicKeyLookup(nodeId);
1049
+ if (key) return key;
1050
+ }
1051
+
1052
+ // KeyResolver: unified key resolution
1053
+ if (this.keyResolver) {
1054
+ return this.keyResolver.resolve(nodeId);
1055
+ }
1056
+
1057
+ return null;
1058
+ }
1059
+
1060
+ /**
1061
+ * Get gossip stats
1062
+ */
1063
+ getStats() {
1064
+ return {
1065
+ ...this.stats,
1066
+ trackedPeers: this.sentTo.size,
1067
+ };
1068
+ }
1069
+ }
1070
+
1071
+ // ═══════════════════════════════════════════════════════════════════════════════
1072
+ // YURT HUB - Complete room discovery system
1073
+ // ═══════════════════════════════════════════════════════════════════════════════
1074
+
1075
+ /**
1076
+ * YurtHub - Full room discovery and direct access
1077
+ *
1078
+ * "The gathering place where paths are shared."
1079
+ */
1080
+ export class YurtHub extends EventEmitter {
1081
+ /**
1082
+ * @param {Object} identity - Node identity
1083
+ * @param {Object} gumbaHub - GUMBA hub for room access
1084
+ * @param {Object} mesh - Mesh network interface
1085
+ */
1086
+ constructor(identity, gumbaHub, mesh, options = {}) {
1087
+ super();
1088
+
1089
+ this.identity = identity;
1090
+ this.gumbaHub = gumbaHub;
1091
+ this.mesh = mesh;
1092
+ this.options = options;
1093
+
1094
+ // Directory and gossip
1095
+ this.directory = new YurtDirectory(options);
1096
+ this.gossip = new YurtGossip(identity, this.directory, mesh, options);
1097
+
1098
+ // Forward events
1099
+ this.directory.on('entry:added', (e) => this.emit('room:discovered', e));
1100
+ this.directory.on('entry:removed', (e) => this.emit('room:removed', e));
1101
+ this.gossip.on('query:response', (r) => this.emit('query:response', r));
1102
+
1103
+ log.info('YurtHub initialized');
1104
+ }
1105
+
1106
+ /**
1107
+ * Start the hub
1108
+ */
1109
+ start() {
1110
+ this.gossip.start();
1111
+
1112
+ // Periodic cleanup
1113
+ this._cleanupTimer = setInterval(() => {
1114
+ this.directory.cleanup();
1115
+ }, 60 * 60 * 1000); // Every hour
1116
+
1117
+ return this;
1118
+ }
1119
+
1120
+ /**
1121
+ * Stop the hub
1122
+ */
1123
+ stop() {
1124
+ this.gossip.stop();
1125
+ if (this._cleanupTimer) {
1126
+ clearInterval(this._cleanupTimer);
1127
+ }
1128
+ }
1129
+
1130
+ /**
1131
+ * Publish a room from our GUMBA hub
1132
+ */
1133
+ publishRoom(bundleId, options = {}) {
1134
+ const bundle = this.gumbaHub.getBundle(bundleId);
1135
+ if (!bundle) {
1136
+ return { success: false, error: 'BUNDLE_NOT_FOUND' };
1137
+ }
1138
+
1139
+ // Don't publish unlisted rooms via gossip
1140
+ if (options.visibility === YURT_CONFIG.visibility.UNLISTED) {
1141
+ log.debug('Unlisted room - not gossiping', { bundleId });
1142
+ }
1143
+
1144
+ const entry = new YurtEntry({
1145
+ bundleId: bundle.bundleId,
1146
+ hostNodeId: this.identity.identity.nodeId,
1147
+ hostEndpoint: options.endpoint || this._getOwnEndpoint(),
1148
+ name: options.name || bundle.name,
1149
+ description: options.description || bundle.description,
1150
+ visibility: options.visibility || YURT_CONFIG.visibility.PUBLIC,
1151
+ tags: options.tags || [],
1152
+ memberCount: bundle.memberTree.size,
1153
+ messageCount: bundle.metadata.messageCount,
1154
+ });
1155
+
1156
+ if (options.visibility !== YURT_CONFIG.visibility.UNLISTED) {
1157
+ return this.gossip.announce(entry);
1158
+ } else {
1159
+ // Just add locally without gossip
1160
+ entry.sign(this.identity.identity.secretKey);
1161
+ const result = this.directory.add(entry, false);
1162
+ if (result.success) {
1163
+ this.directory.markAsOwn(entry.entryId);
1164
+ }
1165
+ return result;
1166
+ }
1167
+ }
1168
+
1169
+ /**
1170
+ * Unpublish a room
1171
+ */
1172
+ unpublishRoom(bundleId) {
1173
+ return this.gossip.withdraw(bundleId);
1174
+ }
1175
+
1176
+ /**
1177
+ * Join a room via yak:// link
1178
+ *
1179
+ * Flow: parse URI → resolve host via mesh → request GUMBA session → return access
1180
+ */
1181
+ async joinViaLink(uri) {
1182
+ const parsed = YurtLink.parse(uri);
1183
+ if (!parsed.valid) {
1184
+ return { success: false, error: 'INVALID_URI', details: parsed.error };
1185
+ }
1186
+
1187
+ log.info('Joining via link', {
1188
+ host: parsed.host,
1189
+ bundleId: parsed.bundleId,
1190
+ });
1191
+
1192
+ // Look up the room in our directory first
1193
+ const entry = this.directory.getByBundle(parsed.bundleId);
1194
+
1195
+ // Request a GUMBA session on the target bundle
1196
+ try {
1197
+ const session = await this.gumbaHub.requestSession(
1198
+ parsed.bundleId,
1199
+ this.identity.identity.nodeId,
1200
+ { invite: parsed.invite }
1201
+ );
1202
+
1203
+ if (session?.error) {
1204
+ return {
1205
+ success: false,
1206
+ error: session.error,
1207
+ parsed,
1208
+ };
1209
+ }
1210
+
1211
+ return {
1212
+ success: true,
1213
+ parsed,
1214
+ endpoint: entry?.hostEndpoint || parsed.endpoint,
1215
+ bundleId: parsed.bundleId,
1216
+ invite: parsed.invite,
1217
+ sessionId: session?.sessionId || null,
1218
+ };
1219
+ } catch (err) {
1220
+ log.warn('joinViaLink failed', { error: err.message, uri });
1221
+ return {
1222
+ success: false,
1223
+ error: 'CONNECTION_FAILED',
1224
+ details: err.message,
1225
+ parsed,
1226
+ };
1227
+ }
1228
+ }
1229
+
1230
+ /**
1231
+ * Browse public rooms
1232
+ */
1233
+ browse(options = {}) {
1234
+ return this.directory.search({
1235
+ visibility: YURT_CONFIG.visibility.PUBLIC,
1236
+ ...options,
1237
+ });
1238
+ }
1239
+
1240
+ /**
1241
+ * Search rooms
1242
+ */
1243
+ search(query, options = {}) {
1244
+ return this.directory.search({ query, ...options });
1245
+ }
1246
+
1247
+ /**
1248
+ * Search by tags
1249
+ */
1250
+ searchByTags(tags) {
1251
+ return this.directory.search({ tags });
1252
+ }
1253
+
1254
+ /**
1255
+ * Query the network for rooms
1256
+ */
1257
+ queryNetwork(options = {}) {
1258
+ return this.gossip.query(options);
1259
+ }
1260
+
1261
+ /**
1262
+ * Get room by bundle ID
1263
+ */
1264
+ getRoom(bundleId) {
1265
+ return this.directory.getByBundle(bundleId);
1266
+ }
1267
+
1268
+ /**
1269
+ * Generate join link for a room
1270
+ */
1271
+ getJoinLink(bundleId, options = {}) {
1272
+ const entry = this.directory.getByBundle(bundleId);
1273
+ if (!entry) return null;
1274
+
1275
+ if (options.invite) {
1276
+ return entry.toInviteUri(options.invite);
1277
+ }
1278
+ return entry.toUri();
1279
+ }
1280
+
1281
+ /**
1282
+ * Get our own endpoint
1283
+ *
1284
+ * Priority: explicit option → mesh advertised address → default
1285
+ */
1286
+ _getOwnEndpoint() {
1287
+ if (this.options.endpoint) {
1288
+ return this.options.endpoint;
1289
+ }
1290
+
1291
+ // Ask the mesh for our advertised address
1292
+ if (this.mesh?.getAdvertisedAddress) {
1293
+ const addr = this.mesh.getAdvertisedAddress();
1294
+ if (addr) return addr;
1295
+ }
1296
+
1297
+ return `localhost:${YURT_CONFIG.defaultPort}`;
1298
+ }
1299
+
1300
+ /**
1301
+ * Get hub stats
1302
+ */
1303
+ getStats() {
1304
+ return {
1305
+ directory: this.directory.getStats(),
1306
+ gossip: this.gossip.getStats(),
1307
+ };
1308
+ }
1309
+
1310
+ /**
1311
+ * Export state
1312
+ */
1313
+ export() {
1314
+ return {
1315
+ directory: this.directory.export(),
1316
+ };
1317
+ }
1318
+
1319
+ /**
1320
+ * Import state
1321
+ */
1322
+ import(data) {
1323
+ if (data.directory) {
1324
+ this.directory.import(data.directory);
1325
+ }
1326
+ }
1327
+ }
1328
+
1329
+ // ═══════════════════════════════════════════════════════════════════════════════
1330
+ // EXPORTS
1331
+ // ═══════════════════════════════════════════════════════════════════════════════
1332
+
1333
+ export default {
1334
+ YurtEntry,
1335
+ YurtLink,
1336
+ YurtDirectory,
1337
+ YurtGossip,
1338
+ YurtHub,
1339
+ YURT_CONFIG,
1340
+ };