yakmesh 1.3.1 → 1.4.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.
@@ -0,0 +1,683 @@
1
+ /**
2
+ * YAKMESH™ Content Store
3
+ * Content-addressed storage with consensus proofs
4
+ *
5
+ * Provides public content delivery while maintaining mesh security:
6
+ * - Content addressed by hash (trustless verification)
7
+ * - Consensus proofs for light client verification
8
+ * - Edge caching for instant public access
9
+ * - Mesh sync for decentralized replication
10
+ *
11
+ * @module content/store
12
+ * @license MIT
13
+ * @copyright 2026 YAKMESH Contributors
14
+ */
15
+
16
+ import { sha3_256 } from '@noble/hashes/sha3.js';
17
+ import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
18
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync, statSync } from 'fs';
19
+ import { join, dirname } from 'path';
20
+
21
+ /**
22
+ * Content types supported
23
+ */
24
+ export const ContentType = {
25
+ JSON: 'application/json',
26
+ HTML: 'text/html',
27
+ TEXT: 'text/plain',
28
+ BINARY: 'application/octet-stream',
29
+ JAVASCRIPT: 'application/javascript',
30
+ CSS: 'text/css',
31
+ IMAGE_PNG: 'image/png',
32
+ IMAGE_JPG: 'image/jpeg',
33
+ IMAGE_SVG: 'image/svg+xml',
34
+ };
35
+
36
+ /**
37
+ * Content status in the network
38
+ */
39
+ export const ContentStatus = {
40
+ LOCAL: 'local', // Only on this node
41
+ PENDING: 'pending', // Awaiting consensus
42
+ VERIFIED: 'verified', // Consensus reached
43
+ REJECTED: 'rejected', // Failed consensus
44
+ };
45
+
46
+ /**
47
+ * Compute content hash (SHA3-256)
48
+ */
49
+ export function computeContentHash(content) {
50
+ if (typeof content === 'string') {
51
+ return bytesToHex(sha3_256(utf8ToBytes(content)));
52
+ }
53
+ if (Buffer.isBuffer(content)) {
54
+ return bytesToHex(sha3_256(new Uint8Array(content)));
55
+ }
56
+ if (content instanceof Uint8Array) {
57
+ return bytesToHex(sha3_256(content));
58
+ }
59
+ // Object - serialize deterministically
60
+ return bytesToHex(sha3_256(utf8ToBytes(JSON.stringify(content))));
61
+ }
62
+
63
+ /**
64
+ * Content metadata
65
+ */
66
+ class ContentMetadata {
67
+ constructor(options = {}) {
68
+ this.hash = options.hash;
69
+ this.contentType = options.contentType || ContentType.BINARY;
70
+ this.size = options.size || 0;
71
+ this.createdAt = options.createdAt || Date.now();
72
+ this.publishedBy = options.publishedBy || null;
73
+ this.status = options.status || ContentStatus.LOCAL;
74
+ this.consensusProof = options.consensusProof || null;
75
+ this.tags = options.tags || [];
76
+ this.name = options.name || null; // Optional human-readable name
77
+ this.ttl = options.ttl || 0; // 0 = permanent
78
+ }
79
+
80
+ toJSON() {
81
+ return {
82
+ hash: this.hash,
83
+ contentType: this.contentType,
84
+ size: this.size,
85
+ createdAt: this.createdAt,
86
+ publishedBy: this.publishedBy,
87
+ status: this.status,
88
+ consensusProof: this.consensusProof,
89
+ tags: this.tags,
90
+ name: this.name,
91
+ ttl: this.ttl,
92
+ };
93
+ }
94
+
95
+ static fromJSON(json) {
96
+ return new ContentMetadata(json);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Consensus proof for light client verification
102
+ */
103
+ class ConsensusProof {
104
+ constructor(options = {}) {
105
+ this.contentHash = options.contentHash;
106
+ this.timestamp = options.timestamp || Date.now();
107
+ this.validators = options.validators || []; // Array of { nodeId, signature }
108
+ this.quorum = options.quorum || 0; // Required signatures
109
+ this.networkId = options.networkId || null;
110
+ }
111
+
112
+ /**
113
+ * Check if proof has quorum
114
+ */
115
+ hasQuorum() {
116
+ return this.validators.length >= this.quorum;
117
+ }
118
+
119
+ /**
120
+ * Add validator signature
121
+ */
122
+ addValidator(nodeId, signature) {
123
+ if (!this.validators.find(v => v.nodeId === nodeId)) {
124
+ this.validators.push({ nodeId, signature, timestamp: Date.now() });
125
+ }
126
+ }
127
+
128
+ toJSON() {
129
+ return {
130
+ contentHash: this.contentHash,
131
+ timestamp: this.timestamp,
132
+ validators: this.validators,
133
+ quorum: this.quorum,
134
+ networkId: this.networkId,
135
+ };
136
+ }
137
+
138
+ static fromJSON(json) {
139
+ return new ConsensusProof(json);
140
+ }
141
+ }
142
+
143
+ /**
144
+ * YAKMESH Content Store
145
+ * Content-addressed storage with mesh sync and public delivery
146
+ */
147
+ export class ContentStore {
148
+ constructor(config = {}) {
149
+ this.config = {
150
+ dataDir: config.dataDir || './data/content',
151
+ maxContentSize: config.maxContentSize || 10 * 1024 * 1024, // 10MB default
152
+ cacheSize: config.cacheSize || 100, // LRU cache entries
153
+ quorumSize: config.quorumSize || 2, // Minimum validators
154
+ ...config,
155
+ };
156
+
157
+ this.contentDir = join(this.config.dataDir, 'objects');
158
+ this.metaDir = join(this.config.dataDir, 'meta');
159
+
160
+ // In-memory caches
161
+ this.contentCache = new Map(); // hash -> content (LRU)
162
+ this.metaCache = new Map(); // hash -> ContentMetadata
163
+ this.nameIndex = new Map(); // name -> hash (for human-readable lookup)
164
+
165
+ // Mesh integration (set by init)
166
+ this.mesh = null;
167
+ this.identity = null;
168
+ this.oracle = null;
169
+ this.gossip = null;
170
+ }
171
+
172
+ /**
173
+ * Initialize the content store
174
+ */
175
+ async init(node = null) {
176
+ // Create directories
177
+ mkdirSync(this.contentDir, { recursive: true });
178
+ mkdirSync(this.metaDir, { recursive: true });
179
+
180
+ // Load existing metadata into cache
181
+ this._loadMetadataIndex();
182
+
183
+ // Integrate with node if provided
184
+ if (node) {
185
+ this.mesh = node.mesh;
186
+ this.identity = node.identity;
187
+ this.oracle = node.oracle;
188
+ this.gossip = node.gossip;
189
+
190
+ // Content gossip is handled by the server via mesh.on('rumor')
191
+ // which calls contentStore._handleContentGossip()
192
+ }
193
+
194
+ console.log(`✓ Content store initialized: ${this.config.dataDir}`);
195
+ console.log(` Objects: ${this.metaCache.size}`);
196
+
197
+ return this;
198
+ }
199
+
200
+ /**
201
+ * Load metadata index from disk
202
+ */
203
+ _loadMetadataIndex() {
204
+ if (!existsSync(this.metaDir)) return;
205
+
206
+ const files = readdirSync(this.metaDir);
207
+ for (const file of files) {
208
+ if (!file.endsWith('.json')) continue;
209
+ try {
210
+ const metaPath = join(this.metaDir, file);
211
+ const json = JSON.parse(readFileSync(metaPath, 'utf8'));
212
+ const meta = ContentMetadata.fromJSON(json);
213
+ this.metaCache.set(meta.hash, meta);
214
+ if (meta.name) {
215
+ this.nameIndex.set(meta.name, meta.hash);
216
+ }
217
+ } catch (e) {
218
+ console.warn(`Failed to load metadata: ${file}`);
219
+ }
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Get content path for a hash
225
+ */
226
+ _getContentPath(hash) {
227
+ // Store in subdirectories for filesystem efficiency (git-style)
228
+ const prefix = hash.slice(0, 2);
229
+ const suffix = hash.slice(2);
230
+ return join(this.contentDir, prefix, suffix);
231
+ }
232
+
233
+ /**
234
+ * Get metadata path for a hash
235
+ */
236
+ _getMetaPath(hash) {
237
+ return join(this.metaDir, `${hash}.json`);
238
+ }
239
+
240
+ /**
241
+ * Store content
242
+ */
243
+ async store(content, options = {}) {
244
+ // Compute hash
245
+ const hash = computeContentHash(content);
246
+
247
+ // Check size limit
248
+ const size = Buffer.isBuffer(content) ? content.length :
249
+ typeof content === 'string' ? Buffer.byteLength(content) :
250
+ Buffer.byteLength(JSON.stringify(content));
251
+
252
+ if (size > this.config.maxContentSize) {
253
+ throw new Error(`Content exceeds max size: ${size} > ${this.config.maxContentSize}`);
254
+ }
255
+
256
+ // Check if already exists
257
+ if (this.has(hash)) {
258
+ const existing = this.getMeta(hash);
259
+ return { hash, status: 'exists', meta: existing };
260
+ }
261
+
262
+ // Create metadata
263
+ const meta = new ContentMetadata({
264
+ hash,
265
+ contentType: options.contentType || this._detectContentType(content),
266
+ size,
267
+ publishedBy: this.identity?.identity?.nodeId || options.publishedBy || 'unknown',
268
+ status: ContentStatus.LOCAL,
269
+ tags: options.tags || [],
270
+ name: options.name || null,
271
+ ttl: options.ttl || 0,
272
+ });
273
+
274
+ // Write content to disk
275
+ const contentPath = this._getContentPath(hash);
276
+ mkdirSync(dirname(contentPath), { recursive: true });
277
+
278
+ if (Buffer.isBuffer(content)) {
279
+ writeFileSync(contentPath, content);
280
+ } else if (typeof content === 'string') {
281
+ writeFileSync(contentPath, content, 'utf8');
282
+ } else {
283
+ writeFileSync(contentPath, JSON.stringify(content), 'utf8');
284
+ }
285
+
286
+ // Write metadata
287
+ writeFileSync(this._getMetaPath(hash), JSON.stringify(meta.toJSON(), null, 2));
288
+
289
+ // Update caches
290
+ this.metaCache.set(hash, meta);
291
+ if (meta.name) {
292
+ this.nameIndex.set(meta.name, hash);
293
+ }
294
+ this._addToContentCache(hash, content);
295
+
296
+ // Gossip to mesh
297
+ if (this.gossip && options.publish !== false) {
298
+ await this.publish(hash);
299
+ }
300
+
301
+ return { hash, status: 'stored', meta };
302
+ }
303
+
304
+ /**
305
+ * Retrieve content by hash
306
+ */
307
+ get(hash) {
308
+ // Resolve name to hash if needed
309
+ if (!hash.match(/^[a-f0-9]{64}$/i)) {
310
+ hash = this.nameIndex.get(hash) || hash;
311
+ }
312
+
313
+ // Check memory cache
314
+ if (this.contentCache.has(hash)) {
315
+ return this.contentCache.get(hash);
316
+ }
317
+
318
+ // Check disk
319
+ const contentPath = this._getContentPath(hash);
320
+ if (!existsSync(contentPath)) {
321
+ return null;
322
+ }
323
+
324
+ // Load and cache
325
+ const content = readFileSync(contentPath);
326
+ this._addToContentCache(hash, content);
327
+
328
+ return content;
329
+ }
330
+
331
+ /**
332
+ * Get content with metadata and proof
333
+ */
334
+ getWithProof(hash) {
335
+ const content = this.get(hash);
336
+ if (!content) return null;
337
+
338
+ const meta = this.getMeta(hash);
339
+
340
+ return {
341
+ content,
342
+ hash,
343
+ meta: meta?.toJSON() || null,
344
+ proof: meta?.consensusProof || null,
345
+ verified: meta?.status === ContentStatus.VERIFIED,
346
+ };
347
+ }
348
+
349
+ /**
350
+ * Get metadata for content
351
+ */
352
+ getMeta(hash) {
353
+ // Resolve name if needed
354
+ if (!hash.match(/^[a-f0-9]{64}$/i)) {
355
+ hash = this.nameIndex.get(hash) || hash;
356
+ }
357
+ return this.metaCache.get(hash) || null;
358
+ }
359
+
360
+ /**
361
+ * Check if content exists
362
+ */
363
+ has(hash) {
364
+ // Resolve name if needed
365
+ if (!hash.match(/^[a-f0-9]{64}$/i)) {
366
+ hash = this.nameIndex.get(hash) || hash;
367
+ }
368
+ return this.metaCache.has(hash) || existsSync(this._getContentPath(hash));
369
+ }
370
+
371
+ /**
372
+ * Delete content
373
+ */
374
+ delete(hash) {
375
+ const meta = this.getMeta(hash);
376
+
377
+ // Remove from disk
378
+ const contentPath = this._getContentPath(hash);
379
+ const metaPath = this._getMetaPath(hash);
380
+
381
+ if (existsSync(contentPath)) unlinkSync(contentPath);
382
+ if (existsSync(metaPath)) unlinkSync(metaPath);
383
+
384
+ // Remove from caches
385
+ this.contentCache.delete(hash);
386
+ this.metaCache.delete(hash);
387
+ if (meta?.name) {
388
+ this.nameIndex.delete(meta.name);
389
+ }
390
+
391
+ return true;
392
+ }
393
+
394
+ /**
395
+ * List all content
396
+ */
397
+ list(options = {}) {
398
+ const { tag, status, limit = 100, offset = 0 } = options;
399
+
400
+ let items = Array.from(this.metaCache.values());
401
+
402
+ // Filter by tag
403
+ if (tag) {
404
+ items = items.filter(m => m.tags.includes(tag));
405
+ }
406
+
407
+ // Filter by status
408
+ if (status) {
409
+ items = items.filter(m => m.status === status);
410
+ }
411
+
412
+ // Sort by created date (newest first)
413
+ items.sort((a, b) => b.createdAt - a.createdAt);
414
+
415
+ // Paginate
416
+ return items.slice(offset, offset + limit).map(m => m.toJSON());
417
+ }
418
+
419
+ /**
420
+ * Publish content to mesh
421
+ */
422
+ async publish(hash) {
423
+ const meta = this.getMeta(hash);
424
+ if (!meta) {
425
+ throw new Error(`Content not found: ${hash}`);
426
+ }
427
+
428
+ // Create announcement message
429
+ const announcement = {
430
+ type: 'content_announce',
431
+ hash,
432
+ meta: {
433
+ contentType: meta.contentType,
434
+ size: meta.size,
435
+ publishedBy: meta.publishedBy,
436
+ tags: meta.tags,
437
+ name: meta.name,
438
+ },
439
+ timestamp: Date.now(),
440
+ };
441
+
442
+ // Sign with node identity
443
+ if (this.identity) {
444
+ announcement.signature = this.identity.sign(JSON.stringify(announcement));
445
+ }
446
+
447
+ // Gossip to mesh
448
+ if (this.gossip) {
449
+ console.log(`📡 Gossiping content_announce for ${hash.slice(0, 16)}...`);
450
+ this.gossip.spreadRumor('content', announcement);
451
+ } else {
452
+ console.log(`⚠️ No gossip protocol available for content announce`);
453
+ }
454
+
455
+ // Update status
456
+ meta.status = ContentStatus.PENDING;
457
+ writeFileSync(this._getMetaPath(hash), JSON.stringify(meta.toJSON(), null, 2));
458
+
459
+ return { published: true, hash };
460
+ }
461
+
462
+ /**
463
+ * Request content from mesh
464
+ */
465
+ async request(hash) {
466
+ if (this.has(hash)) {
467
+ return this.getWithProof(hash);
468
+ }
469
+
470
+ // Broadcast request
471
+ if (this.gossip) {
472
+ this.gossip.spreadRumor('content', {
473
+ type: 'content_request',
474
+ hash,
475
+ requestedBy: this.identity?.identity?.nodeId,
476
+ timestamp: Date.now(),
477
+ });
478
+ }
479
+
480
+ // Wait for response (with timeout)
481
+ return new Promise((resolve, reject) => {
482
+ const timeout = setTimeout(() => {
483
+ reject(new Error(`Content not found: ${hash}`));
484
+ }, 10000);
485
+
486
+ const checkInterval = setInterval(() => {
487
+ if (this.has(hash)) {
488
+ clearTimeout(timeout);
489
+ clearInterval(checkInterval);
490
+ resolve(this.getWithProof(hash));
491
+ }
492
+ }, 500);
493
+ });
494
+ }
495
+
496
+ /**
497
+ * Handle content gossip from peers
498
+ */
499
+ async _handleContentGossip(data, origin) {
500
+ switch (data.type) {
501
+ case 'content_announce':
502
+ // Peer has new content - request it if we don't have it
503
+ if (!this.has(data.hash)) {
504
+ console.log(`📦 New content announced: ${data.hash.slice(0, 16)}... from ${origin.slice(0, 16)}...`);
505
+ // Request full content via gossip
506
+ if (this.gossip) {
507
+ this.gossip.spreadRumor('content', {
508
+ type: 'content_request',
509
+ hash: data.hash,
510
+ requestedBy: this.identity?.identity?.nodeId,
511
+ timestamp: Date.now(),
512
+ });
513
+ }
514
+ }
515
+ break;
516
+
517
+ case 'content_request':
518
+ // Peer wants content - send if we have it
519
+ if (this.has(data.hash)) {
520
+ const result = this.getWithProof(data.hash);
521
+ if (this.gossip && result) {
522
+ // Ensure content is properly encoded as base64
523
+ let contentBase64;
524
+ if (Buffer.isBuffer(result.content)) {
525
+ contentBase64 = result.content.toString('base64');
526
+ } else if (typeof result.content === 'string') {
527
+ contentBase64 = Buffer.from(result.content, 'utf8').toString('base64');
528
+ } else {
529
+ contentBase64 = Buffer.from(JSON.stringify(result.content), 'utf8').toString('base64');
530
+ }
531
+
532
+ this.gossip.spreadRumor('content', {
533
+ type: 'content_response',
534
+ hash: data.hash,
535
+ content: contentBase64,
536
+ meta: result.meta,
537
+ proof: result.proof,
538
+ timestamp: Date.now(),
539
+ });
540
+ }
541
+ }
542
+ break;
543
+
544
+ case 'content_response':
545
+ // Received content from peer
546
+ if (!this.has(data.hash)) {
547
+ const content = Buffer.from(data.content, 'base64');
548
+ const computedHash = computeContentHash(content);
549
+
550
+ // Verify hash
551
+ if (computedHash !== data.hash) {
552
+ console.warn(`⚠️ Content hash mismatch from ${origin.slice(0, 16)}...`);
553
+ return;
554
+ } // Store it
555
+ await this.store(content, {
556
+ ...data.meta,
557
+ publish: false, // Don't re-gossip
558
+ });
559
+
560
+ // Apply consensus proof if present
561
+ if (data.proof) {
562
+ const meta = this.getMeta(data.hash);
563
+ meta.consensusProof = ConsensusProof.fromJSON(data.proof);
564
+ meta.status = data.proof.hasQuorum?.() ? ContentStatus.VERIFIED : ContentStatus.PENDING;
565
+ writeFileSync(this._getMetaPath(data.hash), JSON.stringify(meta.toJSON(), null, 2));
566
+ }
567
+
568
+ console.log(`✓ Content received: ${data.hash.slice(0, 16)}...`);
569
+ }
570
+ break;
571
+
572
+ case 'content_validate':
573
+ // Peer is requesting validation vote
574
+ if (this.has(data.hash) && this.identity && this.oracle) {
575
+ const content = this.get(data.hash);
576
+ const isValid = this.oracle.validateContent(content, data.contentType);
577
+
578
+ if (isValid) {
579
+ // Sign validation
580
+ const vote = {
581
+ type: 'content_vote',
582
+ hash: data.hash,
583
+ nodeId: this.identity.identity.nodeId,
584
+ vote: 'valid',
585
+ signature: this.identity.sign(data.hash),
586
+ timestamp: Date.now(),
587
+ };
588
+ this.gossip.spreadRumor('content', vote);
589
+ }
590
+ }
591
+ break;
592
+
593
+ case 'content_vote':
594
+ // Received validation vote
595
+ const meta = this.getMeta(data.hash);
596
+ if (meta) {
597
+ if (!meta.consensusProof) {
598
+ meta.consensusProof = new ConsensusProof({
599
+ contentHash: data.hash,
600
+ quorum: this.config.quorumSize,
601
+ networkId: this.mesh?.networkId,
602
+ });
603
+ }
604
+ meta.consensusProof.addValidator(data.nodeId, data.signature);
605
+
606
+ if (meta.consensusProof.hasQuorum()) {
607
+ meta.status = ContentStatus.VERIFIED;
608
+ console.log(`✓ Content verified (quorum reached): ${data.hash.slice(0, 16)}...`);
609
+ }
610
+
611
+ writeFileSync(this._getMetaPath(data.hash), JSON.stringify(meta.toJSON(), null, 2));
612
+ }
613
+ break;
614
+ }
615
+ }
616
+
617
+ /**
618
+ * Add to LRU content cache
619
+ */
620
+ _addToContentCache(hash, content) {
621
+ // Simple LRU: remove oldest if at capacity
622
+ if (this.contentCache.size >= this.config.cacheSize) {
623
+ const oldest = this.contentCache.keys().next().value;
624
+ this.contentCache.delete(oldest);
625
+ }
626
+ this.contentCache.set(hash, content);
627
+ }
628
+
629
+ /**
630
+ * Detect content type from content
631
+ */
632
+ _detectContentType(content) {
633
+ if (typeof content === 'object' && !Buffer.isBuffer(content)) {
634
+ return ContentType.JSON;
635
+ }
636
+
637
+ const str = content.toString().slice(0, 100);
638
+
639
+ if (str.startsWith('<!DOCTYPE') || str.startsWith('<html')) {
640
+ return ContentType.HTML;
641
+ }
642
+ if (str.startsWith('{') || str.startsWith('[')) {
643
+ return ContentType.JSON;
644
+ }
645
+ if (str.includes('function') || str.includes('const ') || str.includes('import ')) {
646
+ return ContentType.JAVASCRIPT;
647
+ }
648
+
649
+ return ContentType.TEXT;
650
+ }
651
+
652
+ /**
653
+ * Get store statistics
654
+ */
655
+ getStats() {
656
+ let totalSize = 0;
657
+ let verified = 0;
658
+ let pending = 0;
659
+ let local = 0;
660
+
661
+ for (const meta of this.metaCache.values()) {
662
+ totalSize += meta.size;
663
+ switch (meta.status) {
664
+ case ContentStatus.VERIFIED: verified++; break;
665
+ case ContentStatus.PENDING: pending++; break;
666
+ case ContentStatus.LOCAL: local++; break;
667
+ }
668
+ }
669
+
670
+ return {
671
+ totalObjects: this.metaCache.size,
672
+ totalSize,
673
+ verified,
674
+ pending,
675
+ local,
676
+ cacheSize: this.contentCache.size,
677
+ dataDir: this.config.dataDir,
678
+ };
679
+ }
680
+ }
681
+
682
+ export { ContentMetadata, ConsensusProof };
683
+ export default ContentStore;