lsh-framework 3.2.4 → 3.5.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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -34
  3. package/dist/commands/ipfs.js +7 -12
  4. package/dist/commands/sync.js +51 -39
  5. package/dist/constants/config.js +3 -0
  6. package/dist/lib/floating-point-arithmetic.js +2 -2
  7. package/dist/lib/ipfs-client-manager.js +51 -13
  8. package/dist/lib/ipfs-secrets-storage.js +21 -16
  9. package/dist/lib/ipfs-sync.js +88 -14
  10. package/dist/lib/secrets-manager.js +117 -47
  11. package/dist/lib/sync-key-store.js +87 -0
  12. package/dist/services/secrets/secrets.js +77 -39
  13. package/package.json +16 -16
  14. package/dist/__tests__/fixtures/job-fixtures.js +0 -204
  15. package/dist/__tests__/fixtures/supabase-mocks.js +0 -252
  16. package/dist/daemon/job-registry.js +0 -556
  17. package/dist/daemon/lshd.js +0 -968
  18. package/dist/daemon/saas-api-routes.js +0 -599
  19. package/dist/daemon/saas-api-server.js +0 -231
  20. package/dist/examples/supabase-integration.js +0 -106
  21. package/dist/lib/api-response.js +0 -226
  22. package/dist/lib/base-command-registrar.js +0 -287
  23. package/dist/lib/base-job-manager.js +0 -295
  24. package/dist/lib/cloud-config-manager.js +0 -348
  25. package/dist/lib/cron-job-manager.js +0 -368
  26. package/dist/lib/daemon-client-helper.js +0 -145
  27. package/dist/lib/daemon-client.js +0 -513
  28. package/dist/lib/database-persistence.js +0 -727
  29. package/dist/lib/database-schema.js +0 -259
  30. package/dist/lib/database-types.js +0 -90
  31. package/dist/lib/enhanced-history-system.js +0 -247
  32. package/dist/lib/history-system.js +0 -246
  33. package/dist/lib/job-manager.js +0 -436
  34. package/dist/lib/job-storage-database.js +0 -164
  35. package/dist/lib/job-storage-memory.js +0 -73
  36. package/dist/lib/local-storage-adapter.js +0 -507
  37. package/dist/lib/optimized-job-scheduler.js +0 -356
  38. package/dist/lib/saas-audit.js +0 -215
  39. package/dist/lib/saas-auth.js +0 -465
  40. package/dist/lib/saas-billing.js +0 -503
  41. package/dist/lib/saas-email.js +0 -403
  42. package/dist/lib/saas-encryption.js +0 -221
  43. package/dist/lib/saas-organizations.js +0 -662
  44. package/dist/lib/saas-secrets.js +0 -408
  45. package/dist/lib/saas-types.js +0 -165
  46. package/dist/lib/supabase-client.js +0 -125
  47. package/dist/lib/supabase-utils.js +0 -396
  48. package/dist/services/cron/cron-registrar.js +0 -240
  49. package/dist/services/cron/cron.js +0 -9
  50. package/dist/services/daemon/daemon-registrar.js +0 -585
  51. package/dist/services/daemon/daemon.js +0 -9
  52. package/dist/services/supabase/supabase-registrar.js +0 -375
  53. package/dist/services/supabase/supabase.js +0 -9
@@ -5,14 +5,20 @@
5
5
  import * as fs from 'fs';
6
6
  import * as path from 'path';
7
7
  import * as os from 'os';
8
- import { exec, spawn } from 'child_process';
8
+ import { exec, execFile, spawn } from 'child_process';
9
9
  import { promisify } from 'util';
10
10
  import * as readline from 'readline';
11
11
  import { createLogger } from './logger.js';
12
12
  import { getPlatformInfo } from './platform-utils.js';
13
13
  import { getLshConfig } from './lsh-config.js';
14
14
  const execAsync = promisify(exec);
15
+ // execFile does NOT spawn a shell: arguments are passed literally to the binary,
16
+ // so interpolated values (e.g. a Kubo version) cannot inject shell commands.
17
+ const execFileAsync = promisify(execFile);
15
18
  const logger = createLogger('IPFSClientManager');
19
+ // Kubo releases are strict semver (e.g. "0.26.0"). Reject anything else before it
20
+ // reaches a download URL or subprocess — defense in depth against command injection.
21
+ const KUBO_VERSION_RE = /^\d+\.\d+\.\d+$/;
16
22
  /**
17
23
  * IPFS Client Manager
18
24
  *
@@ -91,6 +97,9 @@ export class IPFSClientManager {
91
97
  logger.info('📦 Installing IPFS client (Kubo)...');
92
98
  // Determine version to install
93
99
  const version = options.version || await this.getLatestKuboVersion();
100
+ if (!KUBO_VERSION_RE.test(version)) {
101
+ throw new Error(`Invalid Kubo version "${version}": expected semver like 0.26.0`);
102
+ }
94
103
  logger.info(` Version: ${version}`);
95
104
  logger.info(` Platform: ${platformInfo.platformName} ${platformInfo.arch}`);
96
105
  // Download and install based on platform
@@ -200,7 +209,7 @@ export class IPFSClientManager {
200
209
  }
201
210
  catch (initError) {
202
211
  const err = initError;
203
- throw new Error(`Failed to auto-initialize IPFS repo: ${err.message}`);
212
+ throw new Error(`Failed to auto-initialize IPFS repo: ${err.message}`, { cause: initError });
204
213
  }
205
214
  }
206
215
  logger.info('🚀 Starting IPFS daemon...');
@@ -267,6 +276,24 @@ export class IPFSClientManager {
267
276
  */
268
277
  async stop() {
269
278
  const pidPath = path.join(this.ipfsDir, 'daemon.pid');
279
+ // Try graceful shutdown via IPFS API first (works even without PID file)
280
+ try {
281
+ const response = await fetch('http://127.0.0.1:5001/api/v0/shutdown', {
282
+ method: 'POST',
283
+ signal: AbortSignal.timeout(5000),
284
+ });
285
+ if (response.ok) {
286
+ // Clean up PID file if it exists
287
+ if (fs.existsSync(pidPath)) {
288
+ fs.unlinkSync(pidPath);
289
+ }
290
+ logger.info('✅ IPFS daemon stopped');
291
+ return;
292
+ }
293
+ }
294
+ catch {
295
+ // API shutdown failed, fall back to PID-based kill
296
+ }
270
297
  if (!fs.existsSync(pidPath)) {
271
298
  logger.info('ℹ️ IPFS daemon not running (no PID file)');
272
299
  return;
@@ -280,6 +307,13 @@ export class IPFSClientManager {
280
307
  }
281
308
  catch (error) {
282
309
  const err = error;
310
+ // Process already gone — clean up stale PID file
311
+ if (err.code === 'ESRCH') {
312
+ if (fs.existsSync(pidPath))
313
+ fs.unlinkSync(pidPath);
314
+ logger.info('✅ IPFS daemon already stopped (stale PID file cleaned)');
315
+ return;
316
+ }
283
317
  logger.error(`❌ Failed to stop daemon: ${err.message}`);
284
318
  throw error;
285
319
  }
@@ -289,8 +323,12 @@ export class IPFSClientManager {
289
323
  */
290
324
  async getLatestKuboVersion() {
291
325
  try {
292
- // Use GitHub API to get latest release
293
- const response = await fetch('https://api.github.com/repos/ipfs/kubo/releases/latest');
326
+ // Use GitHub API to get latest release. Bound the request: an unbounded
327
+ // fetch hangs indefinitely on a blocked/slow network (e.g. CI runners),
328
+ // which would stall install() and time out tests before the fallback.
329
+ const response = await fetch('https://api.github.com/repos/ipfs/kubo/releases/latest', {
330
+ signal: AbortSignal.timeout(3000),
331
+ });
294
332
  const data = await response.json();
295
333
  // Remove 'v' prefix if present
296
334
  return data.tag_name.replace(/^v/, '');
@@ -308,11 +346,11 @@ export class IPFSClientManager {
308
346
  const downloadUrl = `https://dist.ipfs.tech/kubo/v${version}/kubo_v${version}_darwin-${arch}.tar.gz`;
309
347
  const tarPath = path.join(this.ipfsDir, 'kubo.tar.gz');
310
348
  logger.info(' Downloading Kubo...');
311
- // Download
312
- await execAsync(`curl -L -o ${tarPath} ${downloadUrl}`);
349
+ // Download (execFile: no shell, args passed literally)
350
+ await execFileAsync('curl', ['-L', '-o', tarPath, downloadUrl]);
313
351
  logger.info(' Extracting...');
314
352
  // Extract
315
- await execAsync(`tar -xzf ${tarPath} -C ${this.ipfsDir}`);
353
+ await execFileAsync('tar', ['-xzf', tarPath, '-C', this.ipfsDir]);
316
354
  // Move binary
317
355
  const extractedBinPath = path.join(this.ipfsDir, 'kubo', 'ipfs');
318
356
  fs.mkdirSync(this.binDir, { recursive: true });
@@ -331,11 +369,11 @@ export class IPFSClientManager {
331
369
  const downloadUrl = `https://dist.ipfs.tech/kubo/v${version}/kubo_v${version}_linux-${arch}.tar.gz`;
332
370
  const tarPath = path.join(this.ipfsDir, 'kubo.tar.gz');
333
371
  logger.info(' Downloading Kubo...');
334
- // Download
335
- await execAsync(`curl -L -o ${tarPath} ${downloadUrl}`);
372
+ // Download (execFile: no shell, args passed literally)
373
+ await execFileAsync('curl', ['-L', '-o', tarPath, downloadUrl]);
336
374
  logger.info(' Extracting...');
337
375
  // Extract
338
- await execAsync(`tar -xzf ${tarPath} -C ${this.ipfsDir}`);
376
+ await execFileAsync('tar', ['-xzf', tarPath, '-C', this.ipfsDir]);
339
377
  // Move binary
340
378
  const extractedBinPath = path.join(this.ipfsDir, 'kubo', 'ipfs');
341
379
  fs.mkdirSync(this.binDir, { recursive: true });
@@ -353,11 +391,11 @@ export class IPFSClientManager {
353
391
  const downloadUrl = `https://dist.ipfs.tech/kubo/v${version}/kubo_v${version}_windows-amd64.zip`;
354
392
  const zipPath = path.join(this.ipfsDir, 'kubo.zip');
355
393
  logger.info(' Downloading Kubo...');
356
- // Download
357
- await execAsync(`curl -L -o ${zipPath} ${downloadUrl}`);
394
+ // Download (execFile: no shell, args passed literally)
395
+ await execFileAsync('curl', ['-L', '-o', zipPath, downloadUrl]);
358
396
  logger.info(' Extracting...');
359
397
  // Extract (Windows has built-in tar that supports zip)
360
- await execAsync(`tar -xf ${zipPath} -C ${this.ipfsDir}`);
398
+ await execFileAsync('tar', ['-xf', zipPath, '-C', this.ipfsDir]);
361
399
  // Move binary
362
400
  const extractedBinPath = path.join(this.ipfsDir, 'kubo', 'ipfs.exe');
363
401
  fs.mkdirSync(this.binDir, { recursive: true });
@@ -15,6 +15,7 @@ import { createLogger } from './logger.js';
15
15
  import { getIPFSSync } from './ipfs-sync.js';
16
16
  import { deriveKeyInfo, ensureKeyImported } from './ipns-key-manager.js';
17
17
  import { ENV_VARS, DEFAULTS } from '../constants/index.js';
18
+ import { extractErrorMessage } from './lsh-error.js';
18
19
  const logger = createLogger('IPFSSecretsStorage');
19
20
  /**
20
21
  * IPFS Secrets Storage
@@ -107,16 +108,23 @@ export class IPFSSecretsStorage {
107
108
  }
108
109
  }
109
110
  catch (error) {
110
- const err = error;
111
- logger.error(`Content uploaded (CID: ${cid}) but IPNS publish failed: ${err.message}\n` +
111
+ logger.error(`Content uploaded (CID: ${cid}) but IPNS publish failed: ${extractErrorMessage(error)}\n` +
112
112
  `Other machines won't find it via 'lsh pull' until you re-push.`);
113
113
  }
114
114
  }
115
+ // Durable remote pin (best-effort): survives this machine going offline.
116
+ // No-op unless a kubo remote pinning service is configured.
117
+ const pinnedService = await ipfsSync.addRemotePin(cid, `lsh-${gitRepo || DEFAULTS.DEFAULT_ENVIRONMENT}-${environment}`);
118
+ if (pinnedService) {
119
+ logger.info(` 📌 Remote-pinned to "${pinnedService}" (durable)`);
120
+ }
121
+ else {
122
+ logger.warn('No remote pin — content lives only on this machine until a peer caches it. Configure LSH_PIN_SERVICE for durability.');
123
+ }
115
124
  return cid;
116
125
  }
117
126
  catch (error) {
118
- const err = error;
119
- logger.error(`Failed to push secrets to IPFS: ${err.message}`);
127
+ logger.error(`Failed to push secrets to IPFS: ${extractErrorMessage(error)}`);
120
128
  throw error;
121
129
  }
122
130
  }
@@ -155,8 +163,7 @@ export class IPFSSecretsStorage {
155
163
  }
156
164
  }
157
165
  catch (error) {
158
- const err = error;
159
- logger.debug(` IPNS resolution error: ${err.message}`);
166
+ logger.debug(` IPNS resolution error: ${extractErrorMessage(error)}`);
160
167
  }
161
168
  }
162
169
  // No fallback to local metadata — IPNS is the source of truth
@@ -185,8 +192,7 @@ export class IPFSSecretsStorage {
185
192
  }
186
193
  }
187
194
  catch (error) {
188
- const err = error;
189
- logger.debug(` IPFS download failed: ${err.message}`);
195
+ logger.debug(` IPFS download failed: ${extractErrorMessage(error)}`);
190
196
  }
191
197
  }
192
198
  if (!cachedData) {
@@ -201,8 +207,7 @@ export class IPFSSecretsStorage {
201
207
  return secrets;
202
208
  }
203
209
  catch (error) {
204
- const err = error;
205
- logger.error(`Failed to pull secrets from IPFS: ${err.message}`);
210
+ logger.error(`Failed to pull secrets from IPFS: ${extractErrorMessage(error)}`);
206
211
  throw error;
207
212
  }
208
213
  }
@@ -275,19 +280,19 @@ export class IPFSSecretsStorage {
275
280
  return JSON.parse(decrypted);
276
281
  }
277
282
  catch (error) {
278
- const err = error;
283
+ const msg = extractErrorMessage(error);
279
284
  // Catch crypto errors (bad decrypt, wrong block length) AND JSON parse errors
280
285
  // (wrong key can produce garbage that fails JSON.parse)
281
- if (err.message.includes('bad decrypt') ||
282
- err.message.includes('wrong final block length') ||
283
- err.message.includes('Unexpected token') ||
284
- err.message.includes('JSON')) {
286
+ if (msg.includes('bad decrypt') ||
287
+ msg.includes('wrong final block length') ||
288
+ msg.includes('Unexpected token') ||
289
+ msg.includes('JSON')) {
285
290
  throw new Error('Decryption failed. This usually means:\n' +
286
291
  ' 1. You need to set LSH_SECRETS_KEY environment variable\n' +
287
292
  ' 2. The key must match the one used during encryption\n' +
288
293
  ' 3. Generate a shared key with: lsh key\n' +
289
294
  ' 4. Add it to your .env: LSH_SECRETS_KEY=<key>\n' +
290
- '\nOriginal error: ' + err.message);
295
+ '\nOriginal error: ' + msg, { cause: error });
291
296
  }
292
297
  throw error;
293
298
  }
@@ -14,6 +14,8 @@ import * as fsPromises from 'fs/promises';
14
14
  import * as path from 'path';
15
15
  import * as os from 'os';
16
16
  import { createLogger } from './logger.js';
17
+ import { extractErrorMessage } from './lsh-error.js';
18
+ import { ENV_VARS } from '../constants/config.js';
17
19
  const logger = createLogger('IPFSSync');
18
20
  /**
19
21
  * Native IPFS Sync
@@ -123,8 +125,7 @@ export class IPFSSync {
123
125
  return cid;
124
126
  }
125
127
  catch (error) {
126
- const err = error;
127
- logger.error(`IPFS upload error: ${err.message}`);
128
+ logger.error(`IPFS upload error: ${extractErrorMessage(error)}`);
128
129
  return null;
129
130
  }
130
131
  }
@@ -151,6 +152,7 @@ export class IPFSSync {
151
152
  logger.debug('Local daemon download failed, trying gateways...');
152
153
  }
153
154
  // Fall back to public gateways
155
+ let backoffMs = 1000;
154
156
  for (const gatewayTemplate of this.GATEWAYS) {
155
157
  const gatewayUrl = gatewayTemplate.replace('{cid}', cid);
156
158
  try {
@@ -165,6 +167,8 @@ export class IPFSSync {
165
167
  }
166
168
  catch {
167
169
  logger.debug(`Gateway ${gatewayUrl} failed, trying next...`);
170
+ await new Promise((resolve) => { setTimeout(resolve, backoffMs); });
171
+ backoffMs = Math.min(backoffMs * 2, 10000);
168
172
  }
169
173
  }
170
174
  logger.error(`Failed to download CID: ${cid}`);
@@ -229,8 +233,7 @@ export class IPFSSync {
229
233
  return false;
230
234
  }
231
235
  catch (error) {
232
- const err = error;
233
- logger.error(`Pin failed: ${err.message}`);
236
+ logger.error(`Pin failed: ${extractErrorMessage(error)}`);
234
237
  return false;
235
238
  }
236
239
  }
@@ -255,8 +258,7 @@ export class IPFSSync {
255
258
  return false;
256
259
  }
257
260
  catch (error) {
258
- const err = error;
259
- logger.error(`Unpin failed: ${err.message}`);
261
+ logger.error(`Unpin failed: ${extractErrorMessage(error)}`);
260
262
  return false;
261
263
  }
262
264
  }
@@ -297,8 +299,7 @@ export class IPFSSync {
297
299
  await fsPromises.writeFile(this.historyPath, JSON.stringify(history, null, 2), 'utf-8');
298
300
  }
299
301
  catch (error) {
300
- const err = error;
301
- logger.debug(`Failed to save history: ${err.message}`);
302
+ logger.debug(`Failed to save history: ${extractErrorMessage(error)}`);
302
303
  }
303
304
  }
304
305
  /**
@@ -327,8 +328,7 @@ export class IPFSSync {
327
328
  }
328
329
  }
329
330
  catch (error) {
330
- const err = error;
331
- logger.error(`Failed to clear history: ${err.message}`);
331
+ logger.error(`Failed to clear history: ${extractErrorMessage(error)}`);
332
332
  }
333
333
  }
334
334
  /**
@@ -343,6 +343,82 @@ export class IPFSSync {
343
343
  getApiUrl() {
344
344
  return this.LOCAL_IPFS_API;
345
345
  }
346
+ /**
347
+ * List the names of remote pinning services configured in the local Kubo
348
+ * node (via `ipfs pin remote service add`). Returns [] on any error.
349
+ */
350
+ async listRemoteServices() {
351
+ try {
352
+ const response = await fetch(`${this.LOCAL_IPFS_API}/pin/remote/service/ls`, {
353
+ method: 'POST',
354
+ signal: AbortSignal.timeout(5000),
355
+ });
356
+ if (!response.ok)
357
+ return [];
358
+ const data = await response.json();
359
+ return (data.RemoteServices || []).map((s) => s.Service).filter(Boolean);
360
+ }
361
+ catch {
362
+ return [];
363
+ }
364
+ }
365
+ /**
366
+ * Decide which remote pinning service to pin to.
367
+ * - If LSH_PIN_SERVICE is set, use it only when it is actually configured.
368
+ * - Otherwise, use the sole configured service when exactly one exists.
369
+ * - Returns null when nothing is configured or the choice is ambiguous.
370
+ */
371
+ async resolveRemoteService() {
372
+ const services = await this.listRemoteServices();
373
+ const explicit = process.env[ENV_VARS.LSH_PIN_SERVICE];
374
+ if (explicit) {
375
+ return services.includes(explicit) ? explicit : null;
376
+ }
377
+ return services.length === 1 ? services[0] : null;
378
+ }
379
+ /**
380
+ * Pin a CID to a configured remote pinning service so the content survives
381
+ * this machine going offline. This is what makes "pull anywhere, anytime"
382
+ * real: without it, blocks live only on the pushing node.
383
+ *
384
+ * Returns the service name on success, or null when no service is
385
+ * configured (the common zero-config case) or the pin request failed.
386
+ * Never throws — durable pinning is best-effort and the caller decides how
387
+ * loudly to warn.
388
+ */
389
+ async addRemotePin(cid, pinName) {
390
+ try {
391
+ const service = await this.resolveRemoteService();
392
+ if (!service)
393
+ return null;
394
+ const url = `${this.LOCAL_IPFS_API}/pin/remote/add` +
395
+ `?arg=${encodeURIComponent(cid)}` +
396
+ `&service=${encodeURIComponent(service)}` +
397
+ `&name=${encodeURIComponent(pinName)}` +
398
+ `&background=true`;
399
+ const response = await fetch(url, {
400
+ method: 'POST',
401
+ signal: AbortSignal.timeout(30000),
402
+ });
403
+ if (!response.ok) {
404
+ const errorText = await response.text();
405
+ // An already-present pin is reported as an error by some services; treat
406
+ // a "already pinned"/"duplicate" message as success rather than a failure.
407
+ if (/already|duplicate|exists/i.test(errorText)) {
408
+ logger.info(`📌 Already remote-pinned on "${service}": ${cid}`);
409
+ return service;
410
+ }
411
+ logger.warn(`Remote pin failed on "${service}": ${errorText}`);
412
+ return null;
413
+ }
414
+ logger.info(`📌 Remote-pinned to "${service}" (durable): ${cid}`);
415
+ return service;
416
+ }
417
+ catch (error) {
418
+ logger.warn(`Remote pin error: ${extractErrorMessage(error)}`);
419
+ return null;
420
+ }
421
+ }
346
422
  /**
347
423
  * Publish a CID to IPNS under the given key name.
348
424
  * The key must already be imported into Kubo.
@@ -371,8 +447,7 @@ export class IPFSSync {
371
447
  return data.Name;
372
448
  }
373
449
  catch (error) {
374
- const err = error;
375
- logger.warn(`IPNS publish error (attempt ${attempt}): ${err.message}`);
450
+ logger.warn(`IPNS publish error (attempt ${attempt}): ${extractErrorMessage(error)}`);
376
451
  if (attempt === 2) {
377
452
  logger.error(`IPNS publish failed after retry. Content is uploaded (CID: ${cid}) ` +
378
453
  `but other machines won't find it via 'lsh pull' until you re-push.`);
@@ -402,8 +477,7 @@ export class IPFSSync {
402
477
  return resolvedCid;
403
478
  }
404
479
  catch (error) {
405
- const err = error;
406
- logger.debug(`IPNS resolve error: ${err.message}`);
480
+ logger.debug(`IPNS resolve error: ${extractErrorMessage(error)}`);
407
481
  return null;
408
482
  }
409
483
  }