@le-space/orbitdb-storage-bridge 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,7 +26,7 @@ Where "somewhere else" is, is a choice:
26
26
  | Backend | Needs | |
27
27
  | --- | --- | --- |
28
28
  | `aleph` | nothing | the only backend a browser can write to with no key; ingest only |
29
- | `aleph-pin` | a wallet | the signed STORE message that makes Aleph keep it |
29
+ | `aleph-pin` | a signing key: the paying account's, or one it authorised | the signed STORE message that makes Aleph keep it, paid in credits |
30
30
  | `pinata` | a scoped JWT | pin-by-CID as well as CAR upload |
31
31
  | `lighthouse` | an API key | pay once, stored in perpetuity |
32
32
  | `memory` | nothing | in-process, for tests and demos |
@@ -83,6 +83,7 @@ Galaxy A57 with the same key brought the database back and wrote to it. Source:
83
83
  | [docs/STORAGE-BACKENDS.md](docs/STORAGE-BACKENDS.md) | every backend evaluated — prices, limits, and what has been verified against a live account |
84
84
  | [docs/RECOVERY-ON-A-SECOND-DEVICE.md](docs/RECOVERY-ON-A-SECOND-DEVICE.md) | the passkey recovery procedure, step by step |
85
85
  | [docs/CAR-BACKUP.md](docs/CAR-BACKUP.md) | CAR-based timestamped backups |
86
+ | [docs/APP-BACKUP.md](docs/APP-BACKUP.md) | an application's backup in one sealed file: several databases, a keyring header, kept on Aleph from the browser, restored by merging |
86
87
  | [SVELTE-COMPONENTS.md](SVELTE-COMPONENTS.md) | the browser components — older than this repository's copy of them; [docs/STORACHA-UI-HISTORY.md](docs/STORACHA-UI-HISTORY.md) records what each piece could do and where it went |
87
88
  | [docs/LOGGING.md](docs/LOGGING.md) | debug namespaces, in Node and in a browser |
88
89
  | [ROADMAP.md](ROADMAP.md) | what is planned |
@@ -0,0 +1,300 @@
1
+ /**
2
+ * @fileoverview An application's backup in one sealed file (#147).
3
+ *
4
+ * Two applications, Le-Space/belege and Le-Space/invoice, back up the same way:
5
+ * every OrbitDB database they keep, and blocks of their own (belege's receipt
6
+ * files), go into one CAR whose root is a manifest; the CAR is sealed with the
7
+ * application's key; one upload, one STORE. This module is that file,
8
+ * generalised from belege's `archive.js` (Le-Space/belege#77), so that the
9
+ * applications stop inventing their own.
10
+ *
11
+ * ## The file
12
+ *
13
+ * "OSBA" | version | header length (4 bytes, big-endian) | header | envelope
14
+ *
15
+ * - **The header** is the caller's, and is read without a key
16
+ * ({@link readAppBackupHeader}). It is room for a keyring: invoice puts its
17
+ * vault there, one sealed slot per passkey, so that any registered passkey
18
+ * finds the key to the rest. This module does not interpret it. Its SHA-256
19
+ * is in the manifest, so a header swapped after the backup was made is
20
+ * refused once the body is open.
21
+ * - **The envelope** is the package's own (`OSBE`, see ./backends/encryption.js)
22
+ * around the CAR, encrypted by the caller's `encrypt`. The package holds no
23
+ * keys, here as everywhere.
24
+ * - **The CAR's root** is a dag-cbor manifest: `kind` and `v` say what this is,
25
+ * `app` whose it is, and `metadata` is `bundleDatabases`'s, naming every
26
+ * database with its heads. `extra` is the application's own (belege lists its
27
+ * receipt files there).
28
+ *
29
+ * Everything is built in memory, as the rest of the package does; a backup is
30
+ * as large as the books it holds.
31
+ *
32
+ * @requires ./extract-blocks.js - bundleDatabases
33
+ * @requires ./backup-car.js - createCARFromBlocks
34
+ * @requires ./restore-cid.js - the verifying CAR reader, restoreFromBlocks
35
+ */
36
+
37
+ import * as dagCbor from "@ipld/dag-cbor";
38
+ import { CID } from "multiformats/cid";
39
+ import { sha256 } from "multiformats/hashes/sha2";
40
+ import { bundleDatabases } from "./extract-blocks.js";
41
+ import { createCARFromBlocks } from "./backup-car.js";
42
+ import { readBlocksFromCAR, restoreFromBlocks } from "./restore-cid.js";
43
+ import { wrapEnvelope, readEnvelope } from "./backends/encryption.js";
44
+
45
+ /** `OSBA` — orbitdb-storage-bridge, application backup. */
46
+ export const APP_BACKUP_MAGIC = new Uint8Array([0x4f, 0x53, 0x42, 0x41]);
47
+
48
+ /** Bumped when the file changes in a way readers must notice. */
49
+ export const APP_BACKUP_VERSION = 1;
50
+
51
+ /** What the manifest at the CAR's root calls itself. */
52
+ export const APP_BACKUP_KIND = "app-backup";
53
+
54
+ const PREAMBLE = APP_BACKUP_MAGIC.length + 1 + 4;
55
+
56
+ const hex = (bytes) => [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
57
+
58
+ async function sha256Hex(bytes) {
59
+ return hex(new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", bytes)));
60
+ }
61
+
62
+ /**
63
+ * @typedef {object} AppBackupManifest
64
+ * @property {"app-backup"} kind
65
+ * @property {number} v - {@link APP_BACKUP_VERSION}
66
+ * @property {string} app - whose backup this is, e.g. `"invoice"`
67
+ * @property {string} createdAt - ISO
68
+ * @property {string} [appVersion]
69
+ * @property {any} metadata - `bundleDatabases`'s: `databases` with each one's
70
+ * `address`, `name`, `type`, `manifestCID`, `entryCount`, `heads`, and
71
+ * `collection` when the databases were given by name
72
+ * @property {string} [header] - hex SHA-256 of the file's header, when it has one
73
+ * @property {any} [extra] - the application's own, as it passed it
74
+ */
75
+
76
+ /**
77
+ * @typedef {{ stage: "database", index: number, total: number, name: string, entries: number }
78
+ * | { stage: "sealing", bytes: number }} AppBackupProgress
79
+ */
80
+
81
+ /**
82
+ * Build a backup file.
83
+ *
84
+ * @param {object} params
85
+ * @param {string} params.app - whose backup this is; {@link openAppBackup} can insist on it
86
+ * @param {Object[]|Object<string, Object>} params.databases - open OrbitDB
87
+ * databases. Given by name, the names go into the metadata as `collection`.
88
+ * @param {(plaintext: Uint8Array) => Promise<{ ciphertext: Uint8Array, iv: Uint8Array }>} params.encrypt -
89
+ * the caller's, with the caller's key
90
+ * @param {Map<string, { bytes: Uint8Array }>} [params.blocks] - blocks of the
91
+ * application's own, by CID string: they travel in the CAR and come back into
92
+ * the blockstore on restore
93
+ * @param {Uint8Array} [params.header] - read without a key, e.g. a keyring
94
+ * @param {string} [params.appVersion]
95
+ * @param {any} [params.extra] - anything dag-cbor can encode
96
+ * @param {() => Date} [params.now]
97
+ * @param {(progress: AppBackupProgress) => void} [params.onProgress]
98
+ * @returns {Promise<{ bytes: Uint8Array, manifest: AppBackupManifest, blocks: number, carBytes: number }>}
99
+ */
100
+ export async function buildAppBackup({
101
+ app,
102
+ databases,
103
+ encrypt,
104
+ blocks: own,
105
+ header,
106
+ appVersion,
107
+ extra,
108
+ now = () => new Date(),
109
+ onProgress,
110
+ }) {
111
+ if (typeof app !== "string" || !app) throw new Error("buildAppBackup needs the application's name as `app`");
112
+ if (typeof encrypt !== "function") throw new Error("buildAppBackup needs an `encrypt` function: the package holds no keys");
113
+ if (header !== undefined && !(header instanceof Uint8Array)) throw new Error("A backup's header is bytes");
114
+ if (own !== undefined && !(own instanceof Map)) throw new Error("An application's own blocks come as a Map, by CID string");
115
+
116
+ const at = now();
117
+ const names = Array.isArray(databases) ? null : Object.keys(databases ?? {});
118
+ const { blocks, metadata } = await bundleDatabases(databases, {
119
+ timestamp: at.getTime(),
120
+ onProgress: (p) =>
121
+ onProgress?.({
122
+ stage: "database",
123
+ index: p.index,
124
+ total: p.total,
125
+ name: names?.[p.index] ?? p.name,
126
+ entries: p.entries,
127
+ }),
128
+ });
129
+ if (names) metadata.databases.forEach((d, i) => (d.collection = names[i]));
130
+ for (const [name, block] of own ?? []) if (!blocks.has(name)) blocks.set(name, block);
131
+
132
+ /** @type {AppBackupManifest} */
133
+ const manifest = {
134
+ kind: APP_BACKUP_KIND,
135
+ v: APP_BACKUP_VERSION,
136
+ app,
137
+ createdAt: at.toISOString(),
138
+ ...(appVersion ? { appVersion } : {}),
139
+ metadata,
140
+ ...(header?.length ? { header: await sha256Hex(header) } : {}),
141
+ ...(extra !== undefined ? { extra } : {}),
142
+ };
143
+ const root = dagCbor.encode(manifest);
144
+ const rootCid = CID.createV1(dagCbor.code, await sha256.digest(root));
145
+ blocks.set(rootCid.toString(), { cid: rootCid, bytes: root });
146
+
147
+ const car = await createCARFromBlocks(blocks, rootCid.toString());
148
+ onProgress?.({ stage: "sealing", bytes: car.length });
149
+ const { ciphertext, iv } = await encrypt(car);
150
+ const envelope = wrapEnvelope(ciphertext, iv);
151
+
152
+ const head = header ?? new Uint8Array(0);
153
+ const bytes = new Uint8Array(PREAMBLE + head.length + envelope.length);
154
+ bytes.set(APP_BACKUP_MAGIC, 0);
155
+ bytes[APP_BACKUP_MAGIC.length] = APP_BACKUP_VERSION;
156
+ new DataView(bytes.buffer).setUint32(APP_BACKUP_MAGIC.length + 1, head.length);
157
+ bytes.set(head, PREAMBLE);
158
+ bytes.set(envelope, PREAMBLE + head.length);
159
+ return { bytes, manifest, blocks: blocks.size, carBytes: car.length };
160
+ }
161
+
162
+ /** Is this an application backup this module wrote, of any version? */
163
+ export function isAppBackup(bytes) {
164
+ return (
165
+ bytes instanceof Uint8Array &&
166
+ bytes.length >= PREAMBLE &&
167
+ APP_BACKUP_MAGIC.every((byte, index) => bytes[index] === byte)
168
+ );
169
+ }
170
+
171
+ /**
172
+ * The header, without a key: what a keyring needs before anything can be opened.
173
+ *
174
+ * @param {Uint8Array} bytes
175
+ * @returns {{ version: number, header: Uint8Array, body: Uint8Array }}
176
+ */
177
+ export function readAppBackupHeader(bytes) {
178
+ if (!isAppBackup(bytes)) throw new Error("This is not an application backup.");
179
+ const version = bytes[APP_BACKUP_MAGIC.length];
180
+ if (version !== APP_BACKUP_VERSION) {
181
+ throw new Error(
182
+ `This backup was written in format ${version}, and this build reads ${APP_BACKUP_VERSION}. ` +
183
+ "Upgrade @le-space/orbitdb-storage-bridge to open it.",
184
+ );
185
+ }
186
+ const length = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(APP_BACKUP_MAGIC.length + 1);
187
+ if (PREAMBLE + length > bytes.length) throw new Error("This backup is cut short.");
188
+ return {
189
+ version,
190
+ header: bytes.subarray(PREAMBLE, PREAMBLE + length),
191
+ body: bytes.subarray(PREAMBLE + length),
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Open a backup file: the envelope decrypted, every block checked against its
197
+ * own CID, the manifest found among them, and the header checked against it.
198
+ *
199
+ * A wrong key fails in `decrypt` itself (AES-GCM refuses it), and its error
200
+ * comes through unchanged, for the caller to name.
201
+ *
202
+ * @param {Uint8Array} bytes
203
+ * @param {object} options
204
+ * @param {(ciphertext: Uint8Array, iv: Uint8Array) => Promise<Uint8Array>} options.decrypt
205
+ * @param {string} [options.app] - refuse a backup of another application
206
+ * @returns {Promise<{ header: Uint8Array, manifest: AppBackupManifest, blocks: Map<string, { cid: any, bytes: Uint8Array }> }>}
207
+ */
208
+ export async function openAppBackup(bytes, { decrypt, app } = {}) {
209
+ if (typeof decrypt !== "function") throw new Error("openAppBackup needs a `decrypt` function");
210
+ const { header, body } = readAppBackupHeader(bytes);
211
+ const { ciphertext, iv } = readEnvelope(body);
212
+ const car = await decrypt(ciphertext, iv);
213
+ const blocks = await readBlocksFromCAR(car, { verify: true });
214
+
215
+ let manifest;
216
+ for (const [name, { bytes: block }] of blocks) {
217
+ if (CID.parse(name).code !== dagCbor.code) continue;
218
+ let value;
219
+ try {
220
+ value = dagCbor.decode(block);
221
+ } catch {
222
+ continue;
223
+ }
224
+ if (value?.kind === APP_BACKUP_KIND && value?.v === APP_BACKUP_VERSION) {
225
+ manifest = value;
226
+ break;
227
+ }
228
+ }
229
+ if (!manifest) throw new Error("This backup holds no manifest.");
230
+ if (app && manifest.app !== app) throw new Error(`This is a backup of ${manifest.app}, not of ${app}.`);
231
+ // The header is outside the seal; the manifest inside it says which header belongs.
232
+ const expected = manifest.header ?? null;
233
+ const actual = header.length ? await sha256Hex(header) : null;
234
+ if (expected !== actual) throw new Error("This backup's header is not the one it was made with.");
235
+ return { header, manifest, blocks };
236
+ }
237
+
238
+ /**
239
+ * @typedef {{ stage: "database", index: number, total: number, name: string, joined: number }} AppRestoreProgress
240
+ */
241
+
242
+ /**
243
+ * Put an opened backup back into books that are open here.
244
+ *
245
+ * Every database the backup names must be one of these books, by address:
246
+ * restoring merges, and merging another's books into these is never wanted.
247
+ * The databases go back through {@link restoreFromBlocks}: every block into the
248
+ * blockstore (the application's own with them), each database reopened and its
249
+ * heads joined. Joining merges — what is here stays, what the backup holds is
250
+ * added, nothing is deleted. A head that cannot be joined fails the restore
251
+ * with the reason, where `restoreFromBlocks` alone only warns.
252
+ *
253
+ * The open databases are closed and reopened behind the caller's back; reload
254
+ * the application's handles afterwards.
255
+ *
256
+ * @param {object} params
257
+ * @param {any} params.orbitdb
258
+ * @param {{ manifest: AppBackupManifest, blocks: Map<string, any> }} params.opened - from {@link openAppBackup}
259
+ * @param {Record<string, string> | string[]} params.addresses - these books'
260
+ * database addresses, by collection or as a list
261
+ * @param {Record<string, any>} [params.open] - what `orbitdb.open` needs for them (`encryption`, `AccessController`, …)
262
+ * @param {(progress: AppRestoreProgress) => void} [params.onProgress]
263
+ * @returns {Promise<{ databases: { address: string, collection?: string, joined: number, entries: number | null }[] }>}
264
+ */
265
+ export async function restoreAppBackup({ orbitdb, opened, addresses, open, onProgress }) {
266
+ const metadata = opened?.manifest?.metadata;
267
+ if (!metadata?.databases?.length) throw new Error("This backup names no databases.");
268
+ const byAddress = Array.isArray(addresses)
269
+ ? Object.fromEntries(addresses.map((address) => [String(address), undefined]))
270
+ : Object.fromEntries(Object.entries(addresses ?? {}).map(([collection, address]) => [String(address), collection]));
271
+ for (const d of metadata.databases) {
272
+ if (!(String(d.address) in byAddress)) {
273
+ throw new Error("This backup holds databases these books do not have.");
274
+ }
275
+ }
276
+
277
+ const warnings = [];
278
+ const restored = await restoreFromBlocks(orbitdb, opened.blocks, metadata, {
279
+ open,
280
+ log: { info() {}, debug() {}, warn: (message) => warnings.push(String(message)) },
281
+ onProgress: (p) =>
282
+ onProgress?.({
283
+ stage: "database",
284
+ index: p.index,
285
+ total: p.total,
286
+ name: byAddress[p.address] ?? p.address,
287
+ joined: p.joined,
288
+ }),
289
+ });
290
+ const failed = warnings.find((w) => /could not join head/.test(w));
291
+ if (failed) throw new Error(`The backup could not be put back: ${failed.replace(/^\W+/, "")}`);
292
+ return {
293
+ databases: restored.databases.map((d) => ({
294
+ address: d.address,
295
+ ...(byAddress[d.address] ? { collection: byAddress[d.address] } : {}),
296
+ joined: d.joined,
297
+ entries: d.entries ?? null,
298
+ })),
299
+ };
300
+ }
@@ -9,11 +9,29 @@
9
9
  *
10
10
  * ## No tokens move
11
11
  *
12
- * The wallet **signs a string**. Aleph then checks that the signing address has
13
- * enough balance or credit to cover what it is being asked to keep. There is no
14
- * transaction, no gas and no transfer: the token is the evidence, not the
15
- * payment. Worth stating because "pay for storage with a wallet" reads as the
16
- * opposite.
12
+ * The wallet **signs a string**. Aleph then checks that the account the STORE
13
+ * is for has enough credit to cover what it is being asked to keep. There is no
14
+ * transaction, no gas and no transfer at that moment: credits are drawn from the
15
+ * account by the hour, afterwards. Worth stating because "pay for storage with a
16
+ * wallet" reads as the opposite.
17
+ *
18
+ * ## Paid in credits
19
+ *
20
+ * Measured against `api2.aleph.im` on 2026-10-03, with throwaway accounts and
21
+ * 2 MiB of random bytes:
22
+ *
23
+ * - A STORE whose content names no `payment` is booked as **`hold`**: ALEPH
24
+ * tokens locked on the account, a model Aleph has deprecated. It was
25
+ * processed at once for an account holding nothing, so "processed" says
26
+ * nothing about cover there.
27
+ * - With `payment: { type: "credit" }` — what Aleph's own CLI sends by default —
28
+ * Aleph wants credit for at least a day (`min_runtime_days: 1`) and rejects
29
+ * the STORE otherwise: 107.8 credits for 2 MiB, about 54 per MiB and day.
30
+ * - It is the account in `content.address` whose credit is checked and to
31
+ * which the cost is booked, not the sender: a delegate with no credit at all
32
+ * stored for a funded owner, and the cost appeared on the owner.
33
+ *
34
+ * So {@link buildStoreMessage} pays in credits unless told otherwise.
17
35
  *
18
36
  * ## Injected, not imported
19
37
  *
@@ -50,6 +68,9 @@ import { BackendError } from "./types.js";
50
68
  export const DEFAULT_ALEPH_API_HOST = "https://api2.aleph.im";
51
69
  export const DEFAULT_ALEPH_CHANNEL = "ALEPH-CLOUDSOLUTIONS";
52
70
 
71
+ /** How a STORE may be paid for; `null` leaves `payment` out, which Aleph books as `hold`. */
72
+ export const STORE_PAYMENTS = Object.freeze(["credit", "hold"]);
73
+
53
74
  /** Hex sha-256 of a string, via WebCrypto — present in browsers and in Node 18+. */
54
75
  async function sha256Hex(payload) {
55
76
  const digest = await globalThis.crypto.subtle.digest(
@@ -71,10 +92,27 @@ async function sha256Hex(payload) {
71
92
  * by default
72
93
  * @param {string} args.cid - what to keep
73
94
  * @param {string} [args.channel]
95
+ * @param {"credit" | "hold" | null} [args.payment] - `credit` by default; `null`
96
+ * sends no `payment`, as every version up to 0.16.1 did, which Aleph books as
97
+ * `hold`
74
98
  * @param {number} [args.now] - seconds; injected so a test is not a clock
75
99
  * @param {(payload: string) => Promise<string>} [args.hasher]
76
100
  */
77
- export async function buildStoreMessage({ sender, owner, cid, channel = DEFAULT_ALEPH_CHANNEL, now, hasher = sha256Hex }) {
101
+ export async function buildStoreMessage({
102
+ sender,
103
+ owner,
104
+ cid,
105
+ channel = DEFAULT_ALEPH_CHANNEL,
106
+ payment = "credit",
107
+ now,
108
+ hasher = sha256Hex,
109
+ }) {
110
+ if (payment !== null && !STORE_PAYMENTS.includes(payment)) {
111
+ throw new BackendError(
112
+ "INVALID_BACKEND",
113
+ `a STORE is paid by "credit" or "hold", or names no payment (null), not ${JSON.stringify(payment)}`,
114
+ );
115
+ }
78
116
  const time = now ?? Date.now() / 1000;
79
117
  const content = {
80
118
  // Whose STORE this is. Equal to the sender unless the owner's `security`
@@ -83,6 +121,9 @@ export async function buildStoreMessage({ sender, owner, cid, channel = DEFAULT_
83
121
  // "the thing to keep is an IPFS CID" — not the envelope's item_type
84
122
  item_type: "ipfs",
85
123
  item_hash: cid,
124
+ // How that account pays. Without it Aleph books the STORE as `hold`, and an
125
+ // account funded with credits covers nothing (see the file's header).
126
+ ...(payment ? { payment: { type: payment } } : {}),
86
127
  time,
87
128
  };
88
129
  const item_content = JSON.stringify(content);
@@ -114,15 +155,20 @@ export const signaturePayload = (message) =>
114
155
  * `personal_sign`, or anything shaped like it. The library never sees a key.
115
156
  * @param {string} [options.apiHost]
116
157
  * @param {string} [options.channel]
158
+ * @param {"credit" | "hold" | null} [options.payment] - see {@link buildStoreMessage}
117
159
  * @param {(payload: string) => Promise<string>} [options.hasher]
118
160
  * @param {typeof fetch} [options.fetch]
119
161
  * @param {() => number} [options.now] - seconds
120
162
  * @returns {(cid: string, meta?: object) => Promise<{ itemHash: string, status: string }>}
163
+ * `status` is what Aleph answered: `processed`, or `pending` until it has
164
+ * checked the signature, the authorization and the credit — follow a pending
165
+ * one with {@link waitForMessage}.
121
166
  */
122
167
  export function createAlephPin(options = {}) {
123
168
  const { sender, sign, owner } = options;
124
169
  const apiHost = options.apiHost || DEFAULT_ALEPH_API_HOST;
125
170
  const channel = options.channel || DEFAULT_ALEPH_CHANNEL;
171
+ const payment = options.payment === undefined ? "credit" : options.payment;
126
172
  const hasher = options.hasher || sha256Hex;
127
173
  const doFetch = options.fetch || globalThis.fetch;
128
174
  const now = options.now;
@@ -132,7 +178,7 @@ export function createAlephPin(options = {}) {
132
178
  if (typeof doFetch !== "function") throw new BackendError("INVALID_BACKEND", "createAlephPin needs fetch");
133
179
 
134
180
  return async function pin(cid) {
135
- const unsigned = await buildStoreMessage({ sender, owner, cid, channel, hasher, now: now?.() });
181
+ const unsigned = await buildStoreMessage({ sender, owner, cid, channel, payment, hasher, now: now?.() });
136
182
  const signature = await sign(sender, signaturePayload(unsigned));
137
183
  const message = {
138
184
  ...unsigned,
@@ -165,6 +211,126 @@ export function createAlephPin(options = {}) {
165
211
  };
166
212
  }
167
213
 
214
+ /** Statuses after which a message no longer changes by itself. */
215
+ const SETTLED = new Set(["processed", "rejected", "removing", "removed", "forgotten"]);
216
+
217
+ /**
218
+ * Follow a message until Aleph has decided about it.
219
+ *
220
+ * A STORE answered with 202 is `pending`: Aleph has taken it, and checks the
221
+ * signature, the authorization and the account's credit afterwards. A
222
+ * rejected one says why. For a credit STORE without enough credit,
223
+ * `details.errors[0]` names `account_credits`, `required_credits` and
224
+ * `min_runtime_days`, with `errorCode` 6 (measured 2026-10-03).
225
+ *
226
+ * Never throws for what Aleph answers: a message still pending when the time is
227
+ * up comes back as `{ status: "pending", timedOut: true }`, for the caller to
228
+ * report as exactly that.
229
+ *
230
+ * @param {string} itemHash
231
+ * @param {object} [options]
232
+ * @param {string} [options.apiHost]
233
+ * @param {typeof fetch} [options.fetch]
234
+ * @param {number} [options.timeout] - ms in all; default 60 000
235
+ * @param {number} [options.interval] - ms between asks; default 2 000
236
+ * @param {(ms: number) => Promise<void>} [options.sleep] - injected so a test is not a clock
237
+ * @returns {Promise<{ itemHash: string, status: string, errorCode?: number, details?: unknown, timedOut?: true }>}
238
+ */
239
+ export async function waitForMessage(itemHash, options = {}) {
240
+ const apiHost = options.apiHost || DEFAULT_ALEPH_API_HOST;
241
+ const doFetch = options.fetch || globalThis.fetch;
242
+ const timeout = options.timeout ?? 60_000;
243
+ const interval = options.interval ?? 2_000;
244
+ const sleep = options.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
245
+ if (!itemHash) throw new BackendError("INVALID_BACKEND", "waitForMessage needs the message's item hash");
246
+ if (typeof doFetch !== "function") throw new BackendError("INVALID_BACKEND", "waitForMessage needs fetch");
247
+
248
+ // Counted in asks rather than read off a clock, so an injected `sleep` is enough for a test.
249
+ const asks = Math.max(1, Math.floor(timeout / interval) + 1);
250
+ let status = "pending";
251
+ for (let ask = 0; ask < asks; ask++) {
252
+ if (ask > 0) await sleep(interval);
253
+ let response;
254
+ try {
255
+ response = await doFetch(`${apiHost}/api/v0/messages/${itemHash}`);
256
+ } catch {
257
+ continue; // the network, not Aleph's answer: ask again
258
+ }
259
+ // 404: not known on this node yet. Anything else not ok: ask again.
260
+ if (!response.ok) continue;
261
+ const body = await response.json().catch(() => ({}));
262
+ if (typeof body?.status === "string") status = body.status;
263
+ if (SETTLED.has(status)) {
264
+ return {
265
+ itemHash,
266
+ status,
267
+ ...(body.error_code != null ? { errorCode: body.error_code } : {}),
268
+ ...(body.details != null ? { details: body.details } : {}),
269
+ };
270
+ }
271
+ }
272
+ return { itemHash, status, timedOut: true };
273
+ }
274
+
275
+ /**
276
+ * The STORE messages kept for an account, newest first: its own, and those a
277
+ * delegate sent for it.
278
+ *
279
+ * This is how an empty device finds a backup with nothing but the paying
280
+ * account's public address. Aleph's `addresses` filter matches the *sender*,
281
+ * so it misses a delegate's STORE; `owners` matches `content.address`, the
282
+ * account the STORE is for (measured 2026-10-03).
283
+ *
284
+ * @param {object} options
285
+ * @param {string} options.owner - the paying account, EIP-55 checksummed (Aleph
286
+ * keys accounts by that form)
287
+ * @param {string} [options.channel] - one channel; every channel when absent
288
+ * @param {string} [options.apiHost]
289
+ * @param {typeof fetch} [options.fetch]
290
+ * @param {number} [options.pagination] - per page; default 50
291
+ * @param {number} [options.page] - from 1
292
+ * @returns {Promise<{ stores: Array<{ cid: string, itemHash: string, sender: string, owner: string, time: number, channel?: string, payment?: string }>, total: number | null }>}
293
+ * `cid` is what the STORE keeps — for an upload through `createAlephBackend`,
294
+ * the handle's `id`
295
+ */
296
+ export async function listAlephStores(options = {}) {
297
+ const { owner, channel } = options;
298
+ const apiHost = options.apiHost || DEFAULT_ALEPH_API_HOST;
299
+ const doFetch = options.fetch || globalThis.fetch;
300
+ if (!owner) throw new BackendError("INVALID_BACKEND", "listAlephStores needs the paying account's address as `owner`");
301
+ if (typeof doFetch !== "function") throw new BackendError("INVALID_BACKEND", "listAlephStores needs fetch");
302
+
303
+ const url = new URL(`${apiHost}/api/v0/messages.json`);
304
+ url.searchParams.set("owners", owner);
305
+ url.searchParams.set("msgTypes", "STORE");
306
+ if (channel) url.searchParams.set("channels", channel);
307
+ url.searchParams.set("pagination", String(options.pagination ?? 50));
308
+ url.searchParams.set("page", String(options.page ?? 1));
309
+
310
+ const response = await doFetch(url.toString());
311
+ if (!response.ok) {
312
+ throw new BackendError("UNSUPPORTED", `Aleph did not list the STORE messages: ${response.status}`);
313
+ }
314
+ const body = await response.json().catch(() => ({}));
315
+ const messages = Array.isArray(body?.messages) ? body.messages : [];
316
+ const same = (a, b) => String(a).toLowerCase() === String(b).toLowerCase();
317
+ const stores = messages
318
+ .map((m) => ({
319
+ cid: String(m?.content?.item_hash ?? ""),
320
+ itemHash: String(m?.item_hash ?? ""),
321
+ sender: String(m?.sender ?? ""),
322
+ owner: String(m?.content?.address ?? ""),
323
+ time: Number(m?.content?.time ?? m?.time ?? 0),
324
+ ...(m?.channel ? { channel: m.channel } : {}),
325
+ ...(m?.content?.payment?.type ? { payment: m.content.payment.type } : {}),
326
+ }))
327
+ // Aleph answers what it was asked; keeping only this owner's costs nothing
328
+ // and keeps a misread filter from handing back someone else's files.
329
+ .filter((store) => store.cid && store.itemHash && same(store.owner, owner))
330
+ .sort((a, b) => b.time - a.time);
331
+ return { stores, total: Number.isFinite(body?.pagination_total) ? body.pagination_total : null };
332
+ }
333
+
168
334
  /** The reserved channel and aggregate key Aleph keeps permissions in. */
169
335
  export const SECURITY = "security";
170
336
 
@@ -227,8 +393,9 @@ export async function buildAuthorizationMessage({ owner, authorizations, now, ha
227
393
  * (`types: ["STORE"]`, one channel), and revoked on its own. The owner signs
228
394
  * these grants; the delegates then pass `owner` to {@link createAlephPin}.
229
395
  *
230
- * Which balance Aleph charges for a STORE sent by a delegate is not stated in
231
- * its documentation; measure it before relying on it.
396
+ * Aleph's documentation does not say whose credit pays for a STORE a delegate
397
+ * sends. Measured on 2026-10-03: the owner's, the account in `content.address`
398
+ * (see the file's header).
232
399
  *
233
400
  * Aleph keys accounts by their EIP-55 checksummed address; pass `owner` in that
234
401
  * form, or `read()` finds nothing.
@@ -104,8 +104,9 @@ export function createAlephBackend(options = {}) {
104
104
  // No key exists to leak.
105
105
  browserSafeAuth: true,
106
106
  delegation: false,
107
- // The IPFS host offers no listing, and the messages API would need the
108
- // wallet address this driver deliberately does not hold.
107
+ // The IPFS host offers no listing, and the messages API needs the paying
108
+ // account's address, which this driver deliberately does not hold:
109
+ // `listAlephStores` in ./aleph-pin.js takes it.
109
110
  listing: false,
110
111
  // Unpinning is a FORGET message, so it belongs with `pin` rather than
111
112
  // here. Claiming deletion the driver cannot perform would be worse than
@@ -167,8 +168,11 @@ export function createAlephBackend(options = {}) {
167
168
  size: bytes.length,
168
169
  ...(meta.name ? { name: meta.name } : {}),
169
170
  // Said out loud in the handle, because "stored" and "kept" are not the
170
- // same thing here and a caller should not have to read this file.
171
- retained: Boolean(pin),
171
+ // same thing here and a caller should not have to read this file. An
172
+ // upload is never kept by itself, `pin` or not: keeping is a STORE,
173
+ // which `pinCid` sends. (Up to 0.16.1 this said `Boolean(pin)`, a
174
+ // promise no call had kept.)
175
+ retained: false,
172
176
  };
173
177
  },
174
178
 
@@ -190,8 +194,21 @@ export function createAlephBackend(options = {}) {
190
194
  * predicted, and the reason `pinCid` here does not upload anything.
191
195
  */
192
196
  backend.pinCid = async (cid, meta = {}) => {
193
- await pin(cid, meta);
194
- return { id: cid, cid, backend: "aleph", retained: true, ...(meta.name ? { name: meta.name } : {}) };
197
+ const kept = await pin(cid, meta);
198
+ // A pin that reports Aleph's answer (createAlephPin does) is believed:
199
+ // retained only once the STORE is processed, and a pending one says so,
200
+ // with the item hash to follow it by (waitForMessage). A pin that
201
+ // reports nothing keeps the old answer.
202
+ const status = typeof kept?.status === "string" ? kept.status : undefined;
203
+ return {
204
+ id: cid,
205
+ cid,
206
+ backend: "aleph",
207
+ retained: status === undefined || status === "processed",
208
+ ...(status ? { status } : {}),
209
+ ...(kept?.itemHash ? { itemHash: kept.itemHash } : {}),
210
+ ...(meta.name ? { name: meta.name } : {}),
211
+ };
195
212
  };
196
213
  }
197
214
 
@@ -350,8 +350,13 @@ export async function restoreFromBlocks(orbitdb, blocks, metadata, options = {})
350
350
  let joined = 0;
351
351
  for (const head of heads) {
352
352
  try {
353
+ // The log decodes its own entry: an encrypted database's entry is not
354
+ // the raw dag-cbor fields, and joining those throws (an `undefined`
355
+ // the IPLD data model refuses). The raw fields stay as the fallback for
356
+ // a log storage that does not have the block.
357
+ const entry = await database.log.get(head.hash).catch(() => undefined);
353
358
  const { v, id, key, sig, next, refs, clock, payload, identity } = head.value;
354
- if (await database.log.joinEntry({ hash: head.hash, v, id, key, sig, next, refs, clock, payload, identity })) {
359
+ if (await database.log.joinEntry(entry ?? { hash: head.hash, v, id, key, sig, next, refs, clock, payload, identity })) {
355
360
  joined++;
356
361
  }
357
362
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@le-space/orbitdb-storage-bridge",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Back up, restore and replicate OrbitDB databases through pluggable storage backends, with hash and identity preservation",
5
5
  "main": "lib/orbitdb-storacha-bridge.js",
6
6
  "svelte": "dist/components/",
@@ -16,6 +16,7 @@
16
16
  "./dehydrate": "./lib/dehydrate.js",
17
17
  "./restore-cid": "./lib/restore-cid.js",
18
18
  "./extract-blocks": "./lib/extract-blocks.js",
19
+ "./app-backup": "./lib/app-backup.js",
19
20
  "./gateway-fetch": "./lib/gateway-fetch.js",
20
21
  "./peer-fetch": "./lib/peer-fetch.js",
21
22
  "./backends/types": "./lib/backends/types.js",