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.
- package/CHANGELOG.md +637 -0
- package/CONTRIBUTING.md +42 -0
- package/Caddyfile +77 -0
- package/README.md +119 -29
- package/adapters/adapter-mlv-bible/README.md +124 -0
- package/adapters/adapter-mlv-bible/index.js +400 -0
- package/adapters/chat-mod-adapter.js +532 -0
- package/adapters/content-adapter.js +273 -0
- package/content/api.js +50 -41
- package/content/index.js +2 -2
- package/content/store.js +355 -173
- package/dashboard/index.html +19 -3
- package/database/replication.js +117 -37
- package/docs/CRYPTO-AGILITY.md +204 -0
- package/docs/MTLS-RESEARCH.md +367 -0
- package/docs/NAMCHE-SPEC.md +681 -0
- package/docs/PEERQUANTA-YAKMESH-INTEGRATION.md +407 -0
- package/docs/PRECISION-DISCLOSURE.md +96 -0
- package/docs/README.md +76 -0
- package/docs/ROADMAP-2.4.0.md +447 -0
- package/docs/ROADMAP-2.5.0.md +244 -0
- package/docs/SECURITY-AUDIT-REPORT.md +306 -0
- package/docs/SST-INTEGRATION.md +712 -0
- package/docs/STEADYWATCH-IMPLEMENTATION.md +303 -0
- package/docs/TERNARY-AUDIT-REPORT.md +247 -0
- package/docs/TME-FAQ.md +221 -0
- package/docs/WHITEPAPER.md +623 -0
- package/docs/adapters.html +1001 -0
- package/docs/advanced-systems.html +1045 -0
- package/docs/annex.html +1046 -0
- package/docs/api.html +970 -0
- package/docs/business/response-templates.md +160 -0
- package/docs/c2c.html +1225 -0
- package/docs/cli.html +1332 -0
- package/docs/configuration.html +1248 -0
- package/docs/darshan.html +1085 -0
- package/docs/dharma.html +966 -0
- package/docs/docs-bundle.html +1075 -0
- package/docs/docs.css +3120 -0
- package/docs/docs.js +556 -0
- package/docs/doko.html +969 -0
- package/docs/geo-proof.html +858 -0
- package/docs/getting-started.html +840 -0
- package/docs/gumba-tutorial.html +1144 -0
- package/docs/gumba.html +1098 -0
- package/docs/index.html +914 -0
- package/docs/jhilke.html +1312 -0
- package/docs/karma.html +1100 -0
- package/docs/katha.html +1037 -0
- package/docs/lama.html +978 -0
- package/docs/mandala.html +1067 -0
- package/docs/mani.html +964 -0
- package/docs/mantra.html +967 -0
- package/docs/mesh.html +1409 -0
- package/docs/nakpak.html +869 -0
- package/docs/namche.html +928 -0
- package/docs/nav-order.json +53 -0
- package/docs/prahari.html +1043 -0
- package/docs/prism-bash.min.js +1 -0
- package/docs/prism-javascript.min.js +1 -0
- package/docs/prism-json.min.js +1 -0
- package/docs/prism-tomorrow.min.css +1 -0
- package/docs/prism.min.js +1 -0
- package/docs/privacy.html +699 -0
- package/docs/quick-reference.html +1181 -0
- package/docs/sakshi.html +1402 -0
- package/docs/sandboxing.md +386 -0
- package/docs/seva.html +911 -0
- package/docs/sherpa.html +871 -0
- package/docs/studio.html +860 -0
- package/docs/stupa.html +995 -0
- package/docs/tailwind.min.css +2 -0
- package/docs/tattva.html +1332 -0
- package/docs/terms.html +686 -0
- package/docs/time-server-deployment.md +166 -0
- package/docs/time-sources.html +1392 -0
- package/docs/tivra.html +1127 -0
- package/docs/trademark-policy.html +686 -0
- package/docs/tribhuj.html +1183 -0
- package/docs/trust-security.html +1029 -0
- package/docs/tutorials/backup-recovery.html +654 -0
- package/docs/tutorials/dashboard.html +604 -0
- package/docs/tutorials/domain-setup.html +605 -0
- package/docs/tutorials/host-website.html +456 -0
- package/docs/tutorials/mesh-network.html +505 -0
- package/docs/tutorials/mobile-access.html +445 -0
- package/docs/tutorials/privacy.html +467 -0
- package/docs/tutorials/raspberry-pi.html +600 -0
- package/docs/tutorials/security-basics.html +539 -0
- package/docs/tutorials/share-files.html +431 -0
- package/docs/tutorials/troubleshooting.html +637 -0
- package/docs/tutorials/trust-karma.html +419 -0
- package/docs/tutorials/yak-protocol.html +456 -0
- package/docs/tutorials.html +1034 -0
- package/docs/vani.html +1270 -0
- package/docs/webserver.html +809 -0
- package/docs/yak-protocol.html +940 -0
- package/docs/yak-timeserver-design.md +475 -0
- package/docs/yakapp.html +1015 -0
- package/docs/ypc27.html +1069 -0
- package/docs/yurt.html +1344 -0
- package/embedded-docs/bundle.js +334 -74
- package/gossip/protocol.js +247 -27
- package/identity/key-resolver.js +262 -0
- package/identity/machine-seed.js +632 -0
- package/identity/node-key.js +669 -368
- package/identity/tribhuj-ratchet.js +506 -0
- package/knowledge-base.js +37 -8
- package/launcher/yakmesh.bat +62 -0
- package/launcher/yakmesh.sh +70 -0
- package/mesh/annex.js +462 -108
- package/mesh/beacon-broadcast.js +113 -1
- package/mesh/darshan.js +1718 -0
- package/mesh/gumba.js +1567 -0
- package/mesh/jhilke.js +651 -0
- package/mesh/katha.js +1012 -0
- package/mesh/nakpak-routing.js +8 -5
- package/mesh/network.js +724 -34
- package/mesh/pulse-sync.js +4 -1
- package/mesh/rate-limiter.js +127 -15
- package/mesh/seva.js +526 -0
- package/mesh/sherpa-discovery.js +89 -8
- package/mesh/sybil-defense.js +19 -5
- package/mesh/temporal-encoder.js +4 -3
- package/mesh/vani.js +1364 -0
- package/mesh/yurt.js +1340 -0
- package/models/entropy-sentinel.onnx +0 -0
- package/models/karma-trust.onnx +0 -0
- package/models/manifest.json +43 -0
- package/models/sakshi-anomaly.onnx +0 -0
- package/oracle/code-proof-protocol.js +7 -6
- package/oracle/codebase-lock.js +257 -28
- package/oracle/index.js +74 -15
- package/oracle/ma902-snmp.js +678 -0
- package/oracle/module-sealer.js +5 -3
- package/oracle/network-identity.js +16 -0
- package/oracle/packet-checksum.js +201 -0
- package/oracle/sst.js +579 -0
- package/oracle/ternary-144t.js +714 -0
- package/oracle/ternary-ml.js +481 -0
- package/oracle/time-api.js +239 -0
- package/oracle/time-source.js +137 -47
- package/oracle/validation-oracle-hardened.js +1111 -1071
- package/oracle/validation-oracle.js +4 -2
- package/oracle/ypc27.js +211 -0
- package/package.json +20 -3
- package/protocol/yak-handler.js +35 -9
- package/protocol/yak-protocol.js +28 -13
- package/reference/cpp/yakmesh_mceliece_shard.cpp +168 -0
- package/reference/cpp/yakmesh_ypc27.cpp +179 -0
- package/sbom.json +87 -0
- package/scripts/security-audit.mjs +264 -0
- package/scripts/update-docs-nav.js +194 -0
- package/scripts/update-docs-sidebar.cjs +164 -0
- package/security/crypto-config.js +4 -3
- package/security/dharma-moderation.js +517 -0
- package/security/doko-identity.js +193 -143
- package/security/domain-consensus.js +86 -85
- package/security/fs-hardening.js +620 -0
- package/security/hardware-attestation.js +5 -3
- package/security/hybrid-trust.js +227 -87
- package/security/karma-rate-limiter.js +692 -0
- package/security/khata-protocol.js +22 -21
- package/security/khata-trust-integration.js +277 -150
- package/security/memory-safety.js +635 -0
- package/security/mesh-auth.js +11 -10
- package/security/mesh-revocation.js +373 -5
- package/security/namche-gateway.js +298 -69
- package/security/sakshi.js +460 -3
- package/security/sangha.js +770 -0
- package/security/secure-config.js +473 -0
- package/security/silicon-parity.js +13 -10
- package/security/steadywatch.js +1142 -0
- package/security/strike-system.js +32 -3
- package/security/temporal-signing.js +488 -0
- package/security/trit-commitment.js +464 -0
- package/server/crypto/annex.js +247 -0
- package/server/darshan-api.js +343 -0
- package/server/index.js +3259 -362
- package/server/komm-api.js +668 -0
- package/utils/accel.js +2273 -0
- package/utils/ternary-id.js +79 -0
- package/utils/verify-worker.js +57 -0
- package/webserver/index.js +95 -5
- package/assets/yakmesh-logo.png +0 -0
- package/assets/yakmesh-logo.svg +0 -80
- package/assets/yakmesh-logo2.png +0 -0
- package/assets/yakmesh-logo2sm.png +0 -0
- package/assets/ymsm.png +0 -0
- package/website/assets/silhouettes/adapters.svg +0 -107
- package/website/assets/silhouettes/api-endpoints.svg +0 -115
- package/website/assets/silhouettes/atomic-clock.svg +0 -83
- package/website/assets/silhouettes/base-camp.svg +0 -81
- package/website/assets/silhouettes/bridge.svg +0 -69
- package/website/assets/silhouettes/docs-bundle.svg +0 -113
- package/website/assets/silhouettes/doko-basket.svg +0 -70
- package/website/assets/silhouettes/fortress.svg +0 -93
- package/website/assets/silhouettes/gateway.svg +0 -54
- package/website/assets/silhouettes/gears.svg +0 -93
- package/website/assets/silhouettes/globe-satellite.svg +0 -67
- package/website/assets/silhouettes/karma-wheel.svg +0 -137
- package/website/assets/silhouettes/lama-council.svg +0 -141
- package/website/assets/silhouettes/mandala-network.svg +0 -169
- package/website/assets/silhouettes/mani-stones.svg +0 -149
- package/website/assets/silhouettes/mantra-wheel.svg +0 -116
- package/website/assets/silhouettes/mesh-nodes.svg +0 -113
- package/website/assets/silhouettes/nakpak.svg +0 -56
- package/website/assets/silhouettes/peak-lightning.svg +0 -73
- package/website/assets/silhouettes/sherpa.svg +0 -69
- package/website/assets/silhouettes/stupa-tower.svg +0 -119
- package/website/assets/silhouettes/tattva-eye.svg +0 -78
- package/website/assets/silhouettes/terminal.svg +0 -74
- package/website/assets/silhouettes/webserver.svg +0 -145
- package/website/assets/silhouettes/yak.svg +0 -78
- package/website/assets/yakmesh-logo.png +0 -0
- package/website/assets/yakmesh-logo.webp +0 -0
- package/website/assets/yakmesh-logo128x140.webp +0 -0
- package/website/assets/yakmesh-logo2.png +0 -0
- package/website/assets/yakmesh-logo2.svg +0 -51
- package/website/assets/yakmesh-logo40x44.webp +0 -0
- package/website/assets/yakmesh.gif +0 -0
- package/website/assets/yakmesh.ico +0 -0
- package/website/assets/yakmesh.jpg +0 -0
- package/website/assets/yakmesh.pdf +0 -0
- package/website/assets/yakmesh.png +0 -0
- package/website/assets/yakmesh.svg +0 -70
- package/website/assets/yakmesh128.webp +0 -0
- package/website/assets/yakmesh32.png +0 -0
- package/website/assets/yakmesh32.svg +0 -65
- package/website/assets/yakmesh32o.ico +0 -2
- package/website/assets/yakmesh32o.svg +0 -65
- 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
|
+
};
|