@merklevault/core 0.1.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/dist/index.js ADDED
@@ -0,0 +1,2028 @@
1
+ // src/merklevault.ts
2
+ import { EventEmitter as EventEmitter2 } from "events";
3
+ import { readFileSync, mkdirSync as mkdirSync3, existsSync as existsSync3 } from "fs";
4
+ import path3 from "path";
5
+
6
+ // ../daemon/kubo-manager.ts
7
+ import { spawn } from "child_process";
8
+ import { existsSync, mkdirSync } from "fs";
9
+ import path from "path";
10
+ import { EventEmitter } from "events";
11
+ var KuboManager = class extends EventEmitter {
12
+ process = null;
13
+ config;
14
+ apiPort;
15
+ crashTimestamps = [];
16
+ heartbeatInterval = null;
17
+ isShuttingDown = false;
18
+ _ready = false;
19
+ constructor(config) {
20
+ super();
21
+ this.config = config;
22
+ this.apiPort = config.apiPort ?? 5001;
23
+ }
24
+ get ready() {
25
+ return this._ready;
26
+ }
27
+ get apiUrl() {
28
+ return `http://127.0.0.1:${this.apiPort}`;
29
+ }
30
+ get pid() {
31
+ return this.process?.pid ?? null;
32
+ }
33
+ /**
34
+ * Initialise le repo IPFS si inexistant, puis lance Kubo
35
+ */
36
+ async start() {
37
+ if (!existsSync(this.config.repoPath)) {
38
+ mkdirSync(this.config.repoPath, { recursive: true });
39
+ }
40
+ if (!existsSync(path.join(this.config.repoPath, "config"))) {
41
+ await this.initRepo();
42
+ }
43
+ await this.configureRepo();
44
+ await this.spawnDaemon();
45
+ this.startHeartbeat();
46
+ }
47
+ /**
48
+ * Arrêt propre : SIGTERM + timeout 30s
49
+ */
50
+ async stop() {
51
+ this.isShuttingDown = true;
52
+ if (this.heartbeatInterval) {
53
+ clearInterval(this.heartbeatInterval);
54
+ this.heartbeatInterval = null;
55
+ }
56
+ if (!this.process) return;
57
+ return new Promise((resolve) => {
58
+ const timeout = setTimeout(() => {
59
+ this.process?.kill("SIGKILL");
60
+ resolve();
61
+ }, 3e4);
62
+ this.process.on("exit", () => {
63
+ clearTimeout(timeout);
64
+ this.process = null;
65
+ this._ready = false;
66
+ resolve();
67
+ });
68
+ this.process.kill("SIGTERM");
69
+ });
70
+ }
71
+ /**
72
+ * ipfs init avec profil lowpower
73
+ */
74
+ async initRepo() {
75
+ return new Promise((resolve, reject) => {
76
+ const proc = spawn(this.config.kuboBinaryPath, ["init", "--profile=lowpower"], {
77
+ env: { ...process.env, IPFS_PATH: this.config.repoPath },
78
+ stdio: "pipe"
79
+ });
80
+ let stderr = "";
81
+ proc.stderr?.on("data", (d) => {
82
+ stderr += d.toString();
83
+ });
84
+ proc.on("exit", (code) => {
85
+ if (code === 0) resolve();
86
+ else reject(new Error(`ipfs init failed (code ${code}): ${stderr}`));
87
+ });
88
+ });
89
+ }
90
+ /**
91
+ * Configure le repo pour MerkleVault :
92
+ * - API sur 127.0.0.1:port
93
+ * - Gateway désactivée
94
+ * - Routing dhtclient
95
+ * - Swarm limits bas
96
+ */
97
+ async configureRepo() {
98
+ const stringConfigs = [
99
+ ["Addresses.API", `/ip4/127.0.0.1/tcp/${this.apiPort}`],
100
+ ["Addresses.Gateway", ""],
101
+ ["Routing.Type", "dhtclient"],
102
+ ["Reprovider.Interval", "0s"]
103
+ ];
104
+ const jsonConfigs = [
105
+ ["Swarm.ConnMgr.LowWater", "20"],
106
+ ["Swarm.ConnMgr.HighWater", "40"]
107
+ ];
108
+ for (const [key, value] of stringConfigs) {
109
+ await this.runIpfsCommand(["config", key, value]);
110
+ }
111
+ for (const [key, value] of jsonConfigs) {
112
+ await this.runIpfsCommand(["config", key, value, "--json"]);
113
+ }
114
+ }
115
+ async runIpfsCommand(args) {
116
+ return new Promise((resolve, reject) => {
117
+ const proc = spawn(this.config.kuboBinaryPath, args, {
118
+ env: { ...process.env, IPFS_PATH: this.config.repoPath },
119
+ stdio: "pipe"
120
+ });
121
+ let stdout = "";
122
+ let stderr = "";
123
+ proc.stdout?.on("data", (d) => {
124
+ stdout += d.toString();
125
+ });
126
+ proc.stderr?.on("data", (d) => {
127
+ stderr += d.toString();
128
+ });
129
+ proc.on("exit", (code) => {
130
+ if (code === 0) resolve(stdout.trim());
131
+ else reject(new Error(`ipfs ${args[0]} failed: ${stderr}`));
132
+ });
133
+ });
134
+ }
135
+ /**
136
+ * Lance le daemon Kubo en subprocess
137
+ */
138
+ async spawnDaemon() {
139
+ return new Promise((resolve, reject) => {
140
+ const proc = spawn(this.config.kuboBinaryPath, ["daemon", "--migrate"], {
141
+ env: { ...process.env, IPFS_PATH: this.config.repoPath },
142
+ stdio: "pipe"
143
+ });
144
+ this.process = proc;
145
+ let resolved = false;
146
+ proc.stdout?.on("data", (data) => {
147
+ const line = data.toString();
148
+ this.emit("log", line.trim());
149
+ if (!resolved && line.includes("Daemon is ready")) {
150
+ resolved = true;
151
+ this._ready = true;
152
+ this.emit("ready");
153
+ resolve();
154
+ }
155
+ });
156
+ proc.stderr?.on("data", (data) => {
157
+ this.emit("log", `[stderr] ${data.toString().trim()}`);
158
+ });
159
+ proc.on("exit", (code, signal) => {
160
+ this._ready = false;
161
+ this.process = null;
162
+ if (!resolved) {
163
+ reject(new Error(`Kubo exited before ready (code=${code}, signal=${signal})`));
164
+ return;
165
+ }
166
+ if (!this.isShuttingDown) {
167
+ this.emit("crash", { code, signal });
168
+ this.handleCrash();
169
+ }
170
+ });
171
+ proc.on("error", (err) => {
172
+ if (!resolved) reject(err);
173
+ });
174
+ setTimeout(() => {
175
+ if (!resolved) {
176
+ resolved = true;
177
+ proc.kill("SIGKILL");
178
+ reject(new Error("Kubo startup timeout (30s)"));
179
+ }
180
+ }, 3e4);
181
+ });
182
+ }
183
+ /**
184
+ * Restart auto avec protection anti-boucle (max 3 en 60s)
185
+ */
186
+ async handleCrash() {
187
+ const now = Date.now();
188
+ this.crashTimestamps.push(now);
189
+ this.crashTimestamps = this.crashTimestamps.filter((t) => now - t < 6e4);
190
+ if (this.crashTimestamps.length > 3) {
191
+ this.emit("fatal", "Kubo crashed 3+ times in 60s, giving up");
192
+ return;
193
+ }
194
+ this.emit("restarting");
195
+ await new Promise((r) => setTimeout(r, 2e3));
196
+ if (!this.isShuttingDown) {
197
+ try {
198
+ await this.spawnDaemon();
199
+ this.emit("ready");
200
+ } catch (err) {
201
+ this.emit("fatal", `Kubo restart failed: ${err}`);
202
+ }
203
+ }
204
+ }
205
+ /**
206
+ * Heartbeat toutes les 30s sur /api/v0/id
207
+ */
208
+ startHeartbeat() {
209
+ this.heartbeatInterval = setInterval(async () => {
210
+ if (!this._ready || this.isShuttingDown) return;
211
+ try {
212
+ const controller = new AbortController();
213
+ const timeout = setTimeout(() => controller.abort(), 5e3);
214
+ const res = await fetch(`${this.apiUrl}/api/v0/id`, {
215
+ method: "POST",
216
+ signal: controller.signal
217
+ });
218
+ clearTimeout(timeout);
219
+ if (!res.ok) {
220
+ this.emit("unhealthy", `Kubo returned ${res.status}`);
221
+ }
222
+ } catch {
223
+ this.emit("unhealthy", "Kubo heartbeat failed");
224
+ }
225
+ }, 3e4);
226
+ }
227
+ };
228
+
229
+ // ../daemon/database.ts
230
+ import BetterSqlite3 from "better-sqlite3";
231
+ import path2 from "path";
232
+ import { mkdirSync as mkdirSync2, existsSync as existsSync2 } from "fs";
233
+ var Database = class {
234
+ db;
235
+ constructor(dataDir) {
236
+ if (!existsSync2(dataDir)) {
237
+ mkdirSync2(dataDir, { recursive: true });
238
+ }
239
+ const dbPath = path2.join(dataDir, "merklevault.db");
240
+ this.db = new BetterSqlite3(dbPath);
241
+ this.db.pragma("journal_mode = WAL");
242
+ this.db.pragma("synchronous = NORMAL");
243
+ this.db.pragma("foreign_keys = ON");
244
+ this.db.pragma("temp_store = MEMORY");
245
+ this.db.pragma("mmap_size = 268435456");
246
+ this.db.pragma("cache_size = -64000");
247
+ this.db.pragma("busy_timeout = 5000");
248
+ this.migrate();
249
+ }
250
+ /**
251
+ * Applique le schéma initial (migration 001)
252
+ */
253
+ migrate() {
254
+ this.db.exec(`
255
+ CREATE TABLE IF NOT EXISTS settings (
256
+ key TEXT PRIMARY KEY,
257
+ value TEXT NOT NULL,
258
+ updated_at INTEGER NOT NULL
259
+ );
260
+ `);
261
+ const version = this.getSetting("schema_version");
262
+ if (!version) {
263
+ this.migration001();
264
+ this.setSetting("schema_version", "1");
265
+ }
266
+ const currentVersion = parseInt(this.getSetting("schema_version") ?? "0", 10);
267
+ if (currentVersion < 2) {
268
+ this.migration002();
269
+ this.setSetting("schema_version", "2");
270
+ }
271
+ if (parseInt(this.getSetting("schema_version") ?? "0", 10) < 3) {
272
+ this.migration003();
273
+ this.setSetting("schema_version", "3");
274
+ }
275
+ }
276
+ migration001() {
277
+ this.db.exec(`
278
+ -- Table nodes : structure logique du filesystem
279
+ CREATE TABLE IF NOT EXISTS nodes (
280
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
281
+ parent_id INTEGER REFERENCES nodes(id) ON DELETE RESTRICT,
282
+ name TEXT NOT NULL,
283
+ kind TEXT NOT NULL CHECK (kind IN ('file', 'folder')),
284
+ created_at INTEGER NOT NULL,
285
+ modified_at INTEGER NOT NULL,
286
+ deleted_at INTEGER,
287
+ current_version_id INTEGER REFERENCES file_versions(id),
288
+ UNIQUE (parent_id, name, deleted_at)
289
+ );
290
+ CREATE INDEX IF NOT EXISTS idx_nodes_parent ON nodes(parent_id) WHERE deleted_at IS NULL;
291
+ CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name) WHERE deleted_at IS NULL;
292
+
293
+ -- Table file_versions : versions des fichiers
294
+ CREATE TABLE IF NOT EXISTS file_versions (
295
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
296
+ node_id INTEGER NOT NULL REFERENCES nodes(id) ON DELETE RESTRICT,
297
+ cid TEXT NOT NULL,
298
+ size_bytes INTEGER NOT NULL,
299
+ sha256_plain TEXT,
300
+ created_at INTEGER NOT NULL,
301
+ enc_algo TEXT NOT NULL DEFAULT 'none',
302
+ enc_key_wrapped BLOB,
303
+ enc_key_nonce BLOB,
304
+ enc_data_nonce BLOB,
305
+ is_pinned INTEGER NOT NULL DEFAULT 1,
306
+ UNIQUE (node_id, cid)
307
+ );
308
+ CREATE INDEX IF NOT EXISTS idx_versions_node ON file_versions(node_id);
309
+ CREATE INDEX IF NOT EXISTS idx_versions_cid ON file_versions(cid);
310
+ CREATE INDEX IF NOT EXISTS idx_versions_created ON file_versions(created_at);
311
+
312
+ -- Table cid_refcount : compteur de r\xE9f\xE9rences par CID
313
+ CREATE TABLE IF NOT EXISTS cid_refcount (
314
+ cid TEXT PRIMARY KEY,
315
+ ref_count INTEGER NOT NULL DEFAULT 0,
316
+ first_seen_at INTEGER NOT NULL,
317
+ last_seen_at INTEGER NOT NULL
318
+ );
319
+
320
+ -- Triggers pour maintenir le refcount automatiquement
321
+ CREATE TRIGGER IF NOT EXISTS trg_refcount_insert
322
+ AFTER INSERT ON file_versions
323
+ BEGIN
324
+ INSERT INTO cid_refcount (cid, ref_count, first_seen_at, last_seen_at)
325
+ VALUES (NEW.cid, 1, NEW.created_at, NEW.created_at)
326
+ ON CONFLICT(cid) DO UPDATE SET
327
+ ref_count = ref_count + 1,
328
+ last_seen_at = NEW.created_at;
329
+ END;
330
+
331
+ CREATE TRIGGER IF NOT EXISTS trg_refcount_delete
332
+ AFTER DELETE ON file_versions
333
+ BEGIN
334
+ UPDATE cid_refcount SET ref_count = ref_count - 1 WHERE cid = OLD.cid;
335
+ END;
336
+
337
+ -- Table pending_operations : file d'attente des op\xE9rations async
338
+ CREATE TABLE IF NOT EXISTS pending_operations (
339
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
340
+ op_type TEXT NOT NULL,
341
+ op_payload TEXT NOT NULL,
342
+ status TEXT NOT NULL DEFAULT 'pending',
343
+ attempts INTEGER NOT NULL DEFAULT 0,
344
+ last_error TEXT,
345
+ created_at INTEGER NOT NULL,
346
+ updated_at INTEGER NOT NULL,
347
+ scheduled_for INTEGER
348
+ );
349
+ CREATE INDEX IF NOT EXISTS idx_ops_status ON pending_operations(status, scheduled_for);
350
+
351
+ -- Table anchor_jobs : r\xE9serv\xE9e V2 (ancrage blockchain)
352
+ CREATE TABLE IF NOT EXISTS anchor_jobs (
353
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
354
+ merkle_root TEXT NOT NULL,
355
+ file_cids TEXT NOT NULL,
356
+ anchor_target TEXT NOT NULL,
357
+ tx_hash TEXT,
358
+ block_height INTEGER,
359
+ anchored_at INTEGER,
360
+ status TEXT NOT NULL DEFAULT 'pending',
361
+ proof_blob BLOB
362
+ );
363
+
364
+ -- Recherche plein texte sur noms
365
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts_nodes USING fts5(
366
+ name, path, content='', tokenize='unicode61'
367
+ );
368
+
369
+ -- Journal GC
370
+ CREATE TABLE IF NOT EXISTS gc_audit_log (
371
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
372
+ op_type TEXT NOT NULL,
373
+ target TEXT,
374
+ details TEXT,
375
+ executed_at INTEGER NOT NULL
376
+ );
377
+ CREATE INDEX IF NOT EXISTS idx_audit_executed ON gc_audit_log(executed_at);
378
+
379
+ -- Cr\xE9er le noeud racine "Accueil"
380
+ INSERT INTO nodes (parent_id, name, kind, created_at, modified_at)
381
+ VALUES (NULL, 'Accueil', 'folder', unixepoch(), unixepoch());
382
+ `);
383
+ }
384
+ /**
385
+ * Migration 002 — Sprint 2 : table vault_config pour le chiffrement
386
+ */
387
+ migration002() {
388
+ this.db.exec(`
389
+ -- Table vault_config : configuration cryptographique du vault
390
+ CREATE TABLE IF NOT EXISTS vault_config (
391
+ key TEXT PRIMARY KEY,
392
+ value TEXT NOT NULL,
393
+ updated_at INTEGER NOT NULL
394
+ );
395
+ `);
396
+ }
397
+ /**
398
+ * Migration 003 — Sprint 3 : colonnes cloud sync + settings Pinata
399
+ */
400
+ migration003() {
401
+ this.db.exec(`
402
+ -- Ajouter les colonnes cloud \xE0 file_versions
403
+ ALTER TABLE file_versions ADD COLUMN provider TEXT NOT NULL DEFAULT 'local';
404
+ ALTER TABLE file_versions ADD COLUMN pinata_cid TEXT;
405
+ ALTER TABLE file_versions ADD COLUMN sync_status TEXT NOT NULL DEFAULT 'none';
406
+
407
+ -- Index pour requ\xEAtes de sync
408
+ CREATE INDEX IF NOT EXISTS idx_fv_sync_status ON file_versions(sync_status) WHERE sync_status != 'none';
409
+ CREATE INDEX IF NOT EXISTS idx_fv_provider ON file_versions(provider);
410
+ `);
411
+ console.log("[database] Migration 003 applied \u2014 cloud sync columns added");
412
+ }
413
+ // ─── Cloud Sync (Sprint 3) ───────────────────────────────────
414
+ /**
415
+ * Met à jour le statut de sync d'une version de fichier
416
+ */
417
+ updateSyncStatus(versionId, status, pinataCid) {
418
+ if (pinataCid) {
419
+ this.db.prepare(
420
+ "UPDATE file_versions SET sync_status = ?, provider = ?, pinata_cid = ? WHERE id = ?"
421
+ ).run(status, "pinata", pinataCid, versionId);
422
+ } else {
423
+ this.db.prepare(
424
+ "UPDATE file_versions SET sync_status = ? WHERE id = ?"
425
+ ).run(status, versionId);
426
+ }
427
+ }
428
+ /**
429
+ * Récupère les fichiers en attente de synchronisation
430
+ */
431
+ getPendingSyncFiles() {
432
+ return this.db.prepare(
433
+ "SELECT fv.*, n.name FROM file_versions fv JOIN nodes n ON n.id = fv.node_id WHERE fv.sync_status = ? ORDER BY fv.created_at ASC"
434
+ ).all("pending");
435
+ }
436
+ /**
437
+ * Ajoute une opération à la queue de sync
438
+ */
439
+ addPendingOperation(opType, payload) {
440
+ const now = Math.floor(Date.now() / 1e3);
441
+ const info = this.db.prepare(`
442
+ INSERT INTO pending_operations (op_type, op_payload, status, created_at, updated_at)
443
+ VALUES (?, ?, 'pending', ?, ?)
444
+ `).run(opType, JSON.stringify(payload), now, now);
445
+ return Number(info.lastInsertRowid);
446
+ }
447
+ /**
448
+ * Récupère les opérations en attente
449
+ */
450
+ getPendingOperations() {
451
+ return this.db.prepare(
452
+ "SELECT id, op_type, op_payload, attempts FROM pending_operations WHERE status = 'pending' ORDER BY created_at ASC"
453
+ ).all();
454
+ }
455
+ /**
456
+ * Met à jour le statut d'une opération
457
+ */
458
+ updateOperationStatus(opId, status, error) {
459
+ const now = Math.floor(Date.now() / 1e3);
460
+ this.db.prepare(
461
+ "UPDATE pending_operations SET status = ?, last_error = ?, attempts = attempts + 1, updated_at = ? WHERE id = ?"
462
+ ).run(status, error ?? null, now, opId);
463
+ }
464
+ /**
465
+ * Supprime les opérations terminées
466
+ */
467
+ clearCompletedOperations() {
468
+ this.db.prepare("DELETE FROM pending_operations WHERE status = 'completed'").run();
469
+ }
470
+ /**
471
+ * Récupère les CIDs Pinata d'un node et ses descendants (pour unpin cloud)
472
+ */
473
+ getPinataCidsForNode(nodeId) {
474
+ const rows = this.db.prepare(`
475
+ WITH RECURSIVE descendants(id) AS (
476
+ SELECT id FROM nodes WHERE id = ?
477
+ UNION ALL
478
+ SELECT n.id FROM nodes n JOIN descendants d ON n.parent_id = d.id
479
+ )
480
+ SELECT DISTINCT fv.pinata_cid
481
+ FROM file_versions fv
482
+ JOIN descendants d ON fv.node_id = d.id
483
+ WHERE fv.pinata_cid IS NOT NULL AND fv.sync_status = 'synced'
484
+ `).all(nodeId);
485
+ return rows.map((r) => r.pinata_cid);
486
+ }
487
+ // ─── Vault Config (Sprint 2) ──────────────────────────────────
488
+ getVaultConfig() {
489
+ const rows = this.db.prepare("SELECT key, value FROM vault_config").all();
490
+ if (rows.length === 0) return null;
491
+ const config = {};
492
+ for (const row of rows) {
493
+ config[row.key] = row.value;
494
+ }
495
+ return config;
496
+ }
497
+ setVaultConfig(config) {
498
+ const stmt = this.db.prepare(`
499
+ INSERT INTO vault_config (key, value, updated_at) VALUES (?, ?, unixepoch())
500
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
501
+ `);
502
+ const transaction = this.db.transaction(() => {
503
+ for (const [key, value] of Object.entries(config)) {
504
+ stmt.run(key, value);
505
+ }
506
+ });
507
+ transaction();
508
+ }
509
+ isVaultInitialized() {
510
+ const row = this.db.prepare("SELECT COUNT(*) as count FROM vault_config").get();
511
+ return row.count > 0;
512
+ }
513
+ // ─── Settings ─────────────────────────────────────────────────
514
+ getSetting(key) {
515
+ const row = this.db.prepare("SELECT value FROM settings WHERE key = ?").get(key);
516
+ return row?.value ?? null;
517
+ }
518
+ setSetting(key, value) {
519
+ this.db.prepare(`
520
+ INSERT INTO settings (key, value, updated_at) VALUES (?, ?, unixepoch())
521
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
522
+ `).run(key, value);
523
+ }
524
+ // ─── Nodes (fichiers & dossiers) ──────────────────────────────
525
+ getRootNode() {
526
+ return this.db.prepare("SELECT * FROM nodes WHERE parent_id IS NULL AND deleted_at IS NULL").get();
527
+ }
528
+ getNode(id) {
529
+ return this.db.prepare("SELECT * FROM nodes WHERE id = ?").get(id) ?? null;
530
+ }
531
+ listChildren(parentId) {
532
+ return this.db.prepare(
533
+ "SELECT * FROM nodes WHERE parent_id = ? AND deleted_at IS NULL ORDER BY kind DESC, name ASC"
534
+ ).all(parentId);
535
+ }
536
+ createFolder(parentId, name) {
537
+ const now = Math.floor(Date.now() / 1e3);
538
+ const info = this.db.prepare(
539
+ "INSERT INTO nodes (parent_id, name, kind, created_at, modified_at) VALUES (?, ?, ?, ?, ?)"
540
+ ).run(parentId, name, "folder", now, now);
541
+ return this.getNode(Number(info.lastInsertRowid));
542
+ }
543
+ createFileNode(parentId, name) {
544
+ const now = Math.floor(Date.now() / 1e3);
545
+ const info = this.db.prepare(
546
+ "INSERT INTO nodes (parent_id, name, kind, created_at, modified_at) VALUES (?, ?, ?, ?, ?)"
547
+ ).run(parentId, name, "file", now, now);
548
+ return this.getNode(Number(info.lastInsertRowid));
549
+ }
550
+ rename(nodeId, newName) {
551
+ const node = this.getNode(nodeId);
552
+ const oldName = node?.name;
553
+ const oldPath = node ? this.getNodePath(nodeId) : void 0;
554
+ const now = Math.floor(Date.now() / 1e3);
555
+ this.db.prepare("UPDATE nodes SET name = ?, modified_at = ? WHERE id = ?").run(newName, now, nodeId);
556
+ this.reindexNode(nodeId, oldName, oldPath);
557
+ }
558
+ moveNode(nodeId, newParentId) {
559
+ const now = Math.floor(Date.now() / 1e3);
560
+ this.db.prepare("UPDATE nodes SET parent_id = ?, modified_at = ? WHERE id = ?").run(newParentId, now, nodeId);
561
+ }
562
+ softDelete(nodeId) {
563
+ const now = Math.floor(Date.now() / 1e3);
564
+ const deleteRecursive = this.db.prepare(`
565
+ WITH RECURSIVE descendants(id) AS (
566
+ SELECT id FROM nodes WHERE id = ?
567
+ UNION ALL
568
+ SELECT n.id FROM nodes n JOIN descendants d ON n.parent_id = d.id WHERE n.deleted_at IS NULL
569
+ )
570
+ UPDATE nodes SET deleted_at = ? WHERE id IN (SELECT id FROM descendants)
571
+ `);
572
+ deleteRecursive.run(nodeId, now);
573
+ }
574
+ restore(nodeId) {
575
+ const restoreRecursive = this.db.prepare(`
576
+ WITH RECURSIVE descendants(id) AS (
577
+ SELECT id FROM nodes WHERE id = ?
578
+ UNION ALL
579
+ SELECT n.id FROM nodes n JOIN descendants d ON n.parent_id = d.id WHERE n.deleted_at IS NOT NULL
580
+ )
581
+ UPDATE nodes SET deleted_at = NULL WHERE id IN (SELECT id FROM descendants)
582
+ `);
583
+ restoreRecursive.run(nodeId);
584
+ }
585
+ listTrash() {
586
+ return this.db.prepare(
587
+ "SELECT * FROM nodes WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC"
588
+ ).all();
589
+ }
590
+ // ─── File versions ────────────────────────────────────────────
591
+ addFileVersion(nodeId, cid, sizeBytes, sha256Plain, encParams) {
592
+ const now = Math.floor(Date.now() / 1e3);
593
+ const info = this.db.prepare(`
594
+ INSERT INTO file_versions (node_id, cid, size_bytes, sha256_plain, created_at, enc_algo, enc_key_wrapped, enc_key_nonce, enc_data_nonce)
595
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
596
+ `).run(
597
+ nodeId,
598
+ cid,
599
+ sizeBytes,
600
+ sha256Plain ?? null,
601
+ now,
602
+ encParams?.enc_algo ?? "none",
603
+ encParams?.enc_key_wrapped ?? null,
604
+ encParams?.enc_key_nonce ?? null,
605
+ encParams?.enc_data_nonce ?? null
606
+ );
607
+ const versionId = Number(info.lastInsertRowid);
608
+ this.db.prepare("UPDATE nodes SET current_version_id = ?, modified_at = ? WHERE id = ?").run(versionId, now, nodeId);
609
+ return this.db.prepare("SELECT * FROM file_versions WHERE id = ?").get(versionId);
610
+ }
611
+ getFileVersions(nodeId) {
612
+ return this.db.prepare(
613
+ "SELECT * FROM file_versions WHERE node_id = ? ORDER BY created_at DESC"
614
+ ).all(nodeId);
615
+ }
616
+ getCurrentVersion(nodeId) {
617
+ const node = this.getNode(nodeId);
618
+ if (!node?.current_version_id) return null;
619
+ return this.db.prepare("SELECT * FROM file_versions WHERE id = ?").get(node.current_version_id) ?? null;
620
+ }
621
+ // ─── CID refcount ─────────────────────────────────────────────
622
+ getOrphanCids() {
623
+ return this.db.prepare("SELECT cid FROM cid_refcount WHERE ref_count <= 0").all();
624
+ }
625
+ // ─── Paths ────────────────────────────────────────────────────
626
+ getNodePath(nodeId) {
627
+ const parts = [];
628
+ let current = this.getNode(nodeId);
629
+ while (current) {
630
+ parts.unshift(current.name);
631
+ current = current.parent_id ? this.getNode(current.parent_id) : null;
632
+ }
633
+ return "/" + parts.join("/");
634
+ }
635
+ // ─── GC Audit ─────────────────────────────────────────────────
636
+ logGcOp(opType, target, details) {
637
+ const now = Math.floor(Date.now() / 1e3);
638
+ this.db.prepare("INSERT INTO gc_audit_log (op_type, target, details, executed_at) VALUES (?, ?, ?, ?)").run(opType, target, details, now);
639
+ }
640
+ // ─── FTS ──────────────────────────────────────────────────────
641
+ indexNode(nodeId) {
642
+ const node = this.getNode(nodeId);
643
+ if (!node) return;
644
+ const nodePath = this.getNodePath(nodeId);
645
+ this.db.prepare("INSERT INTO fts_nodes(rowid, name, path) VALUES (?, ?, ?)").run(nodeId, node.name, nodePath);
646
+ }
647
+ reindexNode(nodeId, oldName, oldPath) {
648
+ const node = this.getNode(nodeId);
649
+ if (!node) return;
650
+ const nodePath = this.getNodePath(nodeId);
651
+ if (oldName !== void 0 && oldPath !== void 0) {
652
+ this.db.prepare("INSERT INTO fts_nodes(fts_nodes, rowid, name, path) VALUES ('delete', ?, ?, ?)").run(nodeId, oldName, oldPath);
653
+ }
654
+ this.db.prepare("INSERT INTO fts_nodes(rowid, name, path) VALUES (?, ?, ?)").run(nodeId, node.name, nodePath);
655
+ }
656
+ searchNodes(query) {
657
+ return this.db.prepare(
658
+ "SELECT rowid, name, path FROM fts_nodes WHERE fts_nodes MATCH ? ORDER BY rank"
659
+ ).all(query);
660
+ }
661
+ // ─── Sync Stats (Sprint 3) ─────────────────────────────────────
662
+ getSyncStats() {
663
+ return this.db.prepare(
664
+ "SELECT sync_status, COUNT(*) as count FROM file_versions GROUP BY sync_status"
665
+ ).all();
666
+ }
667
+ // ─── Cleanup ──────────────────────────────────────────────────
668
+ close() {
669
+ this.db.close();
670
+ }
671
+ };
672
+
673
+ // ../daemon/content-store.ts
674
+ import { createHash } from "crypto";
675
+ var KuboContentStore = class {
676
+ apiUrl;
677
+ constructor(apiUrl) {
678
+ this.apiUrl = apiUrl;
679
+ }
680
+ /**
681
+ * Ajoute du contenu dans IPFS
682
+ * Équivalent : ipfs add --pin --cid-version=1 --raw-leaves
683
+ */
684
+ async put(data) {
685
+ const boundary = "----MerkleVaultBoundary" + Date.now();
686
+ const header = `--${boundary}\r
687
+ Content-Disposition: form-data; name="file"; filename="data"\r
688
+ Content-Type: application/octet-stream\r
689
+ \r
690
+ `;
691
+ const footer = `\r
692
+ --${boundary}--\r
693
+ `;
694
+ const body = Buffer.concat([
695
+ Buffer.from(header),
696
+ data,
697
+ Buffer.from(footer)
698
+ ]);
699
+ const res = await fetch(
700
+ `${this.apiUrl}/api/v0/add?cid-version=1&raw-leaves=true&pin=true`,
701
+ {
702
+ method: "POST",
703
+ headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` },
704
+ body
705
+ }
706
+ );
707
+ if (!res.ok) {
708
+ throw new Error(`IPFS add failed: ${res.status} ${await res.text()}`);
709
+ }
710
+ const result = JSON.parse(await res.text());
711
+ return { cid: result.Hash, size: parseInt(result.Size, 10) };
712
+ }
713
+ /**
714
+ * Récupère du contenu depuis IPFS
715
+ * Équivalent : ipfs cat <cid>
716
+ */
717
+ async get(cid) {
718
+ const res = await fetch(`${this.apiUrl}/api/v0/cat?arg=${cid}`, {
719
+ method: "POST"
720
+ });
721
+ if (!res.ok) {
722
+ throw new Error(`IPFS cat failed: ${res.status} ${await res.text()}`);
723
+ }
724
+ return Buffer.from(await res.arrayBuffer());
725
+ }
726
+ /**
727
+ * Pin un CID
728
+ */
729
+ async pin(cid) {
730
+ const res = await fetch(`${this.apiUrl}/api/v0/pin/add?arg=${cid}`, {
731
+ method: "POST"
732
+ });
733
+ if (!res.ok) {
734
+ throw new Error(`IPFS pin failed: ${res.status} ${await res.text()}`);
735
+ }
736
+ }
737
+ /**
738
+ * Unpin un CID
739
+ */
740
+ async unpin(cid) {
741
+ const res = await fetch(`${this.apiUrl}/api/v0/pin/rm?arg=${cid}`, {
742
+ method: "POST"
743
+ });
744
+ if (!res.ok) {
745
+ const text = await res.text();
746
+ if (!text.includes("not pinned")) {
747
+ throw new Error(`IPFS unpin failed: ${res.status} ${text}`);
748
+ }
749
+ }
750
+ }
751
+ /**
752
+ * Déclenche le garbage collector Kubo
753
+ */
754
+ async gc() {
755
+ const res = await fetch(`${this.apiUrl}/api/v0/repo/gc`, {
756
+ method: "POST"
757
+ });
758
+ if (!res.ok) {
759
+ throw new Error(`IPFS gc failed: ${res.status} ${await res.text()}`);
760
+ }
761
+ await res.text();
762
+ }
763
+ /**
764
+ * Vérifie si Kubo répond
765
+ */
766
+ async isOnline() {
767
+ try {
768
+ const controller = new AbortController();
769
+ const timeout = setTimeout(() => controller.abort(), 3e3);
770
+ const res = await fetch(`${this.apiUrl}/api/v0/id`, {
771
+ method: "POST",
772
+ signal: controller.signal
773
+ });
774
+ clearTimeout(timeout);
775
+ return res.ok;
776
+ } catch {
777
+ return false;
778
+ }
779
+ }
780
+ };
781
+ function sha256(data) {
782
+ return createHash("sha256").update(data).digest("hex");
783
+ }
784
+
785
+ // ../daemon/pinata-content-store.ts
786
+ var PinataContentStore = class {
787
+ baseUrl = "https://api.pinata.cloud";
788
+ gatewayUrl;
789
+ headers;
790
+ constructor(config) {
791
+ this.gatewayUrl = config.gateway || "https://gateway.pinata.cloud";
792
+ if (config.jwt) {
793
+ this.headers = {
794
+ "Authorization": `Bearer ${config.jwt}`
795
+ };
796
+ } else {
797
+ this.headers = {
798
+ "pinata_api_key": config.apiKey,
799
+ "pinata_secret_api_key": config.secretApiKey
800
+ };
801
+ }
802
+ }
803
+ /**
804
+ * Upload un fichier chiffré vers Pinata et le pin automatiquement
805
+ * Endpoint : POST /pinning/pinFileToIPFS
806
+ */
807
+ async put(data) {
808
+ const boundary = "----MerkleVaultPinata" + Date.now();
809
+ const metadata = JSON.stringify({
810
+ name: `merklevault-${Date.now()}`,
811
+ keyvalues: { app: "merklevault", encrypted: "true" }
812
+ });
813
+ const parts = [];
814
+ parts.push(Buffer.from(
815
+ `--${boundary}\r
816
+ Content-Disposition: form-data; name="pinataMetadata"\r
817
+ Content-Type: application/json\r
818
+ \r
819
+ ` + metadata + "\r\n"
820
+ ));
821
+ parts.push(Buffer.from(
822
+ `--${boundary}\r
823
+ Content-Disposition: form-data; name="file"; filename="encrypted-blob"\r
824
+ Content-Type: application/octet-stream\r
825
+ \r
826
+ `
827
+ ));
828
+ parts.push(data);
829
+ parts.push(Buffer.from(`\r
830
+ --${boundary}--\r
831
+ `));
832
+ const body = Buffer.concat(parts);
833
+ const res = await fetch(`${this.baseUrl}/pinning/pinFileToIPFS`, {
834
+ method: "POST",
835
+ headers: {
836
+ ...this.headers,
837
+ "Content-Type": `multipart/form-data; boundary=${boundary}`
838
+ },
839
+ body
840
+ });
841
+ if (!res.ok) {
842
+ const errText = await res.text();
843
+ throw new Error(`Pinata upload failed: ${res.status} ${errText}`);
844
+ }
845
+ const result = await res.json();
846
+ return {
847
+ cid: result.IpfsHash,
848
+ size: result.PinSize || data.length
849
+ };
850
+ }
851
+ /**
852
+ * Récupère un fichier depuis le gateway Pinata
853
+ * Endpoint : GET {gateway}/ipfs/{CID}
854
+ */
855
+ async get(cid) {
856
+ const controller = new AbortController();
857
+ const timeout = setTimeout(() => controller.abort(), 6e4);
858
+ try {
859
+ const res = await fetch(`${this.gatewayUrl}/ipfs/${cid}`, {
860
+ method: "GET",
861
+ signal: controller.signal
862
+ });
863
+ if (!res.ok) {
864
+ throw new Error(`Pinata get failed: ${res.status} ${await res.text()}`);
865
+ }
866
+ return Buffer.from(await res.arrayBuffer());
867
+ } finally {
868
+ clearTimeout(timeout);
869
+ }
870
+ }
871
+ /**
872
+ * Pin un CID déjà présent sur le réseau IPFS
873
+ * Endpoint : POST /pinning/pinByHash
874
+ */
875
+ async pin(cid) {
876
+ const res = await fetch(`${this.baseUrl}/pinning/pinByHash`, {
877
+ method: "POST",
878
+ headers: {
879
+ ...this.headers,
880
+ "Content-Type": "application/json"
881
+ },
882
+ body: JSON.stringify({ hashToPin: cid })
883
+ });
884
+ if (!res.ok) {
885
+ const errText = await res.text();
886
+ throw new Error(`Pinata pin failed: ${res.status} ${errText}`);
887
+ }
888
+ }
889
+ /**
890
+ * Supprime le pin d'un CID sur Pinata
891
+ * Endpoint : DELETE /pinning/unpin/{CID}
892
+ */
893
+ async unpin(cid) {
894
+ const res = await fetch(`${this.baseUrl}/pinning/unpin/${cid}`, {
895
+ method: "DELETE",
896
+ headers: this.headers
897
+ });
898
+ if (!res.ok) {
899
+ const errText = await res.text();
900
+ if (res.status !== 404) {
901
+ throw new Error(`Pinata unpin failed: ${res.status} ${errText}`);
902
+ }
903
+ }
904
+ }
905
+ /**
906
+ * Pas de GC côté Pinata — opération no-op
907
+ */
908
+ async gc() {
909
+ }
910
+ /**
911
+ * Vérifie la connectivité et l'authentification avec Pinata
912
+ * Endpoint : GET /data/testAuthentication
913
+ */
914
+ async isOnline() {
915
+ try {
916
+ const controller = new AbortController();
917
+ const timeout = setTimeout(() => controller.abort(), 5e3);
918
+ const res = await fetch(`${this.baseUrl}/data/testAuthentication`, {
919
+ method: "GET",
920
+ headers: this.headers,
921
+ signal: controller.signal
922
+ });
923
+ clearTimeout(timeout);
924
+ return res.ok;
925
+ } catch {
926
+ return false;
927
+ }
928
+ }
929
+ /**
930
+ * Vérifie si un CID est déjà pinné sur Pinata
931
+ * Endpoint : GET /data/pinList?cid={CID}
932
+ */
933
+ async stat(cid) {
934
+ try {
935
+ const res = await fetch(
936
+ `${this.baseUrl}/data/pinList?status=pinned&hashContains=${cid}`,
937
+ { method: "GET", headers: this.headers }
938
+ );
939
+ if (!res.ok) return null;
940
+ const result = await res.json();
941
+ if (result.rows && result.rows.length > 0) {
942
+ return {
943
+ pinned: true,
944
+ size: result.rows[0].size || 0
945
+ };
946
+ }
947
+ return { pinned: false, size: 0 };
948
+ } catch {
949
+ return null;
950
+ }
951
+ }
952
+ /**
953
+ * Met à jour la configuration (clé API, gateway)
954
+ */
955
+ updateConfig(config) {
956
+ if (config.gateway) {
957
+ this.gatewayUrl = config.gateway;
958
+ }
959
+ if (config.jwt) {
960
+ this.headers = { "Authorization": `Bearer ${config.jwt}` };
961
+ } else if (config.apiKey && config.secretApiKey) {
962
+ this.headers = {
963
+ "pinata_api_key": config.apiKey,
964
+ "pinata_secret_api_key": config.secretApiKey
965
+ };
966
+ }
967
+ }
968
+ };
969
+
970
+ // ../daemon/storage-router.ts
971
+ var StorageRouter = class {
972
+ kuboStore;
973
+ pinataStore = null;
974
+ provider = "local";
975
+ constructor(kuboStore) {
976
+ this.kuboStore = kuboStore;
977
+ }
978
+ /**
979
+ * Configure le provider cloud.
980
+ * Peut être appelé à tout moment pour basculer local ↔ pinata.
981
+ */
982
+ setProvider(provider, pinataConfig) {
983
+ this.provider = provider;
984
+ if (provider === "pinata" && pinataConfig) {
985
+ if (this.pinataStore) {
986
+ this.pinataStore.updateConfig(pinataConfig);
987
+ } else {
988
+ this.pinataStore = new PinataContentStore(pinataConfig);
989
+ }
990
+ }
991
+ console.log(`[storage-router] Provider set to: ${provider}`);
992
+ }
993
+ /**
994
+ * Retourne le provider actif
995
+ */
996
+ getProvider() {
997
+ return this.provider;
998
+ }
999
+ /**
1000
+ * Vérifie si le cloud est configuré et actif
1001
+ */
1002
+ isCloudActive() {
1003
+ return this.provider === "pinata" && this.pinataStore !== null;
1004
+ }
1005
+ /**
1006
+ * Retourne le PinataContentStore pour les opérations spécifiques (stat, etc.)
1007
+ */
1008
+ getPinataStore() {
1009
+ return this.pinataStore;
1010
+ }
1011
+ // ─── ContentStore interface ────────────────────────────────────
1012
+ /**
1013
+ * Stocke les données selon le provider actif.
1014
+ *
1015
+ * Mode local : put dans Kubo uniquement
1016
+ * Mode pinata : put dans Pinata (le CID IPFS est identique)
1017
+ */
1018
+ async put(data) {
1019
+ const result = await this.kuboStore.put(data);
1020
+ console.log(`[storage-router] Stored locally: ${result.cid} (cloud=${this.provider})`);
1021
+ return result;
1022
+ }
1023
+ /**
1024
+ * Récupère les données selon le provider actif.
1025
+ *
1026
+ * Mode local : get depuis Kubo
1027
+ * Mode pinata : essaie Pinata d'abord, fallback sur Kubo
1028
+ */
1029
+ async get(cid) {
1030
+ if (this.provider === "pinata" && this.pinataStore) {
1031
+ try {
1032
+ return await this.pinataStore.get(cid);
1033
+ } catch (err) {
1034
+ console.warn(`[storage-router] Pinata get failed, trying Kubo: ${err.message}`);
1035
+ return this.kuboStore.get(cid);
1036
+ }
1037
+ }
1038
+ return this.kuboStore.get(cid);
1039
+ }
1040
+ /**
1041
+ * Pin : selon le provider actif
1042
+ */
1043
+ async pin(cid) {
1044
+ if (this.provider === "pinata" && this.pinataStore) {
1045
+ await this.pinataStore.pin(cid);
1046
+ return;
1047
+ }
1048
+ await this.kuboStore.pin(cid);
1049
+ }
1050
+ /**
1051
+ * Unpin : selon le provider actif
1052
+ */
1053
+ async unpin(cid) {
1054
+ if (this.provider === "pinata" && this.pinataStore) {
1055
+ await this.pinataStore.unpin(cid);
1056
+ return;
1057
+ }
1058
+ await this.kuboStore.unpin(cid);
1059
+ }
1060
+ /**
1061
+ * GC : toujours sur Kubo (Pinata gère son propre stockage)
1062
+ */
1063
+ async gc() {
1064
+ await this.kuboStore.gc();
1065
+ }
1066
+ /**
1067
+ * Vérifie la connectivité du provider actif
1068
+ */
1069
+ async isOnline() {
1070
+ if (this.provider === "pinata" && this.pinataStore) {
1071
+ return this.pinataStore.isOnline();
1072
+ }
1073
+ return this.kuboStore.isOnline();
1074
+ }
1075
+ /**
1076
+ * Vérifie la connectivité de Kubo (toujours utile même en mode cloud)
1077
+ */
1078
+ async isKuboOnline() {
1079
+ return this.kuboStore.isOnline();
1080
+ }
1081
+ /**
1082
+ * Vérifie la connectivité Pinata (même si le provider est local)
1083
+ */
1084
+ async isPinataOnline() {
1085
+ if (!this.pinataStore) return false;
1086
+ return this.pinataStore.isOnline();
1087
+ }
1088
+ };
1089
+
1090
+ // ../daemon/sync-queue-processor.ts
1091
+ var SyncQueueProcessor = class {
1092
+ db;
1093
+ store;
1094
+ kuboStore;
1095
+ intervalId = null;
1096
+ isProcessing = false;
1097
+ isOnline = false;
1098
+ CHECK_INTERVAL_MS = 3e4;
1099
+ // 30 secondes
1100
+ MAX_RETRIES = 5;
1101
+ eventCallback = null;
1102
+ constructor(db, store, kuboStore) {
1103
+ this.db = db;
1104
+ this.store = store;
1105
+ this.kuboStore = kuboStore;
1106
+ }
1107
+ /**
1108
+ * Définit le callback pour les événements de sync
1109
+ */
1110
+ onEvent(callback) {
1111
+ this.eventCallback = callback;
1112
+ }
1113
+ emit(event) {
1114
+ if (this.eventCallback) {
1115
+ this.eventCallback(event);
1116
+ }
1117
+ }
1118
+ /**
1119
+ * Démarre le processeur de sync en arrière-plan
1120
+ */
1121
+ start() {
1122
+ if (this.intervalId) return;
1123
+ console.log("[sync] SyncQueueProcessor started");
1124
+ this.processQueue().catch(
1125
+ (err) => console.error("[sync] Initial check failed:", err.message)
1126
+ );
1127
+ this.intervalId = setInterval(() => {
1128
+ this.processQueue().catch(
1129
+ (err) => console.error("[sync] Periodic check failed:", err.message)
1130
+ );
1131
+ }, this.CHECK_INTERVAL_MS);
1132
+ }
1133
+ /**
1134
+ * Arrête le processeur
1135
+ */
1136
+ stop() {
1137
+ if (this.intervalId) {
1138
+ clearInterval(this.intervalId);
1139
+ this.intervalId = null;
1140
+ }
1141
+ console.log("[sync] SyncQueueProcessor stopped");
1142
+ }
1143
+ /**
1144
+ * Force un traitement immédiat de la queue
1145
+ */
1146
+ async forceProcess() {
1147
+ await this.processQueue();
1148
+ }
1149
+ /**
1150
+ * Retourne le statut courant du processeur
1151
+ */
1152
+ getStatus() {
1153
+ return {
1154
+ running: this.intervalId !== null,
1155
+ online: this.isOnline,
1156
+ processing: this.isProcessing
1157
+ };
1158
+ }
1159
+ /**
1160
+ * Traite la queue de sync
1161
+ */
1162
+ async processQueue() {
1163
+ if (this.isProcessing) return;
1164
+ if (!this.store.isCloudActive()) {
1165
+ console.log("[sync] Skipping \u2014 cloud not active");
1166
+ return;
1167
+ }
1168
+ this.isProcessing = true;
1169
+ try {
1170
+ const online = await this.store.isPinataOnline();
1171
+ if (online !== this.isOnline) {
1172
+ this.isOnline = online;
1173
+ this.emit({
1174
+ type: online ? "sync:online" : "sync:offline"
1175
+ });
1176
+ }
1177
+ if (!online) {
1178
+ this.isProcessing = false;
1179
+ return;
1180
+ }
1181
+ const pendingOps = this.db.getPendingOperations();
1182
+ if (pendingOps.length === 0) {
1183
+ this.isProcessing = false;
1184
+ return;
1185
+ }
1186
+ console.log(`[sync] Processing ${pendingOps.length} pending operations`);
1187
+ this.emit({ type: "sync:start", data: { count: pendingOps.length } });
1188
+ let completed = 0;
1189
+ let errors = 0;
1190
+ for (const op of pendingOps) {
1191
+ if (op.attempts >= this.MAX_RETRIES) {
1192
+ this.db.updateOperationStatus(op.id, "failed", "Max retries exceeded");
1193
+ errors++;
1194
+ continue;
1195
+ }
1196
+ try {
1197
+ if (op.op_type === "pinata_upload") {
1198
+ await this.processPinataUpload(op);
1199
+ completed++;
1200
+ } else {
1201
+ console.warn(`[sync] Unknown operation type: ${op.op_type}`);
1202
+ this.db.updateOperationStatus(op.id, "failed", `Unknown op_type: ${op.op_type}`);
1203
+ errors++;
1204
+ }
1205
+ } catch (err) {
1206
+ console.error(`[sync] Operation ${op.id} failed: ${err.message}`);
1207
+ this.db.updateOperationStatus(op.id, "pending", err.message);
1208
+ errors++;
1209
+ if (err.message.includes("fetch") || err.message.includes("network")) {
1210
+ this.isOnline = false;
1211
+ this.emit({ type: "sync:offline" });
1212
+ break;
1213
+ }
1214
+ }
1215
+ this.emit({
1216
+ type: "sync:progress",
1217
+ data: { completed, errors, total: pendingOps.length }
1218
+ });
1219
+ }
1220
+ this.db.clearCompletedOperations();
1221
+ this.emit({
1222
+ type: "sync:complete",
1223
+ data: { completed, errors }
1224
+ });
1225
+ console.log(`[sync] Queue processed: ${completed} completed, ${errors} errors`);
1226
+ } finally {
1227
+ this.isProcessing = false;
1228
+ }
1229
+ }
1230
+ /**
1231
+ * Traite une opération d'upload vers Pinata
1232
+ */
1233
+ async processPinataUpload(op) {
1234
+ const payload = JSON.parse(op.op_payload);
1235
+ const { versionId, cid } = payload;
1236
+ this.db.updateSyncStatus(versionId, "syncing");
1237
+ const data = await this.kuboStore.get(cid);
1238
+ const pinataStore = this.store.getPinataStore();
1239
+ if (!pinataStore) {
1240
+ throw new Error("Pinata store not available");
1241
+ }
1242
+ const result = await pinataStore.put(data);
1243
+ this.db.updateSyncStatus(versionId, "synced", result.cid);
1244
+ this.db.updateOperationStatus(op.id, "completed");
1245
+ console.log(`[sync] Uploaded to Pinata: version ${versionId} \u2192 ${result.cid}`);
1246
+ }
1247
+ };
1248
+
1249
+ // ../daemon/crypto.ts
1250
+ import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
1251
+ import { randomBytes as nobleRandomBytes } from "@noble/ciphers/utils.js";
1252
+ import { argon2id } from "@noble/hashes/argon2.js";
1253
+ import { sha256 as sha2562 } from "@noble/hashes/sha2.js";
1254
+ import { hkdf } from "@noble/hashes/hkdf.js";
1255
+ import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
1256
+ import * as bip39 from "@scure/bip39";
1257
+ import { wordlist as english } from "@scure/bip39/wordlists/english.js";
1258
+ var ENC_ALGO = "xchacha20-poly1305";
1259
+ var ARGON2_PARAMS = {
1260
+ t: 3,
1261
+ // 3 itérations
1262
+ m: 65536,
1263
+ // 64 MB mémoire
1264
+ p: 4
1265
+ // 4 threads parallèles
1266
+ };
1267
+ var KEY_LENGTH = 32;
1268
+ var NONCE_LENGTH = 24;
1269
+ var SALT_LENGTH = 16;
1270
+ function generateMnemonic2() {
1271
+ return bip39.generateMnemonic(english, 256);
1272
+ }
1273
+ function validateMnemonic2(mnemonic) {
1274
+ return bip39.validateMnemonic(mnemonic, english);
1275
+ }
1276
+ async function mnemonicToSeed2(mnemonic) {
1277
+ return bip39.mnemonicToSeed(mnemonic);
1278
+ }
1279
+ function deriveKeyFromPassword(password, salt) {
1280
+ const passwordBytes = new TextEncoder().encode(password);
1281
+ return argon2id(passwordBytes, salt, {
1282
+ t: ARGON2_PARAMS.t,
1283
+ m: ARGON2_PARAMS.m,
1284
+ p: ARGON2_PARAMS.p,
1285
+ dkLen: KEY_LENGTH
1286
+ });
1287
+ }
1288
+ function deriveRecoveryKey(seed) {
1289
+ const info = new TextEncoder().encode("MerkleVault-Recovery-v1");
1290
+ return hkdf(sha2562, seed, void 0, info, KEY_LENGTH);
1291
+ }
1292
+ function encryptFile(plaintext, masterKey) {
1293
+ const fileKey = nobleRandomBytes(KEY_LENGTH);
1294
+ const dataNonce = nobleRandomBytes(NONCE_LENGTH);
1295
+ const cipher = xchacha20poly1305(fileKey, dataNonce);
1296
+ const ciphertext = cipher.encrypt(new Uint8Array(plaintext));
1297
+ const keyNonce = nobleRandomBytes(NONCE_LENGTH);
1298
+ const keyCipher = xchacha20poly1305(masterKey, keyNonce);
1299
+ const wrappedKey = keyCipher.encrypt(fileKey);
1300
+ return {
1301
+ ciphertext: Buffer.from(ciphertext),
1302
+ wrappedKey: Buffer.from(wrappedKey),
1303
+ keyNonce: Buffer.from(keyNonce),
1304
+ dataNonce: Buffer.from(dataNonce)
1305
+ };
1306
+ }
1307
+ function decryptFile(params) {
1308
+ const { ciphertext, wrappedKey, keyNonce, dataNonce, masterKey } = params;
1309
+ const keyCipher = xchacha20poly1305(masterKey, new Uint8Array(keyNonce));
1310
+ const fileKey = keyCipher.decrypt(new Uint8Array(wrappedKey));
1311
+ const dataCipher = xchacha20poly1305(fileKey, new Uint8Array(dataNonce));
1312
+ const plaintext = dataCipher.decrypt(new Uint8Array(ciphertext));
1313
+ return Buffer.from(plaintext);
1314
+ }
1315
+ async function createVault(password) {
1316
+ const mnemonic = generateMnemonic2();
1317
+ const salt = nobleRandomBytes(SALT_LENGTH);
1318
+ const masterKey = nobleRandomBytes(KEY_LENGTH);
1319
+ const passwordKey = deriveKeyFromPassword(password, salt);
1320
+ const pwKeyNonce = nobleRandomBytes(NONCE_LENGTH);
1321
+ const pwCipher = xchacha20poly1305(passwordKey, pwKeyNonce);
1322
+ const pwEncryptedMasterKey = pwCipher.encrypt(masterKey);
1323
+ const seed = await mnemonicToSeed2(mnemonic);
1324
+ const recoveryKey = deriveRecoveryKey(seed);
1325
+ const masterKeyNonce = nobleRandomBytes(NONCE_LENGTH);
1326
+ const recoveryCipher = xchacha20poly1305(recoveryKey, masterKeyNonce);
1327
+ const encryptedMasterKey = recoveryCipher.encrypt(masterKey);
1328
+ const mnemonicHash = sha2562(new TextEncoder().encode(mnemonic));
1329
+ const masterKeyHash = sha2562(masterKey);
1330
+ const config = {
1331
+ salt: bytesToHex(salt),
1332
+ encryptedMasterKey: bytesToHex(encryptedMasterKey),
1333
+ masterKeyNonce: bytesToHex(masterKeyNonce),
1334
+ pwEncryptedMasterKey: bytesToHex(pwEncryptedMasterKey),
1335
+ pwKeyNonce: bytesToHex(pwKeyNonce),
1336
+ mnemonicHash: bytesToHex(mnemonicHash),
1337
+ masterKeyHash: bytesToHex(masterKeyHash)
1338
+ };
1339
+ return { mnemonic, config, masterKey };
1340
+ }
1341
+ function unlockVault(password, config) {
1342
+ const salt = hexToBytes(config.salt);
1343
+ const passwordKey = deriveKeyFromPassword(password, salt);
1344
+ let masterKey;
1345
+ try {
1346
+ const pwNonce = hexToBytes(config.pwKeyNonce);
1347
+ const pwEncrypted = hexToBytes(config.pwEncryptedMasterKey);
1348
+ const cipher = xchacha20poly1305(passwordKey, pwNonce);
1349
+ masterKey = cipher.decrypt(pwEncrypted);
1350
+ } catch {
1351
+ return null;
1352
+ }
1353
+ const computedHash = bytesToHex(sha2562(masterKey));
1354
+ if (computedHash !== config.masterKeyHash) {
1355
+ return null;
1356
+ }
1357
+ return masterKey;
1358
+ }
1359
+ async function recoverVault(mnemonic, newPassword, oldConfig) {
1360
+ if (!validateMnemonic2(mnemonic)) {
1361
+ return null;
1362
+ }
1363
+ const mnemonicHash = bytesToHex(sha2562(new TextEncoder().encode(mnemonic)));
1364
+ if (mnemonicHash !== oldConfig.mnemonicHash) {
1365
+ return null;
1366
+ }
1367
+ const seed = await mnemonicToSeed2(mnemonic);
1368
+ const recoveryKey = deriveRecoveryKey(seed);
1369
+ let masterKey;
1370
+ try {
1371
+ const nonce = hexToBytes(oldConfig.masterKeyNonce);
1372
+ const encryptedMasterKey = hexToBytes(oldConfig.encryptedMasterKey);
1373
+ const cipher = xchacha20poly1305(recoveryKey, nonce);
1374
+ masterKey = cipher.decrypt(encryptedMasterKey);
1375
+ } catch {
1376
+ return null;
1377
+ }
1378
+ const computedHash = bytesToHex(sha2562(masterKey));
1379
+ if (computedHash !== oldConfig.masterKeyHash) {
1380
+ return null;
1381
+ }
1382
+ const newSalt = nobleRandomBytes(SALT_LENGTH);
1383
+ const newPasswordKey = deriveKeyFromPassword(newPassword, newSalt);
1384
+ const newPwKeyNonce = nobleRandomBytes(NONCE_LENGTH);
1385
+ const pwCipher = xchacha20poly1305(newPasswordKey, newPwKeyNonce);
1386
+ const newPwEncryptedMasterKey = pwCipher.encrypt(masterKey);
1387
+ const newMasterKeyNonce = nobleRandomBytes(NONCE_LENGTH);
1388
+ const newRecoveryCipher = xchacha20poly1305(recoveryKey, newMasterKeyNonce);
1389
+ const newEncryptedMasterKey = newRecoveryCipher.encrypt(masterKey);
1390
+ const config = {
1391
+ salt: bytesToHex(newSalt),
1392
+ encryptedMasterKey: bytesToHex(newEncryptedMasterKey),
1393
+ masterKeyNonce: bytesToHex(newMasterKeyNonce),
1394
+ pwEncryptedMasterKey: bytesToHex(newPwEncryptedMasterKey),
1395
+ pwKeyNonce: bytesToHex(newPwKeyNonce),
1396
+ mnemonicHash: oldConfig.mnemonicHash,
1397
+ // Ne change pas
1398
+ masterKeyHash: oldConfig.masterKeyHash
1399
+ // La master key ne change pas
1400
+ };
1401
+ return { masterKey, config };
1402
+ }
1403
+ async function changePassword(oldPassword, newPassword, oldConfig) {
1404
+ const masterKey = unlockVault(oldPassword, oldConfig);
1405
+ if (!masterKey) return null;
1406
+ const newSalt = nobleRandomBytes(SALT_LENGTH);
1407
+ const newPasswordKey = deriveKeyFromPassword(newPassword, newSalt);
1408
+ const newPwKeyNonce = nobleRandomBytes(NONCE_LENGTH);
1409
+ const pwCipher = xchacha20poly1305(newPasswordKey, newPwKeyNonce);
1410
+ const newPwEncryptedMasterKey = pwCipher.encrypt(masterKey);
1411
+ return {
1412
+ ...oldConfig,
1413
+ salt: bytesToHex(newSalt),
1414
+ pwEncryptedMasterKey: bytesToHex(newPwEncryptedMasterKey),
1415
+ pwKeyNonce: bytesToHex(newPwKeyNonce)
1416
+ // encryptedMasterKey, masterKeyNonce, mnemonicHash, masterKeyHash : inchangés
1417
+ };
1418
+ }
1419
+ function zeroKey(key) {
1420
+ key.fill(0);
1421
+ }
1422
+
1423
+ // src/merklevault.ts
1424
+ function toFileNode(row) {
1425
+ return {
1426
+ id: row.id,
1427
+ parentId: row.parent_id,
1428
+ name: row.name,
1429
+ kind: row.kind,
1430
+ createdAt: row.created_at,
1431
+ modifiedAt: row.modified_at,
1432
+ deletedAt: row.deleted_at,
1433
+ currentVersionId: row.current_version_id
1434
+ };
1435
+ }
1436
+ function toFileVersion(row) {
1437
+ return {
1438
+ id: row.id,
1439
+ nodeId: row.node_id,
1440
+ cid: row.cid,
1441
+ sizeBytes: row.size_bytes,
1442
+ sha256Plain: row.sha256_plain,
1443
+ createdAt: row.created_at,
1444
+ encAlgo: row.enc_algo,
1445
+ provider: row.provider || "local",
1446
+ pinataCid: row.pinata_cid || null,
1447
+ syncStatus: row.sync_status || "none"
1448
+ };
1449
+ }
1450
+ var MerkleVault = class extends EventEmitter2 {
1451
+ opts;
1452
+ kubo;
1453
+ db;
1454
+ store;
1455
+ kuboStore;
1456
+ syncProcessor = null;
1457
+ // Vault state
1458
+ masterKey = null;
1459
+ lockTimer = null;
1460
+ started = false;
1461
+ constructor(options) {
1462
+ super();
1463
+ this.opts = {
1464
+ dataDir: options.dataDir,
1465
+ kuboBinaryPath: options.kuboBinaryPath ?? "",
1466
+ kuboApiPort: options.kuboApiPort ?? 5101,
1467
+ autoLockMs: options.autoLockMs ?? 15 * 60 * 1e3,
1468
+ disableAutoLock: options.disableAutoLock ?? false
1469
+ };
1470
+ }
1471
+ // ═══════════════════════════════════════════════════════════════
1472
+ // LIFECYCLE
1473
+ // ═══════════════════════════════════════════════════════════════
1474
+ /**
1475
+ * Demarre le vault : initialise la DB, lance Kubo, restaure la config cloud.
1476
+ * Doit etre appele avant toute autre operation.
1477
+ */
1478
+ async start() {
1479
+ if (this.started) return;
1480
+ const { dataDir, kuboBinaryPath, kuboApiPort } = this.opts;
1481
+ if (!existsSync3(dataDir)) {
1482
+ mkdirSync3(dataDir, { recursive: true });
1483
+ }
1484
+ this.db = new Database(dataDir);
1485
+ const defaultKuboPath = kuboBinaryPath || path3.resolve(path3.join(dataDir, "..", "kubo", "ipfs"));
1486
+ this.kubo = new KuboManager({
1487
+ kuboBinaryPath: defaultKuboPath,
1488
+ repoPath: path3.join(dataDir, "ipfs-repo"),
1489
+ apiPort: kuboApiPort
1490
+ });
1491
+ this.kubo.on("ready", () => this.emitEvent("kubo:ready"));
1492
+ this.kubo.on("crash", (info) => this.emitEvent("kubo:crash", info));
1493
+ this.kubo.on("log", (msg) => {
1494
+ });
1495
+ await this.kubo.start();
1496
+ this.kuboStore = new KuboContentStore(`http://127.0.0.1:${kuboApiPort}`);
1497
+ this.store = new StorageRouter(this.kuboStore);
1498
+ this.restoreCloudConfig();
1499
+ this.syncProcessor = new SyncQueueProcessor(this.db, this.store, this.kuboStore);
1500
+ this.syncProcessor.onEvent((event) => {
1501
+ this.emitEvent(event.type, event.data);
1502
+ });
1503
+ this.syncProcessor.start();
1504
+ this.started = true;
1505
+ }
1506
+ /**
1507
+ * Arrete le vault proprement : ferme Kubo, la DB, le sync processor.
1508
+ */
1509
+ async stop() {
1510
+ if (!this.started) return;
1511
+ this.lock();
1512
+ if (this.syncProcessor) {
1513
+ this.syncProcessor.stop();
1514
+ this.syncProcessor = null;
1515
+ }
1516
+ await this.kubo.stop();
1517
+ this.db.close();
1518
+ this.started = false;
1519
+ }
1520
+ /**
1521
+ * Verifie que le vault est demarre, sinon throw.
1522
+ */
1523
+ ensureStarted() {
1524
+ if (!this.started) {
1525
+ throw new Error("MerkleVault not started \u2014 call vault.start() first");
1526
+ }
1527
+ }
1528
+ /**
1529
+ * Verifie que le vault est deverrouille, sinon throw.
1530
+ */
1531
+ ensureUnlocked() {
1532
+ this.ensureStarted();
1533
+ if (!this.masterKey) {
1534
+ throw new Error("Vault is locked \u2014 call vault.unlock() first");
1535
+ }
1536
+ }
1537
+ // ═══════════════════════════════════════════════════════════════
1538
+ // VAULT MANAGEMENT
1539
+ // ═══════════════════════════════════════════════════════════════
1540
+ /**
1541
+ * Cree un nouveau vault avec un mot de passe.
1542
+ * Retourne la phrase de recuperation BIP39 (24 mots).
1543
+ *
1544
+ * @param password - Mot de passe maitre (min 8 caracteres)
1545
+ * @returns Phrase de recuperation a afficher UNE SEULE FOIS
1546
+ */
1547
+ async create(password) {
1548
+ this.ensureStarted();
1549
+ if (this.db.isVaultInitialized()) {
1550
+ throw new Error("Vault already initialized");
1551
+ }
1552
+ if (!password || password.length < 8) {
1553
+ throw new Error("Password must be at least 8 characters");
1554
+ }
1555
+ const { mnemonic, config, masterKey } = await createVault(password);
1556
+ this.saveVaultConfig(config);
1557
+ this.masterKey = masterKey;
1558
+ this.resetLockTimer();
1559
+ this.emitEvent("vault:created");
1560
+ this.emitEvent("vault:unlocked");
1561
+ return { mnemonic };
1562
+ }
1563
+ /**
1564
+ * Deverrouille le vault avec le mot de passe.
1565
+ *
1566
+ * @param password - Mot de passe maitre
1567
+ * @returns true si le deverrouillage a reussi
1568
+ * @throws Si le mot de passe est invalide
1569
+ */
1570
+ unlock(password) {
1571
+ this.ensureStarted();
1572
+ const config = this.loadVaultConfig();
1573
+ const masterKey = unlockVault(password, config);
1574
+ if (!masterKey) {
1575
+ throw new Error("Invalid password");
1576
+ }
1577
+ this.masterKey = masterKey;
1578
+ this.resetLockTimer();
1579
+ this.emitEvent("vault:unlocked");
1580
+ return true;
1581
+ }
1582
+ /**
1583
+ * Verrouille le vault — efface la master key de la memoire.
1584
+ */
1585
+ lock() {
1586
+ if (this.masterKey) {
1587
+ zeroKey(this.masterKey);
1588
+ this.masterKey = null;
1589
+ }
1590
+ if (this.lockTimer) {
1591
+ clearTimeout(this.lockTimer);
1592
+ this.lockTimer = null;
1593
+ }
1594
+ this.emitEvent("vault:locked", { reason: "manual" });
1595
+ }
1596
+ /**
1597
+ * Recupere le vault via la phrase BIP39 et definit un nouveau mot de passe.
1598
+ *
1599
+ * @param mnemonic - Phrase de recuperation (24 mots)
1600
+ * @param newPassword - Nouveau mot de passe
1601
+ */
1602
+ async recover(mnemonic, newPassword) {
1603
+ this.ensureStarted();
1604
+ const config = this.loadVaultConfig();
1605
+ const result = await recoverVault(mnemonic, newPassword, config);
1606
+ if (!result) {
1607
+ throw new Error("Recovery failed \u2014 invalid mnemonic");
1608
+ }
1609
+ this.saveVaultConfig(result.config);
1610
+ this.masterKey = result.masterKey;
1611
+ this.resetLockTimer();
1612
+ this.emitEvent("vault:unlocked");
1613
+ return true;
1614
+ }
1615
+ /**
1616
+ * Change le mot de passe du vault sans re-chiffrer les fichiers.
1617
+ *
1618
+ * @param oldPassword - Ancien mot de passe
1619
+ * @param newPassword - Nouveau mot de passe
1620
+ */
1621
+ async changePassword(oldPassword, newPassword) {
1622
+ this.ensureStarted();
1623
+ const config = this.loadVaultConfig();
1624
+ const newConfig = await changePassword(oldPassword, newPassword, config);
1625
+ if (!newConfig) {
1626
+ throw new Error("Invalid current password");
1627
+ }
1628
+ this.saveVaultConfig(newConfig);
1629
+ return true;
1630
+ }
1631
+ /**
1632
+ * Retourne l'etat du vault (initialise, deverrouille).
1633
+ */
1634
+ getVaultInfo() {
1635
+ this.ensureStarted();
1636
+ return {
1637
+ initialized: this.db.isVaultInitialized(),
1638
+ unlocked: this.masterKey !== null
1639
+ };
1640
+ }
1641
+ /**
1642
+ * Verifie si le vault est deverrouille.
1643
+ */
1644
+ isUnlocked() {
1645
+ return this.masterKey !== null;
1646
+ }
1647
+ // ═══════════════════════════════════════════════════════════════
1648
+ // FILE OPERATIONS
1649
+ // ═══════════════════════════════════════════════════════════════
1650
+ /**
1651
+ * Ajoute un fichier au vault depuis un Buffer.
1652
+ *
1653
+ * @param data - Contenu du fichier
1654
+ * @param options - Nom et dossier parent
1655
+ * @returns Noeud cree, version et CID
1656
+ */
1657
+ async addFile(data, options) {
1658
+ this.ensureStarted();
1659
+ const parentId = options.parentId ?? this.db.getRootNode().id;
1660
+ const plaintext = data;
1661
+ const hash = sha256(plaintext);
1662
+ let dataToStore;
1663
+ let encParams;
1664
+ if (this.masterKey) {
1665
+ const encrypted = encryptFile(plaintext, this.masterKey);
1666
+ dataToStore = encrypted.ciphertext;
1667
+ encParams = {
1668
+ enc_algo: ENC_ALGO,
1669
+ enc_key_wrapped: encrypted.wrappedKey,
1670
+ enc_key_nonce: encrypted.keyNonce,
1671
+ enc_data_nonce: encrypted.dataNonce
1672
+ };
1673
+ } else {
1674
+ dataToStore = plaintext;
1675
+ }
1676
+ const { cid, size } = await this.store.put(dataToStore);
1677
+ const nodeRow = this.db.createFileNode(parentId, options.name);
1678
+ const versionRow = this.db.addFileVersion(nodeRow.id, cid, size, hash, encParams);
1679
+ this.db.indexNode(nodeRow.id);
1680
+ if (this.store.isCloudActive()) {
1681
+ this.db.updateSyncStatus(versionRow.id, "pending");
1682
+ this.db.addPendingOperation("pinata_upload", { versionId: versionRow.id, cid });
1683
+ }
1684
+ this.resetLockTimer();
1685
+ this.emitEvent("file:added", { nodeId: nodeRow.id, name: options.name, cid });
1686
+ return {
1687
+ node: toFileNode(nodeRow),
1688
+ version: toFileVersion(versionRow),
1689
+ cid
1690
+ };
1691
+ }
1692
+ /**
1693
+ * Ajoute un fichier depuis un chemin sur le disque.
1694
+ *
1695
+ * @param filePath - Chemin absolu du fichier
1696
+ * @param options - Nom (optionnel, deduit du path) et dossier parent
1697
+ */
1698
+ async addFileFromPath(filePath, options) {
1699
+ const data = readFileSync(filePath);
1700
+ const name = options?.name ?? path3.basename(filePath);
1701
+ return this.addFile(data, { name, parentId: options?.parentId });
1702
+ }
1703
+ /**
1704
+ * Recupere le contenu dechiffre d'un fichier.
1705
+ *
1706
+ * @param nodeId - ID du noeud fichier
1707
+ * @returns Contenu dechiffre + metadonnees
1708
+ */
1709
+ async getFile(nodeId) {
1710
+ this.ensureStarted();
1711
+ const version = this.db.getCurrentVersion(nodeId);
1712
+ if (!version) throw new Error("No version found for this file");
1713
+ const nodeRow = this.db.getNode(nodeId);
1714
+ if (!nodeRow) throw new Error(`Node ${nodeId} not found`);
1715
+ const rawData = await this.store.get(version.cid);
1716
+ let data;
1717
+ if (version.enc_algo !== "none" && version.enc_algo !== null) {
1718
+ if (!this.masterKey) {
1719
+ throw new Error("Vault is locked \u2014 unlock required to access encrypted files");
1720
+ }
1721
+ if (!version.enc_key_wrapped || !version.enc_key_nonce || !version.enc_data_nonce) {
1722
+ throw new Error("Missing encryption metadata for this file version");
1723
+ }
1724
+ data = decryptFile({
1725
+ ciphertext: rawData,
1726
+ wrappedKey: version.enc_key_wrapped,
1727
+ keyNonce: version.enc_key_nonce,
1728
+ dataNonce: version.enc_data_nonce,
1729
+ masterKey: this.masterKey
1730
+ });
1731
+ } else {
1732
+ data = rawData;
1733
+ }
1734
+ this.resetLockTimer();
1735
+ return {
1736
+ node: toFileNode(nodeRow),
1737
+ data,
1738
+ cid: version.cid,
1739
+ size: data.length
1740
+ };
1741
+ }
1742
+ /**
1743
+ * Liste le contenu d'un dossier.
1744
+ *
1745
+ * @param parentId - ID du dossier (undefined = racine)
1746
+ */
1747
+ listFolder(parentId) {
1748
+ this.ensureStarted();
1749
+ const nodeRow = parentId ? this.db.getNode(parentId) : this.db.getRootNode();
1750
+ const children = this.db.listChildren(nodeRow.id);
1751
+ return {
1752
+ node: toFileNode(nodeRow),
1753
+ children: children.map(toFileNode)
1754
+ };
1755
+ }
1756
+ /**
1757
+ * Recupere un noeud par son ID.
1758
+ */
1759
+ getNode(nodeId) {
1760
+ this.ensureStarted();
1761
+ const row = this.db.getNode(nodeId);
1762
+ return row ? toFileNode(row) : null;
1763
+ }
1764
+ /**
1765
+ * Recupere le chemin complet d'un noeud (fil d'ariane).
1766
+ */
1767
+ getNodePath(nodeId) {
1768
+ this.ensureStarted();
1769
+ const rows = this.db.getNodePath(nodeId);
1770
+ return rows.map(toFileNode);
1771
+ }
1772
+ /**
1773
+ * Recupere le noeud racine.
1774
+ */
1775
+ getRootNode() {
1776
+ this.ensureStarted();
1777
+ return toFileNode(this.db.getRootNode());
1778
+ }
1779
+ /**
1780
+ * Cree un nouveau dossier.
1781
+ *
1782
+ * @param name - Nom du dossier
1783
+ * @param parentId - ID du dossier parent (defaut: racine)
1784
+ */
1785
+ createFolder(name, parentId) {
1786
+ this.ensureStarted();
1787
+ const pid = parentId ?? this.db.getRootNode().id;
1788
+ const folder = this.db.createFolder(pid, name);
1789
+ this.db.indexNode(folder.id);
1790
+ this.emitEvent("folder:created", { nodeId: folder.id, name });
1791
+ return toFileNode(folder);
1792
+ }
1793
+ /**
1794
+ * Renomme un noeud (fichier ou dossier).
1795
+ */
1796
+ rename(nodeId, newName) {
1797
+ this.ensureStarted();
1798
+ this.db.rename(nodeId, newName);
1799
+ this.emitEvent("file:renamed", { nodeId, newName });
1800
+ }
1801
+ /**
1802
+ * Deplace un noeud vers un autre dossier.
1803
+ */
1804
+ move(nodeId, newParentId) {
1805
+ this.ensureStarted();
1806
+ this.db.moveNode(nodeId, newParentId);
1807
+ this.emitEvent("file:moved", { nodeId, newParentId });
1808
+ }
1809
+ /**
1810
+ * Supprime un noeud (soft delete → corbeille).
1811
+ * Unpin de Pinata si le fichier y est synchronise.
1812
+ */
1813
+ async delete(nodeId) {
1814
+ this.ensureStarted();
1815
+ const pinataCids = this.db.getPinataCidsForNode(nodeId);
1816
+ this.db.softDelete(nodeId);
1817
+ if (pinataCids.length > 0 && this.store.isCloudActive()) {
1818
+ const pinataStore = this.store.getPinataStore();
1819
+ if (pinataStore) {
1820
+ for (const cid of pinataCids) {
1821
+ try {
1822
+ await pinataStore.unpin(cid);
1823
+ } catch (err) {
1824
+ }
1825
+ }
1826
+ }
1827
+ }
1828
+ this.emitEvent("file:deleted", { nodeId });
1829
+ }
1830
+ /**
1831
+ * Restaure un noeud depuis la corbeille.
1832
+ */
1833
+ restore(nodeId) {
1834
+ this.ensureStarted();
1835
+ this.db.restore(nodeId);
1836
+ }
1837
+ /**
1838
+ * Liste les elements dans la corbeille.
1839
+ */
1840
+ listTrash() {
1841
+ this.ensureStarted();
1842
+ return this.db.listTrash().map(toFileNode);
1843
+ }
1844
+ /**
1845
+ * Recupere l'historique des versions d'un fichier.
1846
+ */
1847
+ getVersions(nodeId) {
1848
+ this.ensureStarted();
1849
+ return this.db.getFileVersions(nodeId).map(toFileVersion);
1850
+ }
1851
+ /**
1852
+ * Recherche dans le vault (FTS5).
1853
+ */
1854
+ search(query) {
1855
+ this.ensureStarted();
1856
+ const ftsResults = this.db.searchNodes(query);
1857
+ return ftsResults.map((r) => this.db.getNode(r.rowid)).filter((row) => row !== null).map(toFileNode);
1858
+ }
1859
+ // ═══════════════════════════════════════════════════════════════
1860
+ // CLOUD SYNC
1861
+ // ═══════════════════════════════════════════════════════════════
1862
+ /**
1863
+ * Configure les credentials Pinata et teste la connexion.
1864
+ *
1865
+ * @param credentials - JWT ou apiKey + secretApiKey
1866
+ * @returns true si la connexion a reussi
1867
+ */
1868
+ async configureCloud(credentials) {
1869
+ this.ensureStarted();
1870
+ const config = {
1871
+ apiKey: credentials.apiKey || "",
1872
+ secretApiKey: credentials.secretApiKey || "",
1873
+ jwt: credentials.jwt || void 0,
1874
+ gateway: credentials.gateway || void 0
1875
+ };
1876
+ this.store.setProvider("pinata", config);
1877
+ const online = await this.store.isPinataOnline();
1878
+ if (online) {
1879
+ if (credentials.jwt) this.db.setSetting("pinata_jwt", credentials.jwt);
1880
+ if (credentials.apiKey) this.db.setSetting("pinata_api_key", credentials.apiKey);
1881
+ if (credentials.secretApiKey) this.db.setSetting("pinata_secret_key", credentials.secretApiKey);
1882
+ if (credentials.gateway) this.db.setSetting("pinata_gateway", credentials.gateway);
1883
+ this.db.setSetting("cloud.provider", "pinata");
1884
+ this.emitEvent("cloud:configured", { provider: "pinata" });
1885
+ } else {
1886
+ this.store.setProvider("local");
1887
+ }
1888
+ return online;
1889
+ }
1890
+ /**
1891
+ * Active ou desactive la synchronisation cloud.
1892
+ */
1893
+ toggleCloud(enabled) {
1894
+ this.ensureStarted();
1895
+ if (enabled) {
1896
+ const jwt = this.db.getSetting("pinata_jwt");
1897
+ const apiKey = this.db.getSetting("pinata_api_key");
1898
+ const secretKey = this.db.getSetting("pinata_secret_key");
1899
+ if (!jwt && !(apiKey && secretKey)) {
1900
+ throw new Error("Pinata credentials not configured \u2014 use configureCloud() first");
1901
+ }
1902
+ this.store.setProvider("pinata", {
1903
+ apiKey: apiKey || "",
1904
+ secretApiKey: secretKey || "",
1905
+ jwt: jwt || void 0,
1906
+ gateway: this.db.getSetting("pinata_gateway") || void 0
1907
+ });
1908
+ this.db.setSetting("cloud.provider", "pinata");
1909
+ } else {
1910
+ this.store.setProvider("local");
1911
+ this.db.setSetting("cloud.provider", "local");
1912
+ }
1913
+ const provider = this.store.getProvider();
1914
+ this.emitEvent("cloud:toggled", { enabled, provider });
1915
+ return provider;
1916
+ }
1917
+ /**
1918
+ * Retourne la configuration cloud actuelle.
1919
+ */
1920
+ getCloudConfig() {
1921
+ this.ensureStarted();
1922
+ return {
1923
+ provider: this.store.getProvider(),
1924
+ active: this.store.isCloudActive(),
1925
+ hasCredentials: !!(this.db.getSetting("pinata_jwt") || this.db.getSetting("pinata_api_key")),
1926
+ gateway: this.db.getSetting("pinata_gateway")
1927
+ };
1928
+ }
1929
+ /**
1930
+ * Retourne le statut de synchronisation.
1931
+ */
1932
+ getSyncStatus() {
1933
+ this.ensureStarted();
1934
+ const stats = this.db.getSyncStats();
1935
+ const counts = {};
1936
+ let total = 0;
1937
+ for (const s of stats) {
1938
+ counts[s.sync_status] = s.count;
1939
+ total += s.count;
1940
+ }
1941
+ return {
1942
+ total,
1943
+ synced: counts["synced"] || 0,
1944
+ pending: (counts["pending"] || 0) + (counts["syncing"] || 0),
1945
+ errors: counts["error"] || 0,
1946
+ provider: this.store.getProvider()
1947
+ };
1948
+ }
1949
+ /**
1950
+ * Retourne le statut global du vault.
1951
+ */
1952
+ async getStatus() {
1953
+ this.ensureStarted();
1954
+ return {
1955
+ kuboReady: this.kubo.ready,
1956
+ kuboPid: this.kubo.pid,
1957
+ rootNode: toFileNode(this.db.getRootNode()),
1958
+ ipfsOnline: await this.store.isKuboOnline(),
1959
+ vaultInitialized: this.db.isVaultInitialized(),
1960
+ vaultUnlocked: this.masterKey !== null,
1961
+ cloudProvider: this.store.getProvider(),
1962
+ cloudActive: this.store.isCloudActive()
1963
+ };
1964
+ }
1965
+ // ═══════════════════════════════════════════════════════════════
1966
+ // INTERNALS
1967
+ // ═══════════════════════════════════════════════════════════════
1968
+ loadVaultConfig() {
1969
+ const raw = this.db.getVaultConfig();
1970
+ if (!raw) throw new Error("Vault not initialized");
1971
+ return {
1972
+ salt: raw.salt,
1973
+ encryptedMasterKey: raw.encryptedMasterKey,
1974
+ masterKeyNonce: raw.masterKeyNonce,
1975
+ pwEncryptedMasterKey: raw.pwEncryptedMasterKey,
1976
+ pwKeyNonce: raw.pwKeyNonce,
1977
+ mnemonicHash: raw.mnemonicHash,
1978
+ masterKeyHash: raw.masterKeyHash
1979
+ };
1980
+ }
1981
+ saveVaultConfig(config) {
1982
+ this.db.setVaultConfig({
1983
+ salt: config.salt,
1984
+ encryptedMasterKey: config.encryptedMasterKey,
1985
+ masterKeyNonce: config.masterKeyNonce,
1986
+ pwEncryptedMasterKey: config.pwEncryptedMasterKey,
1987
+ pwKeyNonce: config.pwKeyNonce,
1988
+ mnemonicHash: config.mnemonicHash,
1989
+ masterKeyHash: config.masterKeyHash
1990
+ });
1991
+ }
1992
+ restoreCloudConfig() {
1993
+ const provider = this.db.getSetting("cloud.provider");
1994
+ if (provider === "pinata") {
1995
+ const apiKey = this.db.getSetting("pinata_api_key");
1996
+ const secretKey = this.db.getSetting("pinata_secret_key");
1997
+ const gateway = this.db.getSetting("pinata_gateway");
1998
+ const jwt = this.db.getSetting("pinata_jwt");
1999
+ if (jwt || apiKey && secretKey) {
2000
+ this.store.setProvider("pinata", {
2001
+ apiKey: apiKey || "",
2002
+ secretApiKey: secretKey || "",
2003
+ gateway: gateway || void 0,
2004
+ jwt: jwt || void 0
2005
+ });
2006
+ }
2007
+ }
2008
+ }
2009
+ resetLockTimer() {
2010
+ if (this.opts.disableAutoLock) return;
2011
+ if (this.lockTimer) {
2012
+ clearTimeout(this.lockTimer);
2013
+ }
2014
+ this.lockTimer = setTimeout(() => {
2015
+ this.lock();
2016
+ this.emitEvent("vault:locked", { reason: "inactivity" });
2017
+ }, this.opts.autoLockMs);
2018
+ }
2019
+ emitEvent(type, data) {
2020
+ const event = { type, data, timestamp: Date.now() };
2021
+ this.emit(type, event);
2022
+ this.emit("*", event);
2023
+ }
2024
+ };
2025
+ export {
2026
+ MerkleVault
2027
+ };
2028
+ //# sourceMappingURL=index.js.map