@parity/product-sdk-cloud-storage 0.5.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/src/cid.ts ADDED
@@ -0,0 +1,214 @@
1
+ /**
2
+ * CID helpers for converting between on-chain hex hashes and CIDs.
3
+ *
4
+ * Upstream `@parity/bulletin-sdk` exposes `calculateCid` (data → CID),
5
+ * `parseCid` (string → CID), `cidFromBytes` (full-encoded → CID), and
6
+ * `cidToBytes` (CID → full-encoded). The helpers here add a thin layer
7
+ * for the `0x`-prefixed hex shape that on-chain `TransactionInfo` uses,
8
+ * so callers don't need to do the digest plumbing themselves.
9
+ *
10
+ * Both helpers default to the chain default (blake2b-256, raw codec).
11
+ * Pass `HashAlgorithm` and `CidCodec` for other configurations
12
+ * (sha2-256, dag-pb, etc.).
13
+ */
14
+ import { CID } from "multiformats/cid";
15
+ import * as Digest from "multiformats/hashes/digest";
16
+
17
+ import { CloudStorageCidError } from "./errors.js";
18
+
19
+ /**
20
+ * Hash algorithms supported by Cloud Storage.
21
+ *
22
+ * Values are multihash codes from the multicodec table.
23
+ */
24
+ export const HashAlgorithm = {
25
+ /** BLAKE2b-256 — chain default. */
26
+ Blake2b256: 0xb220,
27
+ /** SHA2-256. */
28
+ Sha2_256: 0x12,
29
+ /** Keccak-256 — Ethereum compatibility. */
30
+ Keccak256: 0x1b,
31
+ } as const;
32
+ export type HashAlgorithm = (typeof HashAlgorithm)[keyof typeof HashAlgorithm];
33
+
34
+ /**
35
+ * CID codecs supported by Cloud Storage.
36
+ */
37
+ export const CidCodec = {
38
+ /** Raw binary — default for single-chunk data. */
39
+ Raw: 0x55,
40
+ /** DAG-PB — used for multi-chunk manifests / IPFS UnixFS. */
41
+ DagPb: 0x70,
42
+ /** DAG-CBOR — alternative DAG encoding. */
43
+ DagCbor: 0x71,
44
+ } as const;
45
+ export type CidCodec = (typeof CidCodec)[keyof typeof CidCodec];
46
+
47
+ const SUPPORTED_HASH_CODES = new Set<number>(Object.values(HashAlgorithm));
48
+ const SUPPORTED_CODEC_CODES = new Set<number>(Object.values(CidCodec));
49
+ const EXPECTED_HEX_LENGTH = 66; // "0x" + 64 hex chars (32-byte digest)
50
+
51
+ /**
52
+ * Reconstruct a CIDv1 from a `0x`-prefixed 32-byte hex hash.
53
+ *
54
+ * Useful when reading on-chain `TransactionInfo.content_hash` and you need
55
+ * the CID to look up content via an IPFS gateway.
56
+ *
57
+ * @param hexHash - 66-char `0x`-prefixed hex of a 32-byte digest.
58
+ * @param hashCode - Multihash code (default: blake2b-256).
59
+ * @param codec - Multicodec code (default: raw).
60
+ */
61
+ export function hashToCid(
62
+ hexHash: `0x${string}`,
63
+ hashCode: HashAlgorithm = HashAlgorithm.Blake2b256,
64
+ codec: CidCodec = CidCodec.Raw,
65
+ ): string {
66
+ if (hexHash.length !== EXPECTED_HEX_LENGTH) {
67
+ throw new CloudStorageCidError(
68
+ `Expected a 0x-prefixed 32-byte hex hash (${EXPECTED_HEX_LENGTH} chars), ` +
69
+ `got ${hexHash.length} chars`,
70
+ );
71
+ }
72
+ if (!/^0x[0-9a-fA-F]{64}$/.test(hexHash)) {
73
+ throw new CloudStorageCidError(
74
+ `Invalid hash format: expected 0x-prefixed 32-byte hex string, got: ${hexHash}`,
75
+ );
76
+ }
77
+ if (!SUPPORTED_HASH_CODES.has(hashCode)) {
78
+ throw new CloudStorageCidError(
79
+ `Unsupported hash algorithm 0x${hashCode.toString(16)}; ` +
80
+ `expected one of: ${[...SUPPORTED_HASH_CODES].map((c) => `0x${c.toString(16)}`).join(", ")}`,
81
+ );
82
+ }
83
+ if (!SUPPORTED_CODEC_CODES.has(codec)) {
84
+ throw new CloudStorageCidError(
85
+ `Unsupported CID codec 0x${codec.toString(16)}; ` +
86
+ `expected one of: ${[...SUPPORTED_CODEC_CODES].map((c) => `0x${c.toString(16)}`).join(", ")}`,
87
+ );
88
+ }
89
+ const digest = hexToBytes(hexHash);
90
+ return CID.createV1(codec, Digest.create(hashCode, digest)).toString();
91
+ }
92
+
93
+ /**
94
+ * Extract the 32-byte content hash digest from a CIDv1 and return it as a
95
+ * `0x`-prefixed hex string.
96
+ *
97
+ * Useful for matching a CID against on-chain `TransactionInfo.content_hash`.
98
+ */
99
+ export function cidToPreimageKey(cid: string): `0x${string}` {
100
+ let parsed;
101
+ try {
102
+ parsed = CID.parse(cid);
103
+ } catch {
104
+ throw new CloudStorageCidError(`Invalid CID: ${cid}`, cid);
105
+ }
106
+ if (parsed.version !== 1) {
107
+ throw new CloudStorageCidError(`Expected CIDv1, got CIDv${parsed.version}`, cid);
108
+ }
109
+ if (!SUPPORTED_HASH_CODES.has(parsed.multihash.code)) {
110
+ throw new CloudStorageCidError(
111
+ `Unsupported hash algorithm 0x${parsed.multihash.code.toString(16)}; ` +
112
+ `expected one of: ${[...SUPPORTED_HASH_CODES].map((c) => `0x${c.toString(16)}`).join(", ")}`,
113
+ cid,
114
+ );
115
+ }
116
+ return `0x${bytesToHex(parsed.multihash.digest)}` as `0x${string}`;
117
+ }
118
+
119
+ function hexToBytes(hex: `0x${string}`): Uint8Array {
120
+ const out = new Uint8Array(32);
121
+ for (let i = 0; i < 32; i++) {
122
+ out[i] = Number.parseInt(hex.slice(2 + i * 2, 4 + i * 2), 16);
123
+ }
124
+ return out;
125
+ }
126
+
127
+ function bytesToHex(bytes: Uint8Array): string {
128
+ let s = "";
129
+ for (let i = 0; i < bytes.length; i++) {
130
+ s += bytes[i]!.toString(16).padStart(2, "0");
131
+ }
132
+ return s;
133
+ }
134
+
135
+ if (import.meta.vitest) {
136
+ const { describe, test, expect } = import.meta.vitest;
137
+
138
+ describe("hashToCid", () => {
139
+ const sampleHex = `0x${"ab".repeat(32)}` as `0x${string}`;
140
+
141
+ test("produces valid base32-lower CIDv1 (default: blake2b-256, raw)", () => {
142
+ const cid = hashToCid(sampleHex);
143
+ expect(cid).toMatch(/^b[a-z2-7]+$/);
144
+ const parsed = CID.parse(cid);
145
+ expect(parsed.version).toBe(1);
146
+ expect(parsed.code).toBe(CidCodec.Raw);
147
+ expect(parsed.multihash.code).toBe(HashAlgorithm.Blake2b256);
148
+ });
149
+
150
+ test("supports sha2-256", () => {
151
+ const cid = hashToCid(sampleHex, HashAlgorithm.Sha2_256);
152
+ expect(CID.parse(cid).multihash.code).toBe(HashAlgorithm.Sha2_256);
153
+ });
154
+
155
+ test("supports dag-pb codec", () => {
156
+ const cid = hashToCid(sampleHex, HashAlgorithm.Blake2b256, CidCodec.DagPb);
157
+ expect(CID.parse(cid).code).toBe(CidCodec.DagPb);
158
+ });
159
+
160
+ test("throws on short hex", () => {
161
+ expect(() => hashToCid("0xabcd" as `0x${string}`)).toThrow(CloudStorageCidError);
162
+ });
163
+
164
+ test("throws on long hex", () => {
165
+ const tooLong = `0x${"aa".repeat(33)}` as `0x${string}`;
166
+ expect(() => hashToCid(tooLong)).toThrow(CloudStorageCidError);
167
+ });
168
+
169
+ test("throws on non-hex characters", () => {
170
+ const bad = `0x${"zz".repeat(32)}` as `0x${string}`;
171
+ expect(() => hashToCid(bad)).toThrow(CloudStorageCidError);
172
+ });
173
+
174
+ test("throws on unsupported hash algorithm", () => {
175
+ expect(() => hashToCid(sampleHex, 0x99 as HashAlgorithm)).toThrow(CloudStorageCidError);
176
+ });
177
+
178
+ test("throws on unsupported codec", () => {
179
+ expect(() => hashToCid(sampleHex, HashAlgorithm.Blake2b256, 0x99 as CidCodec)).toThrow(
180
+ CloudStorageCidError,
181
+ );
182
+ });
183
+ });
184
+
185
+ describe("cidToPreimageKey", () => {
186
+ test("round-trip with hashToCid", () => {
187
+ const hex = `0x${"cd".repeat(32)}` as `0x${string}`;
188
+ const cid = hashToCid(hex);
189
+ expect(cidToPreimageKey(cid)).toBe(hex);
190
+ });
191
+
192
+ test("round-trip with sha2-256", () => {
193
+ const hex = `0x${"ef".repeat(32)}` as `0x${string}`;
194
+ const cid = hashToCid(hex, HashAlgorithm.Sha2_256);
195
+ expect(cidToPreimageKey(cid)).toBe(hex);
196
+ });
197
+
198
+ test("throws on invalid CID string", () => {
199
+ expect(() => cidToPreimageKey("not-a-cid")).toThrow(CloudStorageCidError);
200
+ });
201
+
202
+ test("throws on CIDv0 input", () => {
203
+ const hash = new Uint8Array(32).fill(0xab);
204
+ const cidV0 = CID.create(0, 0x70, Digest.create(HashAlgorithm.Sha2_256, hash));
205
+ expect(() => cidToPreimageKey(cidV0.toString())).toThrow(CloudStorageCidError);
206
+ });
207
+
208
+ test("throws on unsupported hash algorithm", () => {
209
+ const hash = new Uint8Array(32).fill(0xab);
210
+ const cidV1 = CID.createV1(CidCodec.Raw, Digest.create(0x99, hash));
211
+ expect(() => cidToPreimageKey(cidV1.toString())).toThrow(CloudStorageCidError);
212
+ });
213
+ });
214
+ }
package/src/client.ts ADDED
@@ -0,0 +1,316 @@
1
+ import {
2
+ AsyncBulletinClient,
3
+ type AuthCallBuilder,
4
+ type BulletinTypedApi,
5
+ type CallBuilder,
6
+ type ClientConfig,
7
+ type StoreBuilder,
8
+ type SubmitFn,
9
+ } from "@parity/bulletin-sdk";
10
+ import { createChainClient, getChainAPI } from "@parity/product-sdk-chain-client";
11
+ import { createLogger } from "@parity/product-sdk-logger";
12
+ import type { PolkadotSigner } from "polkadot-api";
13
+
14
+ import { checkAuthorization } from "./authorization.js";
15
+ import type { CloudStorageNetworks, CloudStorageEnvironment } from "./networks.js";
16
+ import { executeQuery } from "./query.js";
17
+ import { resolveQueryStrategy, type QueryStrategy } from "./resolve-query.js";
18
+ import type { AuthorizationStatus, CloudStorageApi, QueryOptions } from "./types.js";
19
+ import { verifyStored, type ChainStoredEntry, type VerifyStoredOptions } from "./verify.js";
20
+
21
+ const log = createLogger("bulletin");
22
+
23
+ /**
24
+ * Options for {@link CloudStorageClient.create}.
25
+ *
26
+ * One of two construction shapes is supported:
27
+ *
28
+ * - **Environment shorthand** — pass an `environment` string keyed by
29
+ * {@link CloudStorageNetworks}. Wires up the chain-client automatically.
30
+ * - **Explicit network** — pass `genesisHash` and `descriptor` directly
31
+ * (e.g., spread from a {@link CloudStorageNetworks} entry, or supply custom
32
+ * values for a private chain).
33
+ */
34
+ export type CreateCloudStorageClientOptions =
35
+ | (CreateCloudStorageClientCommon & { environment: CloudStorageEnvironment })
36
+ | (CreateCloudStorageClientCommon & {
37
+ genesisHash: `0x${string}`;
38
+ descriptor: (typeof CloudStorageNetworks)[CloudStorageEnvironment]["descriptor"];
39
+ });
40
+
41
+ interface CreateCloudStorageClientCommon {
42
+ /** Signer for transaction submission. Required — every store needs a signer. */
43
+ signer: PolkadotSigner;
44
+ /** Optional config forwarded to the upstream client. */
45
+ config?: Partial<ClientConfig>;
46
+ }
47
+
48
+ /**
49
+ * Ergonomic entry point for Cloud Storage operations.
50
+ *
51
+ * Wraps the upstream `@parity/bulletin-sdk` client (which handles
52
+ * chunking, DAG-PB manifests, CID calculation, and progress events) and adds:
53
+ *
54
+ * - **Network presets** via {@link CloudStorageClient.create} and {@link CloudStorageNetworks}.
55
+ * - **Read helpers** ({@link fetchBytes}, {@link fetchJson}) routed through
56
+ * the host's preimage subscription — upstream is upload-only and the SDK
57
+ * is container-only by design (no public-gateway fetches).
58
+ * - **Pre-flight authorization check** ({@link checkAuthorization}) for
59
+ * friendlier UX before submitting a store.
60
+ *
61
+ * For uploads, mirror upstream's fluent builders:
62
+ *
63
+ * ```ts
64
+ * const client = await CloudStorageClient.create({ environment: "paseo", signer });
65
+ * const result = await client.store(data).send();
66
+ * ```
67
+ *
68
+ * For chunked uploads with progress:
69
+ *
70
+ * ```ts
71
+ * const result = await client
72
+ * .store(largeFile)
73
+ * .withChunkSize(1 << 20)
74
+ * .withCallback((evt) => console.log(evt))
75
+ * .send();
76
+ * ```
77
+ */
78
+ export class CloudStorageClient {
79
+ /** Underlying upstream client — exposed for power users. */
80
+ readonly inner: AsyncBulletinClient;
81
+ /** Typed CloudStorage API. */
82
+ readonly api: CloudStorageApi;
83
+
84
+ /** Lazy-resolved host-preimage query strategy, cached for the client lifetime. */
85
+ private queryStrategyPromise: Promise<QueryStrategy> | null = null;
86
+
87
+ /** Constructed via {@link create} or {@link from}. */
88
+ private constructor(inner: AsyncBulletinClient, api: CloudStorageApi) {
89
+ this.inner = inner;
90
+ this.api = api;
91
+ }
92
+
93
+ /** Resolve and cache the host query strategy on first use. */
94
+ private resolveQuery(): Promise<QueryStrategy> {
95
+ if (!this.queryStrategyPromise) {
96
+ this.queryStrategyPromise = resolveQueryStrategy();
97
+ }
98
+ return this.queryStrategyPromise;
99
+ }
100
+
101
+ /**
102
+ * Create a client from an environment shorthand or an explicit network.
103
+ *
104
+ * Environment form uses our `getChainAPI(env)` to resolve the typed API.
105
+ * Explicit form skips the environment lookup and lets you pass any
106
+ * genesis/descriptor combo.
107
+ *
108
+ * @example
109
+ * ```ts
110
+ * // Shorthand
111
+ * const client = await CloudStorageClient.create({ environment: "paseo", signer });
112
+ *
113
+ * // Explicit (custom network)
114
+ * const client = await CloudStorageClient.create({
115
+ * ...CloudStorageNetworks.paseo,
116
+ * signer,
117
+ * config: { defaultChunkSize: 1 << 20 },
118
+ * });
119
+ * ```
120
+ */
121
+ static async create(options: CreateCloudStorageClientOptions): Promise<CloudStorageClient> {
122
+ if ("environment" in options) {
123
+ const chain = await getChainAPI(options.environment);
124
+ const inner = new AsyncBulletinClient(
125
+ chain.bulletin as BulletinTypedApi,
126
+ options.signer,
127
+ chain.raw.bulletin.submit as SubmitFn,
128
+ options.config,
129
+ () => chain.destroy(),
130
+ );
131
+ log.info("CloudStorageClient created (environment shorthand)", {
132
+ environment: options.environment,
133
+ });
134
+ return new CloudStorageClient(inner, chain.bulletin);
135
+ }
136
+
137
+ // Explicit form — caller owns the descriptor choice. We still need a
138
+ // PolkadotClient to feed AsyncBulletinClient. Going through
139
+ // chain-client keeps connection management consistent across the SDK.
140
+ const { genesisHash, descriptor, signer, config } = options;
141
+ // Catch the obvious foot-gun where caller mixes a genesis from one
142
+ // network with a descriptor from another — the connection would
143
+ // succeed but typed calls would silently target the wrong chain.
144
+ // The descriptor's own `.genesis` field is the on-chain truth; the
145
+ // user-supplied `genesisHash` is informational today (createChainClient
146
+ // doesn't use it because host routes connections) but kept on the
147
+ // option shape for future RPC-direct paths.
148
+ if (descriptor.genesis && genesisHash.toLowerCase() !== descriptor.genesis.toLowerCase()) {
149
+ throw new Error(
150
+ `CloudStorageClient.create: genesisHash (${genesisHash}) does not match descriptor.genesis (${descriptor.genesis}). These must refer to the same network — check that you're pairing the right descriptor with the right genesis hash.`,
151
+ );
152
+ }
153
+ const chain = await createChainClient({
154
+ chains: { bulletin: descriptor },
155
+ rpcs: { bulletin: [] },
156
+ });
157
+ const inner = new AsyncBulletinClient(
158
+ chain.bulletin as BulletinTypedApi,
159
+ signer,
160
+ chain.raw.bulletin.submit as SubmitFn,
161
+ config,
162
+ () => chain.destroy(),
163
+ );
164
+ log.info("CloudStorageClient created (explicit network)");
165
+ return new CloudStorageClient(inner, chain.bulletin);
166
+ }
167
+
168
+ /**
169
+ * Construct from a pre-built `AsyncBulletinClient` and PAPI typed API.
170
+ *
171
+ * Use this when you already own the connection lifecycle (BYOD setups,
172
+ * tests). The caller is responsible for calling `papiClient.destroy()`
173
+ * — this client's {@link destroy} only tears down the upstream's
174
+ * `onDestroy` hook.
175
+ */
176
+ static from(inner: AsyncBulletinClient, api: CloudStorageApi): CloudStorageClient {
177
+ return new CloudStorageClient(inner, api);
178
+ }
179
+
180
+ // ─── Upload + authorization (forwarded to upstream) ────────────────
181
+
182
+ /** Build a store transaction. See upstream `StoreBuilder` for chained options. */
183
+ store(data: Uint8Array): StoreBuilder {
184
+ return this.inner.store(data);
185
+ }
186
+
187
+ /** Authorize an account to store data on the chain (sudo required on most networks). */
188
+ authorizeAccount(who: string, transactions: number, bytes: bigint): AuthCallBuilder {
189
+ return this.inner.authorizeAccount(who, transactions, bytes);
190
+ }
191
+
192
+ /** Authorize content storage by hash (anyone can store; no fees). */
193
+ authorizePreimage(contentHash: Uint8Array, maxSize: bigint): AuthCallBuilder {
194
+ return this.inner.authorizePreimage(contentHash, maxSize);
195
+ }
196
+
197
+ /** Renew a stored transaction by block + index. */
198
+ renew(block: number, index: number): CallBuilder {
199
+ return this.inner.renew(block, index);
200
+ }
201
+
202
+ /** Estimate the authorization (transactions + bytes) needed for `dataSize`. */
203
+ estimateAuthorization(dataSize: number): { transactions: number; bytes: number } {
204
+ return this.inner.estimateAuthorization(dataSize);
205
+ }
206
+
207
+ // ─── Read side (our own helpers) ───────────────────────────────────
208
+
209
+ /**
210
+ * Fetch raw bytes for a CID via the host's preimage lookup.
211
+ *
212
+ * Container-only — outside a Polkadot Browser / Desktop host this
213
+ * throws {@link CloudStorageHostUnavailableError}. The chain stores
214
+ * content metadata (`content_hash`, size, codec) but the bytes
215
+ * themselves are surfaced through the host's preimage subscription.
216
+ *
217
+ * Use {@link verifyStored} if you only need to confirm a CID was
218
+ * recorded on-chain (no byte fetch).
219
+ */
220
+ async fetchBytes(cid: string, options?: QueryOptions): Promise<Uint8Array> {
221
+ const strategy = await this.resolveQuery();
222
+ return executeQuery(strategy, cid, options);
223
+ }
224
+
225
+ /** Fetch and parse JSON for a CID. */
226
+ async fetchJson<T>(cid: string, options?: QueryOptions): Promise<T> {
227
+ const bytes = await this.fetchBytes(cid, options);
228
+ return JSON.parse(new TextDecoder().decode(bytes)) as T;
229
+ }
230
+
231
+ /** Pre-flight: check whether `address` can store via Cloud Storage. */
232
+ async checkAuthorization(address: string): Promise<AuthorizationStatus> {
233
+ return checkAuthorization(this.api, address);
234
+ }
235
+
236
+ /**
237
+ * Verify that a CID was recorded on-chain at the given block.
238
+ *
239
+ * Common pattern: pass `blockNumber` (and optionally `extrinsicIndex`)
240
+ * from a `store(...).send()` receipt to confirm the upload landed.
241
+ * See {@link verifyStored} for details.
242
+ */
243
+ async verifyStored(
244
+ cid: string,
245
+ options: VerifyStoredOptions,
246
+ ): Promise<ChainStoredEntry | null> {
247
+ return verifyStored(this.api, cid, options);
248
+ }
249
+
250
+ /** Tear down the underlying connection. */
251
+ async destroy(): Promise<void> {
252
+ await this.inner.destroy();
253
+ }
254
+ }
255
+
256
+ if (import.meta.vitest) {
257
+ const { describe, test, expect, vi } = import.meta.vitest;
258
+
259
+ describe("CloudStorageClient.from", () => {
260
+ test("constructs with given inner and api", () => {
261
+ const inner = {
262
+ destroy: vi.fn().mockResolvedValue(undefined),
263
+ } as unknown as AsyncBulletinClient;
264
+ const api = {} as CloudStorageApi;
265
+ const client = CloudStorageClient.from(inner, api);
266
+ expect(client.inner).toBe(inner);
267
+ expect(client.api).toBe(api);
268
+ });
269
+
270
+ test("destroy delegates to upstream", async () => {
271
+ const destroy = vi.fn().mockResolvedValue(undefined);
272
+ const inner = { destroy } as unknown as AsyncBulletinClient;
273
+ const client = CloudStorageClient.from(inner, {} as CloudStorageApi);
274
+ await client.destroy();
275
+ expect(destroy).toHaveBeenCalledOnce();
276
+ });
277
+
278
+ test("store delegates to inner", () => {
279
+ const builder = {} as StoreBuilder;
280
+ const inner = {
281
+ store: vi.fn().mockReturnValue(builder),
282
+ } as unknown as AsyncBulletinClient;
283
+ const client = CloudStorageClient.from(inner, {} as CloudStorageApi);
284
+ const data = new Uint8Array([1, 2, 3]);
285
+ expect(client.store(data)).toBe(builder);
286
+ expect(inner.store).toHaveBeenCalledWith(data);
287
+ });
288
+ });
289
+
290
+ describe("CloudStorageClient.create (BYOD genesis assertion)", () => {
291
+ // Stand-in descriptor with a known genesis. The full PAPI descriptor
292
+ // type is a `ChainDefinition` with a deep type-level shape; the cast
293
+ // below is fine for the assertion test because we never actually
294
+ // reach createChainClient.
295
+ const stubDescriptor = (
296
+ genesis: `0x${string}`,
297
+ ): (typeof CloudStorageNetworks)[CloudStorageEnvironment]["descriptor"] =>
298
+ ({
299
+ genesis,
300
+ }) as unknown as (typeof CloudStorageNetworks)[CloudStorageEnvironment]["descriptor"];
301
+
302
+ const realPaseo =
303
+ "0x744960c32e3a3df5440e1ecd4d34096f1ce2230d7016a5ada8a765d5a622b4ea" as `0x${string}`;
304
+
305
+ test("throws when genesisHash and descriptor.genesis disagree", async () => {
306
+ await expect(
307
+ CloudStorageClient.create({
308
+ genesisHash:
309
+ "0x0000000000000000000000000000000000000000000000000000000000000001",
310
+ descriptor: stubDescriptor(realPaseo),
311
+ signer: {} as PolkadotSigner,
312
+ }),
313
+ ).rejects.toThrow(/does not match descriptor\.genesis/i);
314
+ });
315
+ });
316
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Cloud Storage error types.
3
+ *
4
+ * Two error families coexist:
5
+ *
6
+ * 1. **Upstream SDK errors** — `BulletinError` and `ErrorCode` from
7
+ * `@parity/bulletin-sdk` cover upload/store/authorization failures
8
+ * surfaced by `AsyncBulletinClient`. Each carries `code`, `retryable`,
9
+ * and `recoveryHint`.
10
+ * 2. **Read-side errors** declared here — host preimage availability /
11
+ * lookup timeouts / interrupts, plus CID format problems, surfaced by
12
+ * our retrieval helpers (`fetchBytes`, `fetchJson`, `verifyStored`).
13
+ *
14
+ * Catch upstream errors with `instanceof BulletinError`. Catch our read-side
15
+ * errors with `instanceof ProductCloudStorageError` (or the specific subclass).
16
+ */
17
+ export { BulletinError, ErrorCode } from "@parity/bulletin-sdk";
18
+
19
+ /**
20
+ * Base class for read-side errors raised by `@parity/product-sdk-cloud-storage`.
21
+ *
22
+ * Distinct from upstream `BulletinError` which covers upload/store failures.
23
+ */
24
+ export class ProductCloudStorageError extends Error {
25
+ constructor(message: string, options?: ErrorOptions) {
26
+ super(message, options);
27
+ this.name = "ProductCloudStorageError";
28
+ }
29
+ }
30
+
31
+ /**
32
+ * The host preimage API is unavailable.
33
+ *
34
+ * Thrown when cloud storage operations require the host container but it's not
35
+ * available. This typically means the SDK is running outside Polkadot
36
+ * Browser / Desktop. The Cloud Storage SDK is container-only by design — see
37
+ * the README for the rationale.
38
+ */
39
+ export class CloudStorageHostUnavailableError extends ProductCloudStorageError {
40
+ constructor(operation: "upload" | "query") {
41
+ super(
42
+ `Host preimage API unavailable for ${operation}. Ensure you are running inside a host container (Polkadot Browser / Desktop).`,
43
+ );
44
+ this.name = "CloudStorageHostUnavailableError";
45
+ }
46
+ }
47
+
48
+ /**
49
+ * The host preimage lookup timed out.
50
+ *
51
+ * The host was unable to retrieve the requested content within the timeout
52
+ * period. The content may still become available later.
53
+ */
54
+ export class CloudStorageLookupTimeoutError extends ProductCloudStorageError {
55
+ /** The CID that was being looked up. */
56
+ readonly cid: string;
57
+ /** The timeout duration in milliseconds. */
58
+ readonly timeoutMs: number;
59
+
60
+ constructor(cid: string, timeoutMs: number) {
61
+ super(`Host preimage lookup timed out after ${timeoutMs}ms for CID: ${cid}`);
62
+ this.name = "CloudStorageLookupTimeoutError";
63
+ this.cid = cid;
64
+ this.timeoutMs = timeoutMs;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * The host interrupted the preimage lookup.
70
+ *
71
+ * The host terminated the lookup subscription, typically after repeated
72
+ * failures or when the host determines the content is unavailable.
73
+ */
74
+ export class CloudStorageLookupInterruptedError extends ProductCloudStorageError {
75
+ /** The CID that was being looked up. */
76
+ readonly cid: string;
77
+
78
+ constructor(cid: string) {
79
+ super(`Host preimage lookup was interrupted for CID: ${cid}`);
80
+ this.name = "CloudStorageLookupInterruptedError";
81
+ this.cid = cid;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Invalid CID format or version.
87
+ */
88
+ export class CloudStorageCidError extends ProductCloudStorageError {
89
+ /** The invalid CID string, if available. */
90
+ readonly cid?: string;
91
+
92
+ constructor(message: string, cid?: string) {
93
+ super(message);
94
+ this.name = "CloudStorageCidError";
95
+ this.cid = cid;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Failed to check authorization status for an account.
101
+ *
102
+ * Wraps RPC or query errors that occur when checking if an account
103
+ * is authorized to store data in Cloud Storage.
104
+ */
105
+ export class CloudStorageAuthorizationError extends ProductCloudStorageError {
106
+ /** The address that was being checked. */
107
+ readonly address: string;
108
+
109
+ constructor(address: string, cause?: unknown) {
110
+ super(`Failed to check authorization for ${address}`, { cause });
111
+ this.name = "CloudStorageAuthorizationError";
112
+ this.address = address;
113
+ }
114
+ }
115
+
116
+ if (import.meta.vitest) {
117
+ const { describe, test, expect } = import.meta.vitest;
118
+
119
+ describe("ProductCloudStorageError hierarchy", () => {
120
+ test("ProductCloudStorageError extends Error", () => {
121
+ const err = new ProductCloudStorageError("test");
122
+ expect(err).toBeInstanceOf(Error);
123
+ expect(err.name).toBe("ProductCloudStorageError");
124
+ });
125
+
126
+ test("CloudStorageCidError", () => {
127
+ const err = new CloudStorageCidError("bad", "Qmabc");
128
+ expect(err).toBeInstanceOf(ProductCloudStorageError);
129
+ expect(err.cid).toBe("Qmabc");
130
+ });
131
+
132
+ test("CloudStorageinHostUnavailableError", () => {
133
+ const err = new CloudStorageHostUnavailableError("query");
134
+ expect(err).toBeInstanceOf(ProductCloudStorageError);
135
+ expect(err.message).toContain("query");
136
+ expect(err.message).toContain("Host preimage API unavailable");
137
+ });
138
+
139
+ test("CloudStorageLookupTimeoutError", () => {
140
+ const err = new CloudStorageLookupTimeoutError("bafyabc123", 30000);
141
+ expect(err).toBeInstanceOf(ProductCloudStorageError);
142
+ expect(err.cid).toBe("bafyabc123");
143
+ expect(err.timeoutMs).toBe(30000);
144
+ expect(err.message).toContain("30000ms");
145
+ });
146
+
147
+ test("CloudStorageLookupInterruptedError", () => {
148
+ const err = new CloudStorageLookupInterruptedError("bafyabc123");
149
+ expect(err).toBeInstanceOf(ProductCloudStorageError);
150
+ expect(err.cid).toBe("bafyabc123");
151
+ expect(err.message).toContain("interrupted");
152
+ });
153
+
154
+ test("CloudStorageAuthorizationError carries cause", () => {
155
+ const cause = new Error("RPC down");
156
+ const err = new CloudStorageAuthorizationError("5GrwvaEF...", cause);
157
+ expect(err).toBeInstanceOf(ProductCloudStorageError);
158
+ expect(err.address).toBe("5GrwvaEF...");
159
+ expect(err.cause).toBe(cause);
160
+ });
161
+ });
162
+ }