nodio-cli 1.0.12 → 1.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/.env.example CHANGED
@@ -4,3 +4,8 @@ NODIO_HEARTBEAT_INTERVAL_MS=30000
4
4
  NODIO_OFFLINE_AFTER_MISSES=3
5
5
  NODIO_MIN_REPLICAS=5
6
6
  NODIO_EMERGENCY_REPLICA_FLOOR=2
7
+ NODIO_RELAY_POLL_INTERVAL_MS=1000
8
+ NODIO_WALLET_PRIVATE_KEY=
9
+ NODIO_WALLET_ADDRESS=
10
+ FILECOIN_NETWORK=calibration
11
+ FILECOIN_RPC_URL=https://api.calibration.node.glif.io/rpc/v1
package/README.md CHANGED
@@ -122,6 +122,5 @@ npm run start:cli -- download \
122
122
  - `POST /api/shards/placement-plan`
123
123
  - `GET /api/files/:fileId/manifest`
124
124
 
125
- ## Next Step
126
-
127
- Add authentication between user CLI, central server, and donor nodes for production security.
125
+ ## Next Step( )
126
+ Integrating with nodio-drive.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodio-cli",
3
- "version": "1.0.12",
3
+ "version": "1.1.0",
4
4
  "description": "Nodio distributed storage network",
5
5
  "main": "src/server/index.js",
6
6
  "type": "commonjs",
@@ -24,11 +24,14 @@
24
24
  "author": "",
25
25
  "license": "MIT",
26
26
  "dependencies": {
27
+ "@filoz/synapse-sdk": "^0.41.0",
27
28
  "axios": "^1.11.0",
28
29
  "commander": "^14.0.1",
29
- "dotenv": "^17.2.2",
30
+ "cors": "^2.8.5",
31
+ "dotenv": "^17.4.2",
30
32
  "express": "^5.1.0",
31
33
  "mongoose": "^8.18.0",
32
- "uuid": "^13.0.0"
34
+ "uuid": "^13.0.0",
35
+ "viem": "^2.48.11"
33
36
  }
34
37
  }
@@ -0,0 +1,58 @@
1
+ (async () => {
2
+ try {
3
+ require('dotenv').config();
4
+ const axios = require('axios');
5
+ const { privateKeyToAccount } = require('viem/accounts');
6
+ const { http } = require('viem');
7
+ const { calibration } = require('@filoz/synapse-core/chains');
8
+
9
+ const synapseSdk = await import('@filoz/synapse-sdk');
10
+ const Synapse = synapseSdk?.Synapse || synapseSdk?.default?.Synapse || synapseSdk?.default;
11
+ if (!Synapse || typeof Synapse.create !== 'function') {
12
+ throw new Error('Synapse.create not available');
13
+ }
14
+
15
+ const server = process.argv[2] || 'http://127.0.0.1:4000';
16
+ const fileId = process.argv[3];
17
+ if (!fileId) throw new Error('Usage: node backupDirect.js <serverUrl> <fileId>');
18
+
19
+ const privateKey = process.env.NODIO_WALLET_PRIVATE_KEY;
20
+ if (!privateKey) throw new Error('NODIO_WALLET_PRIVATE_KEY not set');
21
+
22
+ const rpcUrl = process.env.FILECOIN_RPC_URL || 'https://api.calibration.node.glif.io/rpc/v1';
23
+ const account = privateKeyToAccount(privateKey);
24
+
25
+ console.log('Init Synapse...');
26
+ const synapse = await Synapse.create({ chain: calibration, transport: http(rpcUrl), account, source: null, withCDN: false });
27
+ if (!synapse.storage || typeof synapse.storage.upload !== 'function') throw new Error('storage.upload not available');
28
+
29
+ console.log('Fetching manifest...');
30
+ const manifestResp = await axios.get(`${server}/api/files/${fileId}/manifest`);
31
+ const manifest = manifestResp.data;
32
+ const shards = manifest.shards || [];
33
+
34
+ const encryptedShardBuffers = [];
35
+ for (const shard of shards) {
36
+ const replica = (shard.replicas || [])[0];
37
+ if (!replica || !replica.url) throw new Error('no replica url');
38
+ const url = `${replica.url.replace(/\/+$/, '')}/shards/${shard.shardId}`;
39
+ const resp = await axios.get(url, { responseType: 'arraybuffer' });
40
+ encryptedShardBuffers.push(Buffer.from(resp.data));
41
+ }
42
+
43
+ const payload = Buffer.concat(encryptedShardBuffers);
44
+ console.log('Calling synapse.storage.upload (this may take a while)...');
45
+ try {
46
+ const result = await synapse.storage.upload(payload, { copies: 2 });
47
+ console.log('upload result:', result);
48
+ } catch (err) {
49
+ console.error('synapse.storage.upload error:', err);
50
+ if (err && err.cause) console.error('cause:', err.cause);
51
+ }
52
+
53
+ process.exit(0);
54
+ } catch (error) {
55
+ console.error(error && error.stack ? error.stack : error);
56
+ process.exit(1);
57
+ }
58
+ })();
@@ -0,0 +1,72 @@
1
+ (async () => {
2
+ try {
3
+ require('dotenv').config();
4
+ const axios = require('axios');
5
+ const path = require('path');
6
+ const fs = require('fs');
7
+ const { uploadToFilecoin } = require('../services/filecoin');
8
+
9
+ const server = process.argv[2] || process.env.NODIO_SERVER_URL || 'http://127.0.0.1:4000';
10
+ const fileId = process.argv[3];
11
+ if (!fileId) {
12
+ throw new Error('Usage: node backupFromLayer1.js <serverUrl> <fileId>');
13
+ }
14
+
15
+ console.log('Fetching manifest for', fileId);
16
+ const manifestResp = await axios.get(`${server}/api/files/${fileId}/manifest`, { timeout: 20000 });
17
+ const manifest = manifestResp.data;
18
+ const shards = manifest.shards || [];
19
+
20
+ const encryptedShardBuffers = [];
21
+
22
+ for (const shard of shards) {
23
+ const replicas = shard.replicas || [];
24
+ let fetched = null;
25
+ for (const replica of replicas) {
26
+ if (!replica.url) continue;
27
+ try {
28
+ const url = `${replica.url.replace(/\/+$/, '')}/shards/${shard.shardId}`;
29
+ const resp = await axios.get(url, { responseType: 'arraybuffer', timeout: 20000 });
30
+ const buf = Buffer.from(resp.data);
31
+ // simple checksum check
32
+ const crypto = require('crypto');
33
+ const sha = crypto.createHash('sha256').update(buf).digest('hex');
34
+ if (sha === shard.checksum) {
35
+ fetched = buf;
36
+ break;
37
+ }
38
+ } catch (err) {
39
+ console.warn('replica fetch failed', replica.nodeId, replica.url, err.message);
40
+ }
41
+ }
42
+
43
+ if (!fetched) {
44
+ throw new Error(`failed to fetch shard ${shard.shardId} from any replica`);
45
+ }
46
+
47
+ encryptedShardBuffers.push(fetched);
48
+ }
49
+
50
+ const encryptedFileBuffer = Buffer.concat(encryptedShardBuffers);
51
+ console.log('Uploading to Filecoin (this may take a while)...');
52
+ const cid = await uploadToFilecoin(encryptedFileBuffer, fileId);
53
+ console.log('uploadToFilecoin returned cid=', cid);
54
+ if (!cid) {
55
+ throw new Error('uploadToFilecoin failed or returned null');
56
+ }
57
+
58
+ try {
59
+ await axios.post(`${server}/api/files/${fileId}/filecoin`, { filecoinCid: cid, filecoinBackedUp: true });
60
+ console.log('Persisted CID to server');
61
+ } catch (err) {
62
+ console.warn('Failed to persist CID to server:', err.message || err);
63
+ }
64
+
65
+ console.log('Done');
66
+ process.exit(0);
67
+ } catch (error) {
68
+ console.error('error:', error.message || error);
69
+ if (error.stack) console.error(error.stack);
70
+ process.exit(1);
71
+ }
72
+ })();
@@ -0,0 +1,130 @@
1
+ (async () => {
2
+ try {
3
+ require('dotenv').config();
4
+ const axios = require('axios');
5
+ const fs = require('fs/promises');
6
+ const path = require('path');
7
+ const { sha256Hex, decryptAes256Gcm } = require('../src/common/crypto');
8
+
9
+ const server = process.argv[2] || 'http://127.0.0.1:4000';
10
+ const fileId = process.argv[3];
11
+ const keyBase64 = process.argv[4];
12
+
13
+ if (!fileId) {
14
+ console.error('Usage: node scripts/decodeAndReconstruct.js <serverUrl> <fileId> [keyBase64]');
15
+ process.exit(2);
16
+ }
17
+
18
+ console.log('Fetching manifest...');
19
+ const resp = await axios.get(`${server}/api/files/${fileId}/manifest`);
20
+ const manifest = resp.data || {};
21
+ const orderedShards = [...(manifest.shards || [])].sort((a, b) => a.order - b.order);
22
+
23
+ const rawPath = path.resolve(`./${fileId}.filecoin.raw`);
24
+ console.log('Reading raw payload from', rawPath);
25
+ const raw = await fs.readFile(rawPath);
26
+
27
+ let decoded = raw;
28
+ if (raw.length >= 8) {
29
+ const header = raw.subarray(0, 8);
30
+ const originalLength = Number(header.readBigUInt64BE(0));
31
+ const start = 8;
32
+ const end = start + originalLength;
33
+ if (originalLength >= 0 && end <= raw.length) {
34
+ decoded = raw.subarray(start, end);
35
+ console.log('Found 8-byte header; extracted original payload length', originalLength);
36
+ } else {
37
+ console.log('8-byte header present but length invalid; using full buffer');
38
+ }
39
+ } else {
40
+ console.log('No 8-byte header found; using full buffer');
41
+ }
42
+
43
+ const decodedPath = path.resolve(`./${fileId}.filecoin.decoded.raw`);
44
+ await fs.writeFile(decodedPath, decoded);
45
+ console.log('Wrote decoded payload to', decodedPath);
46
+
47
+ if (!orderedShards || orderedShards.length === 0) {
48
+ console.warn('No shard metadata in manifest; stopping after decode');
49
+ process.exit(0);
50
+ }
51
+
52
+ const sizes = orderedShards.map((s) => Number(s.sizeBytes || s.size || 0));
53
+ const totalSizes = sizes.reduce((a, b) => a + b, 0);
54
+ if (totalSizes > decoded.length) {
55
+ console.warn('Sum of shard sizes > decoded payload length; attempting best-effort split');
56
+ }
57
+
58
+ // split by sizes
59
+ const shards = [];
60
+ let offset = 0;
61
+ for (let i = 0; i < sizes.length; i++) {
62
+ const size = sizes[i] || 0;
63
+ const end = Math.min(offset + size, decoded.length);
64
+ const slice = decoded.subarray(offset, end);
65
+ shards.push(slice);
66
+ offset = end;
67
+ }
68
+ if (offset < decoded.length) {
69
+ console.warn('Extra bytes present after shard split; saved as remainder');
70
+ await fs.writeFile(path.resolve(`./${fileId}.filecoin.remainder.raw`), decoded.subarray(offset));
71
+ }
72
+
73
+ for (let i = 0; i < shards.length; i++) {
74
+ const shard = shards[i];
75
+ const shardPath = path.resolve(`./${fileId}.shard.${i}.enc`);
76
+ await fs.writeFile(shardPath, shard);
77
+ const expectedChecksum = orderedShards[i]?.checksum;
78
+ if (expectedChecksum) {
79
+ const actual = sha256Hex(shard);
80
+ console.log(`shard ${i}: wrote ${shardPath} size=${shard.length} checksum match=${actual === expectedChecksum}`);
81
+ } else {
82
+ console.log(`shard ${i}: wrote ${shardPath} size=${shard.length}`);
83
+ }
84
+ }
85
+
86
+ if (!keyBase64) {
87
+ console.log('No key provided; stopping after saving encrypted shards. To decrypt, re-run with keyBase64 as third arg.');
88
+ process.exit(0);
89
+ }
90
+
91
+ const keyBuffer = Buffer.from(keyBase64, 'base64');
92
+ if (keyBuffer.length !== 32) {
93
+ throw new Error('Provided keyBase64 does not decode to 32 bytes');
94
+ }
95
+
96
+ const decryptedParts = [];
97
+
98
+ const encryptionShards = (manifest.file && manifest.file.metadata && manifest.file.metadata.encryption && manifest.file.metadata.encryption.shards) || [];
99
+ const shardMetaMap = new Map((encryptionShards || []).map((s) => [s.shardId, s]));
100
+
101
+ for (let i = 0; i < shards.length; i++) {
102
+ const topShard = orderedShards[i];
103
+ if (!topShard) throw new Error(`missing top-level shard metadata for index ${i}`);
104
+ const shardMeta = shardMetaMap.get(topShard.shardId);
105
+ if (!shardMeta) throw new Error(`missing encryption metadata for shard ${topShard.shardId}`);
106
+ const iv = shardMeta.iv;
107
+ const authTag = shardMeta.authTag;
108
+ const encryptedBuffer = shards[i];
109
+ try {
110
+ const plain = decryptAes256Gcm(encryptedBuffer, keyBuffer, iv, authTag);
111
+ decryptedParts.push(plain);
112
+ console.log(`shard ${i}: decrypted ok size=${plain.length}`);
113
+ } catch (err) {
114
+ console.error(`shard ${i}: decryption failed: ${err.message}`);
115
+ throw err;
116
+ }
117
+ }
118
+
119
+ const reconstructed = Buffer.concat(decryptedParts);
120
+ const originalName = manifest.file?.originalName || `${fileId}.reconstructed`;
121
+ const outPath = path.resolve(`./${originalName}`);
122
+ await fs.writeFile(outPath, reconstructed);
123
+ console.log('Wrote reconstructed file to', outPath, 'size=', reconstructed.length);
124
+
125
+ process.exit(0);
126
+ } catch (err) {
127
+ console.error(err && err.stack ? err.stack : err);
128
+ process.exit(1);
129
+ }
130
+ })();
@@ -0,0 +1,37 @@
1
+ (async () => {
2
+ try {
3
+ require('dotenv').config();
4
+ const axios = require('axios');
5
+ const fs = require('fs/promises');
6
+ const path = require('path');
7
+
8
+ const server = process.argv[2] || 'http://127.0.0.1:4000';
9
+ const fileId = process.argv[3];
10
+ if (!fileId) {
11
+ console.error('Usage: node scripts/downloadFromFilecoin.js <serverUrl> <fileId>');
12
+ process.exit(2);
13
+ }
14
+
15
+ const { retrieveFromFilecoin } = require('../services/filecoin');
16
+
17
+ console.log('Fetching manifest...');
18
+ const resp = await axios.get(`${server}/api/files/${fileId}/manifest`);
19
+ const manifest = resp.data || {};
20
+ const cid = manifest.file && manifest.file.filecoinCid;
21
+ if (!cid) {
22
+ throw new Error('filecoinCid not present on manifest');
23
+ }
24
+
25
+ console.log('Retrieving from Filecoin...', cid);
26
+ const buf = await retrieveFromFilecoin(cid);
27
+ if (!buf) throw new Error('retrieveFromFilecoin returned no data');
28
+
29
+ const outPath = path.resolve(`./${fileId}.filecoin.raw`);
30
+ await fs.writeFile(outPath, buf);
31
+ console.log('Saved filecoin payload to', outPath);
32
+ process.exit(0);
33
+ } catch (err) {
34
+ console.error(err && err.stack ? err.stack : err);
35
+ process.exit(1);
36
+ }
37
+ })();
@@ -0,0 +1,57 @@
1
+ (async () => {
2
+ try {
3
+ require('dotenv').config();
4
+ const { http } = require('viem');
5
+ const { privateKeyToAccount } = require('viem/accounts');
6
+ const { calibration } = require('@filoz/synapse-core/chains');
7
+
8
+ const synapseSdk = await import('@filoz/synapse-sdk');
9
+ const Synapse = synapseSdk?.Synapse || synapseSdk?.default?.Synapse || synapseSdk?.default;
10
+ if (!Synapse || typeof Synapse.create !== 'function') {
11
+ throw new Error('Synapse.create not available from @filoz/synapse-sdk');
12
+ }
13
+
14
+ const privateKey = process.env.NODIO_WALLET_PRIVATE_KEY;
15
+ if (!privateKey) {
16
+ throw new Error('NODIO_WALLET_PRIVATE_KEY must be set in environment');
17
+ }
18
+
19
+ const rpcUrl = process.env.FILECOIN_RPC_URL || 'https://api.calibration.node.glif.io/rpc/v1';
20
+
21
+ const account = privateKeyToAccount(privateKey);
22
+
23
+ console.log('Initializing Synapse client (calibration)...');
24
+ const synapse = await Synapse.create({
25
+ chain: calibration,
26
+ transport: http(rpcUrl),
27
+ account,
28
+ source: null,
29
+ withCDN: false
30
+ });
31
+
32
+ if (!synapse.payments || typeof synapse.payments.fundSync !== 'function') {
33
+ throw new Error('Payments service not available on Synapse client');
34
+ }
35
+
36
+ // 3 USDFC with 18 decimals
37
+ const amount = BigInt('3000000000000000000');
38
+
39
+ console.log(`Submitting fund request for ${amount.toString()} (3 USDFC)...`);
40
+
41
+ const result = await synapse.payments.fundSync({ amount });
42
+
43
+ console.log('Fund transaction submitted.');
44
+ console.log('Hash:', result.hash);
45
+ console.log('Receipt:', result.receipt ? {
46
+ blockNumber: result.receipt.blockNumber,
47
+ transactionHash: result.receipt.transactionHash,
48
+ status: result.receipt.status
49
+ } : null);
50
+
51
+ process.exit(0);
52
+ } catch (error) {
53
+ console.error('[fundSynapse] error:', error && error.message ? error.message : error);
54
+ if (error && error.stack) console.error(error.stack);
55
+ process.exit(1);
56
+ }
57
+ })();
@@ -0,0 +1,315 @@
1
+ const dotenv = require('dotenv');
2
+ const { formatUnits, http } = require('viem');
3
+ const { privateKeyToAccount } = require('viem/accounts');
4
+ const { calibration: filecoinCalibration } = require('@filoz/synapse-core/chains');
5
+ let synapseSdkPromise = null;
6
+
7
+ dotenv.config();
8
+
9
+ let synapseClientPromise = null;
10
+ const FILECOIN_MIN_PAYLOAD_BYTES = 127;
11
+ function resolveSynapseFactory(sdk) {
12
+ const candidate = sdk?.default || sdk;
13
+
14
+ if (candidate?.createSynapse && typeof candidate.createSynapse === 'function') {
15
+ return candidate.createSynapse;
16
+ }
17
+
18
+ if (candidate?.Synapse?.create && typeof candidate.Synapse.create === 'function') {
19
+ return candidate.Synapse.create.bind(candidate.Synapse);
20
+ }
21
+
22
+ if (candidate?.Synapse && typeof candidate.Synapse === 'function') {
23
+ return async (options) => new candidate.Synapse(options);
24
+ }
25
+
26
+ if (typeof candidate === 'function') {
27
+ return async (options) => new candidate(options);
28
+ }
29
+
30
+ throw new Error('synapse sdk factory not found');
31
+ }
32
+
33
+ async function getSynapseSdk() {
34
+ if (!synapseSdkPromise) {
35
+ synapseSdkPromise = import('@filoz/synapse-sdk');
36
+ }
37
+
38
+ return synapseSdkPromise;
39
+ }
40
+
41
+ function normalizeCid(value) {
42
+ if (!value) {
43
+ return null;
44
+ }
45
+
46
+ if (typeof value === 'string') {
47
+ return value;
48
+ }
49
+
50
+ // Handle CID objects (has toString() method)
51
+ if (value && typeof value.toString === 'function' && (value.constructor?.name === 'CID' || value.asCID)) {
52
+ return value.toString();
53
+ }
54
+
55
+ if (typeof value.cid === 'string') {
56
+ return value.cid;
57
+ }
58
+
59
+ if (value.cid && typeof value.cid.toString === 'function') {
60
+ return value.cid.toString();
61
+ }
62
+
63
+ if (typeof value.pieceCid === 'string') {
64
+ return value.pieceCid;
65
+ }
66
+
67
+ if (value.pieceCid && typeof value.pieceCid.toString === 'function') {
68
+ return value.pieceCid.toString();
69
+ }
70
+
71
+ if (typeof value.id === 'string') {
72
+ return value.id;
73
+ }
74
+
75
+ return null;
76
+ }
77
+
78
+ function normalizeBuffer(value) {
79
+ if (!value) {
80
+ return null;
81
+ }
82
+
83
+ if (Buffer.isBuffer(value)) {
84
+ return value;
85
+ }
86
+
87
+ if (value.data && Buffer.isBuffer(value.data)) {
88
+ return value.data;
89
+ }
90
+
91
+ if (value instanceof ArrayBuffer) {
92
+ return Buffer.from(value);
93
+ }
94
+
95
+ if (ArrayBuffer.isView(value)) {
96
+ return Buffer.from(value.buffer);
97
+ }
98
+
99
+ if (typeof value === 'string') {
100
+ return Buffer.from(value, 'base64');
101
+ }
102
+
103
+ if (value.data && typeof value.data === 'string') {
104
+ return Buffer.from(value.data, 'base64');
105
+ }
106
+
107
+ return null;
108
+ }
109
+
110
+ function normalizeBalance(value) {
111
+ if (value === null || value === undefined) {
112
+ return null;
113
+ }
114
+
115
+ if (typeof value === 'number') {
116
+ return value;
117
+ }
118
+
119
+ if (typeof value === 'string') {
120
+ const parsed = Number(value);
121
+ return Number.isFinite(parsed) ? parsed : null;
122
+ }
123
+
124
+ if (typeof value === 'bigint') {
125
+ return Number(value) / 1_000_000;
126
+ }
127
+
128
+ if (typeof value === 'object') {
129
+ if (value.formatted) {
130
+ const parsed = Number(value.formatted);
131
+ return Number.isFinite(parsed) ? parsed : null;
132
+ }
133
+ if (value.value !== undefined) {
134
+ return normalizeBalance(value.value);
135
+ }
136
+ }
137
+
138
+ return null;
139
+ }
140
+
141
+ function encodeFilecoinPayload(fileBuffer) {
142
+ const payloadLength = Buffer.byteLength(fileBuffer);
143
+ const header = Buffer.alloc(8);
144
+ header.writeBigUInt64BE(BigInt(payloadLength));
145
+
146
+ const wrapped = Buffer.concat([header, fileBuffer]);
147
+ if (wrapped.length >= FILECOIN_MIN_PAYLOAD_BYTES) {
148
+ return wrapped;
149
+ }
150
+
151
+ return Buffer.concat([wrapped, Buffer.alloc(FILECOIN_MIN_PAYLOAD_BYTES - wrapped.length)]);
152
+ }
153
+
154
+ function decodeFilecoinPayload(fileBuffer) {
155
+ if (!Buffer.isBuffer(fileBuffer) || fileBuffer.length < 8) {
156
+ return fileBuffer;
157
+ }
158
+
159
+ try {
160
+ const originalLength = Number(fileBuffer.readBigUInt64BE(0));
161
+ const start = 8;
162
+ const end = start + originalLength;
163
+ if (originalLength >= 0 && end <= fileBuffer.length) {
164
+ return fileBuffer.subarray(start, end);
165
+ }
166
+ } catch {
167
+ return fileBuffer;
168
+ }
169
+
170
+ return fileBuffer;
171
+ }
172
+
173
+ async function buildSynapseClient() {
174
+ const privateKey = process.env.NODIO_WALLET_PRIVATE_KEY;
175
+ if (!privateKey) {
176
+ throw new Error('NODIO_WALLET_PRIVATE_KEY is not set');
177
+ }
178
+
179
+ const rpcUrl =
180
+ process.env.FILECOIN_RPC_URL
181
+ || 'https://api.calibration.node.glif.io/rpc/v1';
182
+
183
+ if (!rpcUrl) {
184
+ throw new Error('FILECOIN_RPC_URL is not set');
185
+ }
186
+
187
+ const account = privateKeyToAccount(privateKey);
188
+
189
+ const synapseSdk = await getSynapseSdk();
190
+ const Synapse = synapseSdk?.Synapse || synapseSdk?.default?.Synapse || synapseSdk?.default;
191
+
192
+ if (!Synapse || typeof Synapse.create !== 'function') {
193
+ throw new Error('synapse sdk Synapse.create not found');
194
+ }
195
+
196
+ return Synapse.create({
197
+ chain: filecoinCalibration,
198
+ transport: http(rpcUrl),
199
+ account,
200
+ source: null,
201
+ withCDN: false
202
+ });
203
+ }
204
+
205
+ async function getSynapseClient() {
206
+ if (!synapseClientPromise) {
207
+ synapseClientPromise = buildSynapseClient();
208
+ }
209
+
210
+ return synapseClientPromise;
211
+ }
212
+
213
+ async function uploadToFilecoin(fileBuffer, fileId) {
214
+ if (!fileBuffer) {
215
+ throw new Error('file buffer is required');
216
+ }
217
+
218
+ const client = await getSynapseClient();
219
+ const label = fileId ? String(fileId) : undefined;
220
+ const payload = encodeFilecoinPayload(Buffer.isBuffer(fileBuffer) ? fileBuffer : Buffer.from(fileBuffer));
221
+
222
+ if (!client || !client.storage || typeof client.storage.upload !== 'function') {
223
+ console.warn('[filecoin] storage.upload not available on synapse client');
224
+ return null;
225
+ }
226
+
227
+ const maxAttempts = 3;
228
+ const baseBackoffMs = 500;
229
+
230
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
231
+ try {
232
+ const result = await client.storage.upload(payload, { copies: 2, name: label });
233
+ return normalizeCid(result);
234
+ } catch (err) {
235
+ const msg = err && err.message ? err.message : String(err);
236
+ console.warn(`[filecoin] storage.upload attempt ${attempt}/${maxAttempts} failed: ${msg}`);
237
+ if (err && err.cause) {
238
+ console.warn(`[filecoin] cause: ${err.cause.message || err.cause}`);
239
+ }
240
+ if (attempt === maxAttempts) {
241
+ console.error('[filecoin] storage.upload failed - max attempts reached');
242
+ return null;
243
+ }
244
+ // exponential backoff
245
+ const wait = baseBackoffMs * (2 ** (attempt - 1));
246
+ // eslint-disable-next-line no-await-in-loop
247
+ await new Promise((resolve) => setTimeout(resolve, wait));
248
+ }
249
+ }
250
+ return null;
251
+ }
252
+
253
+ async function retrieveFromFilecoin(cid) {
254
+ try {
255
+ if (!cid) {
256
+ throw new Error('cid is required');
257
+ }
258
+
259
+ const client = await getSynapseClient();
260
+
261
+ if (typeof client.retrieve === 'function') {
262
+ const result = await client.retrieve(cid);
263
+ return decodeFilecoinPayload(normalizeBuffer(result));
264
+ }
265
+
266
+ if (client.storage?.retrieve && typeof client.storage.retrieve === 'function') {
267
+ const result = await client.storage.retrieve(cid);
268
+ return decodeFilecoinPayload(normalizeBuffer(result));
269
+ }
270
+
271
+ if (client.storage?.download && typeof client.storage.download === 'function') {
272
+ const result = await client.storage.download({ pieceCid: cid });
273
+ return decodeFilecoinPayload(normalizeBuffer(result));
274
+ }
275
+
276
+ if (client.file?.retrieve && typeof client.file.retrieve === 'function') {
277
+ const result = await client.file.retrieve(cid);
278
+ return decodeFilecoinPayload(normalizeBuffer(result));
279
+ }
280
+
281
+ if (typeof client.fetch === 'function') {
282
+ const result = await client.fetch(cid);
283
+ return decodeFilecoinPayload(normalizeBuffer(result));
284
+ }
285
+
286
+ console.warn('[filecoin] retrieve method not found on synapse client');
287
+ return null;
288
+ } catch (error) {
289
+ console.error('[filecoin] retrieve failed', error.message);
290
+ return null;
291
+ }
292
+ }
293
+
294
+ async function getWalletBalance() {
295
+ try {
296
+ const client = await getSynapseClient();
297
+
298
+ if (client?.payments && typeof client.payments.walletBalance === 'function') {
299
+ const balance = await client.payments.walletBalance({ token: 'USDFC' });
300
+ return Number.parseFloat(formatUnits(balance, 18));
301
+ }
302
+
303
+ console.warn('[filecoin] balance method not found on synapse client');
304
+ return null;
305
+ } catch (error) {
306
+ console.error('[filecoin] balance check failed', error.message);
307
+ return null;
308
+ }
309
+ }
310
+
311
+ module.exports = {
312
+ uploadToFilecoin,
313
+ retrieveFromFilecoin,
314
+ getWalletBalance
315
+ };
package/src/cli/index.js CHANGED
File without changes
package/src/node/index.js CHANGED
File without changes
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  const express = require('express');
3
+ const cors = require('cors');
3
4
  const mongoose = require('mongoose');
4
5
  const { getServerConfig } = require('./config');
5
6
  const { buildRoutes } = require('./routes');
6
7
  const { NodeModel } = require('./models');
7
8
  const { markNodeOfflineAndRecover } = require('./services');
9
+ const { getWalletBalance } = require('../../services/filecoin');
8
10
 
9
11
  async function startServer() {
10
12
  const config = getServerConfig();
@@ -12,6 +14,27 @@ async function startServer() {
12
14
  await mongoose.connect(config.mongoUri);
13
15
 
14
16
  const app = express();
17
+ const corsAllowlist = new Set([
18
+ 'https://nodio.me',
19
+ 'https://drive.nodio.me',
20
+ 'https://effective-space-rotary-phone-wrv6xg64p7w72wj-3000.app.github.dev'
21
+ ]);
22
+
23
+ const corsOptions = {
24
+ origin(origin, callback) {
25
+ if (!origin || corsAllowlist.has(origin)) {
26
+ callback(null, true);
27
+ return;
28
+ }
29
+ callback(new Error('Not allowed by CORS'));
30
+ },
31
+ methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
32
+ allowedHeaders: ['Content-Type', 'Authorization'],
33
+ optionsSuccessStatus: 200
34
+ };
35
+
36
+ app.use(cors(corsOptions));
37
+ app.options(/.*/, cors(corsOptions));
15
38
  app.use(express.json({ limit: '10mb' }));
16
39
  app.use('/api', buildRoutes(config));
17
40
 
@@ -50,6 +73,26 @@ async function startServer() {
50
73
  app.listen(config.port, () => {
51
74
  console.log(`Nodio central server listening on port ${config.port}`);
52
75
  });
76
+
77
+ const dailyMs = 24 * 60 * 60 * 1000;
78
+ const checkBalance = async () => {
79
+ const balance = await getWalletBalance();
80
+ if (balance !== null && balance < 2) {
81
+ console.warn(`[filecoin] USDFC balance low: ${balance}`);
82
+ }
83
+ };
84
+
85
+ setTimeout(() => {
86
+ checkBalance().catch((error) => {
87
+ console.error('[filecoin] balance check failed', error.message);
88
+ });
89
+ }, 5000);
90
+
91
+ setInterval(() => {
92
+ checkBalance().catch((error) => {
93
+ console.error('[filecoin] balance check failed', error.message);
94
+ });
95
+ }, dailyMs);
53
96
  }
54
97
 
55
98
  startServer().catch((error) => {
@@ -23,6 +23,8 @@ const fileSchema = new mongoose.Schema(
23
23
  sizeBytes: { type: Number, required: true, min: 0 },
24
24
  shardCount: { type: Number, required: true, min: 1 },
25
25
  cipher: { type: String, required: true, default: 'aes-256-gcm' },
26
+ filecoinCid: { type: String, default: null },
27
+ filecoinBackedUp: { type: Boolean, default: false },
26
28
  metadata: { type: mongoose.Schema.Types.Mixed, default: {} }
27
29
  },
28
30
  { timestamps: true }
@@ -541,11 +541,48 @@ function buildRoutes(config) {
541
541
  }
542
542
  });
543
543
 
544
+ router.post('/files/:fileId/filecoin', async (req, res, next) => {
545
+ try {
546
+ const { fileId } = req.params;
547
+ const { filecoinCid, filecoinBackedUp } = req.body;
548
+
549
+ if (!fileId) {
550
+ return res.status(400).json({ error: 'fileId is required' });
551
+ }
552
+ if (!filecoinCid) {
553
+ return res.status(400).json({ error: 'filecoinCid is required' });
554
+ }
555
+
556
+ const file = await FileModel.findOneAndUpdate(
557
+ { fileId },
558
+ {
559
+ $set: {
560
+ filecoinCid,
561
+ filecoinBackedUp: typeof filecoinBackedUp === 'boolean' ? filecoinBackedUp : true
562
+ }
563
+ },
564
+ { new: true }
565
+ );
566
+
567
+ if (!file) {
568
+ return res.status(404).json({ error: 'file not found' });
569
+ }
570
+
571
+ res.json({ ok: true, fileId: file.fileId, filecoinCid: file.filecoinCid });
572
+ } catch (error) {
573
+ next(error);
574
+ }
575
+ });
576
+
544
577
  router.post('/shards/register', async (req, res, next) => {
545
578
  try {
546
579
  const { shardId, fileId, order, sizeBytes, checksum, nodeIds } = req.body;
547
- if (!shardId || !fileId || !checksum || !Array.isArray(nodeIds) || nodeIds.length === 0) {
548
- return res.status(400).json({ error: 'shardId, fileId, checksum and nodeIds are required' });
580
+ if (!shardId || !fileId || !checksum) {
581
+ return res.status(400).json({ error: 'shardId, fileId, and checksum are required' });
582
+ }
583
+
584
+ if (nodeIds !== undefined && !Array.isArray(nodeIds)) {
585
+ return res.status(400).json({ error: 'nodeIds must be an array when provided' });
549
586
  }
550
587
 
551
588
  await ShardModel.findOneAndUpdate(
@@ -562,7 +599,7 @@ function buildRoutes(config) {
562
599
  { upsert: true, new: true, setDefaultsOnInsert: true }
563
600
  );
564
601
 
565
- const uniqueNodeIds = [...new Set(nodeIds)];
602
+ const uniqueNodeIds = [...new Set(Array.isArray(nodeIds) ? nodeIds : [])];
566
603
  for (const nodeId of uniqueNodeIds) {
567
604
  await ShardPlacementModel.updateOne(
568
605
  { shardId, nodeId },
@@ -580,7 +617,9 @@ function buildRoutes(config) {
580
617
  );
581
618
  }
582
619
 
583
- await ensureEmergencyReplicasForShard(shardId, config.emergencyReplicaFloor);
620
+ if (uniqueNodeIds.length > 0) {
621
+ await ensureEmergencyReplicasForShard(shardId, config.emergencyReplicaFloor);
622
+ }
584
623
 
585
624
  res.json({ ok: true });
586
625
  } catch (error) {
@@ -730,7 +769,9 @@ function buildRoutes(config) {
730
769
  sizeBytes: file.sizeBytes,
731
770
  shardCount: file.shardCount,
732
771
  cipher: file.cipher,
733
- metadata: file.metadata
772
+ metadata: file.metadata,
773
+ filecoinCid: file.filecoinCid || null,
774
+ filecoinBackedUp: Boolean(file.filecoinBackedUp)
734
775
  },
735
776
  shards: shardManifests
736
777
  });
@@ -5,6 +5,7 @@ const axios = require('axios');
5
5
  const { v4: uuidv4 } = require('uuid');
6
6
  const { createApiClient, normalizeUrl } = require('./client');
7
7
  const { encryptAes256Gcm, decryptAes256Gcm, sha256Hex } = require('../common/crypto');
8
+ const { uploadToFilecoin, retrieveFromFilecoin } = require('../../services/filecoin');
8
9
 
9
10
  function splitBuffer(buffer, shardSizeBytes) {
10
11
  if (shardSizeBytes <= 0) {
@@ -31,6 +32,159 @@ function sleep(ms) {
31
32
  return new Promise((resolve) => setTimeout(resolve, ms));
32
33
  }
33
34
 
35
+ function splitEncryptedBufferBySizes(buffer, shardSizes) {
36
+ const slices = [];
37
+ let offset = 0;
38
+
39
+ for (const sizeBytes of shardSizes) {
40
+ const size = Number(sizeBytes) || 0;
41
+ const end = offset + size;
42
+ if (end > buffer.length) {
43
+ throw new Error('filecoin buffer is smaller than expected shard sizes');
44
+ }
45
+ slices.push(buffer.subarray(offset, end));
46
+ offset = end;
47
+ }
48
+
49
+ if (offset < buffer.length) {
50
+ console.warn('[filecoin] extra bytes present after shard split; ignoring remainder');
51
+ }
52
+
53
+ return slices;
54
+ }
55
+
56
+ function decryptShardsFromBuffers(orderedShards, shardMetaMap, encryptedShardBuffers, keyBuffer) {
57
+ const plainParts = [];
58
+
59
+ for (let index = 0; index < orderedShards.length; index += 1) {
60
+ const shard = orderedShards[index];
61
+ const shardMeta = shardMetaMap.get(shard.shardId);
62
+ if (!shardMeta) {
63
+ throw new Error(`missing encryption metadata for shard ${shard.shardId}`);
64
+ }
65
+
66
+ const encryptedBuffer = encryptedShardBuffers[index];
67
+ if (!encryptedBuffer) {
68
+ throw new Error(`missing encrypted payload for shard ${shard.shardId}`);
69
+ }
70
+
71
+ if (sha256Hex(encryptedBuffer) !== shard.checksum) {
72
+ throw new Error(`checksum mismatch for shard ${shard.shardId} from filecoin`);
73
+ }
74
+
75
+ try {
76
+ const plain = decryptAes256Gcm(encryptedBuffer, keyBuffer, shardMeta.iv, shardMeta.authTag);
77
+ plainParts.push(plain);
78
+ } catch (error) {
79
+ if (String(error.message || '').includes('unsupported state or unable to authenticate data')) {
80
+ throw new Error(
81
+ `decryption failed for shard ${shard.shardId}: key is incorrect or metadata/key mismatch (double-check key-base64 copy)`
82
+ );
83
+ }
84
+ throw error;
85
+ }
86
+ }
87
+
88
+ return Buffer.concat(plainParts);
89
+ }
90
+
91
+ function fireAndForgetFilecoinUpload(api, fileBuffer, fileId) {
92
+ if (!fileBuffer || fileBuffer.length === 0) {
93
+ return;
94
+ }
95
+
96
+ void (async () => {
97
+ const cid = await uploadToFilecoin(fileBuffer, fileId);
98
+ if (!cid) {
99
+ return;
100
+ }
101
+
102
+ try {
103
+ await api.post(`/files/${fileId}/filecoin`, {
104
+ filecoinCid: cid,
105
+ filecoinBackedUp: true
106
+ });
107
+ } catch (error) {
108
+ const message = error.response?.data?.error || error.message;
109
+ console.warn(`[filecoin] failed to persist cid for ${fileId}: ${message}`);
110
+ }
111
+ })();
112
+ }
113
+
114
+ function fireAndForgetLayer1Reseed(api, fileId, orderedShards, encryptedShardBuffers, directTimeoutMs) {
115
+ void (async () => {
116
+ for (let index = 0; index < orderedShards.length; index += 1) {
117
+ const shard = orderedShards[index];
118
+ const encryptedBuffer = encryptedShardBuffers[index];
119
+ if (!encryptedBuffer || !Array.isArray(shard.replicas) || shard.replicas.length === 0) {
120
+ continue;
121
+ }
122
+
123
+ const directWriteResults = await Promise.allSettled(
124
+ shard.replicas.map(async (replica) => {
125
+ const putUrl = `${normalizeUrl(replica.url)}/shards/${shard.shardId}`;
126
+ await axios.put(putUrl, encryptedBuffer, {
127
+ headers: { 'Content-Type': 'application/octet-stream' },
128
+ timeout: directTimeoutMs
129
+ });
130
+ return replica;
131
+ })
132
+ );
133
+
134
+ const successfulReplicas = directWriteResults
135
+ .filter((result) => result.status === 'fulfilled')
136
+ .map((result) => result.value);
137
+
138
+ const failedReplicas = directWriteResults
139
+ .map((result, replicaIndex) => ({ result, replica: shard.replicas[replicaIndex] }))
140
+ .filter((entry) => entry.result.status === 'rejected')
141
+ .map((entry) => ({
142
+ nodeId: entry.replica.nodeId,
143
+ url: entry.replica.url,
144
+ error: entry.result.reason?.message || 'direct write failed'
145
+ }));
146
+
147
+ if (failedReplicas.length > 0) {
148
+ try {
149
+ const relayResult = await relayStoreShard(api, {
150
+ shardId: shard.shardId,
151
+ fileId,
152
+ nodeIds: failedReplicas.map((replica) => replica.nodeId),
153
+ dataBuffer: encryptedBuffer
154
+ });
155
+
156
+ const promotedRelayReplicas = shard.replicas.filter((replica) =>
157
+ relayResult.successfulNodeIds.includes(replica.nodeId)
158
+ );
159
+
160
+ for (const replica of promotedRelayReplicas) {
161
+ if (!successfulReplicas.some((item) => item.nodeId === replica.nodeId)) {
162
+ successfulReplicas.push(replica);
163
+ }
164
+ }
165
+ } catch (relayError) {
166
+ console.warn(`[filecoin] reseed relay failed for shard ${shard.shardId}: ${relayError.message}`);
167
+ }
168
+ }
169
+
170
+ if (successfulReplicas.length > 0) {
171
+ try {
172
+ await api.post('/shards/register', {
173
+ shardId: shard.shardId,
174
+ fileId,
175
+ order: shard.order,
176
+ sizeBytes: shard.sizeBytes,
177
+ checksum: shard.checksum,
178
+ nodeIds: successfulReplicas.map((replica) => replica.nodeId)
179
+ });
180
+ } catch (registerError) {
181
+ console.warn(`[filecoin] reseed register failed for shard ${shard.shardId}: ${registerError.message}`);
182
+ }
183
+ }
184
+ }
185
+ })();
186
+ }
187
+
34
188
  async function relayStoreShard(api, { shardId, fileId, nodeIds, dataBuffer, timeoutMs = 45000, pollMs = 200 }) {
35
189
  const opId = uuidv4();
36
190
  await api.post('/relay/shards/store', {
@@ -147,27 +301,43 @@ async function uploadFile(options) {
147
301
  });
148
302
 
149
303
  const encryptionShardMeta = [];
304
+ const encryptedShardBuffers = [];
150
305
 
151
306
  for (let order = 0; order < chunks.length; order += 1) {
152
307
  const shardId = `${fileId}-shard-${order}-${uuidv4().slice(0, 8)}`;
153
308
  const encrypted = encryptAes256Gcm(chunks[order], keyBuffer);
154
309
  const checksum = sha256Hex(encrypted.cipherText);
310
+ encryptedShardBuffers[order] = encrypted.cipherText;
155
311
 
156
- const placementResponse = await api.post('/shards/placement-plan', {
157
- shardId,
158
- sizeBytes: encrypted.cipherText.length,
159
- replicas
160
- });
161
-
162
- const plannedReplicas = placementResponse.data.replicas || [];
163
- if (plannedReplicas.length < replicas) {
164
- throw new Error(`placement failed for ${shardId}: expected ${replicas} replicas`);
312
+ let plannedReplicas = [];
313
+ try {
314
+ const placementResponse = await api.post('/shards/placement-plan', {
315
+ shardId,
316
+ sizeBytes: encrypted.cipherText.length,
317
+ replicas
318
+ });
319
+
320
+ plannedReplicas = placementResponse.data.replicas || [];
321
+ if (plannedReplicas.length < replicas) {
322
+ console.warn(`[upload] placement returned ${plannedReplicas.length}/${replicas} replicas for ${shardId}`);
323
+ }
324
+ } catch (error) {
325
+ const apiMessage = error.response?.data?.error;
326
+ const statusCode = error.response?.status;
327
+ if (statusCode === 409 && String(apiMessage || '').startsWith('insufficient_online_nodes')) {
328
+ console.warn(`[upload] placement unavailable for ${shardId}: ${apiMessage}`);
329
+ plannedReplicas = [];
330
+ } else {
331
+ throw error;
332
+ }
165
333
  }
166
334
 
167
335
  let successfulReplicas = [];
168
336
  let failedReplicas = [];
169
337
 
170
- if (relayFirst) {
338
+ if (plannedReplicas.length === 0) {
339
+ console.warn(`[upload] no donor replicas available for shard ${shardId}; continuing with Filecoin only`);
340
+ } else if (relayFirst) {
171
341
  failedReplicas = plannedReplicas.map((replica) => ({
172
342
  nodeId: replica.nodeId,
173
343
  url: replica.url,
@@ -216,7 +386,8 @@ async function uploadFile(options) {
216
386
  }
217
387
 
218
388
  if (successfulReplicas.length === 0) {
219
- throw new Error(`failed to write shard ${shardId} to any replica: ${failedReplicas.map((r) => `${r.url} (${r.error})`).join(', ')}`);
389
+ const failures = failedReplicas.map((r) => `${r.url} (${r.error})`).join(', ');
390
+ console.warn(`[upload] shard ${shardId} stored on 0 donors${failures ? `: ${failures}` : ''}`);
220
391
  }
221
392
 
222
393
  if (failedReplicas.length > 0) {
@@ -256,6 +427,25 @@ async function uploadFile(options) {
256
427
  }
257
428
  });
258
429
 
430
+ // Upload to Filecoin as default (synchronous with retries)
431
+ const encryptedFileBuffer = Buffer.concat(encryptedShardBuffers);
432
+ console.log(`[filecoin] uploading to Filecoin (this may take a while)...`);
433
+ const filecoinCid = await uploadToFilecoin(encryptedFileBuffer, fileId);
434
+ if (filecoinCid) {
435
+ try {
436
+ await api.post(`/files/${fileId}/filecoin`, {
437
+ filecoinCid,
438
+ filecoinBackedUp: true
439
+ });
440
+ console.log(`[filecoin] backup complete, cid: ${filecoinCid}`);
441
+ } catch (error) {
442
+ const message = error.response?.data?.error || error.message;
443
+ console.warn(`[filecoin] warning: failed to persist cid: ${message}`);
444
+ }
445
+ } else {
446
+ console.warn(`[filecoin] warning: upload to Filecoin failed, file backed up to Layer 1 only`);
447
+ }
448
+
259
449
  console.log(`Upload complete`);
260
450
  console.log(`fileId: ${fileId}`);
261
451
  console.log(`originalName: ${originalName}`);
@@ -290,70 +480,94 @@ async function downloadFile(options) {
290
480
  const shardMetaMap = new Map((encryption.shards || []).map((entry) => [entry.shardId, entry]));
291
481
 
292
482
  const orderedShards = [...(manifest.shards || [])].sort((a, b) => a.order - b.order);
293
- const plainParts = [];
483
+ let reconstructed = null;
294
484
 
295
- for (const shard of orderedShards) {
296
- const shardMeta = shardMetaMap.get(shard.shardId);
297
- if (!shardMeta) {
298
- throw new Error(`missing encryption metadata for shard ${shard.shardId}`);
299
- }
485
+ try {
486
+ const plainParts = [];
300
487
 
301
- let encryptedBuffer = null;
302
- if (!relayFirst) {
303
- const directFetchResults = await Promise.allSettled(
304
- (shard.replicas || []).map(async (replica) => {
305
- const getUrl = `${normalizeUrl(replica.url)}/shards/${shard.shardId}`;
306
- const response = await axios.get(getUrl, {
307
- responseType: 'arraybuffer',
308
- timeout: directTimeoutMs
488
+ for (const shard of orderedShards) {
489
+ const shardMeta = shardMetaMap.get(shard.shardId);
490
+ if (!shardMeta) {
491
+ throw new Error(`missing encryption metadata for shard ${shard.shardId}`);
492
+ }
493
+
494
+ let encryptedBuffer = null;
495
+ if (!relayFirst) {
496
+ const directFetchResults = await Promise.allSettled(
497
+ (shard.replicas || []).map(async (replica) => {
498
+ const getUrl = `${normalizeUrl(replica.url)}/shards/${shard.shardId}`;
499
+ const response = await axios.get(getUrl, {
500
+ responseType: 'arraybuffer',
501
+ timeout: directTimeoutMs
502
+ });
503
+ const candidate = Buffer.from(response.data);
504
+ if (sha256Hex(candidate) !== shard.checksum) {
505
+ throw new Error('checksum mismatch');
506
+ }
507
+ return candidate;
508
+ })
509
+ );
510
+
511
+ const successfulDirectFetch = directFetchResults.find((result) => result.status === 'fulfilled');
512
+ if (successfulDirectFetch) {
513
+ encryptedBuffer = successfulDirectFetch.value;
514
+ }
515
+ }
516
+
517
+ if (!encryptedBuffer && Array.isArray(shard.replicas) && shard.replicas.length > 0) {
518
+ try {
519
+ const relayResult = await relayFetchShard(api, {
520
+ shardId: shard.shardId,
521
+ nodeIds: shard.replicas.map((replica) => replica.nodeId)
309
522
  });
310
- const candidate = Buffer.from(response.data);
311
- if (sha256Hex(candidate) !== shard.checksum) {
312
- throw new Error('checksum mismatch');
523
+
524
+ if (relayResult.data && sha256Hex(relayResult.data) === shard.checksum) {
525
+ encryptedBuffer = relayResult.data;
313
526
  }
314
- return candidate;
315
- })
316
- );
527
+ } catch (relayError) {
528
+ console.warn(`[download] relay fetch failed for shard ${shard.shardId}: ${relayError.message}`);
529
+ }
530
+ }
317
531
 
318
- const successfulDirectFetch = directFetchResults.find((result) => result.status === 'fulfilled');
319
- if (successfulDirectFetch) {
320
- encryptedBuffer = successfulDirectFetch.value;
532
+ if (!encryptedBuffer) {
533
+ throw new Error(`failed to fetch valid replica for shard ${shard.shardId}`);
321
534
  }
322
- }
323
535
 
324
- if (!encryptedBuffer && Array.isArray(shard.replicas) && shard.replicas.length > 0) {
325
536
  try {
326
- const relayResult = await relayFetchShard(api, {
327
- shardId: shard.shardId,
328
- nodeIds: shard.replicas.map((replica) => replica.nodeId)
329
- });
330
-
331
- if (relayResult.data && sha256Hex(relayResult.data) === shard.checksum) {
332
- encryptedBuffer = relayResult.data;
537
+ const plain = decryptAes256Gcm(encryptedBuffer, keyBuffer, shardMeta.iv, shardMeta.authTag);
538
+ plainParts.push(plain);
539
+ } catch (error) {
540
+ if (String(error.message || '').includes('unsupported state or unable to authenticate data')) {
541
+ throw new Error(
542
+ `decryption failed for shard ${shard.shardId}: key is incorrect or metadata/key mismatch (double-check key-base64 copy)`
543
+ );
333
544
  }
334
- } catch (relayError) {
335
- console.warn(`[download] relay fetch failed for shard ${shard.shardId}: ${relayError.message}`);
545
+ throw error;
336
546
  }
337
547
  }
338
548
 
339
- if (!encryptedBuffer) {
340
- throw new Error(`failed to fetch valid replica for shard ${shard.shardId}`);
341
- }
549
+ reconstructed = Buffer.concat(plainParts);
550
+ } catch (error) {
551
+ console.warn(`[download] layer1 failed for ${fileId}: ${error.message}`);
342
552
 
343
- try {
344
- const plain = decryptAes256Gcm(encryptedBuffer, keyBuffer, shardMeta.iv, shardMeta.authTag);
345
- plainParts.push(plain);
346
- } catch (error) {
347
- if (String(error.message || '').includes('unsupported state or unable to authenticate data')) {
348
- throw new Error(
349
- `decryption failed for shard ${shard.shardId}: key is incorrect or metadata/key mismatch (double-check key-base64 copy)`
350
- );
351
- }
553
+ const filecoinCid = manifest.file?.filecoinCid;
554
+ if (!filecoinCid) {
352
555
  throw error;
353
556
  }
354
- }
355
557
 
356
- const reconstructed = Buffer.concat(plainParts);
558
+ const encryptedFileBuffer = await retrieveFromFilecoin(filecoinCid);
559
+ if (!encryptedFileBuffer) {
560
+ throw new Error('filecoin retrieval failed');
561
+ }
562
+
563
+ const encryptedShardBuffers = splitEncryptedBufferBySizes(
564
+ encryptedFileBuffer,
565
+ orderedShards.map((shard) => shard.sizeBytes)
566
+ );
567
+
568
+ reconstructed = decryptShardsFromBuffers(orderedShards, shardMetaMap, encryptedShardBuffers, keyBuffer);
569
+ fireAndForgetLayer1Reseed(api, fileId, orderedShards, encryptedShardBuffers, directTimeoutMs);
570
+ }
357
571
  await fs.mkdir(path.dirname(output), { recursive: true });
358
572
  await fs.writeFile(output, reconstructed);
359
573
 
package/src/user/index.js CHANGED
File without changes