@tokono.ma/firkin 0.1.0 → 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 +152 -36
- package/dist/client.d.ts +30 -2
- package/dist/client.js +30 -1
- package/dist/ucan.d.ts +21 -10
- package/dist/ucan.js +84 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,45 +1,161 @@
|
|
|
1
1
|
# @tokono.ma/firkin
|
|
2
2
|
|
|
3
|
-
Browser-first TypeScript SDK for the Firkin
|
|
3
|
+
Browser-first TypeScript SDK for the [Firkin](https://tangled.org/tokono.ma/firkin)
|
|
4
|
+
API: content-addressed blobs (DASL CIDs), UCAN 1.0 authorization, and
|
|
5
|
+
CAR Mirror batch sync — over plain `fetch`.
|
|
4
6
|
|
|
5
7
|
```sh
|
|
6
8
|
npm install @tokono.ma/firkin
|
|
7
9
|
```
|
|
8
10
|
|
|
9
|
-
Pure ESM,
|
|
10
|
-
Node 22+ directly. Unreleased commits:
|
|
11
|
+
Pure ESM, zero Node-only APIs: it runs in browsers through any bundler and
|
|
12
|
+
in Node 22+ directly. Unreleased commits:
|
|
11
13
|
`npm install git+https://tangled.org/tokono.ma/firkin`.
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
15
|
+
## Quickstart
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { Signer, Firkin, signerToken } from "@tokono.ma/firkin";
|
|
19
|
+
|
|
20
|
+
const signer = await Signer.generate(); // or Signer.fromSeedHex(hex)
|
|
21
|
+
console.log(signer.did()); // did:key:z… — an admin must register it
|
|
22
|
+
|
|
23
|
+
const rb = new Firkin("https://firkin.example.com", signerToken(signer));
|
|
24
|
+
|
|
25
|
+
const { cid, deduplicated } = await rb.upload(new Blob(["hello"]));
|
|
26
|
+
const bytes = await rb.get(cid); // same bytes = same CID, stored once
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Every mutating call carries a single-use UCAN invocation minted by the
|
|
30
|
+
`TokenSource`. `signerToken(signer)` is the simple case (the account key
|
|
31
|
+
signs directly); delegates mint with [`invocation()`](#delegation--sessions).
|
|
32
|
+
|
|
33
|
+
## Mental model
|
|
34
|
+
|
|
35
|
+
- **Content addressing.** The CID is computed from the bytes *before*
|
|
36
|
+
upload (`cidForBytes`), and the server verifies the digest — never
|
|
37
|
+
trusts it. Identical bytes anywhere dedupe to one stored object, shared
|
|
38
|
+
by every claimant.
|
|
39
|
+
- **Identity is a key pair.** Your seed is your account; its `did:key` is
|
|
40
|
+
registered by an admin. Passkey-native accounts use a P-256 credential
|
|
41
|
+
instead (see [Passkeys](#passkeys)).
|
|
42
|
+
- **Authorization is per request.** Mutating commands are single-use
|
|
43
|
+
(replay-protected); read tokens are reusable. That is why the
|
|
44
|
+
`TokenSource` is a function, not a token.
|
|
45
|
+
|
|
46
|
+
## API
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
// blobs — reads are authenticated automatically (for PUBLIC_READS=false)
|
|
50
|
+
rb.upload(data, { codec?, hash?: "sha256" | "blake3" }) → { cid, deduplicated }
|
|
51
|
+
rb.get(cid) → Uint8Array
|
|
52
|
+
rb.getRange(cid, { start?, end?, suffix? }) → { data, start, end, total }
|
|
53
|
+
|
|
54
|
+
// claims (multi-account sharing of one stored object)
|
|
55
|
+
rb.publishDelegation(d) // store a delegation so tokens can reference it by CID
|
|
56
|
+
rb.claim(cid) // attach an already-stored CID to your account
|
|
57
|
+
rb.delete(cid) // detach; bytes die with the last claimant
|
|
58
|
+
|
|
59
|
+
// name registry — private name → CID pointers (names imply no claim)
|
|
60
|
+
rb.registrySet(name, cid)
|
|
61
|
+
rb.registryGet(name) // null when unset
|
|
62
|
+
rb.registryList() // Record<name, cid>
|
|
63
|
+
rb.registryDelete(name)
|
|
64
|
+
|
|
65
|
+
// CAR Mirror (batch sync)
|
|
66
|
+
rb.pushFile(chunks) // ordered chunks → dag-cbor list root
|
|
67
|
+
rb.pullFile(root) // inverse of pushFile
|
|
68
|
+
rb.dagPush(root, blocks) // an existing DAG, as-is: [cid, bytes][] | Map
|
|
69
|
+
rb.dagPull(root) // Map<cid, bytes> — only the root is needed
|
|
70
|
+
|
|
71
|
+
// account
|
|
72
|
+
rb.revoke({ cids? , all? }) // kill delegations (account key only)
|
|
73
|
+
rb.register(did, credential?) // admin: register a DID (+ passkey binding)
|
|
74
|
+
rb.listAccounts() // admin
|
|
75
|
+
Firkin.whoami(base, assertion) // passkey re-attachment → account DID
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`dagPull` follows dag-cbor **and dag-pb** links server-side, so a WNFS-shaped
|
|
79
|
+
graph (dag-pb root tree, dag-cbor forest) comes back whole from its root.
|
|
80
|
+
`dagPush` keeps every block under its real CID — nothing is re-hashed —
|
|
81
|
+
which is what makes it composable with other IPLD tooling and with
|
|
82
|
+
registry entries that point at the real root. `pushFile` is sugar over
|
|
83
|
+
`dagPush` for plain chunk lists.
|
|
84
|
+
|
|
85
|
+
Errors throw an `Error` with `status` (the HTTP code) and the server's
|
|
86
|
+
message — branch on `status` (401 unauthorized, 403 forbidden/revoked,
|
|
87
|
+
404 not found, 413 too large, 422 digest mismatch).
|
|
88
|
+
|
|
89
|
+
## Delegation & sessions
|
|
90
|
+
|
|
91
|
+
Delegate a narrow capability to a short-lived key, then mint invocations
|
|
92
|
+
with that key:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { delegationPayloadBytes, invocation, delegate } from "@tokono.ma/firkin";
|
|
96
|
+
|
|
97
|
+
const delegation = await delegate(account, {
|
|
98
|
+
subject: account.did(), // rights holder (the account)
|
|
99
|
+
audience: session.did(), // recipient
|
|
100
|
+
command: "/firkin/blob/upload", // narrow prefixes only ever narrow
|
|
101
|
+
expireIn: 8 * 3600, // omit for an indefinite delegation
|
|
102
|
+
});
|
|
103
|
+
await accountClient.publishDelegation(delegation); // once — idempotent
|
|
104
|
+
|
|
105
|
+
// Tokens reference the proof by CID: no proof bytes on the wire.
|
|
106
|
+
const rb = new Firkin(base, async (command) =>
|
|
107
|
+
invocation(session, { command, proofs: [delegation.cid], subject: account.did(), expireIn: 600 }),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Revocation must be authorized by the account key — not the session:
|
|
111
|
+
const accountClient = new Firkin(base, signerToken(account));
|
|
112
|
+
await accountClient.revoke({ cids: [cid] }); // or { all: true }
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The server enforces that the chain cannot widen rights and that revocations
|
|
116
|
+
are signed by the account key (`revoke` from a plain delegated session is
|
|
117
|
+
refused — a stolen session must not be able to revoke).
|
|
118
|
+
|
|
119
|
+
## Passkeys
|
|
120
|
+
|
|
121
|
+
`WebCryptoSigner` / `WebCryptoP256Signer` hold **non-extractable** keys
|
|
122
|
+
(structured-cloneable into IndexedDB: scripts can sign, never read).
|
|
123
|
+
Passkey-native accounts — the credential key *is* the account — sign
|
|
124
|
+
delegations with a WebAuthn assertion:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
import { assertionToProof, delegationPayloadBytes } from "@tokono.ma/firkin";
|
|
128
|
+
|
|
129
|
+
const payload = delegationPayloadBytes({
|
|
130
|
+
issuerDid: passkeyDid, subject: passkeyDid, audience: session.did(),
|
|
131
|
+
command: "/firkin/blob", expireIn: 8 * 3600,
|
|
132
|
+
});
|
|
133
|
+
const assertion = await navigator.credentials.get({
|
|
134
|
+
publicKey: { challenge: base64url(payload), rpId, userVerification: "required" },
|
|
135
|
+
});
|
|
136
|
+
const proof = assertionToProof(payload, {
|
|
137
|
+
authData: /* bytes */, clientDataJSON: /* exact JSON text */, signature: /* bytes */,
|
|
138
|
+
});
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`Firkin.whoami` resolves a discoverable-credential assertion back to its
|
|
142
|
+
account DID on a fresh browser. Working example:
|
|
143
|
+
[`examples/passkey/`](https://tangled.org/tokono.ma/firkin/tree/main/examples/passkey).
|
|
144
|
+
|
|
145
|
+
## Design notes
|
|
146
|
+
|
|
147
|
+
**Minting is a shim, on purpose.** `ucan.ts` only assembles the wire format;
|
|
148
|
+
the crypto and encoding are library code (`@noble/ed25519`,
|
|
149
|
+
`@ipld/dag-cbor`, `multiformats`, `hash-wasm`). Correctness is pinned by
|
|
150
|
+
golden tests: tokens minted here are accepted by the Rust server and decode
|
|
151
|
+
byte-identically to the Rust reference client's fixtures.
|
|
152
|
+
|
|
153
|
+
**[iso-ucan](https://github.com/hugomrdias/iso-repo/tree/main/packages/iso-ucan)**
|
|
154
|
+
is the fuller TS UCAN library, but it mints final UCAN 1.0.0 (the server is
|
|
155
|
+
pinned to rc.1 today). When the server moves to 1.0.0 final, the three
|
|
156
|
+
mint functions can be swapped for iso-ucan behind the existing
|
|
157
|
+
`TokenSource` interface — no other layer changes.
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
Apache-2.0 OR MIT.
|
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
|
-
|
|
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
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
|
50
|
-
* cid}`
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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 =
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const decoded = opts.proofs.map((
|
|
92
|
-
const
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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.
|
|
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
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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