@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.
- package/LICENSE +21 -0
- package/README.md +290 -0
- package/dist/components/StorachaAuth.svelte +801 -0
- package/dist/components/StorachaIntegration.svelte +965 -0
- package/dist/components/WebAuthnDIDProvider.js +562 -0
- package/dist/components/storacha-backup.js +325 -0
- package/dist/components/theme.js +79 -0
- package/lib/backends/aleph-pin.js +161 -0
- package/lib/backends/aleph.js +197 -0
- package/lib/backends/lighthouse.js +325 -0
- package/lib/backends/memory.js +140 -0
- package/lib/backends/pinata.js +330 -0
- package/lib/backends/resolve.js +72 -0
- package/lib/backends/storacha.js +178 -0
- package/lib/backends/types.js +187 -0
- package/lib/backup-car.js +1286 -0
- package/lib/backup-helpers.js +87 -0
- package/lib/backup-metadata.js +40 -0
- package/lib/backup.js +249 -0
- package/lib/block-bytes.js +39 -0
- package/lib/car-storage.js +342 -0
- package/lib/courier-sync.js +886 -0
- package/lib/dehydrate.js +146 -0
- package/lib/extract-blocks.js +258 -0
- package/lib/gateway-fetch.js +116 -0
- package/lib/ipns-helpers.js +324 -0
- package/lib/logger.js +128 -0
- package/lib/memory-courier.js +105 -0
- package/lib/orbitdb-storacha-bridge.js +2789 -0
- package/lib/pointer-ipns.js +234 -0
- package/lib/restore-cid.js +302 -0
- package/lib/ucan-bridge.js +921 -0
- package/lib/utils.js +316 -0
- package/package.json +172 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A pointer a second device can find with nothing but a key.
|
|
3
|
+
*
|
|
4
|
+
* The problem this solves is not storage — a backup's CID is enough to fetch
|
|
5
|
+
* it from anywhere — but *naming*: a device that has lost everything cannot be
|
|
6
|
+
* told a CID, because there is nobody left to tell it. So the name has to be
|
|
7
|
+
* computable from the one thing that survived. Derive a key from a seed, and
|
|
8
|
+
* the IPNS name follows from the key; two devices holding the same seed
|
|
9
|
+
* compute the same name without ever exchanging anything.
|
|
10
|
+
*
|
|
11
|
+
* The seed is the caller's business. A passkey's PRF output is the case this
|
|
12
|
+
* was written for (funkpost#93), and it never passes through here as anything
|
|
13
|
+
* but bytes to stretch.
|
|
14
|
+
*
|
|
15
|
+
* Publication goes over **delegated routing** — `PUT /routing/v1/ipns/{name}`
|
|
16
|
+
* — because a browser cannot join the DHT, and `w3name`, which used to stand
|
|
17
|
+
* in for this, was Storacha's and Storacha is gone. Measured against
|
|
18
|
+
* `delegated-ipfs.dev` on 2026-09-19, from Node and from a page on a foreign
|
|
19
|
+
* origin: the preflight allows `PUT` from anywhere, the PUT is accepted, and
|
|
20
|
+
* the GET returns the record byte for byte
|
|
21
|
+
* (`test/helpers/probe-ipns-routing.js`).
|
|
22
|
+
*
|
|
23
|
+
* Two things that probe could not establish, and this module therefore does
|
|
24
|
+
* not promise: that a record travels beyond the endpoint that accepted it, and
|
|
25
|
+
* how long it is kept. Publish to more than one endpoint where that matters,
|
|
26
|
+
* and treat a pointer as a shortcut for a device that comes back soon rather
|
|
27
|
+
* than as an archive.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
createIPNSRecord,
|
|
32
|
+
marshalIPNSRecord,
|
|
33
|
+
unmarshalIPNSRecord,
|
|
34
|
+
multihashToIPNSRoutingKey,
|
|
35
|
+
} from "ipns";
|
|
36
|
+
import { ipnsValidator } from "ipns/validator";
|
|
37
|
+
import { generateKeyPairFromSeed } from "@libp2p/crypto/keys";
|
|
38
|
+
import { peerIdFromPrivateKey, peerIdFromString } from "@libp2p/peer-id";
|
|
39
|
+
import { CID } from "multiformats/cid";
|
|
40
|
+
import { base36 } from "multiformats/bases/base36";
|
|
41
|
+
import logger from "./logger.js";
|
|
42
|
+
|
|
43
|
+
const log = logger.child ? logger.child({ module: "pointer-ipns" }) : logger;
|
|
44
|
+
|
|
45
|
+
/** libp2p-key, the codec an IPNS name is a CID of. */
|
|
46
|
+
const LIBP2P_KEY_CODEC = 0x72;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Bumping this changes every name a seed produces, so treat it as a breaking
|
|
50
|
+
* change: pointers published under the old one become unfindable.
|
|
51
|
+
*/
|
|
52
|
+
export const POINTER_INFO = "orbitdb-storage-bridge:pointer-ipns:v1";
|
|
53
|
+
|
|
54
|
+
/** Public endpoints that speak delegated routing v1. */
|
|
55
|
+
export const DEFAULT_ENDPOINTS = ["https://delegated-ipfs.dev"];
|
|
56
|
+
|
|
57
|
+
const DEFAULT_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000; // what a record claims for itself
|
|
58
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
59
|
+
const RECORD_CONTENT_TYPE = "application/vnd.ipfs.ipns-record";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Stretch a seed into the key whose name the pointer lives under.
|
|
63
|
+
*
|
|
64
|
+
* HKDF-SHA256, with `label` mixed into the info string so one seed can name
|
|
65
|
+
* several pointers without their keys being related in any usable way.
|
|
66
|
+
*
|
|
67
|
+
* @param {Uint8Array} seed Secret bytes — a PRF output, say. Never published.
|
|
68
|
+
* @param {Object} [options]
|
|
69
|
+
* @param {string} [options.label=""] Distinguishes pointers of one seed.
|
|
70
|
+
* @param {string} [options.info=POINTER_INFO] Domain separation.
|
|
71
|
+
* @returns {Promise<Object>} A libp2p Ed25519 private key.
|
|
72
|
+
*/
|
|
73
|
+
export async function derivePointerKey(
|
|
74
|
+
seed,
|
|
75
|
+
{ label = "", info = POINTER_INFO } = {},
|
|
76
|
+
) {
|
|
77
|
+
if (!(seed instanceof Uint8Array) || seed.length === 0) {
|
|
78
|
+
throw new Error("derivePointerKey needs seed bytes");
|
|
79
|
+
}
|
|
80
|
+
const base = await globalThis.crypto.subtle.importKey(
|
|
81
|
+
"raw",
|
|
82
|
+
seed,
|
|
83
|
+
"HKDF",
|
|
84
|
+
false,
|
|
85
|
+
["deriveBits"],
|
|
86
|
+
);
|
|
87
|
+
const bits = await globalThis.crypto.subtle.deriveBits(
|
|
88
|
+
{
|
|
89
|
+
name: "HKDF",
|
|
90
|
+
hash: "SHA-256",
|
|
91
|
+
salt: new Uint8Array(0),
|
|
92
|
+
info: new TextEncoder().encode(label ? `${info}:${label}` : info),
|
|
93
|
+
},
|
|
94
|
+
base,
|
|
95
|
+
256,
|
|
96
|
+
);
|
|
97
|
+
return generateKeyPairFromSeed("Ed25519", new Uint8Array(bits));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The IPNS name of a key, as routing endpoints and gateways spell it.
|
|
102
|
+
*
|
|
103
|
+
* @param {Object} key A libp2p private or public key.
|
|
104
|
+
* @returns {string} base36 `k51…`
|
|
105
|
+
*/
|
|
106
|
+
export function pointerName(key) {
|
|
107
|
+
const publicKey = key.publicKey ?? key;
|
|
108
|
+
const peerId = key.publicKey
|
|
109
|
+
? peerIdFromPrivateKey(key)
|
|
110
|
+
: (publicKey.toPeerId?.() ?? publicKey);
|
|
111
|
+
return CID.createV1(LIBP2P_KEY_CODEC, peerId.toMultihash()).toString(base36);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const multihashOfName = (name) => peerIdFromString(name).toMultihash();
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Put a CID under the name this key derives, at every endpoint given.
|
|
118
|
+
*
|
|
119
|
+
* Resolves as soon as one endpoint has taken it; the rest are still tried, and
|
|
120
|
+
* what each of them said comes back in `results` — a pointer that reached one
|
|
121
|
+
* endpoint is published, and a pointer that reached three is more likely to be
|
|
122
|
+
* found later.
|
|
123
|
+
*
|
|
124
|
+
* @param {Object} params
|
|
125
|
+
* @param {Object} params.privateKey From `derivePointerKey`.
|
|
126
|
+
* @param {string|Object} params.cid What the pointer points at.
|
|
127
|
+
* @param {string[]} [params.endpoints]
|
|
128
|
+
* @param {bigint|number} [params.sequence] Must only ever grow for a name.
|
|
129
|
+
* Seconds since the epoch by default, which is monotonic enough for a
|
|
130
|
+
* pointer written by hand and readable in a log.
|
|
131
|
+
* @param {number} [params.lifetimeMs]
|
|
132
|
+
* @param {AbortSignal} [params.signal]
|
|
133
|
+
* @returns {Promise<{name: string, sequence: bigint, results: Array<{endpoint: string, ok: boolean, status?: number, error?: string}>}>}
|
|
134
|
+
*/
|
|
135
|
+
export async function publishPointer({
|
|
136
|
+
privateKey,
|
|
137
|
+
cid,
|
|
138
|
+
endpoints = DEFAULT_ENDPOINTS,
|
|
139
|
+
sequence = BigInt(Math.floor(Date.now() / 1000)),
|
|
140
|
+
lifetimeMs = DEFAULT_LIFETIME_MS,
|
|
141
|
+
signal,
|
|
142
|
+
}) {
|
|
143
|
+
const value = typeof cid === "string" ? CID.parse(cid) : cid;
|
|
144
|
+
const record = await createIPNSRecord(
|
|
145
|
+
privateKey,
|
|
146
|
+
value,
|
|
147
|
+
BigInt(sequence),
|
|
148
|
+
lifetimeMs,
|
|
149
|
+
);
|
|
150
|
+
const body = marshalIPNSRecord(record);
|
|
151
|
+
const name = pointerName(privateKey);
|
|
152
|
+
|
|
153
|
+
const results = await Promise.all(
|
|
154
|
+
endpoints.map(async (endpoint) => {
|
|
155
|
+
try {
|
|
156
|
+
const response = await fetch(pointerUrl(endpoint, name), {
|
|
157
|
+
method: "PUT",
|
|
158
|
+
headers: { "Content-Type": RECORD_CONTENT_TYPE },
|
|
159
|
+
body,
|
|
160
|
+
signal: signal ?? AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
|
161
|
+
});
|
|
162
|
+
return { endpoint, ok: response.ok, status: response.status };
|
|
163
|
+
} catch (error) {
|
|
164
|
+
return { endpoint, ok: false, error: error.message };
|
|
165
|
+
}
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
if (!results.some((result) => result.ok)) {
|
|
170
|
+
const reasons = results
|
|
171
|
+
.map((result) => `${result.endpoint}: ${result.error ?? result.status}`)
|
|
172
|
+
.join("; ");
|
|
173
|
+
throw new Error(`no endpoint took the pointer (${reasons})`);
|
|
174
|
+
}
|
|
175
|
+
log.info?.("📍 pointer %s → %s", name, value.toString());
|
|
176
|
+
return { name, sequence: BigInt(sequence), results };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Read a pointer back, from whichever endpoint answers with a valid record.
|
|
181
|
+
*
|
|
182
|
+
* Every record is checked against the name it was asked for before it is
|
|
183
|
+
* believed: the name is the hash of the key that signs, so an endpoint cannot
|
|
184
|
+
* hand back somebody else's pointer, or an altered one, without being caught.
|
|
185
|
+
*
|
|
186
|
+
* @param {Object} params
|
|
187
|
+
* @param {string} [params.name] The name, if the caller has it.
|
|
188
|
+
* @param {Object} [params.privateKey] Or the key, and the name follows.
|
|
189
|
+
* @param {string[]} [params.endpoints]
|
|
190
|
+
* @param {AbortSignal} [params.signal]
|
|
191
|
+
* @returns {Promise<{name: string, cid: string, sequence: bigint, validity: string|undefined}>}
|
|
192
|
+
*/
|
|
193
|
+
export async function resolvePointer({
|
|
194
|
+
name,
|
|
195
|
+
privateKey,
|
|
196
|
+
endpoints = DEFAULT_ENDPOINTS,
|
|
197
|
+
signal,
|
|
198
|
+
}) {
|
|
199
|
+
const pointer = name ?? (privateKey ? pointerName(privateKey) : null);
|
|
200
|
+
if (!pointer) throw new Error("resolvePointer needs a name or a key");
|
|
201
|
+
const routingKey = multihashToIPNSRoutingKey(multihashOfName(pointer));
|
|
202
|
+
|
|
203
|
+
const failures = [];
|
|
204
|
+
for (const endpoint of endpoints) {
|
|
205
|
+
try {
|
|
206
|
+
const response = await fetch(pointerUrl(endpoint, pointer), {
|
|
207
|
+
headers: { Accept: RECORD_CONTENT_TYPE },
|
|
208
|
+
signal: signal ?? AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
|
209
|
+
});
|
|
210
|
+
if (!response.ok) {
|
|
211
|
+
failures.push(`${endpoint}: ${response.status}`);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
215
|
+
await ipnsValidator(routingKey, bytes); // signed by this name, or nothing
|
|
216
|
+
const record = unmarshalIPNSRecord(bytes);
|
|
217
|
+
return {
|
|
218
|
+
name: pointer,
|
|
219
|
+
cid: String(record.value).replace(/^\/ipfs\//, ""),
|
|
220
|
+
sequence: record.sequence,
|
|
221
|
+
validity: record.validity,
|
|
222
|
+
};
|
|
223
|
+
} catch (error) {
|
|
224
|
+
failures.push(`${endpoint}: ${error.message}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
throw new Error(
|
|
228
|
+
`no endpoint had a valid pointer for ${pointer} (${failures.join("; ")})`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function pointerUrl(endpoint, name) {
|
|
233
|
+
return `${endpoint.replace(/\/$/, "")}/routing/v1/ipns/${name}`;
|
|
234
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Restore an OrbitDB database from a single CID — no Storacha client.
|
|
3
|
+
*
|
|
4
|
+
* `restoreFromSpaceCAR` can already do this: hand it a `metadataCID` and it
|
|
5
|
+
* resolves the blocks CAR out of the metadata and fetches both from public
|
|
6
|
+
* gateways, with no credentials on the restoring side. The problem was never
|
|
7
|
+
* the capability, it was the import — `backup-car.js` reaches into
|
|
8
|
+
* `orbitdb-storacha-bridge.js`, which pulls in `@storacha/client` at module
|
|
9
|
+
* level, so a peer that only ever *reads* paid for the whole backup SDK.
|
|
10
|
+
*
|
|
11
|
+
* Measured against a real consumer (funkpost's `mesh-todo`, 561 kB gzipped):
|
|
12
|
+
* importing `restoreFromSpaceCAR` added **617 kB gzipped**; what the steps
|
|
13
|
+
* below actually need adds about **11 kB**, because a consumer of this package
|
|
14
|
+
* already ships `multiformats` and `@ipld/dag-cbor`, leaving `@ipld/car`.
|
|
15
|
+
*
|
|
16
|
+
* So this module imports no client, no space, no proof, no Helia — and no
|
|
17
|
+
* logger, which is not a detail: the package's logger reaches `@libp2p/logger`,
|
|
18
|
+
* and pulling a logging framework in behind a progress line would undo most of
|
|
19
|
+
* the saving. Both the fetching and the logging are **injectable** instead: the
|
|
20
|
+
* default fetch is `fetch` against public gateways, the default log is silence,
|
|
21
|
+
* and a caller that wants either of the package's own versions passes it.
|
|
22
|
+
*
|
|
23
|
+
* ## Why a peer that only restores is a real shape
|
|
24
|
+
*
|
|
25
|
+
* Backing up needs an account. Reading a backup does not — a CID is a name that
|
|
26
|
+
* anyone can resolve. Alice keeps the space; Bob has a link. Over a courier
|
|
27
|
+
* where a byte is rationed this is the difference between announcing a whole
|
|
28
|
+
* database and announcing where one is: a CID fits in a single LoRa frame.
|
|
29
|
+
*
|
|
30
|
+
* @module restore-cid
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { CarReader } from "@ipld/car";
|
|
34
|
+
import { CID } from "multiformats/cid";
|
|
35
|
+
import { base58btc } from "multiformats/bases/base58";
|
|
36
|
+
import * as Block from "multiformats/block";
|
|
37
|
+
import * as dagCbor from "@ipld/dag-cbor";
|
|
38
|
+
import { sha256, sha512 } from "multiformats/hashes/sha2";
|
|
39
|
+
import { isValidMetadata } from "./backup-metadata.js";
|
|
40
|
+
// Re-exported below, so `restore-cid`'s surface is unchanged by the move.
|
|
41
|
+
import { DEFAULT_GATEWAYS, SILENT, fetchFromGateways } from "./gateway-fetch.js";
|
|
42
|
+
|
|
43
|
+
export { DEFAULT_GATEWAYS, SILENT, fetchFromGateways };
|
|
44
|
+
|
|
45
|
+
const sameBytes = (a, b) => a.length === b.length && a.every((byte, i) => byte === b[i]);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* One spelling of a CID, so two of them can be compared.
|
|
49
|
+
*
|
|
50
|
+
* The same block is called `zdpuAw…` by OrbitDB, which addresses in base58btc,
|
|
51
|
+
* and `bafyrei…` inside a CAR, which uses the CID's own base32. Both are the
|
|
52
|
+
* same 32 bytes wearing different clothes, and comparing the strings says they
|
|
53
|
+
* are different — a mistake that reads as "the block is missing" and is
|
|
54
|
+
* therefore very easy to believe.
|
|
55
|
+
*/
|
|
56
|
+
const canonical = (value) => {
|
|
57
|
+
try {
|
|
58
|
+
return CID.parse(String(value)).toV1().toString();
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** dag-cbor. Only these blocks can be OrbitDB log entries. */
|
|
65
|
+
const DAG_CBOR = 0x71;
|
|
66
|
+
|
|
67
|
+
/** By multihash code, so a block can be checked against the CID that names it. */
|
|
68
|
+
const HASHERS = new Map([
|
|
69
|
+
[sha256.code, sha256],
|
|
70
|
+
[sha512.code, sha512],
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Every block in a CAR, by CID string — each one checked against its own name.
|
|
75
|
+
*
|
|
76
|
+
* **`CarReader` does not do this**, and the omission is easy to miss because it
|
|
77
|
+
* looks like it must: it hands back whatever CID the file claims for whatever
|
|
78
|
+
* bytes sit next to it, unverified. A CAR can therefore carry a block labelled
|
|
79
|
+
* with one CID and containing something else entirely, and a restore would put
|
|
80
|
+
* the attacker's bytes into the blockstore under a name the application trusts.
|
|
81
|
+
*
|
|
82
|
+
* That risk used to be bounded by where the bytes came from — your own space,
|
|
83
|
+
* on a service you had an account with. This module deliberately removed that
|
|
84
|
+
* boundary: it fetches from public gateways and takes an injected fetch, so the
|
|
85
|
+
* bytes can come from anywhere. Verification is what makes that safe, and it is
|
|
86
|
+
* the property worth having rather than a mitigation: **once every block is
|
|
87
|
+
* checked against its CID, where it came from stops mattering.** A hostile
|
|
88
|
+
* mirror, a stale gateway, a file on a USB stick — none of them can forge a
|
|
89
|
+
* block, only fail to provide one.
|
|
90
|
+
*
|
|
91
|
+
* @param {Uint8Array} carBytes
|
|
92
|
+
* @param {Object} [options]
|
|
93
|
+
* @param {boolean} [options.verify=true] Only turn this off for bytes you
|
|
94
|
+
* produced yourself and have not let out of your sight.
|
|
95
|
+
* @returns {Promise<Map<string, { bytes: Uint8Array }>>}
|
|
96
|
+
*/
|
|
97
|
+
export async function readBlocksFromCAR(carBytes, { verify = true } = {}) {
|
|
98
|
+
const reader = await CarReader.fromBytes(carBytes);
|
|
99
|
+
const blocks = new Map();
|
|
100
|
+
|
|
101
|
+
for await (const block of reader.blocks()) {
|
|
102
|
+
const name = block.cid.toString();
|
|
103
|
+
|
|
104
|
+
if (verify) {
|
|
105
|
+
const hasher = HASHERS.get(block.cid.multihash.code);
|
|
106
|
+
// Refused rather than waved through: a hash we cannot compute is a block
|
|
107
|
+
// we cannot vouch for, and silently accepting it would defeat the point.
|
|
108
|
+
if (!hasher) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Block ${name} uses multihash 0x${block.cid.multihash.code.toString(16)}, which this reader cannot verify`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
const digest = await hasher.digest(block.bytes);
|
|
114
|
+
if (!sameBytes(digest.digest, block.cid.multihash.digest)) {
|
|
115
|
+
throw new Error(`Block ${name} does not hash to its own CID — the CAR has been tampered with`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
blocks.set(name, { bytes: block.bytes });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return blocks;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The heads of a hash-linked log: entries nothing else points back to.
|
|
127
|
+
*
|
|
128
|
+
* Derived from the blocks rather than carried in the metadata, because the
|
|
129
|
+
* blocks are the truth and a stated head can be stale. A block counts as a log
|
|
130
|
+
* entry only if it is dag-cbor *and* carries a signature, key and identity —
|
|
131
|
+
* the manifest and the access controller are dag-cbor too.
|
|
132
|
+
*/
|
|
133
|
+
async function headsIn(blocks) {
|
|
134
|
+
const entries = [];
|
|
135
|
+
const pointedAt = new Set();
|
|
136
|
+
|
|
137
|
+
for (const [cidString, { bytes }] of blocks) {
|
|
138
|
+
let cid;
|
|
139
|
+
try {
|
|
140
|
+
cid = CID.parse(cidString);
|
|
141
|
+
} catch {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (cid.code !== DAG_CBOR) continue;
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const { value } = await Block.decode({ cid, bytes, codec: dagCbor, hasher: sha256 });
|
|
148
|
+
if (!value?.sig || !value?.key || !value?.identity) continue;
|
|
149
|
+
entries.push({ hash: cid.toV1().toString(base58btc), value });
|
|
150
|
+
if (Array.isArray(value.next)) for (const next of value.next) pointedAt.add(next);
|
|
151
|
+
} catch {
|
|
152
|
+
/* not a block we can read; the CAR may hold more than this log */
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return entries.filter((entry) => !pointedAt.has(entry.hash));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Restore a database from the CID of a CAR backup's metadata.
|
|
161
|
+
*
|
|
162
|
+
* Everything hangs off that one CID: the metadata names the CAR, the CAR holds
|
|
163
|
+
* the blocks, and the blocks carry their own addresses. Nothing here talks to
|
|
164
|
+
* Storacha, so the caller needs no space, key or proof — only a way to fetch
|
|
165
|
+
* bytes, which by default is `fetch` against public gateways.
|
|
166
|
+
*
|
|
167
|
+
* **The returned `database` replaces any handle the caller already held on this
|
|
168
|
+
* address.** The log has to be reopened to read the blocks put underneath it,
|
|
169
|
+
* and `orbitdb.open` hands out one instance per address — so the instance that
|
|
170
|
+
* gets closed here is the caller's own, and writing through it afterwards fails
|
|
171
|
+
* on an aborted signal.
|
|
172
|
+
*
|
|
173
|
+
* @param {Object} orbitdb an OrbitDB instance to restore into
|
|
174
|
+
* @param {Object} options
|
|
175
|
+
* @param {string} options.metadataCID the pointer — what `backupDatabaseCAR`
|
|
176
|
+
* returns as `backupFiles.metadataCID`
|
|
177
|
+
* @param {string[]} [options.gateways]
|
|
178
|
+
* @param {number} [options.timeout] per request, in ms
|
|
179
|
+
* @param {AbortSignal} [options.signal]
|
|
180
|
+
* @param {boolean} [options.verify=true] Check every block against its own
|
|
181
|
+
* CID, and that the CAR really holds the manifest this backup names. Leaving
|
|
182
|
+
* it on is what makes an untrusted source acceptable.
|
|
183
|
+
* @param {{ info: Function, warn: Function, debug: Function }} [options.log]
|
|
184
|
+
* where to report progress; silent unless given. Pass the package's own
|
|
185
|
+
* `logger` for the verbose behaviour `restoreFromSpaceCAR` has.
|
|
186
|
+
* @param {(cid: string, options: Object) => Promise<Uint8Array>} [options.fetchBytes]
|
|
187
|
+
* how to resolve a CID. Injected rather than imported so that a caller with
|
|
188
|
+
* its own IPFS node can use it without every caller bundling one.
|
|
189
|
+
* @param {Object} [options.open] options for `orbitdb.open`, merged over the
|
|
190
|
+
* type the backup names. A node without pubsub needs `{ sync: false }` here:
|
|
191
|
+
* OrbitDB's Sync subscribes on open, and on a libp2p built without a pubsub
|
|
192
|
+
* service that throws before the database is ever handed back.
|
|
193
|
+
* @returns {Promise<{ address: string, database: Object, blocks: number,
|
|
194
|
+
* entries: number, heads: number, joined: number }>}
|
|
195
|
+
*/
|
|
196
|
+
export async function restoreFromCID(orbitdb, options = {}) {
|
|
197
|
+
const { metadataCID, fetchBytes = fetchFromGateways, log = SILENT, verify = true, open = {}, ...rest } = options;
|
|
198
|
+
|
|
199
|
+
if (!orbitdb?.ipfs?.blockstore) throw new Error("An OrbitDB instance is required");
|
|
200
|
+
if (!metadataCID) throw new Error("A metadataCID is required");
|
|
201
|
+
|
|
202
|
+
log.info(`🔄 Restoring from ${metadataCID}`);
|
|
203
|
+
|
|
204
|
+
// 1 · the metadata names everything else
|
|
205
|
+
const metadataBytes = await fetchBytes(metadataCID, { ...rest, log });
|
|
206
|
+
let metadata;
|
|
207
|
+
try {
|
|
208
|
+
metadata = JSON.parse(new TextDecoder().decode(metadataBytes));
|
|
209
|
+
} catch (error) {
|
|
210
|
+
throw new Error(`Backup metadata at ${metadataCID} is not JSON: ${error.message}`, { cause: error });
|
|
211
|
+
}
|
|
212
|
+
if (!isValidMetadata(metadata)) throw new Error("Invalid backup metadata");
|
|
213
|
+
|
|
214
|
+
// Everything the metadata must name, checked before anything is downloaded —
|
|
215
|
+
// a CAR is the large fetch here, and finding out afterwards that there is no
|
|
216
|
+
// address to open it into wastes the whole transfer.
|
|
217
|
+
const carCID = metadata.carCID;
|
|
218
|
+
if (!carCID) throw new Error("Backup metadata names no CAR file");
|
|
219
|
+
|
|
220
|
+
const dbInfo = metadata.databases?.[0];
|
|
221
|
+
if (!dbInfo?.address) {
|
|
222
|
+
// `isValidMetadata` also accepts the older `{ root, path }` shape, which
|
|
223
|
+
// predates CAR backups and has no address to open. Say which it is.
|
|
224
|
+
throw new Error(
|
|
225
|
+
dbInfo ? "This is a pre-CAR backup; restoreFromCID needs a CAR backup" : "Backup metadata names no database",
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 2 · the CAR holds the blocks, and each one is checked against its own CID
|
|
230
|
+
const blocks = await readBlocksFromCAR(await fetchBytes(carCID, { ...rest, log }), { verify });
|
|
231
|
+
log.info(` ✅ ${blocks.size} blocks`);
|
|
232
|
+
|
|
233
|
+
// The blocks verify individually; this is what ties them to *this* backup.
|
|
234
|
+
// Without it a CAR full of perfectly valid blocks from some other database
|
|
235
|
+
// would restore happily, because every block would be honest about itself.
|
|
236
|
+
const manifestCID = dbInfo.manifestCID ?? metadata.manifestCID;
|
|
237
|
+
if (verify && manifestCID) {
|
|
238
|
+
const wanted = canonical(manifestCID);
|
|
239
|
+
const present = new Set([...blocks.keys()].map(canonical));
|
|
240
|
+
if (!wanted || !present.has(wanted)) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`The CAR does not contain the manifest ${manifestCID} that this backup names`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 3 · into the blockstore, and into the log's own storage
|
|
248
|
+
//
|
|
249
|
+
// Both, and not by accident: the blockstore is where Helia looks, while
|
|
250
|
+
// OrbitDB's log reads from its own store and addresses blocks in base58btc
|
|
251
|
+
// rather than the CAR's base32. A restore that fills only one of them opens
|
|
252
|
+
// a database that is empty in a way nothing reports.
|
|
253
|
+
let stored = 0;
|
|
254
|
+
for (const [cidString, { bytes }] of blocks) {
|
|
255
|
+
try {
|
|
256
|
+
await orbitdb.ipfs.blockstore.put(CID.parse(cidString), bytes);
|
|
257
|
+
stored++;
|
|
258
|
+
} catch (error) {
|
|
259
|
+
log.warn(` ⚠️ could not store ${cidString.slice(0, 12)}…: ${error.message}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const opened = await orbitdb.open(dbInfo.address, { type: dbInfo.type, ...open });
|
|
264
|
+
for (const [cidString, { bytes }] of blocks) {
|
|
265
|
+
try {
|
|
266
|
+
await opened.log.storage.put(CID.parse(cidString).toV1().toString(base58btc), bytes);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
log.warn(` ⚠️ could not copy ${cidString.slice(0, 12)}… to log storage: ${error.message}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Reopened so the log reads what we just put underneath it.
|
|
273
|
+
await opened.close();
|
|
274
|
+
const database = await orbitdb.open(dbInfo.address, { type: dbInfo.type, ...open });
|
|
275
|
+
|
|
276
|
+
// 4 · tell the log where its history ends
|
|
277
|
+
const heads = await headsIn(blocks);
|
|
278
|
+
let joined = 0;
|
|
279
|
+
for (const head of heads) {
|
|
280
|
+
try {
|
|
281
|
+
const { v, id, key, sig, next, refs, clock, payload, identity } = head.value;
|
|
282
|
+
if (await database.log.joinEntry({ hash: head.hash, v, id, key, sig, next, refs, clock, payload, identity })) {
|
|
283
|
+
joined++;
|
|
284
|
+
}
|
|
285
|
+
} catch (error) {
|
|
286
|
+
log.warn(` ⚠️ could not join head ${head.hash.slice(0, 12)}…: ${error.message}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
log.info(`✅ Restored ${dbInfo.address} — ${stored} blocks, ${joined}/${heads.length} heads`);
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
address: dbInfo.address,
|
|
294
|
+
database,
|
|
295
|
+
blocks: stored,
|
|
296
|
+
entries: metadata.totalEntries ?? dbInfo.entryCount ?? null,
|
|
297
|
+
heads: heads.length,
|
|
298
|
+
joined,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export default { restoreFromCID, fetchFromGateways, readBlocksFromCAR, DEFAULT_GATEWAYS, SILENT };
|