@le-space/orbitdb-storage-bridge 0.10.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.
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Put a database somewhere a second device can find it, and get it back.
3
+ *
4
+ * Two calls, and between them nothing but a secret both devices can produce —
5
+ * a passkey's PRF output, in the case this was written for
6
+ * ([funkpost#93](https://github.com/NiKrause/funkpost/issues/93)):
7
+ *
8
+ * // the device that still has the database, while it has internet
9
+ * await dehydrate({ orbitdb, address, seed, backend })
10
+ *
11
+ * // any device holding the same seed, later, knowing nothing else
12
+ * const { db } = await hydrate({ orbitdb, seed })
13
+ *
14
+ * What makes that work is that the *name* is computed rather than remembered:
15
+ * `derivePointerKey` stretches the seed into a key, the IPNS name follows from
16
+ * the key, and the pointer under that name says where the backup is. The
17
+ * second device needs no CID, no address, no file — see `pointer-ipns.js`.
18
+ *
19
+ * **There is no encrypted identity archive here, and that is not an
20
+ * oversight.** The design this follows kept one because the signing key was
21
+ * generated at random and therefore had to be carried. A key *derived* from
22
+ * the same PRF output does not: the device recomputes it, the access
23
+ * controller recognises it, and there is nothing to keep secret in the open.
24
+ * Deriving it is the identity provider's business, not this module's — the
25
+ * seed arrives here as bytes for a name, and nothing about who holds it.
26
+ *
27
+ * What this inherits from the pieces underneath, and says plainly rather than
28
+ * hiding: a pointer lives as long as the routing endpoints keep it, and a
29
+ * backup as long as the storage backend keeps it. Aleph takes an upload
30
+ * without an account and keeps it without a promise. For a device that comes
31
+ * back in a week this is enough; for an archive it is not.
32
+ */
33
+
34
+ import {
35
+ derivePointerKey,
36
+ publishPointer,
37
+ resolvePointer,
38
+ DEFAULT_ENDPOINTS,
39
+ } from "./pointer-ipns.js";
40
+ import logger from "./logger.js";
41
+
42
+ /**
43
+ * Back the database up and publish a pointer to it under a name the seed
44
+ * derives.
45
+ *
46
+ * @param {Object} params
47
+ * @param {Object} params.orbitdb
48
+ * @param {string} params.address Database address to back up.
49
+ * @param {Uint8Array} params.seed Secret bytes both devices can produce.
50
+ * @param {string} [params.label] Distinguishes several pointers of one seed.
51
+ * @param {Object} [params.backend] Storage backend (Aleph, Pinata, …).
52
+ * @param {string[]} [params.endpoints] Routing endpoints to publish to.
53
+ * @param {bigint|number} [params.sequence] Overrides the default, which is
54
+ * seconds since the epoch — monotonic, and readable in a log.
55
+ * @param {Object} [params.backup] Extra options for `backupDatabaseCAR`.
56
+ * @returns {Promise<{name: string, metadataCID: string, carCID: string, sequence: bigint, blocks: number}>}
57
+ */
58
+ export async function dehydrate({
59
+ orbitdb,
60
+ address,
61
+ seed,
62
+ label,
63
+ backend,
64
+ endpoints = DEFAULT_ENDPOINTS,
65
+ sequence,
66
+ backup = {},
67
+ }) {
68
+ if (!address) throw new Error("dehydrate needs the database address");
69
+ const { backupDatabaseCAR } = await import("./backup-car.js");
70
+
71
+ const result = await backupDatabaseCAR(orbitdb, address, {
72
+ backend,
73
+ ...backup,
74
+ });
75
+ if (!result?.success) {
76
+ throw new Error(`the backup failed: ${result?.error ?? "no reason given"}`);
77
+ }
78
+ const metadataCID = result.backupFiles?.metadataCID;
79
+ if (!metadataCID)
80
+ throw new Error("the backup named no metadata CID to point at");
81
+
82
+ const privateKey = await derivePointerKey(seed, { label });
83
+ const published = await publishPointer({
84
+ privateKey,
85
+ cid: metadataCID,
86
+ endpoints,
87
+ ...(sequence === undefined ? {} : { sequence }),
88
+ });
89
+
90
+ logger.info(`💧 dehydrated ${address} → ${published.name}`);
91
+ return {
92
+ name: published.name,
93
+ metadataCID,
94
+ carCID: result.backupFiles?.carCID,
95
+ sequence: published.sequence,
96
+ blocks: result.blocksTotal ?? 0,
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Find the pointer the seed names, and restore what it points at.
102
+ *
103
+ * The database comes back open. It replaces any handle the caller had for the
104
+ * same address: `restoreFromCID` opens it itself, and writing through an older
105
+ * handle fails once this one exists.
106
+ *
107
+ * @param {Object} params
108
+ * @param {Object} params.orbitdb
109
+ * @param {Uint8Array} params.seed
110
+ * @param {string} [params.label]
111
+ * @param {string[]} [params.endpoints]
112
+ * @param {Object} [params.open] Options for `orbitdb.open` — a node without a
113
+ * pubsub service needs `{ sync: false }`.
114
+ * @param {Object} [params.restore] Extra options for `restoreFromCID`.
115
+ * @returns {Promise<{db: Object, address: string, name: string, metadataCID: string, blocks: number, entries: number|null}>}
116
+ */
117
+ export async function hydrate({
118
+ orbitdb,
119
+ seed,
120
+ label,
121
+ endpoints = DEFAULT_ENDPOINTS,
122
+ open = {},
123
+ restore = {},
124
+ }) {
125
+ const privateKey = await derivePointerKey(seed, { label });
126
+ const pointer = await resolvePointer({ privateKey, endpoints });
127
+
128
+ const { restoreFromCID } = await import("./restore-cid.js");
129
+ const result = await restoreFromCID(orbitdb, {
130
+ metadataCID: pointer.cid,
131
+ open,
132
+ ...restore,
133
+ });
134
+
135
+ logger.info(
136
+ `💦 hydrated ${pointer.name} → ${result.address ?? "a database"}`,
137
+ );
138
+ return {
139
+ db: result.database,
140
+ address: result.address,
141
+ name: pointer.name,
142
+ metadataCID: pointer.cid,
143
+ blocks: result.blocks,
144
+ entries: result.entries,
145
+ };
146
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Extracting a database's blocks — the half of a backup that reads.
3
+ *
4
+ * Its own module, and not part of the main entry, because the main entry
5
+ * imports `@storacha/client` at the top: a browser that backs up to Aleph
6
+ * would otherwise carry the whole Storacha SDK to call this one function.
7
+ * The same reason `restore-cid.js` exists (issue #58).
8
+ */
9
+
10
+ import { CID } from "multiformats/cid";
11
+ import * as Block from "multiformats/block";
12
+ import * as dagCbor from "@ipld/dag-cbor";
13
+ import { sha256 } from "multiformats/hashes/sha2";
14
+ import { logger } from "./logger.js";
15
+
16
+ /**
17
+ * Extract blocks from an OrbitDB database
18
+ *
19
+ * @param {Object} database - OrbitDB database instance
20
+ * @param {Object} options - Extraction options
21
+ * @param {boolean} [options.logEntriesOnly] - If true, only extract log entries (for fallback reconstruction)
22
+ * @returns {Promise<Object>} - { blocks, blockSources, manifestCID }
23
+ */
24
+ export async function extractDatabaseBlocks(database, options = {}) {
25
+ const logEntriesOnly = options.logEntriesOnly || false;
26
+ const extractionMode = logEntriesOnly
27
+ ? "log entries only (fallback mode)"
28
+ : "all blocks";
29
+
30
+ logger.info(
31
+ `🔍 Extracting ${extractionMode} from database: ${database.name}`,
32
+ );
33
+
34
+ const blocks = new Map();
35
+ const blockSources = new Map();
36
+
37
+ // 1. Get all log entries
38
+ const entries = await database.log.values();
39
+ logger.info(` Found ${entries.length} log entries`);
40
+
41
+ for (const entry of entries) {
42
+ try {
43
+ const entryBytes = await database.log.storage.get(entry.hash);
44
+ if (entryBytes) {
45
+ const entryCid = CID.parse(entry.hash);
46
+ blocks.set(entry.hash, { cid: entryCid, bytes: entryBytes });
47
+ blockSources.set(entry.hash, "log_entry");
48
+ logger.info(` ✓ Entry block: ${entry.hash}`);
49
+ }
50
+ } catch (error) {
51
+ logger.warn(` ⚠️ Failed to get entry ${entry.hash}: ${error.message}`);
52
+ }
53
+ }
54
+
55
+ // Get manifest CID for metadata (always extract this regardless of mode)
56
+ const addressParts = database.address.split("/");
57
+ const manifestCID = addressParts[addressParts.length - 1];
58
+
59
+ // Only extract metadata blocks if NOT in log-entries-only mode
60
+ if (!logEntriesOnly) {
61
+ // 2. Get database manifest
62
+ try {
63
+ const manifestBytes = await database.log.storage.get(manifestCID);
64
+ if (manifestBytes) {
65
+ const manifestParsedCid = CID.parse(manifestCID);
66
+ blocks.set(manifestCID, {
67
+ cid: manifestParsedCid,
68
+ bytes: manifestBytes,
69
+ });
70
+ blockSources.set(manifestCID, "manifest");
71
+ logger.info(` ✓ Manifest block: ${manifestCID}`);
72
+
73
+ // Decode manifest to get access controller
74
+ try {
75
+ const manifestBlock = await Block.decode({
76
+ cid: manifestParsedCid,
77
+ bytes: manifestBytes,
78
+ codec: dagCbor,
79
+ hasher: sha256,
80
+ });
81
+
82
+ // Get access controller block
83
+ if (manifestBlock.value.accessController) {
84
+ const accessControllerCID =
85
+ manifestBlock.value.accessController.replace("/ipfs/", "");
86
+ try {
87
+ const accessBytes =
88
+ await database.log.storage.get(accessControllerCID);
89
+ if (accessBytes) {
90
+ const accessParsedCid = CID.parse(accessControllerCID);
91
+ blocks.set(accessControllerCID, {
92
+ cid: accessParsedCid,
93
+ bytes: accessBytes,
94
+ });
95
+ blockSources.set(accessControllerCID, "access_controller");
96
+ logger.info(` ✓ Access controller: ${accessControllerCID}`);
97
+ }
98
+ } catch (error) {
99
+ logger.warn(
100
+ ` ⚠️ Could not get access controller: ${error.message}`,
101
+ );
102
+ }
103
+ }
104
+ } catch (error) {
105
+ logger.warn(` ⚠️ Could not decode manifest: ${error.message}`);
106
+ }
107
+ }
108
+ } catch (error) {
109
+ logger.warn(` ⚠️ Could not get manifest: ${error.message}`);
110
+ }
111
+
112
+ // 3. Get identity blocks using identities system and from log entries
113
+ logger.debug(
114
+ `Getting identity blocks from identities system and log entries...`,
115
+ );
116
+
117
+ // Collect all identity references from log entries
118
+ const referencedIdentities = new Set();
119
+ for (const entry of entries) {
120
+ if (entry.identity) {
121
+ referencedIdentities.add(entry.identity);
122
+ }
123
+ }
124
+
125
+ logger.info(
126
+ ` 📝 Found ${referencedIdentities.size} unique identity references in log entries`,
127
+ );
128
+
129
+ // Get identity blocks - try multiple approaches for robustness
130
+ for (const identityHash of referencedIdentities) {
131
+ try {
132
+ // Method 1: the database's own identity, which carries its block. The
133
+ // log's storage may not hold it — `Identities()` without `ipfs` keeps
134
+ // identities in memory — and a Helia blockstore asked for it searches
135
+ // the network until OrbitDB's 30-second timeout, then goes without.
136
+ const ownIdentity = database.identity ?? database.log.identity;
137
+ if (ownIdentity?.hash === identityHash && ownIdentity.bytes) {
138
+ blocks.set(identityHash, {
139
+ cid: CID.parse(identityHash),
140
+ bytes: ownIdentity.bytes,
141
+ });
142
+ blockSources.set(identityHash, "identity_own");
143
+ logger.info(` ✓ Identity block (own): ${identityHash}`);
144
+ continue;
145
+ }
146
+
147
+ // Method 2: Try to get identity from identities system (if available)
148
+ if (
149
+ database.log.identities &&
150
+ typeof database.log.identities.getIdentity === "function"
151
+ ) {
152
+ try {
153
+ const identity =
154
+ await database.log.identities.getIdentity(identityHash);
155
+ if (identity && identity.hash) {
156
+ // Get the identity block from storage
157
+ const identityBytes = await database.log.storage.get(
158
+ identity.hash,
159
+ );
160
+ if (identityBytes) {
161
+ const identityCid = CID.parse(identity.hash);
162
+ blocks.set(identity.hash, {
163
+ cid: identityCid,
164
+ bytes: identityBytes,
165
+ });
166
+ blockSources.set(identity.hash, "identity_system");
167
+ logger.info(` ✓ Identity block (system): ${identity.hash}`);
168
+ continue; // Skip other methods if this works
169
+ }
170
+ }
171
+ } catch (systemError) {
172
+ logger.warn(
173
+ ` ⚠️ Identity system failed for ${identityHash}: ${systemError.message}`,
174
+ );
175
+ }
176
+ } else {
177
+ logger.info(
178
+ ` ℹ️ Identity system not available, using direct storage access`,
179
+ );
180
+ }
181
+
182
+ // Method 3: Try to get the identity hash directly from storage
183
+ try {
184
+ const identityBytes = await database.log.storage.get(identityHash);
185
+ if (identityBytes && !blocks.has(identityHash)) {
186
+ const identityCid = CID.parse(identityHash);
187
+ blocks.set(identityHash, {
188
+ cid: identityCid,
189
+ bytes: identityBytes,
190
+ });
191
+ blockSources.set(identityHash, "identity_direct");
192
+ logger.info(` ✓ Identity block (direct): ${identityHash}`);
193
+ continue; // Skip scanning if direct access works
194
+ }
195
+ } catch (directError) {
196
+ logger.warn(
197
+ ` ⚠️ Could not get identity ${identityHash} directly: ${directError.message}`,
198
+ );
199
+ }
200
+ } catch (error) {
201
+ logger.warn(
202
+ ` ⚠️ Failed to get identity ${identityHash}: ${error.message}`,
203
+ );
204
+ }
205
+ }
206
+
207
+ // Additional scan through all storage blocks for any missed identity blocks
208
+ logger.debug(`Scanning remaining storage blocks for missed identities...`);
209
+ let discoveredIdentities = 0;
210
+
211
+ for await (const [hash, bytes] of database.log.storage.iterator()) {
212
+ try {
213
+ // Skip if we already have this block
214
+ if (blocks.has(hash)) {
215
+ continue;
216
+ }
217
+
218
+ // Try to decode as CBOR to check if it's an identity block
219
+ const cid = CID.parse(hash);
220
+ if (cid.code === 0x71) {
221
+ // dag-cbor codec
222
+ const block = await Block.decode({
223
+ cid,
224
+ bytes,
225
+ codec: dagCbor,
226
+ hasher: sha256,
227
+ });
228
+
229
+ const content = block.value;
230
+
231
+ // Check if this is an identity block (enhanced detection)
232
+ if (content && content.id && (content.type || content.publicKey)) {
233
+ blocks.set(hash, { cid, bytes });
234
+ blockSources.set(hash, "identity_discovered");
235
+ discoveredIdentities++;
236
+ logger.info(
237
+ ` ✓ Identity block discovered: ${hash}${referencedIdentities.has(hash) ? " (was referenced)" : " (unreferenced)"}`,
238
+ );
239
+ }
240
+ }
241
+ } catch {
242
+ // Skip blocks that can't be decoded - they might be raw data or other formats
243
+ continue;
244
+ }
245
+ }
246
+
247
+ logger.info(
248
+ ` 📊 Identity blocks: ${referencedIdentities.size} referenced, ${discoveredIdentities} discovered`,
249
+ );
250
+ } else {
251
+ logger.info(
252
+ ` ⚡ Skipping manifest, access controller, and identity blocks (fallback mode)`,
253
+ );
254
+ }
255
+
256
+ logger.info(` 📊 Extracted ${blocks.size} total blocks`);
257
+ return { blocks, blockSources, manifestCID };
258
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Fetching bytes by CID from public IPFS gateways.
3
+ *
4
+ * Extracted from `restore-cid.js` when a second caller appeared. It is not
5
+ * about restoring and it is not about CARs — it is "resolve this name, from
6
+ * whoever will serve it" — and leaving it where it was would have meant a
7
+ * storage driver importing a CAR reader to make an HTTP request.
8
+ *
9
+ * Nothing here imports anything. That is deliberate and worth keeping: this is
10
+ * the module a browser reaches for when it wants one blob and nothing else.
11
+ *
12
+ * @module gateway-fetch
13
+ */
14
+
15
+ /** Tried in order. The first is Storacha's own, the rest are public. */
16
+ export const DEFAULT_GATEWAYS = [
17
+ "https://w3s.link/ipfs",
18
+ "https://storacha.link/ipfs",
19
+ "https://dweb.link/ipfs",
20
+ "https://ipfs.io/ipfs",
21
+ ];
22
+
23
+ /**
24
+ * Quiet by default. A restore reports itself through its return value and its
25
+ * exceptions; anything more is the caller's choice, and importing a logger to
26
+ * offer it would cost more than the feature.
27
+ */
28
+ export const SILENT = { info() {}, warn() {}, debug() {} };
29
+
30
+ const DEFAULT_TIMEOUT_MS = 60_000;
31
+ const MAX_ATTEMPTS_PER_GATEWAY = 3;
32
+
33
+ /**
34
+ * A gateway that cannot serve a CID usually says so in HTML, with a 200.
35
+ *
36
+ * That is the trap this guards: the bytes arrive, the status is fine, and what
37
+ * you have is an error page that fails much later as an unreadable CAR. Two
38
+ * cheap checks — the declared type, and what the bytes actually start with,
39
+ * because the header is not always honest.
40
+ */
41
+ const looksLikeAnErrorPage = (bytes, contentType = "") => {
42
+ if (contentType.includes("text/html") || contentType.includes("xhtml")) return true;
43
+ const start = new TextDecoder("utf-8", { fatal: false })
44
+ .decode(bytes.subarray(0, Math.min(100, bytes.length)))
45
+ .trim();
46
+ return start.startsWith("<!DOCTYPE") || start.startsWith("<html") || start.startsWith("<?xml");
47
+ };
48
+
49
+ const waitFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
50
+
51
+ /**
52
+ * How long a 429 wants us to wait, in the order the answer is trustworthy:
53
+ * what the server said, when it says the window resets, then a backoff.
54
+ */
55
+ const backoffFor = (response, attempt) => {
56
+ const retryAfter = response.headers?.get?.("Retry-After");
57
+ if (retryAfter) {
58
+ const seconds = Number.parseInt(retryAfter, 10);
59
+ if (Number.isFinite(seconds)) return seconds * 1000;
60
+ const date = new Date(retryAfter);
61
+ if (!Number.isNaN(date.getTime())) return Math.max(0, date.getTime() - Date.now());
62
+ }
63
+ const reset = Number.parseInt(response.headers?.get?.("X-RateLimit-Reset") ?? "", 10);
64
+ if (Number.isFinite(reset)) return Math.max(0, reset * 1000 - Date.now());
65
+ return 2000 * (attempt + 1);
66
+ };
67
+
68
+ /**
69
+ * Fetch the bytes behind a CID, trying each gateway in turn.
70
+ *
71
+ * @param {string} cid
72
+ * @param {Object} [options]
73
+ * @param {string[]} [options.gateways]
74
+ * @param {number} [options.timeout] per request, in ms
75
+ * @param {AbortSignal} [options.signal]
76
+ * @returns {Promise<Uint8Array>}
77
+ */
78
+ export async function fetchFromGateways(cid, { gateways = DEFAULT_GATEWAYS, timeout = DEFAULT_TIMEOUT_MS, signal = null, log = SILENT } = {}) {
79
+ let lastError = null;
80
+
81
+ for (const gateway of gateways) {
82
+ const url = `${gateway}/${cid}`;
83
+ for (let attempt = 0; attempt < MAX_ATTEMPTS_PER_GATEWAY; attempt++) {
84
+ const timer = AbortSignal.timeout ? AbortSignal.timeout(timeout) : null;
85
+ try {
86
+ const response = await fetch(url, { signal: signal ?? timer ?? undefined });
87
+
88
+ if (response.status === 429 && attempt < MAX_ATTEMPTS_PER_GATEWAY - 1) {
89
+ const wait = backoffFor(response, attempt);
90
+ log.warn(` ⚠️ ${gateway} rate-limited; waiting ${Math.round(wait / 1000)}s`);
91
+ await waitFor(wait);
92
+ continue;
93
+ }
94
+ if (!response.ok) {
95
+ log.debug(` ⚠️ ${gateway} answered ${response.status}`);
96
+ break; // a status this gateway will keep giving — move on
97
+ }
98
+
99
+ const bytes = new Uint8Array(await response.arrayBuffer());
100
+ if (looksLikeAnErrorPage(bytes, response.headers?.get?.("content-type") ?? "")) {
101
+ log.warn(` ⚠️ ${gateway} returned an error page with a 200`);
102
+ break;
103
+ }
104
+ log.info(` ✅ ${bytes.length} bytes from ${gateway}`);
105
+ return bytes;
106
+ } catch (error) {
107
+ lastError = error;
108
+ log.debug(` ⚠️ ${gateway} failed: ${error.message}`);
109
+ }
110
+ }
111
+ }
112
+
113
+ throw new Error(
114
+ `Could not fetch ${cid} from any gateway${lastError ? `. Last error: ${lastError.message}` : ""}`,
115
+ );
116
+ }