@tokono.ma/firkin 0.1.1 → 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,12 +46,13 @@ 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 }
53
53
 
54
54
  // claims (multi-account sharing of one stored object)
55
+ rb.publishDelegation(d) // store a delegation so tokens can reference it by CID
55
56
  rb.claim(cid) // attach an already-stored CID to your account
56
57
  rb.delete(cid) // detach; bytes die with the last claimant
57
58
 
@@ -66,6 +67,7 @@ rb.pushFile(chunks) // ordered chunks → dag-cbor list root
66
67
  rb.pullFile(root) // inverse of pushFile
67
68
  rb.dagPush(root, blocks) // an existing DAG, as-is: [cid, bytes][] | Map
68
69
  rb.dagPull(root) // Map<cid, bytes> — only the root is needed
70
+ rb.ingestCar(carBytes) // rootless CARv1: verify + claim each block (no DAG)
69
71
 
70
72
  // account
71
73
  rb.revoke({ cids? , all? }) // kill delegations (account key only)
@@ -93,15 +95,17 @@ with that key:
93
95
  ```ts
94
96
  import { delegationPayloadBytes, invocation, delegate } from "@tokono.ma/firkin";
95
97
 
96
- const { proof, cid } = await delegate(account, {
98
+ const delegation = await delegate(account, {
97
99
  subject: account.did(), // rights holder (the account)
98
100
  audience: session.did(), // recipient
99
101
  command: "/firkin/blob/upload", // narrow prefixes only ever narrow
100
102
  expireIn: 8 * 3600, // omit for an indefinite delegation
101
103
  });
104
+ await accountClient.publishDelegation(delegation); // once — idempotent
102
105
 
106
+ // Tokens reference the proof by CID: no proof bytes on the wire.
103
107
  const rb = new Firkin(base, async (command) =>
104
- invocation(session, { command, proofs: [proof], expireIn: 600 }),
108
+ invocation(session, { command, proofs: [delegation.cid], subject: account.did(), expireIn: 600 }),
105
109
  );
106
110
 
107
111
  // Revocation must be authorized by the account key — not the session:
package/dist/client.d.ts CHANGED
@@ -1,13 +1,36 @@
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";
6
+ /** Per-call options a TokenSource may honor. */
7
+ export interface TokenOptions {
8
+ /** Override the token's expiry (seconds from now). */
9
+ expireIn?: number;
10
+ }
3
11
  /** Supplies a fresh bearer token for a command. Mutating commands are
4
12
  * single-use server-side — implementations MUST mint a new token per call
5
- * (the default signer source does). */
6
- export type TokenSource = (command: string) => Promise<Uint8Array>;
13
+ * (the default signer source does). `options.expireIn` is a hint used by
14
+ * `readUrl` to mint short-lived links; sources may ignore it. */
15
+ export type TokenSource = (command: string, options?: TokenOptions) => Promise<Uint8Array>;
7
16
  /** Signer-backed source: mints a fresh direct invocation per request. */
8
17
  export declare function signerToken(signer: Signer, expireIn?: number): TokenSource;
9
18
  /** Fixed-token source: only appropriate for public/read-only usage. */
10
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
+ }
11
34
  export interface UploadResult {
12
35
  cid: string;
13
36
  /** True when the blob was already stored (dedup; claim joined). */
@@ -54,14 +77,36 @@ export declare class Firkin {
54
77
  codec?: number;
55
78
  hash?: "sha256" | "blake3";
56
79
  }): Promise<UploadResult>;
57
- /** Fetch a whole blob. Authenticated reads: the server's reads are public
58
- * by default, but a `PUBLIC_READS=false` deployment requires the
59
- * `/firkin/blob/read` capability — read tokens are reusable (reads are
60
- * 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. */
61
84
  get(cid: string): Promise<Uint8Array>;
62
85
  /** Fetch a byte range (206 responses; server-trusted verification).
63
86
  * Authenticated like [`get`](#get). */
64
87
  getRange(cid: string, range?: ByteRange): Promise<RangeResult>;
88
+ /** Publish a delegation to the server's delegation store so invocations
89
+ * can reference it by CID — the default token shape. Idempotent: the store
90
+ * dedups by CID, so publishing at session start is always safe. `token`
91
+ * bootstraps with an inline-proof token (`inlineInvocation`) when the
92
+ * caller's own chain is not stored yet. */
93
+ publishDelegation(minted: {
94
+ proof: Uint8Array;
95
+ cid: string;
96
+ }, opts?: {
97
+ token?: Uint8Array;
98
+ }): Promise<{
99
+ cid: string;
100
+ }>;
101
+ /** A short-lived, CID-scoped read URL — the presigned-URL equivalent for
102
+ * media elements (`<audio src>`, `<video src>`), which cannot set an
103
+ * Authorization header. The URL carries a `/firkin/blob/read/<cid>`
104
+ * invocation; the server accepts `?token=` on GET/HEAD only and, with
105
+ * `PUBLIC_READS=false`, enforces the CID narrowing — a leaked URL exposes
106
+ * just that blob until the token expires. Keep `expireIn` short (60s). */
107
+ readUrl(cid: string, opts?: {
108
+ expireIn?: number;
109
+ }): Promise<string>;
65
110
  /** Attach an already-stored CID to the signer's account. */
66
111
  claim(cid: string): Promise<void>;
67
112
  /** Detach the signer's claim; bytes die with the last claimant. */
@@ -85,19 +130,6 @@ export declare class Firkin {
85
130
  id: string;
86
131
  spki: string;
87
132
  }): Promise<void>;
88
- /** (Account key) attest a session delegation with the bound passkey. */
89
- attest(body: {
90
- credential_id: string;
91
- auth_data: string;
92
- client_data: string;
93
- signature: string;
94
- delegation_cid: string;
95
- }): Promise<void>;
96
- /** (Account key) bind a WebAuthn credential to this account. */
97
- bindCredential(credential: {
98
- id: string;
99
- spki: string;
100
- }): Promise<void>;
101
133
  /** Resolve a passkey assertion to its account DID — the login primitive
102
134
  * for passkey-native accounts on a fresh browser. The client performs a
103
135
  * discoverable-credentials WebAuthn get (no `allowCredentials`); the
@@ -124,6 +156,14 @@ export declare class Firkin {
124
156
  registryDelete(name: string): Promise<void>;
125
157
  /** The account's whole registry: name → CID (sorted). */
126
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>;
127
167
  /**
128
168
  * Push a flat file as an ordered list of chunks: each chunk is stored as
129
169
  * a raw block, and a dag-cbor list of their CIDs becomes the root — the
@@ -173,7 +213,7 @@ export declare class Firkin {
173
213
  */
174
214
  pullFile(root: string): Promise<Uint8Array>;
175
215
  }
176
- export declare function carFile(root: CID, entries: [CID, Uint8Array][]): Uint8Array;
216
+ export declare function carFile(root: CID | null, entries: [CID, Uint8Array][]): Uint8Array;
177
217
  export declare function multihashSha256(data: Uint8Array): MultihashDigest;
178
218
  type MultihashDigest = {
179
219
  code: number;
package/dist/client.js CHANGED
@@ -4,10 +4,13 @@ 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) {
10
- return async (command) => mintInvocation(signer, { command, expireIn });
13
+ return async (command, options) => mintInvocation(signer, { command, expireIn: options?.expireIn ?? expireIn });
11
14
  }
12
15
  /** Fixed-token source: only appropriate for public/read-only usage. */
13
16
  export function staticToken(token) {
@@ -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),
@@ -102,10 +105,39 @@ export class Firkin {
102
105
  };
103
106
  }
104
107
  // --- claims & deletion ------------------------------------------------------
108
+ /** Publish a delegation to the server's delegation store so invocations
109
+ * can reference it by CID — the default token shape. Idempotent: the store
110
+ * dedups by CID, so publishing at session start is always safe. `token`
111
+ * bootstraps with an inline-proof token (`inlineInvocation`) when the
112
+ * caller's own chain is not stored yet. */
113
+ async publishDelegation(minted, opts = {}) {
114
+ const token = opts.token ?? (await this.token(CMD.blobUpload));
115
+ const res = await this.fetchImpl(`${this.base}${API_PREFIX}/delegation/${minted.cid}`, {
116
+ method: "PUT",
117
+ headers: { Authorization: `Bearer ${toBase64Url(token)}` },
118
+ body: new Blob([minted.proof]),
119
+ });
120
+ if (!res.ok)
121
+ throw await ApiError.of(res);
122
+ const json = (await res.json());
123
+ return json;
124
+ }
125
+ /** A short-lived, CID-scoped read URL — the presigned-URL equivalent for
126
+ * media elements (`<audio src>`, `<video src>`), which cannot set an
127
+ * Authorization header. The URL carries a `/firkin/blob/read/<cid>`
128
+ * invocation; the server accepts `?token=` on GET/HEAD only and, with
129
+ * `PUBLIC_READS=false`, enforces the CID narrowing — a leaked URL exposes
130
+ * just that blob until the token expires. Keep `expireIn` short (60s). */
131
+ async readUrl(cid, opts = {}) {
132
+ const token = await this.token(`${CMD.blobRead}/${cid}`, {
133
+ expireIn: opts.expireIn ?? 60,
134
+ });
135
+ return `${this.base}${API_PREFIX}/blob/${cid}?token=${toBase64Url(token)}`;
136
+ }
105
137
  /** Attach an already-stored CID to the signer's account. */
106
138
  async claim(cid) {
107
139
  const token = await this.token(CMD.blobClaim);
108
- const res = await this.fetchImpl(`${this.base}/blob/${cid}/claim`, {
140
+ const res = await this.fetchImpl(`${this.base}${API_PREFIX}/blob/${cid}/claim`, {
109
141
  method: "POST",
110
142
  headers: { Authorization: `Bearer ${toBase64Url(token)}` },
111
143
  });
@@ -114,7 +146,7 @@ export class Firkin {
114
146
  }
115
147
  /** Detach the signer's claim; bytes die with the last claimant. */
116
148
  async delete(cid) {
117
- const res = await this.fetchImpl(`${this.base}/blob/${cid}`, {
149
+ const res = await this.fetchImpl(`${this.base}${API_PREFIX}/blob/${cid}`, {
118
150
  method: "DELETE",
119
151
  headers: { Authorization: `Bearer ${toBase64Url(await this.token(CMD.blobDelete))}` },
120
152
  });
@@ -128,7 +160,7 @@ export class Firkin {
128
160
  * (passkey-native accounts; the server caps its lifetime at 10 minutes).
129
161
  * A plain delegated session cannot revoke. */
130
162
  async revoke(opts) {
131
- const res = await this.fetchImpl(`${this.base}/account/revoke`, {
163
+ const res = await this.fetchImpl(`${this.base}${API_PREFIX}/account/revoke`, {
132
164
  method: "POST",
133
165
  headers: {
134
166
  Authorization: `Bearer ${toBase64Url(await this.token(CMD.accountRevoke))}`,
@@ -146,7 +178,7 @@ export class Firkin {
146
178
  * other record of the credential↔account link. */
147
179
  async register(did, credential) {
148
180
  const token = await this.token(CMD.accountRegister);
149
- const res = await this.fetchImpl(`${this.base}/accounts`, {
181
+ const res = await this.fetchImpl(`${this.base}${API_PREFIX}/accounts`, {
150
182
  method: "POST",
151
183
  headers: {
152
184
  Authorization: `Bearer ${toBase64Url(token)}`,
@@ -157,32 +189,6 @@ export class Firkin {
157
189
  if (!res.ok)
158
190
  throw await ApiError.of(res);
159
191
  }
160
- /** (Account key) attest a session delegation with the bound passkey. */
161
- async attest(body) {
162
- const res = await this.fetchImpl(`${this.base}/account/attest`, {
163
- method: "POST",
164
- headers: {
165
- Authorization: `Bearer ${toBase64Url(await this.token(CMD.accountAttest))}`,
166
- "Content-Type": "application/json",
167
- },
168
- body: JSON.stringify(body),
169
- });
170
- if (!res.ok)
171
- throw await ApiError.of(res);
172
- }
173
- /** (Account key) bind a WebAuthn credential to this account. */
174
- async bindCredential(credential) {
175
- const res = await this.fetchImpl(`${this.base}/account/bind`, {
176
- method: "POST",
177
- headers: {
178
- Authorization: `Bearer ${toBase64Url(await this.token(CMD.accountBind))}`,
179
- "Content-Type": "application/json",
180
- },
181
- body: JSON.stringify({ credential }),
182
- });
183
- if (!res.ok)
184
- throw await ApiError.of(res);
185
- }
186
192
  /** Resolve a passkey assertion to its account DID — the login primitive
187
193
  * for passkey-native accounts on a fresh browser. The client performs a
188
194
  * discoverable-credentials WebAuthn get (no `allowCredentials`); the
@@ -191,7 +197,7 @@ export class Firkin {
191
197
  * the endpoint is unauthenticated by design: the verified signature IS
192
198
  * the authentication. */
193
199
  static async whoami(base, assertion, fetchImpl = globalThis.fetch) {
194
- const res = await fetchImpl(`${base}/account/whoami`, {
200
+ const res = await fetchImpl(`${base}${API_PREFIX}/account/whoami`, {
195
201
  method: "POST",
196
202
  headers: { "Content-Type": "application/json" },
197
203
  body: JSON.stringify({
@@ -208,7 +214,7 @@ export class Firkin {
208
214
  }
209
215
  /** (Admin) list registered accounts. */
210
216
  async listAccounts() {
211
- const res = await this.fetchImpl(`${this.base}/accounts`, {
217
+ const res = await this.fetchImpl(`${this.base}${API_PREFIX}/accounts`, {
212
218
  headers: {
213
219
  Authorization: `Bearer ${toBase64Url(await this.token(CMD.accountList))}`,
214
220
  },
@@ -224,7 +230,7 @@ export class Firkin {
224
230
  // a resolved CID may be dangling (not yet uploaded or already GC'd).
225
231
  /** Set `name` → `cid`. Overwrites silently (last-writer-wins). */
226
232
  async registrySet(name, cid) {
227
- const res = await this.fetchImpl(`${this.base}/registry/${encodeURIComponent(name)}`, {
233
+ const res = await this.fetchImpl(`${this.base}${API_PREFIX}/registry/${encodeURIComponent(name)}`, {
228
234
  method: "PUT",
229
235
  headers: {
230
236
  Authorization: `Bearer ${toBase64Url(await this.token(CMD.registryWrite))}`,
@@ -237,7 +243,7 @@ export class Firkin {
237
243
  }
238
244
  /** Resolve `name`; returns null when unset. */
239
245
  async registryGet(name) {
240
- const res = await this.fetchImpl(`${this.base}/registry/${encodeURIComponent(name)}`, {
246
+ const res = await this.fetchImpl(`${this.base}${API_PREFIX}/registry/${encodeURIComponent(name)}`, {
241
247
  headers: { Authorization: `Bearer ${toBase64Url(await this.token(CMD.registryRead))}` },
242
248
  });
243
249
  if (res.status === 404)
@@ -249,7 +255,7 @@ export class Firkin {
249
255
  }
250
256
  /** Unset `name` (idempotent). */
251
257
  async registryDelete(name) {
252
- const res = await this.fetchImpl(`${this.base}/registry/${encodeURIComponent(name)}`, {
258
+ const res = await this.fetchImpl(`${this.base}${API_PREFIX}/registry/${encodeURIComponent(name)}`, {
253
259
  method: "DELETE",
254
260
  headers: { Authorization: `Bearer ${toBase64Url(await this.token(CMD.registryWrite))}` },
255
261
  });
@@ -258,7 +264,7 @@ export class Firkin {
258
264
  }
259
265
  /** The account's whole registry: name → CID (sorted). */
260
266
  async registryList() {
261
- const res = await this.fetchImpl(`${this.base}/registry`, {
267
+ const res = await this.fetchImpl(`${this.base}${API_PREFIX}/registry`, {
262
268
  headers: { Authorization: `Bearer ${toBase64Url(await this.token(CMD.registryRead))}` },
263
269
  });
264
270
  if (!res.ok)
@@ -267,6 +273,26 @@ export class Firkin {
267
273
  return json.names;
268
274
  }
269
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
+ }
270
296
  /**
271
297
  * Push a flat file as an ordered list of chunks: each chunk is stored as
272
298
  * a raw block, and a dag-cbor list of their CIDs becomes the root — the
@@ -363,7 +389,7 @@ export class Firkin {
363
389
  throw new Error("push round has no blocks");
364
390
  const first = send[0];
365
391
  const car = carFile(first === rootCid.toString() ? rootCid : CID.parse(first), send.map((c) => [CID.parse(c), all.get(c)]));
366
- const res = await this.fetchImpl(`${this.base}/dag/push/${rootCid}`, {
392
+ const res = await this.fetchImpl(`${this.base}${API_PREFIX}/dag/push/${rootCid}`, {
367
393
  method: "POST",
368
394
  headers: {
369
395
  Authorization: `Bearer ${toBase64Url(await this.token(CMD.blobUpload))}`,
@@ -418,7 +444,7 @@ export class Firkin {
418
444
  };
419
445
  if (body)
420
446
  headers["Content-Type"] = "application/vnd.ipld.dag-cbor";
421
- const res = await this.fetchImpl(`${this.base}/dag/pull/${root}`, {
447
+ const res = await this.fetchImpl(`${this.base}${API_PREFIX}/dag/pull/${root}`, {
422
448
  method: "POST",
423
449
  headers,
424
450
  ...(body ? { body: new Blob([body]) } : {}),
@@ -493,10 +519,11 @@ function rangeHeader(range) {
493
519
  }
494
520
  // --- CARv1 framing -----------------------------------------------------------
495
521
  export function carFile(root, entries) {
496
- // Header roots = the DAG root; entries = exactly the blocks being sent
497
- // this round. Never repeat a block the server already has: the receiver
498
- // interrupts the round at the first Have block.
499
- 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 });
500
527
  const parts = [uvarintFrame(header.length), header];
501
528
  for (const [cid, block] of entries) {
502
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
@@ -1,3 +1,4 @@
1
+ import { CID } from "multiformats";
1
2
  import type { UcanSigner } from "./keys.ts";
2
3
  export declare const INV_TAG = "ucan/inv@1.0.0-rc.1";
3
4
  export declare const DLG_TAG = "ucan/dlg@1.0.0-rc.1";
@@ -20,8 +21,6 @@ export declare const CMD: {
20
21
  readonly accountList: "/firkin/account/list";
21
22
  readonly accountRevoke: "/firkin/account/revoke";
22
23
  readonly accountRecover: "/firkin/account/recover";
23
- readonly accountAttest: "/firkin/account/attest";
24
- readonly accountBind: "/firkin/account/bind";
25
24
  readonly registryRead: "/firkin/registry/read";
26
25
  readonly registryWrite: "/firkin/registry/write";
27
26
  };
@@ -41,23 +40,34 @@ export interface DelegationOptions extends MintOptions {
41
40
  /** Mint a direct invocation token (issuer == subject == the signer's DID). */
42
41
  export declare function mintInvocation(signer: UcanSigner, opts: MintOptions): Promise<Uint8Array>;
43
42
  /**
44
- * Mint a delegated invocation token: `proofs` are bare delegation envelope
45
- * containers (root-first) minted by the account holder. Each proof's CID is
46
- * written into the invocation payload's `prf` — the server checks that the
47
- * payload's proof CIDs match the envelopes carried in the container.
43
+ * Mint a delegated invocation token whose proofs travel **by reference**:
44
+ * the container carries only the invocation, and its `prf` names the CIDs of
45
+ * delegations previously published to the server's delegation store
46
+ * (`Firkin.publishDelegation`). This keeps tokens small regardless of chain
47
+ * depth or assertion size — a passkey session's read URL is ~700 B instead of
48
+ * ~1.7 kB.
48
49
  *
49
- * A proof is either the bare envelope bytes, or `{proof, cid}` /`{bytes,
50
- * cid}` — the shape `delegate`/`assertionToProof` return. The explicit `cid`
51
- * is required for assertion proofs: their CID is over the envelope the
52
- * SERVER synthesizes, not over the proof bytes.
50
+ * A proof input may carry its own bytes (`delegate`/`assertionToProof` return
51
+ * `{proof, cid}`), which are used locally only — to discover the chain
52
+ * subject and to narrow the command — and are never put on the wire. With a
53
+ * bare CID, pass `subject` (the account DID) yourself.
53
54
  */
54
- type ProofInput = Uint8Array | {
55
+ export type ProofInput = string | CID | {
55
56
  bytes?: Uint8Array;
56
57
  proof?: Uint8Array;
57
58
  cid: string;
58
59
  };
59
60
  export declare function invocation(signer: UcanSigner, opts: MintOptions & {
60
61
  proofs: ProofInput[];
62
+ subject?: string;
63
+ }): Promise<Uint8Array>;
64
+ /**
65
+ * Mint an invocation **with its proofs inline** — bootstrap only: the
66
+ * delegation store's publish call is authorized by a chain that is not stored
67
+ * yet. Everything else should use [`invocation`](#invocation).
68
+ */
69
+ export declare function inlineInvocation(signer: UcanSigner, opts: MintOptions & {
70
+ proofs: ProofInput[];
61
71
  }): Promise<Uint8Array>;
62
72
  export interface MintedDelegation {
63
73
  /** The proof container (DAG-CBOR bytes) the delegate attaches to invocations. */
@@ -90,4 +100,3 @@ export declare function delegate(signer: UcanSigner, opts: DelegationOptions): P
90
100
  export declare function parseContainer(container: Uint8Array): unknown[];
91
101
  /** Decode a container's invocation payload (diagnostics / verification). */
92
102
  export declare function decodeInvocation(container: Uint8Array): Record<string, unknown>;
93
- export {};
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
  };
@@ -85,33 +83,37 @@ export async function mintInvocation(signer, opts) {
85
83
  return dagcbor.encode([invocation]);
86
84
  }
87
85
  export async function invocation(signer, opts) {
88
- // Decode each proof: either a standard varsig dlg envelope
89
- // `[sig, {h, "ucan/dlg@…": payload}]` or a WebAuthn assertion proof
90
- // `{ad, cd, sg}` whose clientDataJSON.challenge carries the dlg payload.
91
- const decoded = opts.proofs.map((p) => {
92
- const bytes = p instanceof Uint8Array ? p : (p.bytes ?? p.proof);
86
+ if (opts.proofs.length === 0) {
87
+ throw new Error("delegated invocation needs at least one proof");
88
+ }
89
+ const decoded = opts.proofs.map((input) => {
90
+ const cid = proofInputCid(input);
91
+ const bytes = proofInputBytes(input);
93
92
  if (!bytes)
94
- throw new Error("proof input lacks bytes");
93
+ return { cid, payload: undefined };
95
94
  const d = dagcbor.decode(bytes);
96
95
  if (Array.isArray(d) &&
97
96
  d.length === 2 &&
98
97
  d[1] instanceof Object &&
99
98
  Object.keys(d[1]).some((k) => k.startsWith("ucan/dlg@"))) {
100
99
  const payload = d[1][Object.keys(d[1]).find((k) => k.startsWith("ucan/dlg@"))];
101
- return { bytes, decoded: d, payload };
100
+ return { cid, payload };
102
101
  }
103
102
  if (d instanceof Object && "ad" in d && "cd" in d && "sg" in d) {
104
103
  // Assertion proof: the dlg payload rides in the challenge.
105
104
  const cd = JSON.parse(d.cd);
106
105
  const payload = dagcbor.decode(base64UrlDecode(cd.challenge));
107
- return { bytes, decoded: d, payload };
106
+ return { cid, payload };
108
107
  }
109
108
  throw new Error("unrecognized proof format");
110
109
  });
111
110
  const first = decoded[0];
112
111
  if (!first)
113
112
  throw new Error("delegated invocation needs at least one proof");
114
- const subject = first.payload.sub;
113
+ const subject = first.payload?.sub ?? opts.subject;
114
+ if (!subject) {
115
+ throw new Error("unknown chain subject: pass `subject` (the account DID) when proofs are referenced by CID only");
116
+ }
115
117
  const payload = invocationPayload(signer, opts);
116
118
  payload.aud = subject;
117
119
  payload.sub = subject;
@@ -120,19 +122,80 @@ export async function invocation(signer, opts) {
120
122
  // so `/firkin/blob/delete/<cid>` proofs can't be invoked with the bare
121
123
  // `/firkin/blob/delete`. Narrow down to the proof's own command when it
122
124
  // extends (never broadens) the requested one.
123
- const dlgCmd = first.payload.cmd;
124
- if (dlgCmd !== opts.command && dlgCmd.startsWith(opts.command)) {
125
+ const dlgCmd = first.payload?.cmd;
126
+ if (dlgCmd && dlgCmd !== opts.command && dlgCmd.startsWith(opts.command)) {
125
127
  payload.cmd = dlgCmd;
126
128
  }
127
- payload.prf = decoded.map((p, i) => {
128
- // Assertion proofs carry their CID (over the envelope the SERVER
129
- // synthesizes); envelope proofs' CID is over their own bytes.
130
- const input = opts.proofs[i];
131
- const cid = input instanceof Uint8Array ? cidForBytes(dagcbor.encode(p.decoded), { codec: 0x71 }) : input.cid;
132
- return CID.parse(cid);
129
+ payload.prf = decoded.map((p) => p.cid);
130
+ const inv = await envelope(signer, INV_TAG, payload);
131
+ // By reference: no proof envelopes ride along.
132
+ return dagcbor.encode([inv]);
133
+ }
134
+ /**
135
+ * Mint an invocation **with its proofs inline** — bootstrap only: the
136
+ * delegation store's publish call is authorized by a chain that is not stored
137
+ * yet. Everything else should use [`invocation`](#invocation).
138
+ */
139
+ export async function inlineInvocation(signer, opts) {
140
+ const decoded = opts.proofs.map((input) => {
141
+ const bytes = proofInputBytes(input);
142
+ if (!bytes)
143
+ throw new Error("inline proofs need their bytes");
144
+ const d = dagcbor.decode(bytes);
145
+ if (Array.isArray(d) &&
146
+ d.length === 2 &&
147
+ d[1] instanceof Object &&
148
+ Object.keys(d[1]).some((k) => k.startsWith("ucan/dlg@"))) {
149
+ return d;
150
+ }
151
+ if (d instanceof Object && "ad" in d && "cd" in d && "sg" in d) {
152
+ return d;
153
+ }
154
+ throw new Error("unrecognized proof format");
133
155
  });
134
- const invocation = await envelope(signer, INV_TAG, payload);
135
- return dagcbor.encode([invocation, ...decoded.map((p) => p.decoded)]);
156
+ // The chain subject comes from the proof (`sub`), not from the signer.
157
+ const firstPayload = (() => {
158
+ const d = decoded[0];
159
+ if (Array.isArray(d) && d[1] instanceof Object) {
160
+ const tag = Object.keys(d[1]).find((key) => key.startsWith("ucan/dlg@"));
161
+ if (tag)
162
+ return d[1][tag];
163
+ }
164
+ if (d instanceof Object && "ad" in d && "cd" in d) {
165
+ const cd = JSON.parse(d.cd);
166
+ return dagcbor.decode(base64UrlDecode(cd.challenge));
167
+ }
168
+ return undefined;
169
+ })();
170
+ const subject = firstPayload?.sub;
171
+ const payload = invocationPayload(signer, opts);
172
+ if (subject) {
173
+ payload.aud = subject;
174
+ payload.sub = subject;
175
+ }
176
+ // Assertion proofs carry their synthesized-envelope CID from the caller —
177
+ // hashing the `{ad, cd, sg}` bytes would name the wrong envelope.
178
+ payload.prf = opts.proofs.map((input) => proofInputCid(input));
179
+ const inv = await envelope(signer, INV_TAG, payload);
180
+ return dagcbor.encode([inv, ...decoded]);
181
+ }
182
+ function proofInputCid(input) {
183
+ if (typeof input === "string")
184
+ return CID.parse(input);
185
+ const direct = CID.asCID(input);
186
+ if (direct)
187
+ return direct;
188
+ if (input instanceof Object && "cid" in input)
189
+ return CID.parse(input.cid);
190
+ throw new Error("proof input lacks a CID");
191
+ }
192
+ function proofInputBytes(input) {
193
+ if (input instanceof Uint8Array)
194
+ return input;
195
+ if (typeof input === "string" || !(input instanceof Object))
196
+ return undefined;
197
+ const holder = input;
198
+ return holder.bytes ?? holder.proof;
136
199
  }
137
200
  function base64UrlDecode(text) {
138
201
  const padded = text.replace(/-/g, "+").replace(/_/g, "/");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokono.ma/firkin",
3
- "version": "0.1.1",
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": {