@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.
Files changed (104) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +18 -0
  3. package/dist/arid-derivation-1CJuU-kZ.cjs +150 -0
  4. package/dist/arid-derivation-1CJuU-kZ.cjs.map +1 -0
  5. package/dist/arid-derivation-CbqACjdg.mjs +126 -0
  6. package/dist/arid-derivation-CbqACjdg.mjs.map +1 -0
  7. package/dist/bin/hubert.cjs +384 -0
  8. package/dist/bin/hubert.cjs.map +1 -0
  9. package/dist/bin/hubert.d.cts +1 -0
  10. package/dist/bin/hubert.d.mts +1 -0
  11. package/dist/bin/hubert.mjs +383 -0
  12. package/dist/bin/hubert.mjs.map +1 -0
  13. package/dist/chunk-CbDLau6x.cjs +34 -0
  14. package/dist/hybrid/index.cjs +14 -0
  15. package/dist/hybrid/index.d.cts +3 -0
  16. package/dist/hybrid/index.d.mts +3 -0
  17. package/dist/hybrid/index.mjs +6 -0
  18. package/dist/hybrid-BZhumygj.mjs +356 -0
  19. package/dist/hybrid-BZhumygj.mjs.map +1 -0
  20. package/dist/hybrid-dX5JLumO.cjs +410 -0
  21. package/dist/hybrid-dX5JLumO.cjs.map +1 -0
  22. package/dist/index-BEzpUC7r.d.mts +380 -0
  23. package/dist/index-BEzpUC7r.d.mts.map +1 -0
  24. package/dist/index-C2F6ugLL.d.mts +210 -0
  25. package/dist/index-C2F6ugLL.d.mts.map +1 -0
  26. package/dist/index-CUnDouMb.d.mts +215 -0
  27. package/dist/index-CUnDouMb.d.mts.map +1 -0
  28. package/dist/index-CV6lZJqY.d.cts +380 -0
  29. package/dist/index-CV6lZJqY.d.cts.map +1 -0
  30. package/dist/index-CY3TCzIm.d.cts +217 -0
  31. package/dist/index-CY3TCzIm.d.cts.map +1 -0
  32. package/dist/index-DEr4SR1J.d.cts +215 -0
  33. package/dist/index-DEr4SR1J.d.cts.map +1 -0
  34. package/dist/index-T1LHanIb.d.mts +217 -0
  35. package/dist/index-T1LHanIb.d.mts.map +1 -0
  36. package/dist/index-jyzuOhFB.d.cts +210 -0
  37. package/dist/index-jyzuOhFB.d.cts.map +1 -0
  38. package/dist/index.cjs +60 -0
  39. package/dist/index.d.cts +161 -0
  40. package/dist/index.d.cts.map +1 -0
  41. package/dist/index.d.mts +161 -0
  42. package/dist/index.d.mts.map +1 -0
  43. package/dist/index.mjs +10 -0
  44. package/dist/ipfs/index.cjs +13 -0
  45. package/dist/ipfs/index.d.cts +3 -0
  46. package/dist/ipfs/index.d.mts +3 -0
  47. package/dist/ipfs/index.mjs +5 -0
  48. package/dist/ipfs-BRMMCBjv.mjs +1 -0
  49. package/dist/ipfs-CetOVQcO.cjs +0 -0
  50. package/dist/kv-BAmhmMOo.cjs +425 -0
  51. package/dist/kv-BAmhmMOo.cjs.map +1 -0
  52. package/dist/kv-C-emxv0w.mjs +375 -0
  53. package/dist/kv-C-emxv0w.mjs.map +1 -0
  54. package/dist/kv-DJiKvypY.mjs +403 -0
  55. package/dist/kv-DJiKvypY.mjs.map +1 -0
  56. package/dist/kv-store-DmngWWuw.d.mts +183 -0
  57. package/dist/kv-store-DmngWWuw.d.mts.map +1 -0
  58. package/dist/kv-store-ww-AUyLd.d.cts +183 -0
  59. package/dist/kv-store-ww-AUyLd.d.cts.map +1 -0
  60. package/dist/kv-yjvQa_LH.cjs +457 -0
  61. package/dist/kv-yjvQa_LH.cjs.map +1 -0
  62. package/dist/logging-hmzNzifq.mjs +158 -0
  63. package/dist/logging-hmzNzifq.mjs.map +1 -0
  64. package/dist/logging-qc9uMgil.cjs +212 -0
  65. package/dist/logging-qc9uMgil.cjs.map +1 -0
  66. package/dist/mainline/index.cjs +12 -0
  67. package/dist/mainline/index.d.cts +3 -0
  68. package/dist/mainline/index.d.mts +3 -0
  69. package/dist/mainline/index.mjs +5 -0
  70. package/dist/mainline-D_jfeFMh.cjs +0 -0
  71. package/dist/mainline-cFIuXbo-.mjs +1 -0
  72. package/dist/server/index.cjs +14 -0
  73. package/dist/server/index.d.cts +3 -0
  74. package/dist/server/index.d.mts +3 -0
  75. package/dist/server/index.mjs +3 -0
  76. package/dist/server-BBNRZ30D.cjs +912 -0
  77. package/dist/server-BBNRZ30D.cjs.map +1 -0
  78. package/dist/server-DVyk9gqU.mjs +836 -0
  79. package/dist/server-DVyk9gqU.mjs.map +1 -0
  80. package/package.json +125 -0
  81. package/src/arid-derivation.ts +155 -0
  82. package/src/bin/hubert.ts +667 -0
  83. package/src/error.ts +89 -0
  84. package/src/hybrid/error.ts +77 -0
  85. package/src/hybrid/index.ts +24 -0
  86. package/src/hybrid/kv.ts +236 -0
  87. package/src/hybrid/reference.ts +176 -0
  88. package/src/index.ts +145 -0
  89. package/src/ipfs/error.ts +83 -0
  90. package/src/ipfs/index.ts +24 -0
  91. package/src/ipfs/kv.ts +476 -0
  92. package/src/ipfs/value.ts +85 -0
  93. package/src/kv-store.ts +128 -0
  94. package/src/logging.ts +88 -0
  95. package/src/mainline/error.ts +108 -0
  96. package/src/mainline/index.ts +23 -0
  97. package/src/mainline/kv.ts +411 -0
  98. package/src/server/error.ts +83 -0
  99. package/src/server/index.ts +29 -0
  100. package/src/server/kv.ts +211 -0
  101. package/src/server/memory-kv.ts +191 -0
  102. package/src/server/server-kv.ts +92 -0
  103. package/src/server/server.ts +369 -0
  104. 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
+ }
@@ -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
+ }