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