From d590015f7af0ebfdad1b3843d53b6ab38c8dac3a Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Tue, 28 Mar 2023 00:58:29 -0700 Subject: [PATCH] feat!: implement receipts --- packages/client/src/connection.js | 4 +- packages/client/test/api.ts | 6 +- packages/client/test/service.js | 5 +- packages/core/package.json | 12 + .../src/cbor/codec.js => core/src/cbor.js} | 4 +- packages/core/src/dag.js | 102 ++++++ packages/core/src/delegation.js | 11 + packages/core/src/invocation.js | 40 ++- packages/core/src/lib.js | 5 + packages/core/src/receipt.js | 157 +++++++++ packages/core/src/receipt/outcome.js | 129 ++++++++ packages/core/test/cbor.spec.js | 75 +++++ packages/core/test/delegation.spec.js | 19 ++ packages/core/test/invocation.spec.js | 9 +- packages/core/test/receipt.spec.js | 304 ++++++++++++++++++ packages/interface/src/lib.ts | 196 ++++++++++- packages/interface/src/query.ts | 223 ------------- packages/interface/src/transport.ts | 12 +- packages/server/src/server.js | 72 +++-- packages/server/test/server.spec.js | 82 +++-- packages/transport/src/car.js | 60 +--- packages/transport/src/car/codec.js | 8 +- packages/transport/src/car/request.js | 69 ++++ packages/transport/src/car/response.js | 67 ++++ packages/transport/src/cbor.js | 6 +- packages/transport/src/http.js | 4 +- packages/transport/test/car.spec.js | 102 +++++- 27 files changed, 1410 insertions(+), 373 deletions(-) rename packages/{transport/src/cbor/codec.js => core/src/cbor.js} (95%) create mode 100644 packages/core/src/dag.js create mode 100644 packages/core/src/receipt.js create mode 100644 packages/core/src/receipt/outcome.js create mode 100644 packages/core/test/cbor.spec.js create mode 100644 packages/core/test/receipt.spec.js delete mode 100644 packages/interface/src/query.ts create mode 100644 packages/transport/src/car/request.js create mode 100644 packages/transport/src/car/response.js diff --git a/packages/client/src/connection.js b/packages/client/src/connection.js index 1ecaf30a..1d4695b2 100644 --- a/packages/client/src/connection.js +++ b/packages/client/src/connection.js @@ -8,7 +8,7 @@ import { sha256 } from 'multiformats/hashes/sha2' * @param {API.ConnectionOptions} options * @returns {API.ConnectionView} */ -export const connect = (options) => new Connection(options) +export const connect = options => new Connection(options) /** * @template {Record} T @@ -42,7 +42,7 @@ class Connection { * @template {API.Tuple>} I * @param {API.Connection} connection * @param {I} invocations - * @returns {Promise>} + * @returns {Promise>} */ export const execute = async (invocations, connection) => { const request = await connection.encoder.encode(invocations, connection) diff --git a/packages/client/test/api.ts b/packages/client/test/api.ts index 12ec0ca5..4658fca7 100644 --- a/packages/client/test/api.ts +++ b/packages/client/test/api.ts @@ -133,16 +133,16 @@ export interface AccessProvider { * Associates a DID with another DID in the system. If there is no account * associated with a `to` DID will produce an error. */ - link(member: DID, group: DID, proof: Link): Result + link(member: DID, group: DID, proof: Link): Result<{}, UnknownDIDError> - unlink(member: DID, group: DID, proof: Link): Result + unlink(member: DID, group: DID, proof: Link): Result<{}, UnknownDIDError> /** * Associates new child DID with an accound of the parent DID. If there is no * account associated with a parent it creates account with `parent` did first * and then associates child DID with it. */ - register(member: DID, group: DID, proof: Link): Result + register(member: DID, group: DID, proof: Link): Result<{}, UnknownDIDError> /** * Resolves account DID associated with a given DID. Returns either account diff --git a/packages/client/test/service.js b/packages/client/test/service.js index cb687a47..a242b54b 100644 --- a/packages/client/test/service.js +++ b/packages/client/test/service.js @@ -114,7 +114,7 @@ class AccessService { * with: API.DID * }} Identify * @param {API.Invocation} ucan - * @returns {Promise>} + * @returns {Promise>} */ async identify(ucan) { const [capability] = ucan.capabilities @@ -133,9 +133,6 @@ class AccessService { /** @type {any} */ (ucan).link ) } - // } else { - // return access - // } } } diff --git a/packages/core/package.json b/packages/core/package.json index f5054fb6..fa934842 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -75,6 +75,18 @@ "./delegation": { "types": "./dist/src/delegation.d.ts", "import": "./src/delegation.js" + }, + "./receipt": { + "types": "./dist/src/receipt.d.ts", + "import": "./src/receipt.js" + }, + "./cbor": { + "types": "./dist/src/cbor.d.ts", + "import": "./src/cbor.js" + }, + "./dag": { + "types": "./dist/src/dag.d.ts", + "import": "./src/dag.js" } }, "c8": { diff --git a/packages/transport/src/cbor/codec.js b/packages/core/src/cbor.js similarity index 95% rename from packages/transport/src/cbor/codec.js rename to packages/core/src/cbor.js index 0f32838d..cf1c4754 100644 --- a/packages/transport/src/cbor/codec.js +++ b/packages/core/src/cbor.js @@ -1,8 +1,8 @@ import * as API from '@ucanto/interface' import * as CBOR from '@ipld/dag-cbor' -export { code, decode } from '@ipld/dag-cbor' +export { code, name, decode } from '@ipld/dag-cbor' import { sha256 } from 'multiformats/hashes/sha2' -import { createLink, isLink } from '@ucanto/core' +import { create as createLink, isLink } from 'multiformats/link' /** * @param {unknown} data diff --git a/packages/core/src/dag.js b/packages/core/src/dag.js new file mode 100644 index 00000000..c411acc8 --- /dev/null +++ b/packages/core/src/dag.js @@ -0,0 +1,102 @@ +import * as API from '@ucanto/interface' +import { create as createLink } from './link.js' +import { sha256 } from 'multiformats/hashes/sha2' +import * as MF from 'multiformats/interface' +import * as CBOR from './cbor.js' + +/** + * @param {unknown} value + * @returns {IterableIterator} + */ +export const iterate = function* (value) { + if ( + value && + typeof value === 'object' && + 'iterateIPLDBlocks' in value && + typeof value.iterateIPLDBlocks === 'function' + ) { + yield* value.iterateIPLDBlocks() + } +} + +/** + * @template T + * @typedef {Map, API.Block>} BlockStore + */ + +/** + * @template [T=unknown] + * @returns {BlockStore} + */ +export const createStore = () => new Map() + +/** + * @template T + * @template {T} U + * @param {U} source + * @param {BlockStore} store + * @param {object} options + * @param {MF.BlockEncoder} [options.codec] + * @param {MF.MultihashHasher} [options.hasher] + * @returns {Promise & { data: U }>} + */ +export const encodeInto = async ( + source, + store, + { codec = CBOR, hasher = sha256 } = {} +) => { + const bytes = codec.encode(source) + const digest = await hasher.digest(bytes) + /** @type {API.Link} */ + const link = createLink(codec.code, digest) + store.set(/** @type {API.ToString} */ (link.toString()), { + bytes, + cid: link, + }) + + return { bytes, cid: link, data: source } +} + +/** + * @template T + * @template {T} U + * @param {API.Block} block + * @param {BlockStore} store + * @returns {API.Block} + */ +export const addInto = ({ cid, bytes }, store) => { + store.set(/** @type {API.ToString} */ (cid.toString()), { + bytes, + cid, + }) + + return { bytes, cid } +} + +/** + * @template T + * @template {T} U + * @param {Iterable>} source + * @param {BlockStore} store + */ +export const addEveryInto = (source, store) => { + for (const block of source) { + addInto(block, store) + } +} + +/** + * @template T + * @param {API.Link} link + * @param {BlockStore} store + * @returns {API.Block & { data: T }} + */ +export const decodeFrom = (link, store) => { + const block = store.get(`${link}`) + /* c8 ignore next 3 */ + if (!block) { + throw new Error(`Block for the ${link} is not found`) + } + const data = /** @type {T} */ (CBOR.decode(block.bytes)) + return { cid: link, bytes: block.bytes, data } +} diff --git a/packages/core/src/delegation.js b/packages/core/src/delegation.js index 711793ab..07157092 100644 --- a/packages/core/src/delegation.js +++ b/packages/core/src/delegation.js @@ -177,6 +177,9 @@ export class Delegation { get cid() { return this.root.cid } + link() { + return this.root.cid + } get asCID() { return this.cid } @@ -192,6 +195,10 @@ export class Delegation { return exportDAG(this.root, this.blocks) } + iterateIPLDBlocks() { + return exportDAG(this.root, this.blocks) + } + /** * @type {API.Proof[]} */ @@ -262,6 +269,10 @@ export class Delegation { return this } + buildIPLDView() { + return this + } + /** * @returns {API.DelegationJSON} */ diff --git a/packages/core/src/invocation.js b/packages/core/src/invocation.js index 07ec3994..986ec8d1 100644 --- a/packages/core/src/invocation.js +++ b/packages/core/src/invocation.js @@ -1,5 +1,6 @@ import * as API from '@ucanto/interface' -import { delegate } from './delegation.js' +import { delegate, Delegation } from './delegation.js' +import * as DAG from './dag.js' /** * @template {API.Capability} Capability @@ -8,6 +9,30 @@ import { delegate } from './delegation.js' */ export const invoke = options => new IssuedInvocation(options) +/** + * @template {API.Capability} C + * @param {object} dag + * @param {API.UCANLink<[C]>} dag.root + * @param {Map} dag.blocks + * @returns {API.Invocation} + */ +export const view = ({ root, blocks }) => { + const { bytes, cid } = DAG.decodeFrom(root, blocks) + return new Invocation({ bytes, cid }, blocks) +} + +/** + * @template {API.Invocation} Invocation + * @param {object} dag + * @param {ReturnType} dag.root + * @param {Map} dag.blocks + * @returns {Invocation|ReturnType} + */ +export const embed = ({ root, blocks }) => + blocks.has(root.toString()) + ? /** @type {Invocation} */ (view({ root, blocks })) + : root + /** * @template {API.Capability} Capability * @implements {API.IssuedInvocationView} @@ -52,10 +77,14 @@ class IssuedInvocation { return delegate(this) } + buildIPLDView() { + return delegate(this) + } + /** * @template {API.InvocationService} Service * @param {API.ConnectionView} connection - * @returns {Promise>} + * @returns {Promise>} */ async execute(connection) { /** @type {API.ServiceInvocation} */ @@ -67,3 +96,10 @@ class IssuedInvocation { return result } } + +/** + * @template {API.Capability} Capability + * @implements {API.Invocation} + * @extends {Delegation<[Capability]>} + */ +export class Invocation extends Delegation {} diff --git a/packages/core/src/lib.js b/packages/core/src/lib.js index 96492458..5fe2492f 100644 --- a/packages/core/src/lib.js +++ b/packages/core/src/lib.js @@ -1,4 +1,9 @@ +export * as API from '@ucanto/interface' export * as Delegation from './delegation.js' +export * as Invocation from './invocation.js' +export * as Receipt from './receipt.js' +import * as DAG from './dag.js' +import * as CBOR from './cbor.js' export { delegate, isDelegation } from './delegation.js' export { invoke } from './invocation.js' export { diff --git a/packages/core/src/receipt.js b/packages/core/src/receipt.js new file mode 100644 index 00000000..1a48f53c --- /dev/null +++ b/packages/core/src/receipt.js @@ -0,0 +1,157 @@ +import * as API from '@ucanto/interface' +import * as Outcome from './receipt/outcome.js' +import * as DID from '@ipld/dag-ucan/did' +import * as Signature from '@ipld/dag-ucan/signature' +import * as DAG from './dag.js' + +export { Outcome } + +/** + * @param {object} input + * @param {API.Link} input.root + * @param {Map} input.blocks + */ +export const view = ({ root, blocks }) => { + const block = DAG.decodeFrom(root, blocks) + const outcome = Outcome.view({ root: block.data.ocm, blocks }) + + return new Receipt({ root: block, store: blocks, outcome }) +} + +/** + * @template {{}} Ok + * @template {{}} Error + * @template {API.Invocation} Ran + * @template {API.SigAlg} [SigAlg=API.SigAlg] + * @implements {API.Receipt} + */ +class Receipt { + /** + * @param {object} input + * @param {Required>>} input.root + * @param {API.Outcome} input.outcome + * @param {Map} input.store + * @param {API.Signature>, SigAlg>} [input.signature] + */ + constructor({ root, store, outcome, signature }) { + this.store = store + + this.root = root + this.outcome = outcome + this._signature = signature + } + + get issuer() { + return this.outcome.issuer + } + + get ran() { + return this.outcome.ran + } + get proofs() { + return this.outcome.proofs + } + + buildIPLDView() { + return this + } + /** + * @returns {IterableIterator} + */ + *iterateIPLDBlocks() { + yield* DAG.iterate(this.outcome) + + yield this.root + } + + get out() { + return this.outcome.out + } + + get fx() { + return this.outcome.fx + } + + get meta() { + return this.outcome.meta + } + + get signature() { + const signature = this._signature + if (signature) { + return signature + } else { + const signature = + /** @type {API.Signature>, SigAlg>} */ ( + Signature.view(this.root.data.sig) + ) + this._signature = signature + return signature + } + } +} + +const NOFX = Object.freeze({ fork: Object.freeze([]) }) + +/** + * @template {{}} Ok + * @template {{}} Error + * @template {API.Invocation} Ran + * @template {API.SigAlg} SigAlg + * @param {object} options + * @param {API.Signer} options.issuer + * @param {Ran|ReturnType} options.ran + * @param {API.ReceiptResult} options.result + * @param {API.EffectsModel} [options.fx] + * @param {API.Proof[]} [options.proofs] + * @param {Record} [options.meta] + * @returns {Promise>} + */ +export const issue = async ({ + issuer, + result, + ran, + proofs = [], + meta = {}, + fx = NOFX, +}) => { + const store = DAG.createStore() + + // copy invocation blocks int + DAG.addEveryInto(DAG.iterate(ran), store) + + // copy proof blocks into store + for (const proof of proofs) { + DAG.addEveryInto(DAG.iterate(proof), store) + } + + const { cid } = await DAG.encodeInto( + { + ran: /** @type {ReturnType} */ (ran.link()), + out: result, + fx, + meta, + iss: issuer.did(), + prf: proofs.map(p => p.link()), + }, + store + ) + + const outcome = Outcome.view({ root: cid, blocks: store }) + /** @type {API.Signature>, SigAlg>} */ + const signature = await issuer.sign(outcome.root.cid.bytes) + + /** @type {API.ReceiptModel} */ + const model = { + ocm: outcome.root.cid, + sig: signature, + } + const root = await DAG.encodeInto(model, store) + + return new Receipt({ + root, + outcome, + store, + signature, + }) +} diff --git a/packages/core/src/receipt/outcome.js b/packages/core/src/receipt/outcome.js new file mode 100644 index 00000000..7d37a3da --- /dev/null +++ b/packages/core/src/receipt/outcome.js @@ -0,0 +1,129 @@ +import * as API from '@ucanto/interface' +import * as Invocation from '../invocation.js' +import * as Instruction from './instruction.js' +import { Delegation } from '../lib.js' +import * as DID from '@ipld/dag-ucan/did' +import * as DAG from '../dag.js' + +/** + * @template {{}} Ok + * @template {{}} Error + * @template {API.Invocation} Ran + * @param {object} source + * @param {API.Link>} source.root + * @param {Map} source.blocks + * @returns {API.Outcome} + */ +export const view = ({ root, blocks }) => { + return new Outcome({ + root: DAG.decodeFrom(root, blocks), + store: blocks, + }) +} + +/** + * @template {{}} Ok + * @template {{}} Error + * @template {API.Invocation} Ran + * @implements {API.Outcome} + * @implements {API.IPLDView>} + */ +export class Outcome { + /** + * + * @param {object} source + * @param {Required>>} source.root + * @param {Map} source.store + * + */ + constructor({ root, store }) { + this.root = root + this.store = store + } + get model() { + return this.root.data + } + + link() { + return this.root.cid + } + + buildIPLDView() { + return this + } + + *iterateIPLDBlocks() { + yield* DAG.iterate(this.ran) + + const { fork, join } = this.fx + for (const concurrent of fork) { + yield* DAG.iterate(concurrent) + } + + if (join) { + yield* DAG.iterate(join) + } + + for (const proof of this.proofs) { + yield* DAG.iterate(proof) + } + + yield this.root + } + /** + * @returns {Ran|ReturnType} + */ + get ran() { + const ran = this._ran + if (!ran) { + const ran = Invocation.embed({ root: this.model.ran, blocks: this.store }) + this._ran = ran + return ran + } else { + return ran + } + } + get proofs() { + const proofs = this._proofs + if (proofs) { + return proofs + } else { + const { store: blocks, model } = this + const proofs = [] + if (model.prf) { + for (const link of model.prf) { + const root = blocks.get(link.toString()) + if (root) { + proofs.push(Delegation.create({ root, blocks: blocks })) + } else { + proofs.push(link) + } + } + } + + this._proofs = proofs + return proofs + } + } + get meta() { + return this.model.meta + } + get issuer() { + const issuer = this._issuer + if (issuer) { + return issuer + } else if (this.model.iss) { + const issuer = DID.parse(this.model.iss) + this._issuer = issuer + return issuer + } + } + + get out() { + return this.model.out + } + + get fx() { + return this.model.fx + } +} diff --git a/packages/core/test/cbor.spec.js b/packages/core/test/cbor.spec.js new file mode 100644 index 00000000..c30e2a8e --- /dev/null +++ b/packages/core/test/cbor.spec.js @@ -0,0 +1,75 @@ +import { test, assert } from './test.js' +import * as CBOR from '../src/cbor.js' +import { decode, encode } from '@ipld/dag-cbor' + +test('encode / decode', async () => { + const bytes = CBOR.encode([{ ok: true, value: 1 }]) + + assert.deepEqual(bytes, encode([{ ok: true, value: 1 }])) + + assert.deepEqual(await CBOR.decode(bytes), [{ ok: true, value: 1 }]) +}) + +{ + const { encode, decode, write } = CBOR + + /** + * @template T + * @param {T} value + */ + const transcode = value => decode(encode(value)) + + const dataset = [ + undefined, + null, + Symbol('hello'), + [1, , 3], + { x: 1, y: undefined }, + { x: 3, p: Symbol('hi') }, + { + x: 1, + y: 2, + toJSON() { + return [1, 2] + }, + }, + ] + + for (const data of dataset) { + test(`encode / decode ${JSON.stringify(data)}`, async () => { + const actual = transcode(data) + const expect = JSON.parse(JSON.stringify(data) || 'null') + assert.deepEqual(actual, expect) + }) + } + + test(`encode / decode bytes`, async () => { + const UTF8 = new TextEncoder() + const actual = transcode({ bytes: UTF8.encode('hello') }) + assert.deepEqual(actual, { bytes: UTF8.encode('hello') }) + }) + + test('circular objects throw', () => { + const circular = { a: 1, circle: {} } + circular.circle = circular + + const nested = { pointer: {} } + const structure = { + x: 1, + sub: { + items: [1, nested], + }, + } + nested.pointer = structure + + assert.throws(() => transcode(nested), /Can not encode circular structure/) + }) + + test('cids', async () => { + const hello = await write({ hello: 'world' }) + + assert.deepEqual(transcode({ message: hello.cid }), { + message: hello.cid, + }) + }) +} diff --git a/packages/core/test/delegation.spec.js b/packages/core/test/delegation.spec.js index 52be65d4..d53eb8d2 100644 --- a/packages/core/test/delegation.spec.js +++ b/packages/core/test/delegation.spec.js @@ -268,3 +268,22 @@ test('.delegate() return same value', async () => { assert.equal(ucan.delegate(), ucan) }) + +test('.buildIPLDView() return same value', async () => { + const ucan = await delegate({ + issuer: alice, + audience: w3, + capabilities: [ + { + with: alice.did(), + can: 'test/echo', + nb: { + message: 'data:1', + }, + }, + ], + expiration: Infinity, + }) + + assert.equal(ucan.buildIPLDView(), ucan) +}) diff --git a/packages/core/test/invocation.spec.js b/packages/core/test/invocation.spec.js index 2bb49eed..b2b37cfb 100644 --- a/packages/core/test/invocation.spec.js +++ b/packages/core/test/invocation.spec.js @@ -1,4 +1,4 @@ -import { invoke, UCAN } from '../src/lib.js' +import { invoke, UCAN, Invocation } from '../src/lib.js' import { alice, service as w3 } from './fixtures.js' import { assert, test } from './test.js' @@ -44,7 +44,7 @@ test('expired invocation', async () => { expiration, }) - assert.deepNestedInclude(await invocation.delegate(), { + assert.deepNestedInclude(await invocation.buildIPLDView(), { capabilities: [ { can: 'store/add', @@ -67,7 +67,7 @@ test('invocation with notBefore', async () => { notBefore, }) - assert.deepNestedInclude(await invocation.delegate(), { + assert.deepNestedInclude(await invocation.buildIPLDView(), { capabilities: [ { can: 'store/add', @@ -89,7 +89,7 @@ test('invocation with nonce', async () => { nonce: 'hello', }) - assert.deepNestedInclude(await invocation.delegate(), { + assert.deepNestedInclude(await invocation.buildIPLDView(), { capabilities: [ { can: 'store/add', @@ -154,5 +154,6 @@ test('execute invocation', async () => { }, }) + // @ts-expect-error assert.deepEqual(result, { hello: 'world' }) }) diff --git a/packages/core/test/receipt.spec.js b/packages/core/test/receipt.spec.js new file mode 100644 index 00000000..e476f43b --- /dev/null +++ b/packages/core/test/receipt.spec.js @@ -0,0 +1,304 @@ +import { Receipt, invoke, API, delegate } from '../src/lib.js' +import { alice, bob, service as w3 } from './fixtures.js' +import { assert, test } from './test.js' +import * as CBOR from '../src/cbor.js' + +test('basic receipt', async () => { + const invocation = await invoke({ + issuer: alice, + audience: w3, + capability: { + can: 'test/echo', + with: alice.did(), + }, + }).delegate() + + const receipt = await Receipt.issue({ + issuer: w3, + result: { ok: { hello: 'message' } }, + ran: invocation, + }) + + await assertReceipt(receipt, { + out: { ok: { hello: 'message' } }, + meta: {}, + fx: { fork: [] }, + ran: invocation, + issuer: w3, + verifier: w3, + proofs: [], + }) + + await assertRoundtrip(receipt) + + assert.equal(receipt.buildIPLDView().buildIPLDView(), receipt) + assert.equal(receipt.outcome.buildIPLDView(), receipt.outcome) +}) + +test('receipt with ran as link', async () => { + const invocation = await invoke({ + issuer: alice, + audience: w3, + capability: { + can: 'test/echo', + with: alice.did(), + }, + }).delegate() + + const receipt = await Receipt.issue({ + issuer: w3, + result: { ok: { hello: 'message' } }, + ran: invocation.link(), + }) + + await assertReceipt(receipt, { + out: { ok: { hello: 'message' } }, + meta: {}, + fx: { fork: [] }, + ran: invocation.link(), + issuer: w3, + verifier: w3, + proofs: [], + }) + + await assertRoundtrip(receipt) +}) + +test('receipt with proofs', async () => { + const invocation = await invoke({ + issuer: alice, + audience: w3, + capability: { + can: 'test/echo', + with: alice.did(), + }, + }).delegate() + + const proof = await delegate({ + issuer: w3, + audience: bob, + capabilities: [ + { + with: w3.did(), + can: '*', + }, + ], + }) + + const { cid: proofCid } = await await delegate({ + issuer: w3, + audience: bob, + capabilities: [ + { + with: w3.did(), + can: '*', + }, + ], + nonce: 'second one', + }) + + const receipt = await Receipt.issue({ + issuer: bob, + result: { ok: { hello: 'message' } }, + ran: invocation, + proofs: [proof, proofCid], + }) + + await assertReceipt(receipt, { + out: { ok: { hello: 'message' } }, + meta: {}, + fx: { fork: [] }, + ran: invocation, + issuer: bob, + verifier: bob, + proofs: [proof, proofCid], + }) + + await assertRoundtrip(receipt) +}) + +test('receipt with meta', async () => { + const invocation = await invoke({ + issuer: alice, + audience: w3, + capability: { + can: 'test/echo', + with: alice.did(), + }, + }).delegate() + + const receipt = await Receipt.issue({ + issuer: w3, + result: { ok: { hello: 'message' } }, + ran: invocation, + meta: { test: 'metadata' }, + }) + + await assertReceipt(receipt, { + out: { ok: { hello: 'message' } }, + meta: { test: 'metadata' }, + fx: { fork: [] }, + ran: invocation, + issuer: w3, + verifier: w3, + proofs: [], + }) + + await assertRoundtrip(receipt) +}) + +test('receipt with fx.fork', async () => { + const invocation = await invoke({ + issuer: alice, + audience: w3, + capability: { + can: 'test/echo', + with: alice.did(), + }, + }).delegate() + + const echo = await CBOR.write( + /** @type {API.InstructionModel} */ ({ + op: 'debug/echo', + rsc: alice.did(), + input: {}, + nnc: '', + }) + ) + + const receipt = await Receipt.issue({ + issuer: w3, + result: { ok: { hello: 'message' } }, + ran: invocation, + meta: { test: 'metadata' }, + fx: { + fork: [echo.cid], + }, + }) + + await assertReceipt(receipt, { + out: { ok: { hello: 'message' } }, + meta: { test: 'metadata' }, + fx: { + fork: [echo.cid], + }, + ran: invocation, + issuer: w3, + verifier: w3, + proofs: [], + }) + + await assertRoundtrip(receipt) +}) + +test('receipt with fx.join', async () => { + const invocation = await invoke({ + issuer: alice, + audience: w3, + capability: { + can: 'test/echo', + with: alice.did(), + }, + }).delegate() + + const echo = await CBOR.write( + /** @type {API.InstructionModel} */ ({ + op: 'debug/echo', + rsc: alice.did(), + input: {}, + nnc: '', + }) + ) + + const receipt = await Receipt.issue({ + issuer: w3, + result: { ok: { hello: 'message' } }, + ran: invocation, + meta: { test: 'metadata' }, + fx: { + fork: [], + join: echo.cid, + }, + }) + + await assertReceipt(receipt, { + out: { ok: { hello: 'message' } }, + meta: { test: 'metadata' }, + fx: { + fork: [], + join: echo.cid, + }, + ran: invocation, + issuer: w3, + verifier: w3, + proofs: [], + }) + + await assertRoundtrip(receipt) +}) + +/** + * @template {API.Receipt} Receipt + * @param {Receipt} receipt + * @param {Partial & { verifier?: API.Verifier }} expect + */ +const assertReceipt = async (receipt, expect) => { + if (expect.out) { + assert.deepEqual(receipt.out, expect.out, 'out is correct') + } + + if (expect.meta) { + assert.deepEqual(receipt.meta, expect.meta, 'meta is correct') + } + + if (expect.fx) { + assert.deepEqual(receipt.fx, expect.fx, 'fx is correct') + } + + if (expect.issuer) { + assert.deepEqual( + receipt.issuer?.did(), + expect.issuer.did(), + 'issuer is correct' + ) + } + + if (expect.ran) { + assert.deepEqual(receipt.ran, expect.ran, 'ran field is correct') + } + + if (expect.verifier) { + assert.deepEqual( + await expect.verifier.verify( + receipt.outcome.link().bytes, + receipt.signature + ), + true, + 'signature is valid' + ) + } + + if (expect.proofs) { + assert.deepEqual(receipt.proofs, expect.proofs, 'proofs are correct') + } +} + +/** + * @template {API.Receipt} Receipt + * @param {Receipt} receipt + */ +const assertRoundtrip = async receipt => { + const blocks = new Map() + for await (const block of receipt.iterateIPLDBlocks()) { + blocks.set(block.cid.toString(), block) + } + + const view = Receipt.view({ root: receipt.root.cid, blocks }) + assert.deepEqual(view.out, receipt.out) + assert.deepEqual(view.meta, receipt.meta) + assert.deepEqual(view.fx, receipt.fx) + assert.deepEqual(view.ran, receipt.ran) + assert.deepEqual(view.issuer, receipt.issuer) + assert.deepEqual(view.proofs, receipt.proofs) + assert.deepEqual(view.signature, receipt.signature) +} diff --git a/packages/interface/src/lib.ts b/packages/interface/src/lib.ts index 46bcb6f2..24479484 100644 --- a/packages/interface/src/lib.ts +++ b/packages/interface/src/lib.ts @@ -130,11 +130,35 @@ export interface DelegationOptions extends UCANOptions { proofs?: Proof[] } +/** + * An interface for representing an IPLD DAG View that can be materialized into + * on demand. It is a useful abstraction that can be used to defer encoding of + * IPLD blocks. + */ +export interface IPLDViewBuilder { + /** + * Encodes all the blocks and creates a new IPLDView instance over them. Can + * be passed an multihasher to parameterize hashing algorithm. + * + * Please note that some `IPLDView`s also implement `IPLDViewBuilder` + * interface and they will discard any options. + */ + buildIPLDView(options?: Transport.EncodeOptions): Await> +} + +export interface IPLDView + extends IPLDViewBuilder { + buildIPLDView(): IPLDView + root: Block + iterateIPLDBlocks(): IterableIterator +} + /** * A materialized view of a UCAN delegation, which can be encoded into a UCAN token and * used as proof for an invocation or further delegations. */ -export interface Delegation { +export interface Delegation + extends IPLDView> { readonly root: UCANBlock /** * Map of all the IPLD blocks that were included with this delegation DAG. @@ -147,6 +171,7 @@ export interface Delegation { * Also note that map may contain blocks that are not part of this * delegation DAG. That is because `Delegation` is usually constructed as * view / selection over the CAR which may contain bunch of other blocks. + * @deprecated */ readonly blocks: Map @@ -155,6 +180,7 @@ export interface Delegation { readonly data: UCAN.View asCID: UCANLink + link(): UCANLink export(): IterableIterator @@ -318,6 +344,136 @@ export type LinkJSON = ToJSON< export interface Invocation extends Delegation<[C]> {} +export interface OutcomeModel< + Ok extends {} = {}, + Error extends {} = {}, + Ran extends Invocation = Invocation +> { + ran: ReturnType + out: ReceiptResult + fx: EffectsModel + meta: Meta + iss?: DID + prf: UCANLink[] +} + +export interface Outcome< + Ok extends {} = {}, + Error extends {} = {}, + Ran extends Invocation = Invocation +> extends IPLDView> { + link(): Link> + ran: Ran | ReturnType + out: ReceiptResult + fx: Effects + meta: Meta + issuer?: Principal + proofs: Proof[] +} + +/** + * A receipt is an attestation of the Result and requested Effects by a task + * invocation. It is issued and signed by the task executor or its delegate. + * + * @see https://github.com/ucan-wg/invocation#8-receipt + */ +export interface ReceiptModel< + Ok extends {} = {}, + Error extends {} = {}, + Ran extends Invocation = Invocation +> { + ocm: Link> + sig: Signature +} + +export interface Receipt< + Ok extends {} = {}, + Error extends {} = {}, + Ran extends Invocation = Invocation, + Alg extends SigAlg = SigAlg +> extends IPLDView> { + outcome: Outcome + ran: Ran | ReturnType + out: ReceiptResult + fx: Effects + meta: Meta + + issuer?: Principal + proofs: Proof[] + + signature: Signature>, Alg> +} + +export interface Meta extends Record {} + +export interface EffectsModel { + fork: readonly Link[] + join?: Link +} + +export interface Effects extends EffectsModel {} +export interface InstructionModel< + Op extends Ability = Ability, + URI extends Resource = Resource, + Input extends Record = Record +> { + op: Op + rsc: URI + input: Input + nnc: string +} + +/** + * Defines result type as per invocation spec + * + * @see https://github.com/ucan-wg/invocation/#6-result + */ + +export type ReceiptResult = Variant<{ + ok: T + error: X +}> + +/** + * Utility type for defining a [keyed union] type as in IPLD Schema. In practice + * this just works around typescript limitation that requires discriminant field + * on all variants. + * + * ```ts + * type Result = + * | { ok: T } + * | { error: X } + * + * const demo = (result: Result) => { + * if (result.ok) { + * // ^^^^^^^^^ Property 'ok' does not exist on type '{ error: Error; }` + * } + * } + * ``` + * + * Using `Variant` type we can define same union type that works as expected: + * + * ```ts + * type Result = Variant<{ + * ok: T + * error: X + * }> + * + * const demo = (result: Result) => { + * if (result.ok) { + * result.ok.toUpperCase() + * } + * } + * ``` + * + * [keyed union]:https://ipld.io/docs/schemas/features/representation-strategies/#union-keyed-representation + */ +export type Variant> = { + [Key in keyof U]: { [K in Exclude]?: never } & { + [K in Key]: U[Key] + } +}[keyof U] + /** * A {@link UCANOptions} instance that includes options specific to {@link Invocation}s. */ @@ -330,7 +486,8 @@ export interface InvocationOptions capability: C } -export interface IssuedInvocation { +export interface IssuedInvocation + extends IPLDViewBuilder> { readonly issuer: Principal readonly audience: Principal readonly capabilities: [C] @@ -429,6 +586,24 @@ export type InferServiceInvocationReturn< > : never +export type InferServiceInvocationReceipt< + C extends Capability, + S extends Record +> = ResolveServiceMethod extends ServiceMethod< + infer _, + infer T, + infer X +> + ? Receipt< + T & {}, + | X + | HandlerNotFound + | HandlerExecutionError + | InvalidAudience + | Unauthorized + > + : never + export type InferServiceInvocations< I extends unknown[], T extends Record @@ -438,12 +613,21 @@ export type InferServiceInvocations< ? [InferServiceInvocationReturn, ...InferServiceInvocations] : never +export type InferWorkflowReceipts< + I extends unknown[], + T extends Record +> = I extends [] + ? [] + : I extends [ServiceInvocation, ...infer Rest] + ? [InferServiceInvocationReceipt, ...InferWorkflowReceipts] + : never + export interface IssuedInvocationView extends IssuedInvocation { - delegate(): Promise> + delegate(): Await> execute>( service: ConnectionView - ): Await> + ): Await> } export type ServiceInvocations = IssuedInvocation & @@ -531,7 +715,7 @@ export interface ConnectionView> I extends Transport.Tuple> >( ...invocations: I - ): Await> + ): Await> } export interface InboundTransportOptions { @@ -569,7 +753,7 @@ export interface ServerOptions * Service DID which will be used to verify that received invocation * audience matches it. */ - readonly id: Verifier + readonly id: Signer } /** diff --git a/packages/interface/src/query.ts b/packages/interface/src/query.ts deleted file mode 100644 index 5f2b5ba5..00000000 --- a/packages/interface/src/query.ts +++ /dev/null @@ -1,223 +0,0 @@ -import type { - InvocationOptions, - IssuedInvocation, - IssuedInvocationView, - Invocation, - Proof, - UCAN, - Result, - Connection, - ConnectionView, - Service, - Principal, - Signer, - Failure, -} from './lib.js' - -export type QueryInput = { - [K in string]: Select | QueryInput -} - -export interface Input - extends Record { - with: Resource - - proofs?: Proof[] -} - -export type InstructionHandler< - Ability extends UCAN.Ability = UCAN.Ability, - In extends Input = Input, - T = unknown, - X extends Failure = Failure -> = (instruction: Invocation) => Result - -interface Select { - input: In - selector: S -} - -export type Selector = - | true - | { - [K in string]: Selector - } - -export interface Query { - input: In - - queryService(): QueryService - - execute>( - service: Connection - ): ExecuteQuery -} - -type ExecuteQuery> = { - [Key in keyof In & keyof Service & string]: ExecuteSubQuery< - Key, - In[Key], - Service[Key] - > -} - -type ExecuteSubQuery = { - [Key in keyof In & keyof Service & string]: In[Key] extends Select< - infer Input, - infer Selector - > - ? Service[Key] extends InstructionHandler< - `${Path}/${Key}`, - Input, - infer T, - infer X - > - ? ExecuteSelect - : never - : ExecuteSubQuery<`${Path}/${Key}`, In[Key], Service[Key]> -} - -type ExecuteSelect = S extends true - ? Result - : Result, X> - -type ExecuteSubSelect = T extends infer E | infer U - ? Pick | Pick - : Pick - -type QueryService = { - [Key in keyof In & string]: SubService -} - -type SubService = { - [Key in keyof In & string]: In[Key] extends Select - ? InstructionHandler<`${Path}/${Key}`, In, Matches> - : SubService<`${Path}/${Key}`, In[Key]> -} - -export type Matches = S extends true - ? unknown - : { - [Key in keyof S]: S[Key] extends Selector ? Matches : unknown - } - -export declare function invoke( - input: InvocationOptions -): IssuedInvocationView - -export declare function connection< - T extends Record ->(): Connection - -export declare function query(query: In): Query -export declare function select( - input: In, - selector?: S -): S extends true ? Select : Select - -type Match = { - [Key in keyof T]: T[Key] extends (input: In) => infer Out ? Out : never -}[keyof T] - -type StoreAdd = ( - input: Invocation<{ can: 'store/add'; with: UCAN.DID; link: UCAN.Link }> -) => Result< - | { status: 'done'; with: UCAN.DID; link: UCAN.Link } - | { status: 'pending'; with: UCAN.DID; link: UCAN.Link; url: string }, - Failure -> - -type StoreRemove = ( - input: Invocation<{ can: 'store/remove'; with: UCAN.DID; link: UCAN.Link }> -) => Result - -type Store = { - add: StoreAdd - remove: StoreRemove -} -declare var store: Store -declare var channel: ConnectionView<{ store: Store }> -declare const alice: Signer -declare const bob: Principal -declare const car: UCAN.Link - -type ToPath = T extends `${infer Base}/${infer Path}` - ? [Base, ...ToPath] - : [T] - -type A = ToPath<''> -type B = ToPath<'foo'> -type C = ToPath<'foo/bar'> - -type Unpack = T extends infer A & infer B ? [A, B] : [] - -type U = Unpack - -// store({ -// issuer: alice, -// audence: bob.did(), -// capabilities: [ -// { -// with: alice.did(), -// can: 'store/add', -// link: car, -// }, -// ], -// }) - -declare var host: ConnectionView<{ store: Store }> - -const demo = async () => { - const add = invoke({ - issuer: alice, - audience: bob, - capability: { - can: 'store/add', - with: alice.did(), - link: car, - }, - }) - - const remove = invoke({ - issuer: alice, - audience: bob, - capability: { - can: 'store/remove', - with: alice.did(), - link: car, - }, - }) - - const a = add.execute(channel) - const b = remove.execute(channel) - - const result = await add.execute(channel) - if (!result.error) { - result - } - - const q = query({ - store: { - add: select( - { - with: alice.did(), - link: car, - proofs: [], - }, - { - link: true, - } - ), - remove: select({ - with: alice.did(), - link: car, - }), - }, - }) - - const r3 = q.execute(host) - if (!r3.store.add.error) { - if (r3.store.add) { - } - } -} diff --git a/packages/interface/src/transport.ts b/packages/interface/src/transport.ts index 6a63b5b9..392d755c 100644 --- a/packages/interface/src/transport.ts +++ b/packages/interface/src/transport.ts @@ -7,8 +7,9 @@ import type { Phantom, Await } from '@ipld/dag-ucan' import * as UCAN from '@ipld/dag-ucan' import type { ServiceInvocation, - InferServiceInvocations, + InferWorkflowReceipts, InferInvocations, + Receipt, } from './lib.js' /** @@ -25,7 +26,7 @@ export interface EncodeOptions { export interface Channel> extends Phantom { request>>( request: HTTPRequest - ): Await>> + ): Await>> } export interface RequestEncoder { @@ -42,11 +43,14 @@ export interface RequestDecoder { } export interface ResponseEncoder { - encode(result: I, options?: EncodeOptions): Await> + encode>( + result: I, + options?: EncodeOptions + ): Await> } export interface ResponseDecoder { - decode(response: HTTPResponse): Await + decode>(response: HTTPResponse): Await } export interface HTTPRequest extends Phantom { diff --git a/packages/server/src/server.js b/packages/server/src/server.js index b215b9c1..06913eb4 100644 --- a/packages/server/src/server.js +++ b/packages/server/src/server.js @@ -7,6 +7,7 @@ export { Failure, MalformedCapability, } from '@ucanto/validator' +import { Receipt } from '@ucanto/core' /** * Creates a connection to a service. @@ -61,11 +62,11 @@ class Server { * @template {API.Tuple>} I * @param {API.ServerView} server * @param {API.HTTPRequest} request - * @returns {Promise>>} + * @returns {Promise>>} */ export const handle = async (server, request) => { - const invocations = await server.decoder.decode(request) - const result = await execute(invocations, server) + const workflow = await server.decoder.decode(request) + const result = await execute(workflow, server) return server.encoder.encode(result) } @@ -73,21 +74,22 @@ export const handle = async (server, request) => { * @template {Record} Service * @template {API.Capability} C * @template {API.Tuple>} I - * @param {API.InferInvocations} invocations + * @param {API.InferInvocations} workflow * @param {API.ServerView} server - * @returns {Promise>} + * @returns {Promise & API.Tuple>} */ -export const execute = async (invocations, server) => { - const results = [] +export const execute = async (workflow, server) => { const input = /** @type {API.InferInvocation>[]} */ ( - invocations + workflow ) - for (const invocation of input) { - results.push(await invoke(invocation, server)) - } - return /** @type {API.InferServiceInvocations} */ (results) + const promises = input.map(invocation => invoke(invocation, server)) + const results = await Promise.all(promises) + + return /** @type {API.InferWorkflowReceipts & API.Tuple} */ ( + results + ) } /** @@ -95,14 +97,18 @@ export const execute = async (invocations, server) => { * @template {API.Capability} C * @param {API.InferInvocation>} invocation * @param {API.ServerView} server - * @returns {Promise>} + * @returns {Promise} */ export const invoke = async (invocation, server) => { // Invocation needs to have one single capability if (invocation.capabilities.length !== 1) { - return /** @type {API.Result} */ ( - new InvocationCapabilityError(invocation.capabilities) - ) + return await Receipt.issue({ + issuer: server.id, + ran: invocation, + result: { + error: new InvocationCapabilityError(invocation.capabilities), + }, + }) } const [capability] = invocation.capabilities @@ -111,21 +117,37 @@ export const invoke = async (invocation, server) => { const method = /** @type {string} */ (path.pop()) const handler = resolve(server.service, path) if (handler == null || typeof handler[method] !== 'function') { - return /** @type {API.Result} */ ( - new HandlerNotFound(capability) - ) + return await Receipt.issue({ + issuer: server.id, + ran: invocation, + result: { + /** @type {API.HandlerNotFound} */ + error: new HandlerNotFound(capability), + }, + }) } else { try { - return await handler[method](invocation, server.context) - } catch (error) { - const err = new HandlerExecutionError( + const value = await handler[method](invocation, server.context) + return await Receipt.issue({ + issuer: server.id, + ran: invocation, + result: /** @type {API.ReceiptResult<{}>} */ ( + value?.error ? { error: value } : { ok: value || {} } + ), + }) + } catch (cause) { + const error = new HandlerExecutionError( capability, - /** @type {Error} */ (error) + /** @type {Error} */ (cause) ) - server.catch(err) + server.catch(error) - return /** @type {API.Result} */ (err) + return await Receipt.issue({ + issuer: server.id, + ran: invocation, + result: { error }, + }) } } } diff --git a/packages/server/test/server.spec.js b/packages/server/test/server.spec.js index cf518dac..c6da2812 100644 --- a/packages/server/test/server.spec.js +++ b/packages/server/test/server.spec.js @@ -67,15 +67,15 @@ test('encode delegated invocation', async () => { const server = Server.create({ service: Service.create(), - decoder: CAR, - encoder: CBOR, + decoder: CAR.request, + encoder: CAR.response, id: w3, }) const connection = Client.connect({ id: w3, - encoder: CAR, - decoder: CBOR, + encoder: CAR.request, + decoder: CAR.response, channel: server, }) @@ -115,23 +115,30 @@ test('encode delegated invocation', async () => { }, }) - const result = await Client.execute([add, remove], connection) + { + const result = await Client.execute([add, remove], connection) - assert.deepEqual(result, [ - { - error: true, + assert.equal(result.length, 2) + const [r1, r2] = result - name: 'UnknownDIDError', - did: alice.did(), - message: `DID ${alice.did()} has no account`, - }, - { - error: true, - name: 'UnknownDIDError', - did: alice.did(), - message: `DID ${alice.did()} has no account`, - }, - ]) + assert.deepEqual(r1.out, { + error: { + error: true, + name: 'UnknownDIDError', + did: alice.did(), + message: `DID ${alice.did()} has no account`, + }, + }) + + assert.deepEqual(r2.out, { + error: { + error: true, + name: 'UnknownDIDError', + did: alice.did(), + message: `DID ${alice.did()} has no account`, + }, + }) + } const identify = Client.invoke({ issuer: alice, @@ -144,25 +151,32 @@ test('encode delegated invocation', async () => { const register = await identify.execute(connection) - assert.deepEqual(register, null) + assert.deepEqual(register.out, { ok: {} }) - const result2 = await Client.execute([add, remove], connection) + { + const receipts = await Client.execute([add, remove], connection) + assert.deepEqual(receipts.length, 2) + const [r1, r2] = receipts - assert.deepEqual(result2, [ - { - status: 'upload', - with: alice.did(), - link: car.cid, - url: 'http://localhost:9090/', - }, - { - can: 'store/remove', - with: alice.did(), - nb: { + assert.deepEqual(r1.out, { + ok: { + status: 'upload', + with: alice.did(), link: car.cid, + url: 'http://localhost:9090/', }, - }, - ]) + }) + + assert.deepEqual(r2.out, { + ok: { + can: 'store/remove', + with: alice.did(), + nb: { + link: car.cid, + }, + }, + }) + } }) test('unknown handler', async () => { diff --git a/packages/transport/src/car.js b/packages/transport/src/car.js index 24baa9ea..b693f8cf 100644 --- a/packages/transport/src/car.js +++ b/packages/transport/src/car.js @@ -1,67 +1,25 @@ import * as API from '@ucanto/interface' import * as CAR from './car/codec.js' -import { Delegation } from '@ucanto/core' +import * as request from './car/request.js' +import * as response from './car/response.js' -export { CAR as codec } +export { CAR as codec, request, response } const HEADERS = Object.freeze({ 'content-type': 'application/car', }) /** - * Encodes invocation batch into an HTTPRequest. - * + * @deprecated * @template {API.Tuple} I * @param {I} invocations - * @param {API.EncodeOptions} [options] + * @param {API.EncodeOptions & { headers?: Record }} [options] * @returns {Promise>} */ -export const encode = async (invocations, options) => { - const roots = [] - const blocks = new Map() - for (const invocation of invocations) { - const delegation = await invocation.delegate() - roots.push(delegation.root) - for (const block of delegation.export()) { - blocks.set(block.cid.toString(), block) - } - blocks.delete(delegation.root.cid.toString()) - } - const body = CAR.encode({ roots, blocks }) - - return { - headers: HEADERS, - body, - } -} +export const encode = (invocations, options) => + request.encode(invocations, { headers: HEADERS, ...options }) /** - * Decodes HTTPRequest to an invocation batch. - * - * @template {API.Tuple} Invocations - * @param {API.HTTPRequest} request - * @returns {Promise>} + * @deprecated */ -export const decode = async ({ headers, body }) => { - const contentType = headers['content-type'] || headers['Content-Type'] - if (contentType !== 'application/car') { - throw TypeError( - `Only 'content-type: application/car' is supported, instead got '${contentType}'` - ) - } - - const { roots, blocks } = CAR.decode(body) - - const invocations = [] - - for (const root of /** @type {API.UCANBlock[]} */ (roots)) { - invocations.push( - Delegation.create({ - root, - blocks: /** @type {Map} */ (blocks), - }) - ) - } - - return /** @type {API.InferInvocations} */ (invocations) -} +export const decode = request.decode diff --git a/packages/transport/src/car/codec.js b/packages/transport/src/car/codec.js index 77c33f27..5ccbefd4 100644 --- a/packages/transport/src/car/codec.js +++ b/packages/transport/src/car/codec.js @@ -1,5 +1,5 @@ import * as API from '@ucanto/interface' -import { CarBufferReader } from '@ipld/car/buffer-reader' +import { CarBufferReader } from '@ipld/car/buffer-reader' import * as CarBufferWriter from '@ipld/car/buffer-writer' import { base32 } from 'multiformats/bases/base32' import { UCAN, createLink } from '@ucanto/core' @@ -107,9 +107,9 @@ export const decode = bytes => { } for (const block of reader.blocks()) { - if (!roots.includes(block)) { - blocks.set(block.cid.toString(), block) - } + // if (!roots.includes(block)) { + blocks.set(block.cid.toString(), block) + // } } return { roots, blocks } diff --git a/packages/transport/src/car/request.js b/packages/transport/src/car/request.js new file mode 100644 index 00000000..67e33981 --- /dev/null +++ b/packages/transport/src/car/request.js @@ -0,0 +1,69 @@ +import * as API from '@ucanto/interface' +import * as CAR from './codec.js' +import { Delegation } from '@ucanto/core' + +export { CAR as codec } + +const HEADERS = Object.freeze({ + 'content-type': 'application/car', + // We will signal that we want to receive a CAR file in the response + accept: 'application/car', +}) + +/** + * Encodes invocation batch into an HTTPRequest. + * + * @template {API.Tuple} I + * @param {I} invocations + * @param {API.EncodeOptions & { headers?: Record }} [options] + * @returns {Promise>} + */ +export const encode = async (invocations, options) => { + const roots = [] + const blocks = new Map() + for (const invocation of invocations) { + const delegation = await invocation.delegate() + roots.push(delegation.root) + for (const block of delegation.export()) { + blocks.set(block.cid.toString(), block) + } + blocks.delete(delegation.root.cid.toString()) + } + const body = CAR.encode({ roots, blocks }) + + return { + headers: options?.headers || HEADERS, + body, + } +} + +/** + * Decodes HTTPRequest to an invocation batch. + * + * @template {API.Tuple} Invocations + * @param {API.HTTPRequest} request + * @returns {Promise>} + */ +export const decode = async ({ headers, body }) => { + const contentType = headers['content-type'] || headers['Content-Type'] + if (contentType !== 'application/car') { + throw TypeError( + `Only 'content-type: application/car' is supported, instead got '${contentType}'` + ) + } + + const { roots, blocks } = CAR.decode(body) + + const invocations = [] + + for (const root of /** @type {API.UCANBlock[]} */ (roots)) { + invocations.push( + Delegation.create({ + root, + blocks: /** @type {Map} */ (blocks), + }) + ) + } + + return /** @type {API.InferInvocations} */ (invocations) +} diff --git a/packages/transport/src/car/response.js b/packages/transport/src/car/response.js new file mode 100644 index 00000000..3e0ecfd3 --- /dev/null +++ b/packages/transport/src/car/response.js @@ -0,0 +1,67 @@ +import * as API from '@ucanto/interface' +import * as CAR from './codec.js' +import { Receipt } from '@ucanto/core' + +export { CAR as codec } + +const HEADERS = Object.freeze({ + 'content-type': 'application/car', +}) + +/** + * Encodes invocation batch into an HTTPRequest. + * + * @template {API.Tuple} I + * @param {I} receipts + * @param {API.EncodeOptions} [options] + * @returns {Promise>} + */ +export const encode = async (receipts, options) => { + const roots = [] + const blocks = new Map() + for (const receipt of receipts) { + const reader = await receipt.buildIPLDView() + roots.push(reader.root) + for (const block of reader.iterateIPLDBlocks()) { + blocks.set(block.cid.toString(), block) + } + // blocks.delete(reader.root.cid.toString()) + } + const body = CAR.encode({ roots, blocks }) + + return { + headers: HEADERS, + body, + } +} + +/** + * Decodes HTTPRequest to an invocation batch. + * + * @template {API.Tuple} I + * @param {API.HTTPRequest} request + * @returns {I} + */ +export const decode = ({ headers, body }) => { + const contentType = headers['content-type'] || headers['Content-Type'] + if (contentType !== 'application/car') { + throw TypeError( + `Only 'content-type: application/car' is supported, instead got '${contentType}'` + ) + } + + const { roots, blocks } = CAR.decode(body) + + const receipts = /** @type {API.Receipt[]} */ ([]) + + for (const root of /** @type {API.UCANBlock[]} */ (roots)) { + receipts.push( + Receipt.view({ + root: root.cid, + blocks: /** @type {Map} */ (blocks), + }) + ) + } + + return /** @type {I} */ (receipts) +} diff --git a/packages/transport/src/cbor.js b/packages/transport/src/cbor.js index 1dd29015..a11d6147 100644 --- a/packages/transport/src/cbor.js +++ b/packages/transport/src/cbor.js @@ -1,5 +1,5 @@ import * as API from '@ucanto/interface' -import * as CBOR from './cbor/codec.js' +import * as CBOR from '@ucanto/core/cbor' const HEADERS = Object.freeze({ 'content-type': 'application/cbor', @@ -14,7 +14,7 @@ export const codec = CBOR * @param {I} result * @returns {API.HTTPResponse} */ -export const encode = (result) => { +export const encode = result => { return { headers: HEADERS, body: CBOR.encode(result), @@ -32,7 +32,7 @@ export const decode = async ({ headers, body }) => { const contentType = headers['content-type'] || headers['Content-Type'] if (contentType !== 'application/cbor') { throw TypeError( - `Only 'content-type: application/cbor' is supported, intsead got '${contentType}'` + `Only 'content-type: application/cbor' is supported, instead got '${contentType}'` ) } diff --git a/packages/transport/src/http.js b/packages/transport/src/http.js index 19c29190..97db4834 100644 --- a/packages/transport/src/http.js +++ b/packages/transport/src/http.js @@ -64,8 +64,8 @@ class Channel { return { headers: response.headers.entries ? Object.fromEntries(response.headers.entries()) - /* c8 ignore next */ - : {}, + : /* c8 ignore next */ + {}, body: new Uint8Array(buffer), } } diff --git a/packages/transport/test/car.spec.js b/packages/transport/test/car.spec.js index 14e195d4..ab882054 100644 --- a/packages/transport/test/car.spec.js +++ b/packages/transport/test/car.spec.js @@ -4,15 +4,14 @@ import * as CBOR from '../src/cbor.js' import { delegate, invoke, + Receipt, Delegation, UCAN, parseLink, isLink, } from '@ucanto/core' -import * as UTF8 from '../src/utf8.js' import { alice, bob, mallory, service } from './fixtures.js' import { CarReader } from '@ipld/car/reader' -import * as API from '@ucanto/interface' import { collect } from './util.js' test('encode / decode', async () => { @@ -61,7 +60,7 @@ test('encode / decode', async () => { assert.deepEqual([expect], await CAR.decode(request), 'roundtrips') }) -test('decode requires application/car contet type', async () => { +test('decode requires application/car content type', async () => { const { body } = await CAR.encode([ invoke({ issuer: alice, @@ -237,7 +236,7 @@ test('codec', async () => { roots: [root], }) const { blocks, roots } = await CAR.codec.decode(bytes) - assert.equal(blocks.size, 0) + assert.equal(blocks.size, 1) assert.deepEqual(roots, [root]) const car = await CAR.codec.write({ roots: [root] }) @@ -258,3 +257,98 @@ test('car writer', async () => { assert.deepEqual(car.roots, []) assert.deepEqual([...car.blocks], [[hello.cid.toString(), hello]]) }) + +test('CAR.request encode / decode', async () => { + const cid = parseLink( + 'bafyreiaxnmoptsqiehdff2blpptvdbenxcz6xgrbojw5em36xovn2xea4y' + ) + const expiration = 1654298135 + + const request = await CAR.request.encode([ + invoke({ + issuer: alice, + audience: bob, + capability: { + can: 'store/add', + with: alice.did(), + }, + expiration, + proofs: [], + }), + ]) + + assert.deepEqual(request.headers, { + 'content-type': 'application/car', + accept: 'application/car', + }) + const reader = await CarReader.fromBytes(request.body) + + assert.deepEqual( + await reader.getRoots(), + // @ts-expect-error - CAR refers to old CID + [cid] + ) + + const expect = await Delegation.delegate({ + issuer: alice, + audience: bob, + capabilities: [ + { + can: 'store/add', + with: alice.did(), + }, + ], + expiration, + proofs: [], + }) + + assert.deepEqual([expect], await CAR.request.decode(request), 'roundtrips') +}) + +test('CAR.response encode/decode', async () => { + const ran = await invoke({ + issuer: bob, + audience: service, + capability: { + can: 'test/hello', + with: alice.did(), + }, + }).delegate() + + const receipt = await Receipt.issue({ + issuer: alice, + result: { + ok: { hello: 'world' }, + }, + ran, + meta: { test: 'run' }, + }) + + const message = await CAR.response.encode([receipt]) + assert.deepEqual(message.headers, { + 'content-type': 'application/car', + }) + + const reader = await CarReader.fromBytes(message.body) + assert.deepEqual( + await reader.getRoots(), + // @ts-expect-error - CAR refers to old CID + [receipt.root.cid] + ) + + const [received, ...other] = await CAR.response.decode(message) + assert.equal(other.length, 0) + assert.deepEqual(received.issuer, receipt.issuer) + assert.deepEqual(received.meta, receipt.meta) + assert.deepEqual(received.ran, receipt.ran) + assert.deepEqual(received.proofs, receipt.proofs) + assert.deepEqual(received.fx, receipt.fx) + assert.deepEqual(received.signature, receipt.signature) + + assert.throws(() => { + CAR.response.decode({ + headers: {}, + body: message.body, + }) + }) +})