nodio-cli 1.0.12 → 1.1.1

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.1",
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,7 +14,28 @@ async function startServer() {
12
14
  await mongoose.connect(config.mongoUri);
13
15
 
14
16
  const app = express();
15
- app.use(express.json({ limit: '10mb' }));
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));
38
+ app.use(express.json({ limit: '50mb' }));
16
39
  app.use('/api', buildRoutes(config));
17
40
 
18
41
  app.use((error, _req, res, _next) => {
@@ -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 }
@@ -9,6 +9,7 @@ const {
9
9
  ReplicationTaskModel,
10
10
  RelayTaskModel
11
11
  } = require('./models');
12
+ const { uploadToFilecoin } = require('../../services/filecoin');
12
13
  const {
13
14
  chooseDistinctOnlineNodes,
14
15
  ensureEmergencyReplicasForShard
@@ -541,11 +542,91 @@ function buildRoutes(config) {
541
542
  }
542
543
  });
543
544
 
545
+ router.post('/files/:fileId/filecoin', async (req, res, next) => {
546
+ try {
547
+ const { fileId } = req.params;
548
+ const { filecoinCid, filecoinBackedUp } = req.body;
549
+
550
+ if (!fileId) {
551
+ return res.status(400).json({ error: 'fileId is required' });
552
+ }
553
+ if (!filecoinCid) {
554
+ return res.status(400).json({ error: 'filecoinCid is required' });
555
+ }
556
+
557
+ const file = await FileModel.findOneAndUpdate(
558
+ { fileId },
559
+ {
560
+ $set: {
561
+ filecoinCid,
562
+ filecoinBackedUp: typeof filecoinBackedUp === 'boolean' ? filecoinBackedUp : true
563
+ }
564
+ },
565
+ { new: true }
566
+ );
567
+
568
+ if (!file) {
569
+ return res.status(404).json({ error: 'file not found' });
570
+ }
571
+
572
+ res.json({ ok: true, fileId: file.fileId, filecoinCid: file.filecoinCid });
573
+ } catch (error) {
574
+ next(error);
575
+ }
576
+ });
577
+
578
+ router.post('/files/:fileId/filecoin/upload', async (req, res, next) => {
579
+ try {
580
+ const { fileId } = req.params;
581
+ const { dataBase64 } = req.body;
582
+
583
+ if (!fileId) {
584
+ return res.status(400).json({ error: 'fileId is required' });
585
+ }
586
+ if (!dataBase64 || typeof dataBase64 !== 'string') {
587
+ return res.status(400).json({ error: 'dataBase64 is required' });
588
+ }
589
+
590
+ const payload = Buffer.from(dataBase64, 'base64');
591
+ if (!payload || payload.length === 0) {
592
+ return res.status(400).json({ error: 'dataBase64 decoded to empty payload' });
593
+ }
594
+
595
+ const cid = await uploadToFilecoin(payload, fileId);
596
+ if (!cid) {
597
+ return res.status(502).json({ error: 'filecoin upload failed' });
598
+ }
599
+
600
+ const file = await FileModel.findOneAndUpdate(
601
+ { fileId },
602
+ {
603
+ $set: {
604
+ filecoinCid: cid,
605
+ filecoinBackedUp: true
606
+ }
607
+ },
608
+ { new: true }
609
+ );
610
+
611
+ if (!file) {
612
+ return res.status(404).json({ error: 'file not found' });
613
+ }
614
+
615
+ res.json({ ok: true, fileId: file.fileId, filecoinCid: cid });
616
+ } catch (error) {
617
+ next(error);
618
+ }
619
+ });
620
+
544
621
  router.post('/shards/register', async (req, res, next) => {
545
622
  try {
546
623
  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' });
624
+ if (!shardId || !fileId || !checksum) {
625
+ return res.status(400).json({ error: 'shardId, fileId, and checksum are required' });
626
+ }
627
+
628
+ if (nodeIds !== undefined && !Array.isArray(nodeIds)) {
629
+ return res.status(400).json({ error: 'nodeIds must be an array when provided' });
549
630
  }
550
631
 
551
632
  await ShardModel.findOneAndUpdate(
@@ -562,7 +643,7 @@ function buildRoutes(config) {
562
643
  { upsert: true, new: true, setDefaultsOnInsert: true }
563
644
  );
564
645
 
565
- const uniqueNodeIds = [...new Set(nodeIds)];
646
+ const uniqueNodeIds = [...new Set(Array.isArray(nodeIds) ? nodeIds : [])];
566
647
  for (const nodeId of uniqueNodeIds) {
567
648
  await ShardPlacementModel.updateOne(
568
649
  { shardId, nodeId },
@@ -580,7 +661,9 @@ function buildRoutes(config) {
580
661
  );
581
662
  }
582
663
 
583
- await ensureEmergencyReplicasForShard(shardId, config.emergencyReplicaFloor);
664
+ if (uniqueNodeIds.length > 0) {
665
+ await ensureEmergencyReplicasForShard(shardId, config.emergencyReplicaFloor);
666
+ }
584
667
 
585
668
  res.json({ ok: true });
586
669
  } catch (error) {
@@ -730,7 +813,9 @@ function buildRoutes(config) {
730
813
  sizeBytes: file.sizeBytes,
731
814
  shardCount: file.shardCount,
732
815
  cipher: file.cipher,
733
- metadata: file.metadata
816
+ metadata: file.metadata,
817
+ filecoinCid: file.filecoinCid || null,
818
+ filecoinBackedUp: Boolean(file.filecoinBackedUp)
734
819
  },
735
820
  shards: shardManifests
736
821
  });
@@ -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 { retrieveFromFilecoin } = require('../../services/filecoin');
8
9
 
9
10
  function splitBuffer(buffer, shardSizeBytes) {
10
11
  if (shardSizeBytes <= 0) {
@@ -31,6 +32,157 @@ 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
+ async function requestFilecoinBackup(api, fileBuffer, fileId) {
92
+ if (!fileBuffer || fileBuffer.length === 0) {
93
+ return;
94
+ }
95
+
96
+ try {
97
+ await api.post(
98
+ `/files/${fileId}/filecoin/upload`,
99
+ {
100
+ dataBase64: fileBuffer.toString('base64')
101
+ },
102
+ {
103
+ timeout: 180000
104
+ }
105
+ );
106
+ } catch (error) {
107
+ const message = error.response?.data?.error || error.message;
108
+ console.warn(`[filecoin] server upload failed for ${fileId}: ${message}`);
109
+ }
110
+ }
111
+
112
+ function fireAndForgetLayer1Reseed(api, fileId, orderedShards, encryptedShardBuffers, directTimeoutMs) {
113
+ void (async () => {
114
+ for (let index = 0; index < orderedShards.length; index += 1) {
115
+ const shard = orderedShards[index];
116
+ const encryptedBuffer = encryptedShardBuffers[index];
117
+ if (!encryptedBuffer || !Array.isArray(shard.replicas) || shard.replicas.length === 0) {
118
+ continue;
119
+ }
120
+
121
+ const directWriteResults = await Promise.allSettled(
122
+ shard.replicas.map(async (replica) => {
123
+ const putUrl = `${normalizeUrl(replica.url)}/shards/${shard.shardId}`;
124
+ await axios.put(putUrl, encryptedBuffer, {
125
+ headers: { 'Content-Type': 'application/octet-stream' },
126
+ timeout: directTimeoutMs
127
+ });
128
+ return replica;
129
+ })
130
+ );
131
+
132
+ const successfulReplicas = directWriteResults
133
+ .filter((result) => result.status === 'fulfilled')
134
+ .map((result) => result.value);
135
+
136
+ const failedReplicas = directWriteResults
137
+ .map((result, replicaIndex) => ({ result, replica: shard.replicas[replicaIndex] }))
138
+ .filter((entry) => entry.result.status === 'rejected')
139
+ .map((entry) => ({
140
+ nodeId: entry.replica.nodeId,
141
+ url: entry.replica.url,
142
+ error: entry.result.reason?.message || 'direct write failed'
143
+ }));
144
+
145
+ if (failedReplicas.length > 0) {
146
+ try {
147
+ const relayResult = await relayStoreShard(api, {
148
+ shardId: shard.shardId,
149
+ fileId,
150
+ nodeIds: failedReplicas.map((replica) => replica.nodeId),
151
+ dataBuffer: encryptedBuffer
152
+ });
153
+
154
+ const promotedRelayReplicas = shard.replicas.filter((replica) =>
155
+ relayResult.successfulNodeIds.includes(replica.nodeId)
156
+ );
157
+
158
+ for (const replica of promotedRelayReplicas) {
159
+ if (!successfulReplicas.some((item) => item.nodeId === replica.nodeId)) {
160
+ successfulReplicas.push(replica);
161
+ }
162
+ }
163
+ } catch (relayError) {
164
+ console.warn(`[filecoin] reseed relay failed for shard ${shard.shardId}: ${relayError.message}`);
165
+ }
166
+ }
167
+
168
+ if (successfulReplicas.length > 0) {
169
+ try {
170
+ await api.post('/shards/register', {
171
+ shardId: shard.shardId,
172
+ fileId,
173
+ order: shard.order,
174
+ sizeBytes: shard.sizeBytes,
175
+ checksum: shard.checksum,
176
+ nodeIds: successfulReplicas.map((replica) => replica.nodeId)
177
+ });
178
+ } catch (registerError) {
179
+ console.warn(`[filecoin] reseed register failed for shard ${shard.shardId}: ${registerError.message}`);
180
+ }
181
+ }
182
+ }
183
+ })();
184
+ }
185
+
34
186
  async function relayStoreShard(api, { shardId, fileId, nodeIds, dataBuffer, timeoutMs = 45000, pollMs = 200 }) {
35
187
  const opId = uuidv4();
36
188
  await api.post('/relay/shards/store', {
@@ -147,27 +299,43 @@ async function uploadFile(options) {
147
299
  });
148
300
 
149
301
  const encryptionShardMeta = [];
302
+ const encryptedShardBuffers = [];
150
303
 
151
304
  for (let order = 0; order < chunks.length; order += 1) {
152
305
  const shardId = `${fileId}-shard-${order}-${uuidv4().slice(0, 8)}`;
153
306
  const encrypted = encryptAes256Gcm(chunks[order], keyBuffer);
154
307
  const checksum = sha256Hex(encrypted.cipherText);
308
+ encryptedShardBuffers[order] = encrypted.cipherText;
155
309
 
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`);
310
+ let plannedReplicas = [];
311
+ try {
312
+ const placementResponse = await api.post('/shards/placement-plan', {
313
+ shardId,
314
+ sizeBytes: encrypted.cipherText.length,
315
+ replicas
316
+ });
317
+
318
+ plannedReplicas = placementResponse.data.replicas || [];
319
+ if (plannedReplicas.length < replicas) {
320
+ console.warn(`[upload] placement returned ${plannedReplicas.length}/${replicas} replicas for ${shardId}`);
321
+ }
322
+ } catch (error) {
323
+ const apiMessage = error.response?.data?.error;
324
+ const statusCode = error.response?.status;
325
+ if (statusCode === 409 && String(apiMessage || '').startsWith('insufficient_online_nodes')) {
326
+ console.warn(`[upload] placement unavailable for ${shardId}: ${apiMessage}`);
327
+ plannedReplicas = [];
328
+ } else {
329
+ throw error;
330
+ }
165
331
  }
166
332
 
167
333
  let successfulReplicas = [];
168
334
  let failedReplicas = [];
169
335
 
170
- if (relayFirst) {
336
+ if (plannedReplicas.length === 0) {
337
+ console.warn(`[upload] no donor replicas available for shard ${shardId}; continuing with Filecoin only`);
338
+ } else if (relayFirst) {
171
339
  failedReplicas = plannedReplicas.map((replica) => ({
172
340
  nodeId: replica.nodeId,
173
341
  url: replica.url,
@@ -216,7 +384,8 @@ async function uploadFile(options) {
216
384
  }
217
385
 
218
386
  if (successfulReplicas.length === 0) {
219
- throw new Error(`failed to write shard ${shardId} to any replica: ${failedReplicas.map((r) => `${r.url} (${r.error})`).join(', ')}`);
387
+ const failures = failedReplicas.map((r) => `${r.url} (${r.error})`).join(', ');
388
+ console.warn(`[upload] shard ${shardId} stored on 0 donors${failures ? `: ${failures}` : ''}`);
220
389
  }
221
390
 
222
391
  if (failedReplicas.length > 0) {
@@ -256,6 +425,11 @@ async function uploadFile(options) {
256
425
  }
257
426
  });
258
427
 
428
+ // Upload to Filecoin via the central server (uses server-side wallet key)
429
+ const encryptedFileBuffer = Buffer.concat(encryptedShardBuffers);
430
+ console.log(`[filecoin] uploading to Filecoin via central server (this may take a while)...`);
431
+ await requestFilecoinBackup(api, encryptedFileBuffer, fileId);
432
+
259
433
  console.log(`Upload complete`);
260
434
  console.log(`fileId: ${fileId}`);
261
435
  console.log(`originalName: ${originalName}`);
@@ -290,70 +464,94 @@ async function downloadFile(options) {
290
464
  const shardMetaMap = new Map((encryption.shards || []).map((entry) => [entry.shardId, entry]));
291
465
 
292
466
  const orderedShards = [...(manifest.shards || [])].sort((a, b) => a.order - b.order);
293
- const plainParts = [];
467
+ let reconstructed = null;
294
468
 
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
- }
469
+ try {
470
+ const plainParts = [];
300
471
 
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
472
+ for (const shard of orderedShards) {
473
+ const shardMeta = shardMetaMap.get(shard.shardId);
474
+ if (!shardMeta) {
475
+ throw new Error(`missing encryption metadata for shard ${shard.shardId}`);
476
+ }
477
+
478
+ let encryptedBuffer = null;
479
+ if (!relayFirst) {
480
+ const directFetchResults = await Promise.allSettled(
481
+ (shard.replicas || []).map(async (replica) => {
482
+ const getUrl = `${normalizeUrl(replica.url)}/shards/${shard.shardId}`;
483
+ const response = await axios.get(getUrl, {
484
+ responseType: 'arraybuffer',
485
+ timeout: directTimeoutMs
486
+ });
487
+ const candidate = Buffer.from(response.data);
488
+ if (sha256Hex(candidate) !== shard.checksum) {
489
+ throw new Error('checksum mismatch');
490
+ }
491
+ return candidate;
492
+ })
493
+ );
494
+
495
+ const successfulDirectFetch = directFetchResults.find((result) => result.status === 'fulfilled');
496
+ if (successfulDirectFetch) {
497
+ encryptedBuffer = successfulDirectFetch.value;
498
+ }
499
+ }
500
+
501
+ if (!encryptedBuffer && Array.isArray(shard.replicas) && shard.replicas.length > 0) {
502
+ try {
503
+ const relayResult = await relayFetchShard(api, {
504
+ shardId: shard.shardId,
505
+ nodeIds: shard.replicas.map((replica) => replica.nodeId)
309
506
  });
310
- const candidate = Buffer.from(response.data);
311
- if (sha256Hex(candidate) !== shard.checksum) {
312
- throw new Error('checksum mismatch');
507
+
508
+ if (relayResult.data && sha256Hex(relayResult.data) === shard.checksum) {
509
+ encryptedBuffer = relayResult.data;
313
510
  }
314
- return candidate;
315
- })
316
- );
511
+ } catch (relayError) {
512
+ console.warn(`[download] relay fetch failed for shard ${shard.shardId}: ${relayError.message}`);
513
+ }
514
+ }
317
515
 
318
- const successfulDirectFetch = directFetchResults.find((result) => result.status === 'fulfilled');
319
- if (successfulDirectFetch) {
320
- encryptedBuffer = successfulDirectFetch.value;
516
+ if (!encryptedBuffer) {
517
+ throw new Error(`failed to fetch valid replica for shard ${shard.shardId}`);
321
518
  }
322
- }
323
519
 
324
- if (!encryptedBuffer && Array.isArray(shard.replicas) && shard.replicas.length > 0) {
325
520
  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;
521
+ const plain = decryptAes256Gcm(encryptedBuffer, keyBuffer, shardMeta.iv, shardMeta.authTag);
522
+ plainParts.push(plain);
523
+ } catch (error) {
524
+ if (String(error.message || '').includes('unsupported state or unable to authenticate data')) {
525
+ throw new Error(
526
+ `decryption failed for shard ${shard.shardId}: key is incorrect or metadata/key mismatch (double-check key-base64 copy)`
527
+ );
333
528
  }
334
- } catch (relayError) {
335
- console.warn(`[download] relay fetch failed for shard ${shard.shardId}: ${relayError.message}`);
529
+ throw error;
336
530
  }
337
531
  }
338
532
 
339
- if (!encryptedBuffer) {
340
- throw new Error(`failed to fetch valid replica for shard ${shard.shardId}`);
341
- }
533
+ reconstructed = Buffer.concat(plainParts);
534
+ } catch (error) {
535
+ console.warn(`[download] layer1 failed for ${fileId}: ${error.message}`);
342
536
 
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
- }
537
+ const filecoinCid = manifest.file?.filecoinCid;
538
+ if (!filecoinCid) {
352
539
  throw error;
353
540
  }
354
- }
355
541
 
356
- const reconstructed = Buffer.concat(plainParts);
542
+ const encryptedFileBuffer = await retrieveFromFilecoin(filecoinCid);
543
+ if (!encryptedFileBuffer) {
544
+ throw new Error('filecoin retrieval failed');
545
+ }
546
+
547
+ const encryptedShardBuffers = splitEncryptedBufferBySizes(
548
+ encryptedFileBuffer,
549
+ orderedShards.map((shard) => shard.sizeBytes)
550
+ );
551
+
552
+ reconstructed = decryptShardsFromBuffers(orderedShards, shardMetaMap, encryptedShardBuffers, keyBuffer);
553
+ fireAndForgetLayer1Reseed(api, fileId, orderedShards, encryptedShardBuffers, directTimeoutMs);
554
+ }
357
555
  await fs.mkdir(path.dirname(output), { recursive: true });
358
556
  await fs.writeFile(output, reconstructed);
359
557
 
package/src/user/index.js CHANGED
File without changes