@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 +2 -1
- package/dist/client.d.ts +30 -18
- package/dist/client.js +49 -51
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/rasl.d.ts +30 -0
- package/dist/rasl.js +107 -0
- package/dist/ucan.d.ts +0 -2
- package/dist/ucan.js +0 -2
- package/package.json +1 -1
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 (
|
|
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.
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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.
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
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
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
|
|
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
package/dist/index.js
CHANGED
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