@tokono.ma/firkin 0.1.1 → 0.2.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
@@ -52,6 +52,7 @@ 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
 
@@ -93,15 +94,17 @@ with that key:
93
94
  ```ts
94
95
  import { delegationPayloadBytes, invocation, delegate } from "@tokono.ma/firkin";
95
96
 
96
- const { proof, cid } = await delegate(account, {
97
+ const delegation = await delegate(account, {
97
98
  subject: account.did(), // rights holder (the account)
98
99
  audience: session.did(), // recipient
99
100
  command: "/firkin/blob/upload", // narrow prefixes only ever narrow
100
101
  expireIn: 8 * 3600, // omit for an indefinite delegation
101
102
  });
103
+ await accountClient.publishDelegation(delegation); // once — idempotent
102
104
 
105
+ // Tokens reference the proof by CID: no proof bytes on the wire.
103
106
  const rb = new Firkin(base, async (command) =>
104
- invocation(session, { command, proofs: [proof], expireIn: 600 }),
107
+ invocation(session, { command, proofs: [delegation.cid], subject: account.did(), expireIn: 600 }),
105
108
  );
106
109
 
107
110
  // Revocation must be authorized by the account key — not the session:
package/dist/client.d.ts CHANGED
@@ -1,9 +1,15 @@
1
1
  import { CID } from "multiformats";
2
2
  import type { Signer } from "./keys.ts";
3
+ /** Per-call options a TokenSource may honor. */
4
+ export interface TokenOptions {
5
+ /** Override the token's expiry (seconds from now). */
6
+ expireIn?: number;
7
+ }
3
8
  /** Supplies a fresh bearer token for a command. Mutating commands are
4
9
  * 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>;
10
+ * (the default signer source does). `options.expireIn` is a hint used by
11
+ * `readUrl` to mint short-lived links; sources may ignore it. */
12
+ export type TokenSource = (command: string, options?: TokenOptions) => Promise<Uint8Array>;
7
13
  /** Signer-backed source: mints a fresh direct invocation per request. */
8
14
  export declare function signerToken(signer: Signer, expireIn?: number): TokenSource;
9
15
  /** Fixed-token source: only appropriate for public/read-only usage. */
@@ -62,6 +68,28 @@ export declare class Firkin {
62
68
  /** Fetch a byte range (206 responses; server-trusted verification).
63
69
  * Authenticated like [`get`](#get). */
64
70
  getRange(cid: string, range?: ByteRange): Promise<RangeResult>;
71
+ /** Publish a delegation to the server's delegation store so invocations
72
+ * can reference it by CID — the default token shape. Idempotent: the store
73
+ * dedups by CID, so publishing at session start is always safe. `token`
74
+ * bootstraps with an inline-proof token (`inlineInvocation`) when the
75
+ * caller's own chain is not stored yet. */
76
+ publishDelegation(minted: {
77
+ proof: Uint8Array;
78
+ cid: string;
79
+ }, opts?: {
80
+ token?: Uint8Array;
81
+ }): Promise<{
82
+ cid: string;
83
+ }>;
84
+ /** A short-lived, CID-scoped read URL — the presigned-URL equivalent for
85
+ * media elements (`<audio src>`, `<video src>`), which cannot set an
86
+ * Authorization header. The URL carries a `/firkin/blob/read/<cid>`
87
+ * invocation; the server accepts `?token=` on GET/HEAD only and, with
88
+ * `PUBLIC_READS=false`, enforces the CID narrowing — a leaked URL exposes
89
+ * just that blob until the token expires. Keep `expireIn` short (60s). */
90
+ readUrl(cid: string, opts?: {
91
+ expireIn?: number;
92
+ }): Promise<string>;
65
93
  /** Attach an already-stored CID to the signer's account. */
66
94
  claim(cid: string): Promise<void>;
67
95
  /** Detach the signer's claim; bytes die with the last claimant. */
package/dist/client.js CHANGED
@@ -7,7 +7,7 @@ import { BloomFilter } from "./bloom.js";
7
7
  import { CMD, mintInvocation } from "./ucan.js";
8
8
  /** Signer-backed source: mints a fresh direct invocation per request. */
9
9
  export function signerToken(signer, expireIn = 600) {
10
- return async (command) => mintInvocation(signer, { command, expireIn });
10
+ return async (command, options) => mintInvocation(signer, { command, expireIn: options?.expireIn ?? expireIn });
11
11
  }
12
12
  /** Fixed-token source: only appropriate for public/read-only usage. */
13
13
  export function staticToken(token) {
@@ -102,6 +102,35 @@ export class Firkin {
102
102
  };
103
103
  }
104
104
  // --- claims & deletion ------------------------------------------------------
105
+ /** Publish a delegation to the server's delegation store so invocations
106
+ * can reference it by CID — the default token shape. Idempotent: the store
107
+ * dedups by CID, so publishing at session start is always safe. `token`
108
+ * bootstraps with an inline-proof token (`inlineInvocation`) when the
109
+ * caller's own chain is not stored yet. */
110
+ async publishDelegation(minted, opts = {}) {
111
+ const token = opts.token ?? (await this.token(CMD.blobUpload));
112
+ const res = await this.fetchImpl(`${this.base}/delegation/${minted.cid}`, {
113
+ method: "PUT",
114
+ headers: { Authorization: `Bearer ${toBase64Url(token)}` },
115
+ body: new Blob([minted.proof]),
116
+ });
117
+ if (!res.ok)
118
+ throw await ApiError.of(res);
119
+ const json = (await res.json());
120
+ return json;
121
+ }
122
+ /** A short-lived, CID-scoped read URL — the presigned-URL equivalent for
123
+ * media elements (`<audio src>`, `<video src>`), which cannot set an
124
+ * Authorization header. The URL carries a `/firkin/blob/read/<cid>`
125
+ * invocation; the server accepts `?token=` on GET/HEAD only and, with
126
+ * `PUBLIC_READS=false`, enforces the CID narrowing — a leaked URL exposes
127
+ * just that blob until the token expires. Keep `expireIn` short (60s). */
128
+ async readUrl(cid, opts = {}) {
129
+ const token = await this.token(`${CMD.blobRead}/${cid}`, {
130
+ expireIn: opts.expireIn ?? 60,
131
+ });
132
+ return `${this.base}/blob/${cid}?token=${toBase64Url(token)}`;
133
+ }
105
134
  /** Attach an already-stored CID to the signer's account. */
106
135
  async claim(cid) {
107
136
  const token = await this.token(CMD.blobClaim);
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";
@@ -41,23 +42,34 @@ export interface DelegationOptions extends MintOptions {
41
42
  /** Mint a direct invocation token (issuer == subject == the signer's DID). */
42
43
  export declare function mintInvocation(signer: UcanSigner, opts: MintOptions): Promise<Uint8Array>;
43
44
  /**
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.
45
+ * Mint a delegated invocation token whose proofs travel **by reference**:
46
+ * the container carries only the invocation, and its `prf` names the CIDs of
47
+ * delegations previously published to the server's delegation store
48
+ * (`Firkin.publishDelegation`). This keeps tokens small regardless of chain
49
+ * depth or assertion size — a passkey session's read URL is ~700 B instead of
50
+ * ~1.7 kB.
48
51
  *
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.
52
+ * A proof input may carry its own bytes (`delegate`/`assertionToProof` return
53
+ * `{proof, cid}`), which are used locally only — to discover the chain
54
+ * subject and to narrow the command — and are never put on the wire. With a
55
+ * bare CID, pass `subject` (the account DID) yourself.
53
56
  */
54
- type ProofInput = Uint8Array | {
57
+ export type ProofInput = string | CID | {
55
58
  bytes?: Uint8Array;
56
59
  proof?: Uint8Array;
57
60
  cid: string;
58
61
  };
59
62
  export declare function invocation(signer: UcanSigner, opts: MintOptions & {
60
63
  proofs: ProofInput[];
64
+ subject?: string;
65
+ }): Promise<Uint8Array>;
66
+ /**
67
+ * Mint an invocation **with its proofs inline** — bootstrap only: the
68
+ * delegation store's publish call is authorized by a chain that is not stored
69
+ * yet. Everything else should use [`invocation`](#invocation).
70
+ */
71
+ export declare function inlineInvocation(signer: UcanSigner, opts: MintOptions & {
72
+ proofs: ProofInput[];
61
73
  }): Promise<Uint8Array>;
62
74
  export interface MintedDelegation {
63
75
  /** The proof container (DAG-CBOR bytes) the delegate attaches to invocations. */
@@ -90,4 +102,3 @@ export declare function delegate(signer: UcanSigner, opts: DelegationOptions): P
90
102
  export declare function parseContainer(container: Uint8Array): unknown[];
91
103
  /** Decode a container's invocation payload (diagnostics / verification). */
92
104
  export declare function decodeInvocation(container: Uint8Array): Record<string, unknown>;
93
- export {};
package/dist/ucan.js CHANGED
@@ -85,33 +85,37 @@ export async function mintInvocation(signer, opts) {
85
85
  return dagcbor.encode([invocation]);
86
86
  }
87
87
  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);
88
+ if (opts.proofs.length === 0) {
89
+ throw new Error("delegated invocation needs at least one proof");
90
+ }
91
+ const decoded = opts.proofs.map((input) => {
92
+ const cid = proofInputCid(input);
93
+ const bytes = proofInputBytes(input);
93
94
  if (!bytes)
94
- throw new Error("proof input lacks bytes");
95
+ return { cid, payload: undefined };
95
96
  const d = dagcbor.decode(bytes);
96
97
  if (Array.isArray(d) &&
97
98
  d.length === 2 &&
98
99
  d[1] instanceof Object &&
99
100
  Object.keys(d[1]).some((k) => k.startsWith("ucan/dlg@"))) {
100
101
  const payload = d[1][Object.keys(d[1]).find((k) => k.startsWith("ucan/dlg@"))];
101
- return { bytes, decoded: d, payload };
102
+ return { cid, payload };
102
103
  }
103
104
  if (d instanceof Object && "ad" in d && "cd" in d && "sg" in d) {
104
105
  // Assertion proof: the dlg payload rides in the challenge.
105
106
  const cd = JSON.parse(d.cd);
106
107
  const payload = dagcbor.decode(base64UrlDecode(cd.challenge));
107
- return { bytes, decoded: d, payload };
108
+ return { cid, payload };
108
109
  }
109
110
  throw new Error("unrecognized proof format");
110
111
  });
111
112
  const first = decoded[0];
112
113
  if (!first)
113
114
  throw new Error("delegated invocation needs at least one proof");
114
- const subject = first.payload.sub;
115
+ const subject = first.payload?.sub ?? opts.subject;
116
+ if (!subject) {
117
+ throw new Error("unknown chain subject: pass `subject` (the account DID) when proofs are referenced by CID only");
118
+ }
115
119
  const payload = invocationPayload(signer, opts);
116
120
  payload.aud = subject;
117
121
  payload.sub = subject;
@@ -120,19 +124,80 @@ export async function invocation(signer, opts) {
120
124
  // so `/firkin/blob/delete/<cid>` proofs can't be invoked with the bare
121
125
  // `/firkin/blob/delete`. Narrow down to the proof's own command when it
122
126
  // extends (never broadens) the requested one.
123
- const dlgCmd = first.payload.cmd;
124
- if (dlgCmd !== opts.command && dlgCmd.startsWith(opts.command)) {
127
+ const dlgCmd = first.payload?.cmd;
128
+ if (dlgCmd && dlgCmd !== opts.command && dlgCmd.startsWith(opts.command)) {
125
129
  payload.cmd = dlgCmd;
126
130
  }
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);
131
+ payload.prf = decoded.map((p) => p.cid);
132
+ const inv = await envelope(signer, INV_TAG, payload);
133
+ // By reference: no proof envelopes ride along.
134
+ return dagcbor.encode([inv]);
135
+ }
136
+ /**
137
+ * Mint an invocation **with its proofs inline** — bootstrap only: the
138
+ * delegation store's publish call is authorized by a chain that is not stored
139
+ * yet. Everything else should use [`invocation`](#invocation).
140
+ */
141
+ export async function inlineInvocation(signer, opts) {
142
+ const decoded = opts.proofs.map((input) => {
143
+ const bytes = proofInputBytes(input);
144
+ if (!bytes)
145
+ throw new Error("inline proofs need their bytes");
146
+ const d = dagcbor.decode(bytes);
147
+ if (Array.isArray(d) &&
148
+ d.length === 2 &&
149
+ d[1] instanceof Object &&
150
+ Object.keys(d[1]).some((k) => k.startsWith("ucan/dlg@"))) {
151
+ return d;
152
+ }
153
+ if (d instanceof Object && "ad" in d && "cd" in d && "sg" in d) {
154
+ return d;
155
+ }
156
+ throw new Error("unrecognized proof format");
133
157
  });
134
- const invocation = await envelope(signer, INV_TAG, payload);
135
- return dagcbor.encode([invocation, ...decoded.map((p) => p.decoded)]);
158
+ // The chain subject comes from the proof (`sub`), not from the signer.
159
+ const firstPayload = (() => {
160
+ const d = decoded[0];
161
+ if (Array.isArray(d) && d[1] instanceof Object) {
162
+ const tag = Object.keys(d[1]).find((key) => key.startsWith("ucan/dlg@"));
163
+ if (tag)
164
+ return d[1][tag];
165
+ }
166
+ if (d instanceof Object && "ad" in d && "cd" in d) {
167
+ const cd = JSON.parse(d.cd);
168
+ return dagcbor.decode(base64UrlDecode(cd.challenge));
169
+ }
170
+ return undefined;
171
+ })();
172
+ const subject = firstPayload?.sub;
173
+ const payload = invocationPayload(signer, opts);
174
+ if (subject) {
175
+ payload.aud = subject;
176
+ payload.sub = subject;
177
+ }
178
+ // Assertion proofs carry their synthesized-envelope CID from the caller —
179
+ // hashing the `{ad, cd, sg}` bytes would name the wrong envelope.
180
+ payload.prf = opts.proofs.map((input) => proofInputCid(input));
181
+ const inv = await envelope(signer, INV_TAG, payload);
182
+ return dagcbor.encode([inv, ...decoded]);
183
+ }
184
+ function proofInputCid(input) {
185
+ if (typeof input === "string")
186
+ return CID.parse(input);
187
+ const direct = CID.asCID(input);
188
+ if (direct)
189
+ return direct;
190
+ if (input instanceof Object && "cid" in input)
191
+ return CID.parse(input.cid);
192
+ throw new Error("proof input lacks a CID");
193
+ }
194
+ function proofInputBytes(input) {
195
+ if (input instanceof Uint8Array)
196
+ return input;
197
+ if (typeof input === "string" || !(input instanceof Object))
198
+ return undefined;
199
+ const holder = input;
200
+ return holder.bytes ?? holder.proof;
136
201
  }
137
202
  function base64UrlDecode(text) {
138
203
  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.2.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": {