@tokono.ma/firkin 0.2.0 → 0.3.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
@@ -46,7 +46,7 @@ signs directly); delegates mint with [`invocation()`](#delegation--sessions).
46
46
  ## API
47
47
 
48
48
  ```ts
49
- // blobs — reads are authenticated automatically (for PUBLIC_READS=false)
49
+ // blobs — reads are authenticated automatically (read tokens)
50
50
  rb.upload(data, { codec?, hash?: "sha256" | "blake3" }) → { cid, deduplicated }
51
51
  rb.get(cid) → Uint8Array
52
52
  rb.getRange(cid, { start?, end?, suffix? }) → { data, start, end, total }
@@ -67,6 +67,7 @@ rb.pushFile(chunks) // ordered chunks → dag-cbor list root
67
67
  rb.pullFile(root) // inverse of pushFile
68
68
  rb.dagPush(root, blocks) // an existing DAG, as-is: [cid, bytes][] | Map
69
69
  rb.dagPull(root) // Map<cid, bytes> — only the root is needed
70
+ rb.ingestCar(carBytes) // rootless CARv1: verify + claim each block (no DAG)
70
71
 
71
72
  // account
72
73
  rb.revoke({ cids? , all? }) // kill delegations (account key only)
package/dist/client.d.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import { CID } from "multiformats";
2
+ /** Wire version prefix for every API path (`/health` and `/.well-known/*`
3
+ * live outside it). */
4
+ export declare const API_PREFIX = "/v1";
2
5
  import type { Signer } from "./keys.ts";
3
6
  /** Per-call options a TokenSource may honor. */
4
7
  export interface TokenOptions {
@@ -14,6 +17,20 @@ export type TokenSource = (command: string, options?: TokenOptions) => Promise<U
14
17
  export declare function signerToken(signer: Signer, expireIn?: number): TokenSource;
15
18
  /** Fixed-token source: only appropriate for public/read-only usage. */
16
19
  export declare function staticToken(token: Uint8Array): TokenSource;
20
+ export interface CarIngestResult {
21
+ /** Blocks found in the CAR. */
22
+ blocks: number;
23
+ /** Blocks written by this call. */
24
+ stored: number;
25
+ /** Blocks that were already stored (claim joined). */
26
+ already: number;
27
+ /** Total rejected blocks (the `failed` list may be capped). */
28
+ failed_total: number;
29
+ failed: {
30
+ cid: string;
31
+ error: string;
32
+ }[];
33
+ }
17
34
  export interface UploadResult {
18
35
  cid: string;
19
36
  /** True when the blob was already stored (dedup; claim joined). */
@@ -60,10 +77,10 @@ export declare class Firkin {
60
77
  codec?: number;
61
78
  hash?: "sha256" | "blake3";
62
79
  }): Promise<UploadResult>;
63
- /** Fetch a whole blob. Authenticated reads: the server's reads are public
64
- * by default, but a `PUBLIC_READS=false` deployment requires the
65
- * `/firkin/blob/read` capability — read tokens are reusable (reads are
66
- * not replay-tracked), so sending one is always correct and cheap. */
80
+ /** Fetch a whole blob. Reads are authenticated: the server requires the
81
+ * `/firkin/blob/read` capability (an operator can opt into anonymous
82
+ * reads with `PUBLIC_READS=true`, in which case the header is ignored).
83
+ * Read tokens are reusable — reads are not replay-tracked. */
67
84
  get(cid: string): Promise<Uint8Array>;
68
85
  /** Fetch a byte range (206 responses; server-trusted verification).
69
86
  * Authenticated like [`get`](#get). */
@@ -113,19 +130,6 @@ export declare class Firkin {
113
130
  id: string;
114
131
  spki: string;
115
132
  }): Promise<void>;
116
- /** (Account key) attest a session delegation with the bound passkey. */
117
- attest(body: {
118
- credential_id: string;
119
- auth_data: string;
120
- client_data: string;
121
- signature: string;
122
- delegation_cid: string;
123
- }): Promise<void>;
124
- /** (Account key) bind a WebAuthn credential to this account. */
125
- bindCredential(credential: {
126
- id: string;
127
- spki: string;
128
- }): Promise<void>;
129
133
  /** Resolve a passkey assertion to its account DID — the login primitive
130
134
  * for passkey-native accounts on a fresh browser. The client performs a
131
135
  * discoverable-credentials WebAuthn get (no `allowCredentials`); the
@@ -152,6 +156,14 @@ export declare class Firkin {
152
156
  registryDelete(name: string): Promise<void>;
153
157
  /** The account's whole registry: name → CID (sorted). */
154
158
  registryList(): Promise<Record<string, string>>;
159
+ /**
160
+ * Ingest a rootless CARv1 — "upload a `.car` file". Every block is verified
161
+ * against its own CID, policy-checked and claimed by the account; there is
162
+ * no DAG traversal and no root requirement (unlike [`dagPush`](#dagpush),
163
+ * which syncs one DAG over multiple rounds). Partial success is normal:
164
+ * `failed` lists the rejected blocks (digest mismatch, policy, size).
165
+ */
166
+ ingestCar(car: Uint8Array): Promise<CarIngestResult>;
155
167
  /**
156
168
  * Push a flat file as an ordered list of chunks: each chunk is stored as
157
169
  * a raw block, and a dag-cbor list of their CIDs becomes the root — the
@@ -201,7 +213,7 @@ export declare class Firkin {
201
213
  */
202
214
  pullFile(root: string): Promise<Uint8Array>;
203
215
  }
204
- export declare function carFile(root: CID, entries: [CID, Uint8Array][]): Uint8Array;
216
+ export declare function carFile(root: CID | null, entries: [CID, Uint8Array][]): Uint8Array;
205
217
  export declare function multihashSha256(data: Uint8Array): MultihashDigest;
206
218
  type MultihashDigest = {
207
219
  code: number;
package/dist/client.js CHANGED
@@ -4,6 +4,9 @@ import { CID } from "multiformats";
4
4
  import { sha256 } from "@noble/hashes/sha2";
5
5
  import { cidForBytes, CODEC_DAG_CBOR, CODEC_DAG_PB, CODEC_RAW } from "./cid.js";
6
6
  import { BloomFilter } from "./bloom.js";
7
+ /** Wire version prefix for every API path (`/health` and `/.well-known/*`
8
+ * live outside it). */
9
+ export const API_PREFIX = "/v1";
7
10
  import { CMD, mintInvocation } from "./ucan.js";
8
11
  /** Signer-backed source: mints a fresh direct invocation per request. */
9
12
  export function signerToken(signer, expireIn = 600) {
@@ -54,7 +57,7 @@ export class Firkin {
54
57
  cid = cidForBytes(data, opts);
55
58
  body = new Blob([data]);
56
59
  }
57
- const res = await this.fetchImpl(`${this.base}/blob/${cid}`, {
60
+ const res = await this.fetchImpl(`${this.base}${API_PREFIX}/blob/${cid}`, {
58
61
  method: "PUT",
59
62
  headers: {
60
63
  Authorization: `Bearer ${toBase64Url(await this.token(CMD.blobUpload))}`,
@@ -67,12 +70,12 @@ export class Firkin {
67
70
  const json = (await res.json());
68
71
  return { cid, deduplicated: json.status === "already_stored" };
69
72
  }
70
- /** Fetch a whole blob. Authenticated reads: the server's reads are public
71
- * by default, but a `PUBLIC_READS=false` deployment requires the
72
- * `/firkin/blob/read` capability — read tokens are reusable (reads are
73
- * not replay-tracked), so sending one is always correct and cheap. */
73
+ /** Fetch a whole blob. Reads are authenticated: the server requires the
74
+ * `/firkin/blob/read` capability (an operator can opt into anonymous
75
+ * reads with `PUBLIC_READS=true`, in which case the header is ignored).
76
+ * Read tokens are reusable — reads are not replay-tracked. */
74
77
  async get(cid) {
75
- const res = await this.fetchImpl(`${this.base}/blob/${cid}`, {
78
+ const res = await this.fetchImpl(`${this.base}${API_PREFIX}/blob/${cid}`, {
76
79
  headers: { Authorization: `Bearer ${toBase64Url(await this.token(CMD.blobRead))}` },
77
80
  });
78
81
  if (!res.ok)
@@ -82,7 +85,7 @@ export class Firkin {
82
85
  /** Fetch a byte range (206 responses; server-trusted verification).
83
86
  * Authenticated like [`get`](#get). */
84
87
  async getRange(cid, range = {}) {
85
- const res = await this.fetchImpl(`${this.base}/blob/${cid}`, {
88
+ const res = await this.fetchImpl(`${this.base}${API_PREFIX}/blob/${cid}`, {
86
89
  headers: {
87
90
  Authorization: `Bearer ${toBase64Url(await this.token(CMD.blobRead))}`,
88
91
  Range: rangeHeader(range),
@@ -109,7 +112,7 @@ export class Firkin {
109
112
  * caller's own chain is not stored yet. */
110
113
  async publishDelegation(minted, opts = {}) {
111
114
  const token = opts.token ?? (await this.token(CMD.blobUpload));
112
- const res = await this.fetchImpl(`${this.base}/delegation/${minted.cid}`, {
115
+ const res = await this.fetchImpl(`${this.base}${API_PREFIX}/delegation/${minted.cid}`, {
113
116
  method: "PUT",
114
117
  headers: { Authorization: `Bearer ${toBase64Url(token)}` },
115
118
  body: new Blob([minted.proof]),
@@ -129,12 +132,12 @@ export class Firkin {
129
132
  const token = await this.token(`${CMD.blobRead}/${cid}`, {
130
133
  expireIn: opts.expireIn ?? 60,
131
134
  });
132
- return `${this.base}/blob/${cid}?token=${toBase64Url(token)}`;
135
+ return `${this.base}${API_PREFIX}/blob/${cid}?token=${toBase64Url(token)}`;
133
136
  }
134
137
  /** Attach an already-stored CID to the signer's account. */
135
138
  async claim(cid) {
136
139
  const token = await this.token(CMD.blobClaim);
137
- const res = await this.fetchImpl(`${this.base}/blob/${cid}/claim`, {
140
+ const res = await this.fetchImpl(`${this.base}${API_PREFIX}/blob/${cid}/claim`, {
138
141
  method: "POST",
139
142
  headers: { Authorization: `Bearer ${toBase64Url(token)}` },
140
143
  });
@@ -143,7 +146,7 @@ export class Firkin {
143
146
  }
144
147
  /** Detach the signer's claim; bytes die with the last claimant. */
145
148
  async delete(cid) {
146
- const res = await this.fetchImpl(`${this.base}/blob/${cid}`, {
149
+ const res = await this.fetchImpl(`${this.base}${API_PREFIX}/blob/${cid}`, {
147
150
  method: "DELETE",
148
151
  headers: { Authorization: `Bearer ${toBase64Url(await this.token(CMD.blobDelete))}` },
149
152
  });
@@ -157,7 +160,7 @@ export class Firkin {
157
160
  * (passkey-native accounts; the server caps its lifetime at 10 minutes).
158
161
  * A plain delegated session cannot revoke. */
159
162
  async revoke(opts) {
160
- const res = await this.fetchImpl(`${this.base}/account/revoke`, {
163
+ const res = await this.fetchImpl(`${this.base}${API_PREFIX}/account/revoke`, {
161
164
  method: "POST",
162
165
  headers: {
163
166
  Authorization: `Bearer ${toBase64Url(await this.token(CMD.accountRevoke))}`,
@@ -175,7 +178,7 @@ export class Firkin {
175
178
  * other record of the credential↔account link. */
176
179
  async register(did, credential) {
177
180
  const token = await this.token(CMD.accountRegister);
178
- const res = await this.fetchImpl(`${this.base}/accounts`, {
181
+ const res = await this.fetchImpl(`${this.base}${API_PREFIX}/accounts`, {
179
182
  method: "POST",
180
183
  headers: {
181
184
  Authorization: `Bearer ${toBase64Url(token)}`,
@@ -186,32 +189,6 @@ export class Firkin {
186
189
  if (!res.ok)
187
190
  throw await ApiError.of(res);
188
191
  }
189
- /** (Account key) attest a session delegation with the bound passkey. */
190
- async attest(body) {
191
- const res = await this.fetchImpl(`${this.base}/account/attest`, {
192
- method: "POST",
193
- headers: {
194
- Authorization: `Bearer ${toBase64Url(await this.token(CMD.accountAttest))}`,
195
- "Content-Type": "application/json",
196
- },
197
- body: JSON.stringify(body),
198
- });
199
- if (!res.ok)
200
- throw await ApiError.of(res);
201
- }
202
- /** (Account key) bind a WebAuthn credential to this account. */
203
- async bindCredential(credential) {
204
- const res = await this.fetchImpl(`${this.base}/account/bind`, {
205
- method: "POST",
206
- headers: {
207
- Authorization: `Bearer ${toBase64Url(await this.token(CMD.accountBind))}`,
208
- "Content-Type": "application/json",
209
- },
210
- body: JSON.stringify({ credential }),
211
- });
212
- if (!res.ok)
213
- throw await ApiError.of(res);
214
- }
215
192
  /** Resolve a passkey assertion to its account DID — the login primitive
216
193
  * for passkey-native accounts on a fresh browser. The client performs a
217
194
  * discoverable-credentials WebAuthn get (no `allowCredentials`); the
@@ -220,7 +197,7 @@ export class Firkin {
220
197
  * the endpoint is unauthenticated by design: the verified signature IS
221
198
  * the authentication. */
222
199
  static async whoami(base, assertion, fetchImpl = globalThis.fetch) {
223
- const res = await fetchImpl(`${base}/account/whoami`, {
200
+ const res = await fetchImpl(`${base}${API_PREFIX}/account/whoami`, {
224
201
  method: "POST",
225
202
  headers: { "Content-Type": "application/json" },
226
203
  body: JSON.stringify({
@@ -237,7 +214,7 @@ export class Firkin {
237
214
  }
238
215
  /** (Admin) list registered accounts. */
239
216
  async listAccounts() {
240
- const res = await this.fetchImpl(`${this.base}/accounts`, {
217
+ const res = await this.fetchImpl(`${this.base}${API_PREFIX}/accounts`, {
241
218
  headers: {
242
219
  Authorization: `Bearer ${toBase64Url(await this.token(CMD.accountList))}`,
243
220
  },
@@ -253,7 +230,7 @@ export class Firkin {
253
230
  // a resolved CID may be dangling (not yet uploaded or already GC'd).
254
231
  /** Set `name` → `cid`. Overwrites silently (last-writer-wins). */
255
232
  async registrySet(name, cid) {
256
- const res = await this.fetchImpl(`${this.base}/registry/${encodeURIComponent(name)}`, {
233
+ const res = await this.fetchImpl(`${this.base}${API_PREFIX}/registry/${encodeURIComponent(name)}`, {
257
234
  method: "PUT",
258
235
  headers: {
259
236
  Authorization: `Bearer ${toBase64Url(await this.token(CMD.registryWrite))}`,
@@ -266,7 +243,7 @@ export class Firkin {
266
243
  }
267
244
  /** Resolve `name`; returns null when unset. */
268
245
  async registryGet(name) {
269
- const res = await this.fetchImpl(`${this.base}/registry/${encodeURIComponent(name)}`, {
246
+ const res = await this.fetchImpl(`${this.base}${API_PREFIX}/registry/${encodeURIComponent(name)}`, {
270
247
  headers: { Authorization: `Bearer ${toBase64Url(await this.token(CMD.registryRead))}` },
271
248
  });
272
249
  if (res.status === 404)
@@ -278,7 +255,7 @@ export class Firkin {
278
255
  }
279
256
  /** Unset `name` (idempotent). */
280
257
  async registryDelete(name) {
281
- const res = await this.fetchImpl(`${this.base}/registry/${encodeURIComponent(name)}`, {
258
+ const res = await this.fetchImpl(`${this.base}${API_PREFIX}/registry/${encodeURIComponent(name)}`, {
282
259
  method: "DELETE",
283
260
  headers: { Authorization: `Bearer ${toBase64Url(await this.token(CMD.registryWrite))}` },
284
261
  });
@@ -287,7 +264,7 @@ export class Firkin {
287
264
  }
288
265
  /** The account's whole registry: name → CID (sorted). */
289
266
  async registryList() {
290
- const res = await this.fetchImpl(`${this.base}/registry`, {
267
+ const res = await this.fetchImpl(`${this.base}${API_PREFIX}/registry`, {
291
268
  headers: { Authorization: `Bearer ${toBase64Url(await this.token(CMD.registryRead))}` },
292
269
  });
293
270
  if (!res.ok)
@@ -296,6 +273,26 @@ export class Firkin {
296
273
  return json.names;
297
274
  }
298
275
  // --- CAR Mirror batch sync ---------------------------------------------------
276
+ /**
277
+ * Ingest a rootless CARv1 — "upload a `.car` file". Every block is verified
278
+ * against its own CID, policy-checked and claimed by the account; there is
279
+ * no DAG traversal and no root requirement (unlike [`dagPush`](#dagpush),
280
+ * which syncs one DAG over multiple rounds). Partial success is normal:
281
+ * `failed` lists the rejected blocks (digest mismatch, policy, size).
282
+ */
283
+ async ingestCar(car) {
284
+ const res = await this.fetchImpl(`${this.base}${API_PREFIX}/car`, {
285
+ method: "POST",
286
+ headers: {
287
+ Authorization: `Bearer ${toBase64Url(await this.token(CMD.blobUpload))}`,
288
+ "Content-Type": "application/vnd.ipld.car",
289
+ },
290
+ body: new Blob([car]),
291
+ });
292
+ if (!res.ok)
293
+ throw await ApiError.of(res);
294
+ return (await res.json());
295
+ }
299
296
  /**
300
297
  * Push a flat file as an ordered list of chunks: each chunk is stored as
301
298
  * a raw block, and a dag-cbor list of their CIDs becomes the root — the
@@ -392,7 +389,7 @@ export class Firkin {
392
389
  throw new Error("push round has no blocks");
393
390
  const first = send[0];
394
391
  const car = carFile(first === rootCid.toString() ? rootCid : CID.parse(first), send.map((c) => [CID.parse(c), all.get(c)]));
395
- const res = await this.fetchImpl(`${this.base}/dag/push/${rootCid}`, {
392
+ const res = await this.fetchImpl(`${this.base}${API_PREFIX}/dag/push/${rootCid}`, {
396
393
  method: "POST",
397
394
  headers: {
398
395
  Authorization: `Bearer ${toBase64Url(await this.token(CMD.blobUpload))}`,
@@ -447,7 +444,7 @@ export class Firkin {
447
444
  };
448
445
  if (body)
449
446
  headers["Content-Type"] = "application/vnd.ipld.dag-cbor";
450
- const res = await this.fetchImpl(`${this.base}/dag/pull/${root}`, {
447
+ const res = await this.fetchImpl(`${this.base}${API_PREFIX}/dag/pull/${root}`, {
451
448
  method: "POST",
452
449
  headers,
453
450
  ...(body ? { body: new Blob([body]) } : {}),
@@ -522,10 +519,11 @@ function rangeHeader(range) {
522
519
  }
523
520
  // --- CARv1 framing -----------------------------------------------------------
524
521
  export function carFile(root, entries) {
525
- // Header roots = the DAG root; entries = exactly the blocks being sent
526
- // this round. Never repeat a block the server already has: the receiver
527
- // interrupts the round at the first Have block.
528
- const header = dagcbor.encode({ roots: [root], version: 1 });
522
+ // Header roots = the DAG root (`null` for a rootless bag — the DASL CAR
523
+ // format allows an empty roots array); entries = exactly the blocks being
524
+ // sent this round. Never repeat a block the server already has: the
525
+ // receiver interrupts the round at the first Have block.
526
+ const header = dagcbor.encode({ roots: root ? [root] : [], version: 1 });
529
527
  const parts = [uvarintFrame(header.length), header];
530
528
  for (const [cid, block] of entries) {
531
529
  const entry = new Uint8Array(cid.bytes.length + block.length);
package/dist/index.d.ts CHANGED
@@ -4,5 +4,6 @@ export * from "./ucan.ts";
4
4
  export * from "./client.ts";
5
5
  export * from "./bloom.ts";
6
6
  export * from "./webauthn.ts";
7
+ export * from "./rasl.ts";
7
8
  export * from "./bip39.ts";
8
9
  export * from "./bloom.ts";
package/dist/index.js CHANGED
@@ -4,5 +4,6 @@ export * from "./ucan.js";
4
4
  export * from "./client.js";
5
5
  export * from "./bloom.js";
6
6
  export * from "./webauthn.js";
7
+ export * from "./rasl.js";
7
8
  export * from "./bip39.js";
8
9
  export * from "./bloom.js";
package/dist/rasl.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ export interface RaslUrl {
2
+ /** The DASL/BDASL CID (the URL's authority). */
3
+ cid: string;
4
+ /** Hosts that may serve it (`?hint=` values, invalid ones dropped). */
5
+ hints: string[];
6
+ }
7
+ /** Build a `rasl://` URL for a CID and optional hint hosts. */
8
+ export declare function raslUrl(cid: string, hints?: string[]): string;
9
+ /** Parse a `rasl://` URL per the spec: the host is the CID, `hint` entries
10
+ * are the (valid) hint hosts; everything else, including the path, is
11
+ * ignored. */
12
+ export declare function parseRasl(url: string): RaslUrl;
13
+ /** Check that `bytes` hash to `cid` (SHA-256 and BLAKE3 multihashes).
14
+ * Compares the digest, so CIDv0/CIDv1 spelling doesn't matter. */
15
+ export declare function verifyCid(cid: string, bytes: Uint8Array): boolean;
16
+ export interface ResolveOptions {
17
+ fetchImpl?: typeof fetch;
18
+ /** Cancels the whole resolution (including in-flight hint requests). */
19
+ signal?: AbortSignal;
20
+ }
21
+ /**
22
+ * Resolve a `rasl://` URL: request every hint's
23
+ * `https://<hint>/.well-known/rasl/<cid>` in parallel (statelessly), take the
24
+ * first response whose bytes hash to the CID. Failing or mismatching hints
25
+ * are skipped; if none verify, it throws.
26
+ */
27
+ export declare function resolveRasl(url: string, opts?: ResolveOptions): Promise<{
28
+ cid: string;
29
+ bytes: Uint8Array;
30
+ }>;
package/dist/rasl.js ADDED
@@ -0,0 +1,107 @@
1
+ // RASL (Retrieval of Arbitrary Structures & Links) — https://dasl.ing/rasl.html
2
+ //
3
+ // A `rasl://<cid>/?hint=<host>&hint=<host>` URL names a CID plus optional
4
+ // places to fetch it. Retrieval is stateless (no cookies, no credentials —
5
+ // the hash is the authority) and self-verifying: whatever comes back must
6
+ // hash to the CID.
7
+ import { sha256 } from "@noble/hashes/sha2";
8
+ import { blake3 } from "@noble/hashes/blake3";
9
+ import { CID } from "multiformats";
10
+ import { HASH_BLAKE3, HASH_SHA256 } from "./cid.js";
11
+ /** A valid `https` host: DNS labels + optional port (IPv6 literals too). */
12
+ const HOST_RE = /^(\[[0-9a-fA-F:]+\]|[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*)(:\d{1,5})?$/;
13
+ /** Build a `rasl://` URL for a CID and optional hint hosts. */
14
+ export function raslUrl(cid, hints = []) {
15
+ const query = hints.map((hint) => `hint=${encodeURIComponent(hint)}`).join("&");
16
+ return `rasl://${cid}/${query ? `?${query}` : ""}`;
17
+ }
18
+ /** Parse a `rasl://` URL per the spec: the host is the CID, `hint` entries
19
+ * are the (valid) hint hosts; everything else, including the path, is
20
+ * ignored. */
21
+ export function parseRasl(url) {
22
+ const parsed = new URL(url);
23
+ if (parsed.protocol !== "rasl:") {
24
+ throw new Error(`not a rasl:// URL: ${url}`);
25
+ }
26
+ const cid = parsed.host;
27
+ if (!cid)
28
+ throw new Error(`rasl:// URL lacks a CID: ${url}`);
29
+ CID.parse(cid); // must be a well-formed CID
30
+ const hints = parsed.searchParams
31
+ .getAll("hint")
32
+ .map((hint) => hint.trim())
33
+ .filter((hint) => HOST_RE.test(hint));
34
+ return { cid, hints };
35
+ }
36
+ /** Check that `bytes` hash to `cid` (SHA-256 and BLAKE3 multihashes).
37
+ * Compares the digest, so CIDv0/CIDv1 spelling doesn't matter. */
38
+ export function verifyCid(cid, bytes) {
39
+ const parsed = CID.parse(cid);
40
+ const digest = parsed.multihash.code === HASH_SHA256
41
+ ? sha256(bytes)
42
+ : parsed.multihash.code === HASH_BLAKE3
43
+ ? blake3(bytes)
44
+ : undefined;
45
+ if (!digest || digest.length !== parsed.multihash.size)
46
+ return false;
47
+ return parsed.multihash.digest.every((byte, i) => byte === digest[i]);
48
+ }
49
+ /**
50
+ * Resolve a `rasl://` URL: request every hint's
51
+ * `https://<hint>/.well-known/rasl/<cid>` in parallel (statelessly), take the
52
+ * first response whose bytes hash to the CID. Failing or mismatching hints
53
+ * are skipped; if none verify, it throws.
54
+ */
55
+ export async function resolveRasl(url, opts = {}) {
56
+ const { cid, hints } = parseRasl(url);
57
+ if (hints.length === 0) {
58
+ throw new Error("rasl:// URL has no usable hints");
59
+ }
60
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
61
+ const controller = new AbortController();
62
+ const signals = [controller.signal];
63
+ if (opts.signal) {
64
+ if (opts.signal.aborted)
65
+ throw new Error("rasl resolution aborted");
66
+ opts.signal.addEventListener("abort", () => controller.abort(), { once: true });
67
+ signals.push(opts.signal);
68
+ }
69
+ const attempts = hints.map(async (hint) => {
70
+ const res = await fetchImpl(`https://${hint}/.well-known/rasl/${cid}`, {
71
+ method: "GET",
72
+ // RASL is stateless: no cookies, no credentials, no content negotiation.
73
+ credentials: "omit",
74
+ headers: { Accept: "*/*" },
75
+ signal: controller.signal,
76
+ });
77
+ if (!res.ok)
78
+ throw new Error(`${hint}: HTTP ${res.status}`);
79
+ const bytes = new Uint8Array(await res.arrayBuffer());
80
+ if (!verifyCid(cid, bytes))
81
+ throw new Error(`${hint}: bytes do not hash to ${cid}`);
82
+ return { cid, bytes };
83
+ });
84
+ try {
85
+ return await firstSuccess(attempts);
86
+ }
87
+ finally {
88
+ controller.abort(); // stop the losers
89
+ }
90
+ }
91
+ /** The first fulfilled promise; rejects when all fail. */
92
+ async function firstSuccess(promises) {
93
+ return new Promise((resolve, reject) => {
94
+ let pending = promises.length;
95
+ const errors = [];
96
+ for (const promise of promises) {
97
+ promise.then(resolve, (error) => {
98
+ errors.push(error);
99
+ if (--pending === 0) {
100
+ reject(new Error(`RASL retrieval failed for all hints: ${errors
101
+ .map((e) => (e instanceof Error ? e.message : String(e)))
102
+ .join("; ")}`));
103
+ }
104
+ });
105
+ }
106
+ });
107
+ }
package/dist/ucan.d.ts CHANGED
@@ -21,8 +21,6 @@ export declare const CMD: {
21
21
  readonly accountList: "/firkin/account/list";
22
22
  readonly accountRevoke: "/firkin/account/revoke";
23
23
  readonly accountRecover: "/firkin/account/recover";
24
- readonly accountAttest: "/firkin/account/attest";
25
- readonly accountBind: "/firkin/account/bind";
26
24
  readonly registryRead: "/firkin/registry/read";
27
25
  readonly registryWrite: "/firkin/registry/write";
28
26
  };
package/dist/ucan.js CHANGED
@@ -46,8 +46,6 @@ export const CMD = {
46
46
  accountList: "/firkin/account/list",
47
47
  accountRevoke: "/firkin/account/revoke",
48
48
  accountRecover: "/firkin/account/recover",
49
- accountAttest: "/firkin/account/attest",
50
- accountBind: "/firkin/account/bind",
51
49
  registryRead: "/firkin/registry/read",
52
50
  registryWrite: "/firkin/registry/write",
53
51
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokono.ma/firkin",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Browser-first TypeScript client SDK for the Firkin API (UCAN 1.0, DASL CIDs, CAR Mirror sync)",
5
5
  "license": "Apache-2.0 OR MIT",
6
6
  "repository": {