@bcts/hubert 1.0.0-alpha.17
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/LICENSE +48 -0
- package/README.md +18 -0
- package/dist/arid-derivation-1CJuU-kZ.cjs +150 -0
- package/dist/arid-derivation-1CJuU-kZ.cjs.map +1 -0
- package/dist/arid-derivation-CbqACjdg.mjs +126 -0
- package/dist/arid-derivation-CbqACjdg.mjs.map +1 -0
- package/dist/bin/hubert.cjs +384 -0
- package/dist/bin/hubert.cjs.map +1 -0
- package/dist/bin/hubert.d.cts +1 -0
- package/dist/bin/hubert.d.mts +1 -0
- package/dist/bin/hubert.mjs +383 -0
- package/dist/bin/hubert.mjs.map +1 -0
- package/dist/chunk-CbDLau6x.cjs +34 -0
- package/dist/hybrid/index.cjs +14 -0
- package/dist/hybrid/index.d.cts +3 -0
- package/dist/hybrid/index.d.mts +3 -0
- package/dist/hybrid/index.mjs +6 -0
- package/dist/hybrid-BZhumygj.mjs +356 -0
- package/dist/hybrid-BZhumygj.mjs.map +1 -0
- package/dist/hybrid-dX5JLumO.cjs +410 -0
- package/dist/hybrid-dX5JLumO.cjs.map +1 -0
- package/dist/index-BEzpUC7r.d.mts +380 -0
- package/dist/index-BEzpUC7r.d.mts.map +1 -0
- package/dist/index-C2F6ugLL.d.mts +210 -0
- package/dist/index-C2F6ugLL.d.mts.map +1 -0
- package/dist/index-CUnDouMb.d.mts +215 -0
- package/dist/index-CUnDouMb.d.mts.map +1 -0
- package/dist/index-CV6lZJqY.d.cts +380 -0
- package/dist/index-CV6lZJqY.d.cts.map +1 -0
- package/dist/index-CY3TCzIm.d.cts +217 -0
- package/dist/index-CY3TCzIm.d.cts.map +1 -0
- package/dist/index-DEr4SR1J.d.cts +215 -0
- package/dist/index-DEr4SR1J.d.cts.map +1 -0
- package/dist/index-T1LHanIb.d.mts +217 -0
- package/dist/index-T1LHanIb.d.mts.map +1 -0
- package/dist/index-jyzuOhFB.d.cts +210 -0
- package/dist/index-jyzuOhFB.d.cts.map +1 -0
- package/dist/index.cjs +60 -0
- package/dist/index.d.cts +161 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +161 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +10 -0
- package/dist/ipfs/index.cjs +13 -0
- package/dist/ipfs/index.d.cts +3 -0
- package/dist/ipfs/index.d.mts +3 -0
- package/dist/ipfs/index.mjs +5 -0
- package/dist/ipfs-BRMMCBjv.mjs +1 -0
- package/dist/ipfs-CetOVQcO.cjs +0 -0
- package/dist/kv-BAmhmMOo.cjs +425 -0
- package/dist/kv-BAmhmMOo.cjs.map +1 -0
- package/dist/kv-C-emxv0w.mjs +375 -0
- package/dist/kv-C-emxv0w.mjs.map +1 -0
- package/dist/kv-DJiKvypY.mjs +403 -0
- package/dist/kv-DJiKvypY.mjs.map +1 -0
- package/dist/kv-store-DmngWWuw.d.mts +183 -0
- package/dist/kv-store-DmngWWuw.d.mts.map +1 -0
- package/dist/kv-store-ww-AUyLd.d.cts +183 -0
- package/dist/kv-store-ww-AUyLd.d.cts.map +1 -0
- package/dist/kv-yjvQa_LH.cjs +457 -0
- package/dist/kv-yjvQa_LH.cjs.map +1 -0
- package/dist/logging-hmzNzifq.mjs +158 -0
- package/dist/logging-hmzNzifq.mjs.map +1 -0
- package/dist/logging-qc9uMgil.cjs +212 -0
- package/dist/logging-qc9uMgil.cjs.map +1 -0
- package/dist/mainline/index.cjs +12 -0
- package/dist/mainline/index.d.cts +3 -0
- package/dist/mainline/index.d.mts +3 -0
- package/dist/mainline/index.mjs +5 -0
- package/dist/mainline-D_jfeFMh.cjs +0 -0
- package/dist/mainline-cFIuXbo-.mjs +1 -0
- package/dist/server/index.cjs +14 -0
- package/dist/server/index.d.cts +3 -0
- package/dist/server/index.d.mts +3 -0
- package/dist/server/index.mjs +3 -0
- package/dist/server-BBNRZ30D.cjs +912 -0
- package/dist/server-BBNRZ30D.cjs.map +1 -0
- package/dist/server-DVyk9gqU.mjs +836 -0
- package/dist/server-DVyk9gqU.mjs.map +1 -0
- package/package.json +125 -0
- package/src/arid-derivation.ts +155 -0
- package/src/bin/hubert.ts +667 -0
- package/src/error.ts +89 -0
- package/src/hybrid/error.ts +77 -0
- package/src/hybrid/index.ts +24 -0
- package/src/hybrid/kv.ts +236 -0
- package/src/hybrid/reference.ts +176 -0
- package/src/index.ts +145 -0
- package/src/ipfs/error.ts +83 -0
- package/src/ipfs/index.ts +24 -0
- package/src/ipfs/kv.ts +476 -0
- package/src/ipfs/value.ts +85 -0
- package/src/kv-store.ts +128 -0
- package/src/logging.ts +88 -0
- package/src/mainline/error.ts +108 -0
- package/src/mainline/index.ts +23 -0
- package/src/mainline/kv.ts +411 -0
- package/src/server/error.ts +83 -0
- package/src/server/index.ts +29 -0
- package/src/server/kv.ts +211 -0
- package/src/server/memory-kv.ts +191 -0
- package/src/server/server-kv.ts +92 -0
- package/src/server/server.ts +369 -0
- package/src/server/sqlite-kv.ts +295 -0
package/src/ipfs/kv.ts
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPFS-backed key-value store using IPNS for ARID-based addressing.
|
|
3
|
+
*
|
|
4
|
+
* Port of ipfs/kv.rs from hubert-rust.
|
|
5
|
+
*
|
|
6
|
+
* @module
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { type ARID } from "@bcts/components";
|
|
10
|
+
import { type Envelope, EnvelopeDecoder } from "@bcts/envelope";
|
|
11
|
+
import { create, type KuboRPCClient } from "kubo-rpc-client";
|
|
12
|
+
|
|
13
|
+
import { AlreadyExistsError } from "../error.js";
|
|
14
|
+
import { type KvStore } from "../kv-store.js";
|
|
15
|
+
import { deriveIpfsKeyName, obfuscateWithArid } from "../arid-derivation.js";
|
|
16
|
+
import { verboseNewline, verbosePrintDot, verbosePrintln } from "../logging.js";
|
|
17
|
+
import {
|
|
18
|
+
EnvelopeTooLargeError,
|
|
19
|
+
IpfsDaemonError,
|
|
20
|
+
IpfsTimeoutError,
|
|
21
|
+
UnexpectedIpnsPathFormatError,
|
|
22
|
+
} from "./error.js";
|
|
23
|
+
import { addBytes, catBytes, pinCid } from "./value.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Key info cached from IPFS key operations.
|
|
27
|
+
*
|
|
28
|
+
* @internal
|
|
29
|
+
*/
|
|
30
|
+
interface KeyInfo {
|
|
31
|
+
peerId: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* IPFS-backed key-value store using IPNS for ARID-based addressing.
|
|
36
|
+
*
|
|
37
|
+
* This implementation uses:
|
|
38
|
+
* - ARID → IPNS key name derivation (deterministic)
|
|
39
|
+
* - IPFS content addressing (CID) for immutable storage
|
|
40
|
+
* - IPNS for publish-once mutable names
|
|
41
|
+
* - Write-once semantics (publish fails if name already exists)
|
|
42
|
+
*
|
|
43
|
+
* Port of `struct IpfsKv` from ipfs/kv.rs lines 54-60.
|
|
44
|
+
*
|
|
45
|
+
* # Requirements
|
|
46
|
+
*
|
|
47
|
+
* Requires a running Kubo daemon (or compatible IPFS node) with RPC API
|
|
48
|
+
* available at the configured endpoint (default: http://127.0.0.1:5001).
|
|
49
|
+
*
|
|
50
|
+
* @category IPFS Backend
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```typescript
|
|
54
|
+
* const store = new IpfsKv("http://127.0.0.1:5001");
|
|
55
|
+
* const arid = ARID.new();
|
|
56
|
+
* const envelope = Envelope.new("Hello, IPFS!");
|
|
57
|
+
*
|
|
58
|
+
* // Put envelope (write-once)
|
|
59
|
+
* await store.put(arid, envelope);
|
|
60
|
+
*
|
|
61
|
+
* // Get envelope with verbose logging
|
|
62
|
+
* const retrieved = await store.get(arid, undefined, true);
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export class IpfsKv implements KvStore {
|
|
66
|
+
private readonly client: KuboRPCClient;
|
|
67
|
+
private readonly keyCache: Map<string, KeyInfo>;
|
|
68
|
+
private maxEnvelopeSize: number;
|
|
69
|
+
private resolveTimeoutMs: number;
|
|
70
|
+
private pinContent: boolean;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Create a new IPFS KV store with default settings.
|
|
74
|
+
*
|
|
75
|
+
* Port of `IpfsKv::new()` from ipfs/kv.rs lines 73-81.
|
|
76
|
+
*
|
|
77
|
+
* @param rpcUrl - IPFS RPC endpoint (e.g., "http://127.0.0.1:5001")
|
|
78
|
+
*/
|
|
79
|
+
constructor(rpcUrl: string) {
|
|
80
|
+
this.client = create({ url: rpcUrl });
|
|
81
|
+
this.keyCache = new Map();
|
|
82
|
+
this.maxEnvelopeSize = 10 * 1024 * 1024; // 10 MB
|
|
83
|
+
this.resolveTimeoutMs = 30000; // 30 seconds
|
|
84
|
+
this.pinContent = false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Set the maximum envelope size (default: 10 MB).
|
|
89
|
+
*
|
|
90
|
+
* Port of `IpfsKv::with_max_size()` from ipfs/kv.rs lines 84-87.
|
|
91
|
+
*/
|
|
92
|
+
withMaxSize(size: number): this {
|
|
93
|
+
this.maxEnvelopeSize = size;
|
|
94
|
+
return this;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Set the IPNS resolve timeout (default: 30 seconds).
|
|
99
|
+
*
|
|
100
|
+
* Port of `IpfsKv::with_resolve_timeout()` from ipfs/kv.rs lines 90-93.
|
|
101
|
+
*
|
|
102
|
+
* @param timeoutMs - Timeout in milliseconds
|
|
103
|
+
*/
|
|
104
|
+
withResolveTimeout(timeoutMs: number): this {
|
|
105
|
+
this.resolveTimeoutMs = timeoutMs;
|
|
106
|
+
return this;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Set whether to pin content (default: false).
|
|
111
|
+
*
|
|
112
|
+
* Port of `IpfsKv::with_pin_content()` from ipfs/kv.rs lines 96-99.
|
|
113
|
+
*/
|
|
114
|
+
withPinContent(pin: boolean): this {
|
|
115
|
+
this.pinContent = pin;
|
|
116
|
+
return this;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get or create an IPNS key for the given ARID.
|
|
121
|
+
*
|
|
122
|
+
* Port of `IpfsKv::get_or_create_key()` from ipfs/kv.rs lines 102-142.
|
|
123
|
+
*
|
|
124
|
+
* @internal
|
|
125
|
+
*/
|
|
126
|
+
private async getOrCreateKey(arid: ARID): Promise<KeyInfo> {
|
|
127
|
+
const keyName = deriveIpfsKeyName(arid);
|
|
128
|
+
|
|
129
|
+
// Check cache first
|
|
130
|
+
const cachedInfo = this.keyCache.get(keyName);
|
|
131
|
+
if (cachedInfo) {
|
|
132
|
+
return cachedInfo;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
// List existing keys to see if it already exists
|
|
137
|
+
const keys = await this.client.key.list();
|
|
138
|
+
|
|
139
|
+
const existingKey = keys.find((k) => k.name === keyName);
|
|
140
|
+
if (existingKey) {
|
|
141
|
+
const info: KeyInfo = { peerId: existingKey.id };
|
|
142
|
+
this.keyCache.set(keyName, info);
|
|
143
|
+
return info;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Generate new key
|
|
147
|
+
const keyInfo = await this.client.key.gen(keyName, { type: "ed25519" });
|
|
148
|
+
const info: KeyInfo = { peerId: keyInfo.id };
|
|
149
|
+
this.keyCache.set(keyName, info);
|
|
150
|
+
return info;
|
|
151
|
+
} catch (error) {
|
|
152
|
+
throw new IpfsDaemonError(error instanceof Error ? error.message : String(error));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Check if an IPNS name is already published.
|
|
158
|
+
*
|
|
159
|
+
* Port of `IpfsKv::is_published()` from ipfs/kv.rs lines 145-161.
|
|
160
|
+
*
|
|
161
|
+
* @internal
|
|
162
|
+
*/
|
|
163
|
+
private async isPublished(peerId: string): Promise<boolean> {
|
|
164
|
+
try {
|
|
165
|
+
// Try to resolve the name
|
|
166
|
+
|
|
167
|
+
for await (const _path of this.client.name.resolve(peerId, { recursive: false })) {
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
return true;
|
|
171
|
+
} catch (error) {
|
|
172
|
+
const errStr = error instanceof Error ? error.message : String(error);
|
|
173
|
+
// IPNS name not found errors indicate unpublished name
|
|
174
|
+
if (
|
|
175
|
+
errStr.includes("could not resolve name") ||
|
|
176
|
+
errStr.includes("no link named") ||
|
|
177
|
+
errStr.includes("not found")
|
|
178
|
+
) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
throw new IpfsDaemonError(errStr);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Publish a CID to an IPNS name (write-once).
|
|
187
|
+
*
|
|
188
|
+
* Port of `IpfsKv::publish_once()` from ipfs/kv.rs lines 164-204.
|
|
189
|
+
*
|
|
190
|
+
* @internal
|
|
191
|
+
*/
|
|
192
|
+
private async publishOnce(
|
|
193
|
+
keyName: string,
|
|
194
|
+
peerId: string,
|
|
195
|
+
cid: string,
|
|
196
|
+
ttlSeconds: number | undefined,
|
|
197
|
+
arid: ARID,
|
|
198
|
+
): Promise<void> {
|
|
199
|
+
// Check if already published
|
|
200
|
+
if (await this.isPublished(peerId)) {
|
|
201
|
+
throw new AlreadyExistsError(arid.urString());
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Convert TTL seconds to lifetime string for IPNS
|
|
205
|
+
// Format: "Ns" for seconds, "Nm" for minutes, "Nh" for hours, "Nd" for days
|
|
206
|
+
let lifetime: string | undefined;
|
|
207
|
+
if (ttlSeconds !== undefined) {
|
|
208
|
+
if (ttlSeconds < 60) {
|
|
209
|
+
lifetime = `${ttlSeconds}s`;
|
|
210
|
+
} else if (ttlSeconds < 3600) {
|
|
211
|
+
lifetime = `${Math.floor(ttlSeconds / 60)}m`;
|
|
212
|
+
} else if (ttlSeconds < 86400) {
|
|
213
|
+
lifetime = `${Math.floor(ttlSeconds / 3600)}h`;
|
|
214
|
+
} else {
|
|
215
|
+
lifetime = `${Math.floor(ttlSeconds / 86400)}d`;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
// Publish to IPNS
|
|
221
|
+
const publishOptions: { key: string; lifetime?: string } = { key: keyName };
|
|
222
|
+
if (lifetime !== undefined) {
|
|
223
|
+
publishOptions.lifetime = lifetime;
|
|
224
|
+
}
|
|
225
|
+
await this.client.name.publish(`/ipfs/${cid}`, publishOptions);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
throw new IpfsDaemonError(error instanceof Error ? error.message : String(error));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Resolve an IPNS name to a CID with polling and custom timeout.
|
|
233
|
+
*
|
|
234
|
+
* Port of `IpfsKv::resolve_with_retry_timeout()` from ipfs/kv.rs lines 207-258.
|
|
235
|
+
*
|
|
236
|
+
* @internal
|
|
237
|
+
*/
|
|
238
|
+
private async resolveWithRetryTimeout(
|
|
239
|
+
peerId: string,
|
|
240
|
+
timeoutMs: number,
|
|
241
|
+
verbose: boolean,
|
|
242
|
+
): Promise<string | null> {
|
|
243
|
+
const deadline = Date.now() + timeoutMs;
|
|
244
|
+
// Changed to 1000ms for verbose mode polling
|
|
245
|
+
const pollInterval = 1000;
|
|
246
|
+
|
|
247
|
+
while (true) {
|
|
248
|
+
try {
|
|
249
|
+
for await (const path of this.client.name.resolve(peerId, { recursive: false })) {
|
|
250
|
+
// Extract CID from path (e.g., "/ipfs/bafy..." -> "bafy...")
|
|
251
|
+
const pathStr = path.toString();
|
|
252
|
+
if (pathStr.startsWith("/ipfs/")) {
|
|
253
|
+
return pathStr.slice(6);
|
|
254
|
+
} else {
|
|
255
|
+
throw new UnexpectedIpnsPathFormatError(pathStr);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// If iterator completes without yielding, name not found
|
|
259
|
+
return null;
|
|
260
|
+
} catch (error) {
|
|
261
|
+
const errStr = error instanceof Error ? error.message : String(error);
|
|
262
|
+
// Check if name simply doesn't exist (not published)
|
|
263
|
+
if (
|
|
264
|
+
errStr.includes("could not resolve name") ||
|
|
265
|
+
errStr.includes("no link named") ||
|
|
266
|
+
errStr.includes("not found")
|
|
267
|
+
) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Check if we've timed out
|
|
272
|
+
if (Date.now() >= deadline) {
|
|
273
|
+
throw new IpfsTimeoutError();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Print polling dot if verbose
|
|
277
|
+
if (verbose) {
|
|
278
|
+
verbosePrintDot();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Retry after interval (now 1000ms)
|
|
282
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Store an envelope at the given ARID.
|
|
289
|
+
*
|
|
290
|
+
* Port of `KvStore::put()` implementation from ipfs/kv.rs lines 289-367.
|
|
291
|
+
*/
|
|
292
|
+
async put(
|
|
293
|
+
arid: ARID,
|
|
294
|
+
envelope: Envelope,
|
|
295
|
+
ttlSeconds?: number,
|
|
296
|
+
verbose?: boolean,
|
|
297
|
+
): Promise<string> {
|
|
298
|
+
if (verbose) {
|
|
299
|
+
verbosePrintln("Starting IPFS put operation");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Serialize envelope
|
|
303
|
+
const bytes = envelope.taggedCborData();
|
|
304
|
+
|
|
305
|
+
if (verbose) {
|
|
306
|
+
verbosePrintln(`Envelope size: ${bytes.length} bytes`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Obfuscate with ARID-derived key so it appears as random data
|
|
310
|
+
const obfuscated = obfuscateWithArid(arid, bytes);
|
|
311
|
+
|
|
312
|
+
// Check size after obfuscation (same size, but check anyway)
|
|
313
|
+
if (obfuscated.length > this.maxEnvelopeSize) {
|
|
314
|
+
throw new EnvelopeTooLargeError(obfuscated.length);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (verbose) {
|
|
318
|
+
verbosePrintln("Obfuscated envelope data");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Get or create IPNS key
|
|
322
|
+
if (verbose) {
|
|
323
|
+
verbosePrintln("Getting or creating IPNS key");
|
|
324
|
+
}
|
|
325
|
+
const keyInfo = await this.getOrCreateKey(arid);
|
|
326
|
+
|
|
327
|
+
const keyName = deriveIpfsKeyName(arid);
|
|
328
|
+
|
|
329
|
+
// Add obfuscated data to IPFS
|
|
330
|
+
if (verbose) {
|
|
331
|
+
verbosePrintln("Adding content to IPFS");
|
|
332
|
+
}
|
|
333
|
+
const cid = await addBytes(this.client, obfuscated);
|
|
334
|
+
|
|
335
|
+
if (verbose) {
|
|
336
|
+
verbosePrintln(`Content CID: ${cid}`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Pin if requested
|
|
340
|
+
if (this.pinContent) {
|
|
341
|
+
if (verbose) {
|
|
342
|
+
verbosePrintln("Pinning content");
|
|
343
|
+
}
|
|
344
|
+
await pinCid(this.client, cid, true);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Publish to IPNS (write-once)
|
|
348
|
+
if (verbose) {
|
|
349
|
+
verbosePrintln("Publishing to IPNS (write-once check)");
|
|
350
|
+
}
|
|
351
|
+
await this.publishOnce(keyName, keyInfo.peerId, cid, ttlSeconds, arid);
|
|
352
|
+
|
|
353
|
+
if (verbose) {
|
|
354
|
+
verbosePrintln("IPFS put operation completed");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return `ipns://${keyInfo.peerId} -> ipfs://${cid}`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Retrieve an envelope for the given ARID.
|
|
362
|
+
*
|
|
363
|
+
* Port of `KvStore::get()` implementation from ipfs/kv.rs lines 370-450.
|
|
364
|
+
*/
|
|
365
|
+
async get(arid: ARID, timeoutSeconds?: number, verbose?: boolean): Promise<Envelope | null> {
|
|
366
|
+
if (verbose) {
|
|
367
|
+
verbosePrintln("Starting IPFS get operation");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const keyName = deriveIpfsKeyName(arid);
|
|
371
|
+
|
|
372
|
+
// Get key info from cache or daemon
|
|
373
|
+
if (verbose) {
|
|
374
|
+
verbosePrintln("Looking up IPNS key");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
const keys = await this.client.key.list();
|
|
379
|
+
const key = keys.find((k) => k.name === keyName);
|
|
380
|
+
|
|
381
|
+
if (!key) {
|
|
382
|
+
// Key doesn't exist, so nothing published
|
|
383
|
+
if (verbose) {
|
|
384
|
+
verbosePrintln("Key not found");
|
|
385
|
+
}
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const peerId = key.id;
|
|
390
|
+
|
|
391
|
+
// Resolve IPNS to CID with specified timeout
|
|
392
|
+
if (verbose) {
|
|
393
|
+
verbosePrintln("Resolving IPNS name (polling)");
|
|
394
|
+
}
|
|
395
|
+
const timeout = timeoutSeconds !== undefined ? timeoutSeconds * 1000 : this.resolveTimeoutMs;
|
|
396
|
+
const cid = await this.resolveWithRetryTimeout(peerId, timeout, verbose ?? false);
|
|
397
|
+
|
|
398
|
+
if (verbose) {
|
|
399
|
+
verboseNewline();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (cid === null) {
|
|
403
|
+
if (verbose) {
|
|
404
|
+
verbosePrintln("IPNS name not published");
|
|
405
|
+
}
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (verbose) {
|
|
410
|
+
verbosePrintln(`Resolved to CID: ${cid}`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Cat CID to get obfuscated bytes
|
|
414
|
+
if (verbose) {
|
|
415
|
+
verbosePrintln("Fetching content from IPFS");
|
|
416
|
+
}
|
|
417
|
+
const obfuscatedBytes = await catBytes(this.client, cid);
|
|
418
|
+
|
|
419
|
+
// Deobfuscate using ARID-derived key
|
|
420
|
+
const deobfuscated = obfuscateWithArid(arid, obfuscatedBytes);
|
|
421
|
+
|
|
422
|
+
if (verbose) {
|
|
423
|
+
verbosePrintln("Deobfuscated envelope data");
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Deserialize envelope from deobfuscated data
|
|
427
|
+
const envelope = EnvelopeDecoder.tryFromCborData(deobfuscated);
|
|
428
|
+
|
|
429
|
+
if (verbose) {
|
|
430
|
+
verbosePrintln("IPFS get operation completed");
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return envelope;
|
|
434
|
+
} catch (error) {
|
|
435
|
+
if (
|
|
436
|
+
error instanceof AlreadyExistsError ||
|
|
437
|
+
error instanceof EnvelopeTooLargeError ||
|
|
438
|
+
error instanceof IpfsDaemonError ||
|
|
439
|
+
error instanceof IpfsTimeoutError ||
|
|
440
|
+
error instanceof UnexpectedIpnsPathFormatError
|
|
441
|
+
) {
|
|
442
|
+
throw error;
|
|
443
|
+
}
|
|
444
|
+
throw new IpfsDaemonError(error instanceof Error ? error.message : String(error));
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Check if an envelope exists at the given ARID.
|
|
450
|
+
*
|
|
451
|
+
* Port of `KvStore::exists()` implementation from ipfs/kv.rs lines 453-481.
|
|
452
|
+
*/
|
|
453
|
+
async exists(arid: ARID): Promise<boolean> {
|
|
454
|
+
const keyName = deriveIpfsKeyName(arid);
|
|
455
|
+
|
|
456
|
+
try {
|
|
457
|
+
// List keys to check if key exists
|
|
458
|
+
const keys = await this.client.key.list();
|
|
459
|
+
const key = keys.find((k) => k.name === keyName);
|
|
460
|
+
|
|
461
|
+
if (!key) {
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const peerId = key.id;
|
|
466
|
+
|
|
467
|
+
// Check if published (quick resolve)
|
|
468
|
+
return await this.isPublished(peerId);
|
|
469
|
+
} catch (error) {
|
|
470
|
+
if (error instanceof IpfsDaemonError) {
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
473
|
+
throw new IpfsDaemonError(error instanceof Error ? error.message : String(error));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPFS helper functions for content operations.
|
|
3
|
+
*
|
|
4
|
+
* Port of ipfs/value.rs from hubert-rust.
|
|
5
|
+
*
|
|
6
|
+
* @module
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { KuboRPCClient } from "kubo-rpc-client";
|
|
10
|
+
|
|
11
|
+
import { IpfsDaemonError } from "./error.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Add (upload) bytes to IPFS and return the CID.
|
|
15
|
+
*
|
|
16
|
+
* Port of `add_bytes()` from ipfs/value.rs lines 9-15.
|
|
17
|
+
*
|
|
18
|
+
* @param client - Kubo RPC client
|
|
19
|
+
* @param bytes - Data to add
|
|
20
|
+
* @returns CID of the added content
|
|
21
|
+
*
|
|
22
|
+
* @category IPFS
|
|
23
|
+
*/
|
|
24
|
+
export async function addBytes(client: KuboRPCClient, bytes: Uint8Array): Promise<string> {
|
|
25
|
+
try {
|
|
26
|
+
const addResult = await client.add(bytes);
|
|
27
|
+
return addResult.cid.toString();
|
|
28
|
+
} catch (error) {
|
|
29
|
+
throw new IpfsDaemonError(error instanceof Error ? error.message : String(error));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Cat (download) bytes from IPFS by CID.
|
|
35
|
+
*
|
|
36
|
+
* Port of `cat_bytes()` from ipfs/value.rs lines 18-28.
|
|
37
|
+
*
|
|
38
|
+
* @param client - Kubo RPC client
|
|
39
|
+
* @param cid - Content identifier
|
|
40
|
+
* @returns Content bytes
|
|
41
|
+
*
|
|
42
|
+
* @category IPFS
|
|
43
|
+
*/
|
|
44
|
+
export async function catBytes(client: KuboRPCClient, cid: string): Promise<Uint8Array> {
|
|
45
|
+
try {
|
|
46
|
+
const chunks: Uint8Array[] = [];
|
|
47
|
+
for await (const chunk of client.cat(cid)) {
|
|
48
|
+
chunks.push(chunk);
|
|
49
|
+
}
|
|
50
|
+
// Concatenate all chunks
|
|
51
|
+
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
52
|
+
const result = new Uint8Array(totalLength);
|
|
53
|
+
let offset = 0;
|
|
54
|
+
for (const chunk of chunks) {
|
|
55
|
+
result.set(chunk, offset);
|
|
56
|
+
offset += chunk.length;
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
throw new IpfsDaemonError(error instanceof Error ? error.message : String(error));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Pin a CID to ensure it persists in local IPFS storage.
|
|
66
|
+
*
|
|
67
|
+
* Port of `pin_cid()` from ipfs/value.rs lines 31-38.
|
|
68
|
+
*
|
|
69
|
+
* @param client - Kubo RPC client
|
|
70
|
+
* @param cid - Content identifier to pin
|
|
71
|
+
* @param recursive - Whether to recursively pin linked content
|
|
72
|
+
*
|
|
73
|
+
* @category IPFS
|
|
74
|
+
*/
|
|
75
|
+
export async function pinCid(
|
|
76
|
+
client: KuboRPCClient,
|
|
77
|
+
cid: string,
|
|
78
|
+
recursive: boolean,
|
|
79
|
+
): Promise<void> {
|
|
80
|
+
try {
|
|
81
|
+
await client.pin.add(cid, { recursive });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
throw new IpfsDaemonError(error instanceof Error ? error.message : String(error));
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/kv-store.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KvStore interface for key-value storage backends using ARID-based addressing.
|
|
3
|
+
*
|
|
4
|
+
* Port of kv_store.rs from hubert-rust.
|
|
5
|
+
*
|
|
6
|
+
* @module
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { type ARID } from "@bcts/components";
|
|
10
|
+
import { type Envelope } from "@bcts/envelope";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Unified interface for key-value storage backends using ARID-based addressing.
|
|
14
|
+
*
|
|
15
|
+
* All implementations provide write-once semantics: once an envelope is stored
|
|
16
|
+
* at an ARID, subsequent attempts to write to the same ARID will fail with an
|
|
17
|
+
* `AlreadyExistsError`.
|
|
18
|
+
*
|
|
19
|
+
* ## Security Model
|
|
20
|
+
*
|
|
21
|
+
* - ARID holder can read (by deriving storage key)
|
|
22
|
+
* - ARID creator can write once (by deriving storage key)
|
|
23
|
+
* - Storage networks see only derived keys, never ARIDs themselves
|
|
24
|
+
* - ARIDs shared only via secure channels (GSTP, Signal, QR codes)
|
|
25
|
+
*
|
|
26
|
+
* ## Implementations
|
|
27
|
+
*
|
|
28
|
+
* - `MainlineDhtKv`: Fast, lightweight DHT storage (≤1 KB messages)
|
|
29
|
+
* - `IpfsKv`: Large capacity, content-addressed storage (up to 10 MB messages)
|
|
30
|
+
* - `HybridKv`: Automatic optimization by size, combining DHT speed with IPFS capacity
|
|
31
|
+
* - `ServerKvClient`: HTTP client for centralized server backend
|
|
32
|
+
*
|
|
33
|
+
* Port of `trait KvStore` from kv_store.rs lines 81-214.
|
|
34
|
+
*
|
|
35
|
+
* @category KvStore Interface
|
|
36
|
+
*/
|
|
37
|
+
export interface KvStore {
|
|
38
|
+
/**
|
|
39
|
+
* Store an envelope at the given ARID.
|
|
40
|
+
*
|
|
41
|
+
* ## Write-Once Semantics
|
|
42
|
+
*
|
|
43
|
+
* This operation will fail if the ARID already exists. The implementation
|
|
44
|
+
* must check for existence before writing and return an appropriate error
|
|
45
|
+
* if the key is already present.
|
|
46
|
+
*
|
|
47
|
+
* @param arid - Cryptographic identifier for this storage location
|
|
48
|
+
* @param envelope - The envelope to store
|
|
49
|
+
* @param ttlSeconds - Optional time-to-live in seconds. After this time, the
|
|
50
|
+
* envelope may be removed from storage.
|
|
51
|
+
* - **Mainline DHT**: Ignored (no TTL support)
|
|
52
|
+
* - **IPFS**: Used as IPNS record lifetime (default: 24h if undefined)
|
|
53
|
+
* - **Server**: Clamped to max_ttl if exceeded; uses max_ttl if undefined.
|
|
54
|
+
* All entries expire (hubert is for coordination, not long-term storage).
|
|
55
|
+
* @param verbose - If true, log operations with timestamps
|
|
56
|
+
* @returns A receipt containing storage metadata on success
|
|
57
|
+
* @throws {AlreadyExistsError} If the ARID already exists
|
|
58
|
+
* @throws {Error} If the envelope is too large for this backend
|
|
59
|
+
* @throws {Error} If network operation fails
|
|
60
|
+
* @throws {Error} If serialization fails
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```typescript
|
|
64
|
+
* const arid = ARID.new();
|
|
65
|
+
* const envelope = Envelope.new("Hello, Hubert!");
|
|
66
|
+
*
|
|
67
|
+
* // Store without TTL
|
|
68
|
+
* const receipt = await store.put(arid, envelope);
|
|
69
|
+
*
|
|
70
|
+
* // Store with 1 hour TTL and verbose logging
|
|
71
|
+
* const arid2 = ARID.new();
|
|
72
|
+
* const receipt2 = await store.put(arid2, envelope, 3600, true);
|
|
73
|
+
* console.log("Stored at:", receipt2);
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
put(arid: ARID, envelope: Envelope, ttlSeconds?: number, verbose?: boolean): Promise<string>;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Retrieve an envelope for the given ARID.
|
|
80
|
+
*
|
|
81
|
+
* Polls the storage backend until the envelope becomes available or the
|
|
82
|
+
* timeout is reached. This is useful for coordinating between parties
|
|
83
|
+
* where one party puts data and another polls for it.
|
|
84
|
+
*
|
|
85
|
+
* @param arid - The ARID to look up
|
|
86
|
+
* @param timeoutSeconds - Maximum time to wait for the envelope to appear. If
|
|
87
|
+
* undefined, uses a backend-specific default (typically 30 seconds). After
|
|
88
|
+
* timeout, returns `null` rather than continuing to poll.
|
|
89
|
+
* @param verbose - If true, log operations with timestamps and print polling dots
|
|
90
|
+
* @returns The envelope if found within the timeout, or `null` if not found
|
|
91
|
+
* @throws {Error} On network or deserialization errors
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* ```typescript
|
|
95
|
+
* // Wait up to 10 seconds for envelope to appear with verbose logging
|
|
96
|
+
* const envelope = await store.get(arid, 10, true);
|
|
97
|
+
* if (envelope) {
|
|
98
|
+
* console.log("Found:", envelope);
|
|
99
|
+
* } else {
|
|
100
|
+
* console.log("Not found within timeout");
|
|
101
|
+
* }
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
get(arid: ARID, timeoutSeconds?: number, verbose?: boolean): Promise<Envelope | null>;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Check if an envelope exists at the given ARID.
|
|
108
|
+
*
|
|
109
|
+
* @param arid - The ARID to check
|
|
110
|
+
* @returns `true` if the ARID exists, `false` otherwise
|
|
111
|
+
* @throws {Error} On network errors
|
|
112
|
+
*
|
|
113
|
+
* ## Implementation Note
|
|
114
|
+
*
|
|
115
|
+
* For hybrid storage, this only checks the DHT layer. Reference envelopes
|
|
116
|
+
* count as existing even if the referenced IPFS content is not available.
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ```typescript
|
|
120
|
+
* if (await store.exists(arid)) {
|
|
121
|
+
* console.log("ARID already used");
|
|
122
|
+
* } else {
|
|
123
|
+
* console.log("ARID available");
|
|
124
|
+
* }
|
|
125
|
+
* ```
|
|
126
|
+
*/
|
|
127
|
+
exists(arid: ARID): Promise<boolean>;
|
|
128
|
+
}
|