lsh-framework 3.5.1 → 3.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -47
- package/dist/constants/config.js +15 -0
- package/dist/lib/discovery-backend.js +150 -0
- package/dist/lib/ipfs-secrets-storage.js +12 -14
- package/dist/lib/ipfs-sync.js +52 -5
- package/dist/lib/w3name-pointer.js +72 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -10,15 +10,13 @@
|
|
|
10
10
|
[](https://github.com/gwicho38/lsh/actions/workflows/node.js.yml)
|
|
11
11
|
[](https://opensource.org/licenses/MIT)
|
|
12
12
|
|
|
13
|
-
## What's New in v3.
|
|
13
|
+
## What's New in v3.5.x
|
|
14
14
|
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
- **Test Coverage** - 59 new tests for constants modules (142 total constants tests)
|
|
19
|
-
- **Code Quality** - Lint warnings reduced from 744 to 550 (26.1% reduction)
|
|
15
|
+
- **Focused on secrets** - Removed the dormant pre-pivot platform code (SaaS multi-tenant, job/cron daemon, Supabase/Postgres persistence). LSH is now purely an encrypted `.env` sync tool over IPFS.
|
|
16
|
+
- **Dependency modernization** - Express 5, TypeScript 6, ESLint 10, Jest 30.
|
|
17
|
+
- **Hardening** - Bounded network calls (no hangs), command-injection fix in the Kubo installer, reliable npm publishing.
|
|
20
18
|
|
|
21
|
-
See [Release Notes](docs/releases/3.
|
|
19
|
+
See [Release Notes](docs/releases/3.5.0.md) for full details.
|
|
22
20
|
|
|
23
21
|
## Quick Start
|
|
24
22
|
|
|
@@ -117,6 +115,21 @@ lsh sync push --env dev
|
|
|
117
115
|
|
|
118
116
|
If exactly one remote service is configured, `lsh` uses it automatically and `LSH_PIN_SERVICE` is optional.
|
|
119
117
|
|
|
118
|
+
### Quickest: bundled pinner (just a token)
|
|
119
|
+
|
|
120
|
+
Skip the manual `ipfs pin remote service add`. Set **`LSH_PIN_TOKEN`** and `lsh` auto-registers a
|
|
121
|
+
remote pinning service for you on first push — defaulting to **4EVERLAND** (free 5GB, standard
|
|
122
|
+
Pinning Service API):
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
# Get a free accessToken from the 4EVERLAND "4EVER Pin" page (https://4everland.org)
|
|
126
|
+
export LSH_PIN_TOKEN=<your-4everland-accessToken>
|
|
127
|
+
lsh push --env dev # auto-registers "lsh-pin" → https://api.4everland.dev, then pins
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Use a different provider by overriding the endpoint: `export LSH_PIN_ENDPOINT=<psa-endpoint>`.
|
|
131
|
+
(Note: Pinata's pin-by-CID PSA is paid-only; 4EVERLAND and Filebase offer it free.)
|
|
132
|
+
|
|
120
133
|
## Installation
|
|
121
134
|
|
|
122
135
|
### Prerequisites
|
|
@@ -211,22 +224,17 @@ lsh pull --env prod
|
|
|
211
224
|
|
|
212
225
|
## Automatic Secret Rotation
|
|
213
226
|
|
|
214
|
-
|
|
227
|
+
Schedule rotation with any external scheduler (system `cron`, a CI job, etc.) that
|
|
228
|
+
runs your rotation script and then pushes the updated secrets:
|
|
215
229
|
|
|
216
230
|
```bash
|
|
217
|
-
#
|
|
218
|
-
lsh
|
|
219
|
-
|
|
220
|
-
# Schedule monthly key rotation
|
|
221
|
-
lsh cron add \
|
|
222
|
-
--name "rotate-keys" \
|
|
223
|
-
--schedule "0 0 1 * *" \
|
|
224
|
-
--command "./scripts/rotate.sh && lsh push"
|
|
225
|
-
|
|
226
|
-
# List scheduled jobs
|
|
227
|
-
lsh cron list
|
|
231
|
+
# Example: monthly rotation via crontab — `crontab -e`
|
|
232
|
+
0 0 1 * * cd /path/to/project && ./scripts/rotate.sh && lsh push
|
|
228
233
|
```
|
|
229
234
|
|
|
235
|
+
Or as a scheduled CI job (GitHub Actions, etc.) that runs the script and `lsh push`.
|
|
236
|
+
LSH focuses on encrypting and syncing the `.env`; the rotation policy/schedule is yours.
|
|
237
|
+
|
|
230
238
|
## Export Formats
|
|
231
239
|
|
|
232
240
|
Export secrets in multiple formats:
|
|
@@ -322,27 +330,6 @@ lsh push
|
|
|
322
330
|
- **[Installation](docs/deployment/INSTALL.md)** - Detailed installation
|
|
323
331
|
- **[Developer Guide](CLAUDE.md)** - Contributing to LSH
|
|
324
332
|
|
|
325
|
-
## Advanced Features
|
|
326
|
-
|
|
327
|
-
LSH includes a full automation platform:
|
|
328
|
-
|
|
329
|
-
- **Persistent Daemon** - Background job execution
|
|
330
|
-
- **Cron Scheduling** - Time-based job scheduling
|
|
331
|
-
- **REST API** - HTTP API for integration
|
|
332
|
-
- **CI/CD Webhooks** - GitHub/GitLab webhook support
|
|
333
|
-
- **POSIX Shell** - Interactive shell with ZSH features
|
|
334
|
-
|
|
335
|
-
```bash
|
|
336
|
-
# Start daemon
|
|
337
|
-
lsh daemon start
|
|
338
|
-
|
|
339
|
-
# API server
|
|
340
|
-
LSH_API_KEY=xxx lsh api start --port 3030
|
|
341
|
-
|
|
342
|
-
# Interactive shell
|
|
343
|
-
lsh -i
|
|
344
|
-
```
|
|
345
|
-
|
|
346
333
|
## Configuration
|
|
347
334
|
|
|
348
335
|
### Environment Variables
|
|
@@ -355,14 +342,15 @@ LSH_SECRETS_KEY=<your-encryption-key>
|
|
|
355
342
|
# (configure once with: ipfs pin remote service add <name> <endpoint> <key>)
|
|
356
343
|
LSH_PIN_SERVICE=<service-name>
|
|
357
344
|
|
|
358
|
-
# Optional -
|
|
359
|
-
|
|
360
|
-
|
|
345
|
+
# Optional - pointer discovery backends, comma-separated in priority order.
|
|
346
|
+
# Default 'w3name,ipns': durable w3name (signed IPNS via name.web3.storage, no
|
|
347
|
+
# account, no DHT TTL) with IPNS-over-DHT fallback. Set 'ipns' for DHT-only.
|
|
348
|
+
LSH_DISCOVERY=w3name,ipns
|
|
361
349
|
|
|
362
|
-
# Optional -
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
350
|
+
# Optional - bundled pinner: with a token set, lsh auto-registers a remote pin
|
|
351
|
+
# service so pushed content is durable. Endpoint defaults to 4EVERLAND (free 5GB).
|
|
352
|
+
LSH_PIN_TOKEN=<psa-access-token>
|
|
353
|
+
LSH_PIN_ENDPOINT=https://api.4everland.dev # override for another PSA provider
|
|
366
354
|
```
|
|
367
355
|
|
|
368
356
|
### Configuration Files
|
package/dist/constants/config.js
CHANGED
|
@@ -33,6 +33,15 @@ export const ENV_VARS = {
|
|
|
33
33
|
// Name of the kubo remote pinning service to use for durable sync
|
|
34
34
|
// (configured via `ipfs pin remote service add <name> <endpoint> <key>`).
|
|
35
35
|
LSH_PIN_SERVICE: 'LSH_PIN_SERVICE',
|
|
36
|
+
// Discovery backend(s) for the key→CID pointer, comma-separated in priority
|
|
37
|
+
// order. Supported: 'w3name' (durable, hosted), 'ipns' (DHT). Default: 'w3name,ipns'.
|
|
38
|
+
LSH_DISCOVERY: 'LSH_DISCOVERY',
|
|
39
|
+
// Access token for a bundled IPFS remote pinning service. When set (and no
|
|
40
|
+
// remote pin service is already configured), lsh auto-registers one so pushed
|
|
41
|
+
// content is durably pinned. Endpoint defaults to 4EVERLAND (free 5GB).
|
|
42
|
+
LSH_PIN_TOKEN: 'LSH_PIN_TOKEN',
|
|
43
|
+
// Override the PSA endpoint used with LSH_PIN_TOKEN (any IPFS Pinning Service).
|
|
44
|
+
LSH_PIN_ENDPOINT: 'LSH_PIN_ENDPOINT',
|
|
36
45
|
// Feature flags
|
|
37
46
|
LSH_LOCAL_STORAGE_QUIET: 'LSH_LOCAL_STORAGE_QUIET',
|
|
38
47
|
LSH_V1_COMPAT: 'LSH_V1_COMPAT',
|
|
@@ -143,4 +152,10 @@ export const DEFAULTS = {
|
|
|
143
152
|
IPNS_RESOLVE_TIMEOUT_MS: 30000, // 30s for DHT lookup
|
|
144
153
|
IPNS_KEY_PREFIX: 'lsh-',
|
|
145
154
|
IPNS_KEY_DERIVATION_CONTEXT: 'lsh-ipns-v1',
|
|
155
|
+
// Default discovery backends (priority order): durable w3name, then DHT-IPNS fallback.
|
|
156
|
+
DISCOVERY_BACKENDS: 'w3name,ipns',
|
|
157
|
+
// Bundled remote pinning service (used with LSH_PIN_TOKEN). 4EVERLAND free tier (5GB),
|
|
158
|
+
// standard IPFS Pinning Service API. Override the endpoint via LSH_PIN_ENDPOINT.
|
|
159
|
+
DEFAULT_PIN_ENDPOINT: 'https://api.4everland.dev',
|
|
160
|
+
DEFAULT_PIN_SERVICE_NAME: 'lsh-pin',
|
|
146
161
|
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovery backend seam.
|
|
3
|
+
*
|
|
4
|
+
* "Discovery" = mapping a key-derived pointer to the latest content CID
|
|
5
|
+
* (publish on push, resolve on pull). Today the only backend is IPNS over the
|
|
6
|
+
* DHT (`IpnsDiscoveryBackend`), which is what LSH has always used — this module
|
|
7
|
+
* just extracts that behind an interface so additional backends (e.g. a durable
|
|
8
|
+
* w3name pointer; see issue #194) can be added without touching the storage layer.
|
|
9
|
+
*
|
|
10
|
+
* This is a behaviour-preserving refactor: `getDiscoveryBackend()` returns the
|
|
11
|
+
* IPNS backend, identical to the previous inline logic.
|
|
12
|
+
*/
|
|
13
|
+
import { deriveKeyInfo as defaultDeriveKeyInfo, ensureKeyImported as defaultEnsureKeyImported, } from './ipns-key-manager.js';
|
|
14
|
+
import { w3namePublish as defaultW3namePublish, w3nameResolve as defaultW3nameResolve, } from './w3name-pointer.js';
|
|
15
|
+
import { ENV_VARS, DEFAULTS } from '../constants/config.js';
|
|
16
|
+
import { createLogger } from './logger.js';
|
|
17
|
+
const logger = createLogger('Discovery');
|
|
18
|
+
/**
|
|
19
|
+
* IPNS-over-DHT discovery: derive a deterministic ed25519 key from the secrets
|
|
20
|
+
* key, import it into the local Kubo keystore, and publish/resolve via IPNS.
|
|
21
|
+
*/
|
|
22
|
+
export class IpnsDiscoveryBackend {
|
|
23
|
+
ipfsSync;
|
|
24
|
+
deps;
|
|
25
|
+
id = 'ipns';
|
|
26
|
+
constructor(ipfsSync, deps = {
|
|
27
|
+
deriveKeyInfo: defaultDeriveKeyInfo,
|
|
28
|
+
ensureKeyImported: defaultEnsureKeyImported,
|
|
29
|
+
}) {
|
|
30
|
+
this.ipfsSync = ipfsSync;
|
|
31
|
+
this.deps = deps;
|
|
32
|
+
}
|
|
33
|
+
async publish({ secretsKey, repoName, env, cid }) {
|
|
34
|
+
const keyInfo = this.deps.deriveKeyInfo(secretsKey, repoName, env);
|
|
35
|
+
const ipnsName = await this.deps.ensureKeyImported(this.ipfsSync.getApiUrl(), keyInfo);
|
|
36
|
+
if (!ipnsName) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return this.ipfsSync.publishToIPNS(cid, keyInfo.keyName);
|
|
40
|
+
}
|
|
41
|
+
async resolve({ secretsKey, repoName, env }) {
|
|
42
|
+
const keyInfo = this.deps.deriveKeyInfo(secretsKey, repoName, env);
|
|
43
|
+
const ipnsName = await this.deps.ensureKeyImported(this.ipfsSync.getApiUrl(), keyInfo);
|
|
44
|
+
if (!ipnsName) {
|
|
45
|
+
return { cid: null, name: null };
|
|
46
|
+
}
|
|
47
|
+
const cid = await this.ipfsSync.resolveIPNS(ipnsName);
|
|
48
|
+
return { cid, name: ipnsName };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Durable discovery via w3name (Storacha). Publishes/resolves a signed IPNS
|
|
53
|
+
* record hosted at name.web3.storage — survives offline nodes, no DHT TTL, no
|
|
54
|
+
* account. Uses the SAME seed-derived IPNS name as the `ipns` backend.
|
|
55
|
+
*/
|
|
56
|
+
export class W3nameDiscoveryBackend {
|
|
57
|
+
deps;
|
|
58
|
+
id = 'w3name';
|
|
59
|
+
constructor(deps = {
|
|
60
|
+
deriveKeyInfo: defaultDeriveKeyInfo,
|
|
61
|
+
publish: defaultW3namePublish,
|
|
62
|
+
resolve: defaultW3nameResolve,
|
|
63
|
+
}) {
|
|
64
|
+
this.deps = deps;
|
|
65
|
+
}
|
|
66
|
+
async publish({ secretsKey, repoName, env, cid }) {
|
|
67
|
+
const { seed } = this.deps.deriveKeyInfo(secretsKey, repoName, env);
|
|
68
|
+
return this.deps.publish(seed, cid);
|
|
69
|
+
}
|
|
70
|
+
async resolve({ secretsKey, repoName, env }) {
|
|
71
|
+
const { seed } = this.deps.deriveKeyInfo(secretsKey, repoName, env);
|
|
72
|
+
return this.deps.resolve(seed);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Composes backends in priority order. Publish dual-writes to all (best-effort —
|
|
77
|
+
* a failure in one doesn't block the others). Resolve tries each in order and
|
|
78
|
+
* returns the first hit, so a durable backend wins but a fallback still works.
|
|
79
|
+
*/
|
|
80
|
+
export class CompositeDiscoveryBackend {
|
|
81
|
+
backends;
|
|
82
|
+
id;
|
|
83
|
+
constructor(backends) {
|
|
84
|
+
this.backends = backends;
|
|
85
|
+
this.id = backends.map((b) => b.id).join('+');
|
|
86
|
+
}
|
|
87
|
+
async publish(params) {
|
|
88
|
+
let firstName = null;
|
|
89
|
+
for (const backend of this.backends) {
|
|
90
|
+
try {
|
|
91
|
+
const name = await backend.publish(params);
|
|
92
|
+
if (name && !firstName) {
|
|
93
|
+
firstName = name;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
logger.warn(`Discovery publish via "${backend.id}" failed: ${error.message}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return firstName;
|
|
101
|
+
}
|
|
102
|
+
async resolve(params) {
|
|
103
|
+
let firstName = null;
|
|
104
|
+
for (const backend of this.backends) {
|
|
105
|
+
try {
|
|
106
|
+
const result = await backend.resolve(params);
|
|
107
|
+
if (result.name && !firstName) {
|
|
108
|
+
firstName = result.name;
|
|
109
|
+
}
|
|
110
|
+
if (result.cid) {
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
logger.warn(`Discovery resolve via "${backend.id}" failed: ${error.message}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { cid: null, name: firstName };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Construct a single backend by id, or null if unknown. */
|
|
122
|
+
function buildBackend(id, ipfsSync) {
|
|
123
|
+
switch (id) {
|
|
124
|
+
case 'ipns':
|
|
125
|
+
return new IpnsDiscoveryBackend(ipfsSync);
|
|
126
|
+
case 'w3name':
|
|
127
|
+
return new W3nameDiscoveryBackend();
|
|
128
|
+
default:
|
|
129
|
+
logger.warn(`Unknown discovery backend "${id}" — ignoring.`);
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Select discovery backend(s) from `LSH_DISCOVERY` (comma-separated, priority
|
|
135
|
+
* order; default 'w3name,ipns'). Returns a single backend or a composite.
|
|
136
|
+
* Falls back to IPNS if the setting resolves to nothing valid.
|
|
137
|
+
*/
|
|
138
|
+
export function getDiscoveryBackend(ipfsSync) {
|
|
139
|
+
const setting = process.env[ENV_VARS.LSH_DISCOVERY] || DEFAULTS.DISCOVERY_BACKENDS;
|
|
140
|
+
const backends = setting
|
|
141
|
+
.split(',')
|
|
142
|
+
.map((s) => s.trim().toLowerCase())
|
|
143
|
+
.filter(Boolean)
|
|
144
|
+
.map((id) => buildBackend(id, ipfsSync))
|
|
145
|
+
.filter((b) => b !== null);
|
|
146
|
+
if (backends.length === 0) {
|
|
147
|
+
return new IpnsDiscoveryBackend(ipfsSync);
|
|
148
|
+
}
|
|
149
|
+
return backends.length === 1 ? backends[0] : new CompositeDiscoveryBackend(backends);
|
|
150
|
+
}
|
|
@@ -13,7 +13,7 @@ import * as os from 'os';
|
|
|
13
13
|
import * as crypto from 'crypto';
|
|
14
14
|
import { createLogger } from './logger.js';
|
|
15
15
|
import { getIPFSSync } from './ipfs-sync.js';
|
|
16
|
-
import {
|
|
16
|
+
import { getDiscoveryBackend } from './discovery-backend.js';
|
|
17
17
|
import { ENV_VARS, DEFAULTS } from '../constants/index.js';
|
|
18
18
|
import { extractErrorMessage } from './lsh-error.js';
|
|
19
19
|
const logger = createLogger('IPFSSecretsStorage');
|
|
@@ -95,16 +95,13 @@ export class IPFSSecretsStorage {
|
|
|
95
95
|
try {
|
|
96
96
|
const repoName = gitRepo || DEFAULTS.DEFAULT_ENVIRONMENT;
|
|
97
97
|
const env = environment || DEFAULTS.DEFAULT_ENVIRONMENT;
|
|
98
|
-
const
|
|
99
|
-
const
|
|
100
|
-
if (
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
await this.saveMetadata();
|
|
106
|
-
logger.info(` 🔗 Published to IPNS: ${publishedName}`);
|
|
107
|
-
}
|
|
98
|
+
const discovery = getDiscoveryBackend(ipfsSync);
|
|
99
|
+
const publishedName = await discovery.publish({ secretsKey: encryptionKey, repoName, env, cid });
|
|
100
|
+
if (publishedName) {
|
|
101
|
+
metadata.ipns_name = publishedName;
|
|
102
|
+
this.metadata[this.getMetadataKey(gitRepo, environment)] = metadata;
|
|
103
|
+
await this.saveMetadata();
|
|
104
|
+
logger.info(` 🔗 Published to ${discovery.id}: ${publishedName}`);
|
|
108
105
|
}
|
|
109
106
|
}
|
|
110
107
|
catch (error) {
|
|
@@ -140,11 +137,12 @@ export class IPFSSecretsStorage {
|
|
|
140
137
|
try {
|
|
141
138
|
const repoName = gitRepo || DEFAULTS.DEFAULT_ENVIRONMENT;
|
|
142
139
|
const env = environment || DEFAULTS.DEFAULT_ENVIRONMENT;
|
|
143
|
-
const
|
|
144
|
-
const
|
|
140
|
+
const discovery = getDiscoveryBackend(ipfsSync);
|
|
141
|
+
const resolved = await discovery.resolve({ secretsKey: encryptionKey, repoName, env });
|
|
142
|
+
const ipnsName = resolved.name;
|
|
145
143
|
if (ipnsName) {
|
|
146
144
|
logger.info(` 🔍 Resolving via IPNS: ${ipnsName.substring(0, 20)}...`);
|
|
147
|
-
resolvedCid =
|
|
145
|
+
resolvedCid = resolved.cid;
|
|
148
146
|
if (resolvedCid) {
|
|
149
147
|
logger.info(` ✅ IPNS resolved to CID: ${resolvedCid}`);
|
|
150
148
|
// Update local metadata
|
package/dist/lib/ipfs-sync.js
CHANGED
|
@@ -15,7 +15,23 @@ import * as path from 'path';
|
|
|
15
15
|
import * as os from 'os';
|
|
16
16
|
import { createLogger } from './logger.js';
|
|
17
17
|
import { extractErrorMessage } from './lsh-error.js';
|
|
18
|
-
import { ENV_VARS } from '../constants/config.js';
|
|
18
|
+
import { ENV_VARS, DEFAULTS } from '../constants/config.js';
|
|
19
|
+
/**
|
|
20
|
+
* Pure helper: choose which configured remote pinning service to use.
|
|
21
|
+
* - explicit `LSH_PIN_SERVICE` wins, but only if it's actually configured;
|
|
22
|
+
* - else the bundled default service (`lsh-pin`) if present;
|
|
23
|
+
* - else the sole configured service;
|
|
24
|
+
* - else null (none / ambiguous).
|
|
25
|
+
*/
|
|
26
|
+
export function chooseRemoteService(services, explicit, defaultName) {
|
|
27
|
+
if (explicit) {
|
|
28
|
+
return services.includes(explicit) ? explicit : null;
|
|
29
|
+
}
|
|
30
|
+
if (services.includes(defaultName)) {
|
|
31
|
+
return defaultName;
|
|
32
|
+
}
|
|
33
|
+
return services.length === 1 ? services[0] : null;
|
|
34
|
+
}
|
|
19
35
|
const logger = createLogger('IPFSSync');
|
|
20
36
|
/**
|
|
21
37
|
* Native IPFS Sync
|
|
@@ -369,12 +385,43 @@ export class IPFSSync {
|
|
|
369
385
|
* - Returns null when nothing is configured or the choice is ambiguous.
|
|
370
386
|
*/
|
|
371
387
|
async resolveRemoteService() {
|
|
388
|
+
await this.ensureDefaultPinService();
|
|
372
389
|
const services = await this.listRemoteServices();
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
390
|
+
return chooseRemoteService(services, process.env[ENV_VARS.LSH_PIN_SERVICE], DEFAULTS.DEFAULT_PIN_SERVICE_NAME);
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Bundled pinner: when LSH_PIN_TOKEN is set and no remote pin service named
|
|
394
|
+
* `lsh-pin` is registered yet, auto-register one (endpoint defaults to
|
|
395
|
+
* 4EVERLAND, override via LSH_PIN_ENDPOINT). Lets users get durable pinning
|
|
396
|
+
* with just a token — no manual `ipfs pin remote service add`. Best-effort;
|
|
397
|
+
* never throws. Respects an existing service of the same name and the
|
|
398
|
+
* explicit LSH_PIN_SERVICE (handled by chooseRemoteService).
|
|
399
|
+
*/
|
|
400
|
+
async ensureDefaultPinService() {
|
|
401
|
+
const token = process.env[ENV_VARS.LSH_PIN_TOKEN];
|
|
402
|
+
if (!token)
|
|
403
|
+
return;
|
|
404
|
+
const name = DEFAULTS.DEFAULT_PIN_SERVICE_NAME;
|
|
405
|
+
try {
|
|
406
|
+
const existing = await this.listRemoteServices();
|
|
407
|
+
if (existing.includes(name))
|
|
408
|
+
return;
|
|
409
|
+
const endpoint = process.env[ENV_VARS.LSH_PIN_ENDPOINT] || DEFAULTS.DEFAULT_PIN_ENDPOINT;
|
|
410
|
+
const url = `${this.LOCAL_IPFS_API}/pin/remote/service/add` +
|
|
411
|
+
`?arg=${encodeURIComponent(name)}` +
|
|
412
|
+
`&arg=${encodeURIComponent(endpoint)}` +
|
|
413
|
+
`&arg=${encodeURIComponent(token)}`;
|
|
414
|
+
const response = await fetch(url, { method: 'POST', signal: AbortSignal.timeout(10000) });
|
|
415
|
+
if (response.ok) {
|
|
416
|
+
logger.info(`📌 Registered bundled pin service "${name}" → ${endpoint}`);
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
logger.warn(`Could not register pin service "${name}": ${await response.text()}`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
logger.warn(`Pin service registration error: ${extractErrorMessage(error)}`);
|
|
376
424
|
}
|
|
377
|
-
return services.length === 1 ? services[0] : null;
|
|
378
425
|
}
|
|
379
426
|
/**
|
|
380
427
|
* Pin a CID to a configured remote pinning service so the content survives
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* w3name pointer ops (issue #194, Phase 2).
|
|
3
|
+
*
|
|
4
|
+
* Durable IPNS via Storacha's w3name: publish a signed record mapping the
|
|
5
|
+
* key-derived IPNS name → `/ipfs/<cid>`, hosted at name.web3.storage (no account,
|
|
6
|
+
* no auth). The name is IDENTICAL to the one Kubo derives from the same seed
|
|
7
|
+
* (verified in the Phase-2 spike), so this shares one logical pointer with the
|
|
8
|
+
* IPNS-over-DHT backend.
|
|
9
|
+
*
|
|
10
|
+
* w3name + @libp2p/crypto are heavy ESM deps; they are **lazily imported** inside
|
|
11
|
+
* the functions so the CLI / unit tests don't load them unless w3name is actually
|
|
12
|
+
* used.
|
|
13
|
+
*/
|
|
14
|
+
import { createLogger } from './logger.js';
|
|
15
|
+
const logger = createLogger('W3namePointer');
|
|
16
|
+
const PUBLISH_TIMEOUT_MS = 15000;
|
|
17
|
+
const RESOLVE_TIMEOUT_MS = 10000;
|
|
18
|
+
function withTimeout(p, ms, label) {
|
|
19
|
+
return Promise.race([
|
|
20
|
+
p,
|
|
21
|
+
new Promise((_, reject) => {
|
|
22
|
+
const timer = setTimeout(() => reject(new Error(`w3name ${label} timed out after ${ms}ms`)), ms);
|
|
23
|
+
timer.unref?.();
|
|
24
|
+
}),
|
|
25
|
+
]);
|
|
26
|
+
}
|
|
27
|
+
/** Build a w3name WritableName from a 32-byte ed25519 seed (deterministic). */
|
|
28
|
+
async function writableFromSeed(seed) {
|
|
29
|
+
const [{ generateKeyPairFromSeed, privateKeyToProtobuf }, Name] = await Promise.all([
|
|
30
|
+
import('@libp2p/crypto/keys'),
|
|
31
|
+
import('w3name'),
|
|
32
|
+
]);
|
|
33
|
+
const priv = await generateKeyPairFromSeed('Ed25519', new Uint8Array(seed));
|
|
34
|
+
const name = await Name.from(privateKeyToProtobuf(priv));
|
|
35
|
+
return { Name, name };
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Publish `cid` under the seed-derived w3name pointer. Returns the IPNS name.
|
|
39
|
+
* Handles IPNS sequencing: increments from the current revision if one exists,
|
|
40
|
+
* else publishes v0.
|
|
41
|
+
*/
|
|
42
|
+
export async function w3namePublish(seed, cid) {
|
|
43
|
+
const { Name, name } = await writableFromSeed(seed);
|
|
44
|
+
const value = `/ipfs/${cid}`;
|
|
45
|
+
let revision;
|
|
46
|
+
try {
|
|
47
|
+
const current = await withTimeout(Name.resolve(name), RESOLVE_TIMEOUT_MS, 'resolve(pre-publish)');
|
|
48
|
+
revision = await Name.increment(current, value);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// No existing record (or unresolvable) → start a fresh revision.
|
|
52
|
+
revision = await Name.v0(name, value);
|
|
53
|
+
}
|
|
54
|
+
await withTimeout(Name.publish(revision, name.key), PUBLISH_TIMEOUT_MS, 'publish');
|
|
55
|
+
logger.debug(`Published to w3name: ${name.toString()} → ${value}`);
|
|
56
|
+
return name.toString();
|
|
57
|
+
}
|
|
58
|
+
/** Resolve the latest CID for the seed-derived w3name pointer. */
|
|
59
|
+
export async function w3nameResolve(seed) {
|
|
60
|
+
const { Name, name } = await writableFromSeed(seed);
|
|
61
|
+
const nameStr = name.toString();
|
|
62
|
+
try {
|
|
63
|
+
const revision = await withTimeout(Name.resolve(Name.parse(nameStr)), RESOLVE_TIMEOUT_MS, 'resolve');
|
|
64
|
+
const value = revision.value; // "/ipfs/<cid>"
|
|
65
|
+
const cid = value.startsWith('/ipfs/') ? value.slice('/ipfs/'.length) : null;
|
|
66
|
+
return { cid, name: nameStr };
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
logger.debug(`w3name resolve failed for ${nameStr}: ${error.message}`);
|
|
70
|
+
return { cid: null, name: nameStr };
|
|
71
|
+
}
|
|
72
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lsh-framework",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.7.0",
|
|
4
4
|
"description": "Simple, cross-platform encrypted secrets manager with automatic sync, IPFS audit logs, and multi-environment support. Just run lsh sync and start managing your secrets.",
|
|
5
5
|
"main": "dist/cli.js",
|
|
6
6
|
"bin": {
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"package.json"
|
|
64
64
|
],
|
|
65
65
|
"dependencies": {
|
|
66
|
+
"@libp2p/crypto": "^5.1.19",
|
|
66
67
|
"@supabase/supabase-js": "^2.57.4",
|
|
67
68
|
"bcrypt": "^6.0.0",
|
|
68
69
|
"chalk": "^5.3.0",
|
|
@@ -80,7 +81,8 @@
|
|
|
80
81
|
"ora": "^9.0.0",
|
|
81
82
|
"pg": "^8.16.3",
|
|
82
83
|
"proper-lockfile": "^4.1.2",
|
|
83
|
-
"smol-toml": "^1.3.1"
|
|
84
|
+
"smol-toml": "^1.3.1",
|
|
85
|
+
"w3name": "^1.1.3"
|
|
84
86
|
},
|
|
85
87
|
"devDependencies": {
|
|
86
88
|
"@eslint/js": "^10.0.1",
|