From 25abb679a05b1f4010cdb949c71537ca2611d9c7 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Mon, 10 Apr 2023 20:36:21 -0700 Subject: [PATCH] feat!: versioned wire transport (#274) --- packages/client/package.json | 2 +- packages/client/src/connection.js | 25 +- packages/client/test/client.spec.js | 50 ++-- packages/core/src/car.js | 18 +- packages/core/src/cbor.js | 3 + packages/core/src/dag.js | 28 +- packages/core/src/delegation.js | 10 +- packages/core/src/invocation.js | 21 +- packages/core/src/lib.js | 1 + packages/core/src/message.js | 238 ++++++++++++++++ packages/core/src/receipt.js | 31 ++- packages/core/src/result.js | 11 + packages/core/src/schema/schema.js | 16 +- packages/core/test/invocation.spec.js | 22 ++ packages/core/test/lib.spec.js | 8 +- packages/core/test/message.spec.js | 315 ++++++++++++++++++++++ packages/core/test/receipt.spec.js | 31 +++ packages/core/test/schema.spec.js | 1 - packages/interface/src/lib.ts | 86 +++++- packages/interface/src/transport.ts | 47 ++-- packages/server/src/server.js | 70 ++--- packages/server/test/server.spec.js | 53 +++- packages/transport/package.json | 2 +- packages/transport/src/car.js | 30 +-- packages/transport/src/car/request.js | 73 +++-- packages/transport/src/car/response.js | 73 +++-- packages/transport/src/codec.js | 15 +- packages/transport/src/http.js | 18 +- packages/transport/src/jwt.js | 97 ------- packages/transport/src/legacy.js | 48 +--- packages/transport/src/legacy/request.js | 29 ++ packages/transport/src/legacy/response.js | 38 +++ packages/transport/src/lib.js | 1 - packages/transport/src/utf8.js | 4 +- packages/transport/test/car.spec.js | 283 +++++++------------ packages/transport/test/codec.spec.js | 116 ++++---- packages/transport/test/jwt.spec.js | 271 ------------------- packages/transport/test/legacy.spec.js | 106 +++++--- packages/transport/test/utf8.spec.js | 6 + 39 files changed, 1324 insertions(+), 973 deletions(-) create mode 100644 packages/core/src/message.js create mode 100644 packages/core/test/message.spec.js delete mode 100644 packages/transport/src/jwt.js create mode 100644 packages/transport/src/legacy/request.js create mode 100644 packages/transport/src/legacy/response.js delete mode 100644 packages/transport/test/jwt.spec.js create mode 100644 packages/transport/test/utf8.spec.js diff --git a/packages/client/package.json b/packages/client/package.json index 038faaf0..7f0f5f0f 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -23,7 +23,7 @@ "scripts": { "test:web": "playwright-test test/**/*.spec.js --cov && nyc report", "test:node": "c8 --check-coverage --branches 100 --functions 100 --lines 100 mocha test/**/*.spec.js", - "test": "npm run test:node", + "test": "c8 --check-coverage --branches 100 --functions 100 --lines 100 mocha --bail test/**/*.spec.js", "coverage": "c8 --reporter=html mocha test/test-*.js && npm_config_yes=true npx st -d coverage -p 8080", "check": "tsc --build", "build": "tsc --build" diff --git a/packages/client/src/connection.js b/packages/client/src/connection.js index 6c6bebed..5794a840 100644 --- a/packages/client/src/connection.js +++ b/packages/client/src/connection.js @@ -1,5 +1,5 @@ import * as API from '@ucanto/interface' -import { Receipt, Signature, sha256 } from '@ucanto/core' +import { Signature, Message, Receipt, sha256 } from '@ucanto/core' /** * Creates a connection to a service. @@ -29,8 +29,9 @@ class Connection { * @template {API.Capability} C * @template {API.Tuple>} I * @param {I} invocations + * @returns {Promise>} */ - execute(...invocations) { + async execute(...invocations) { return execute(invocations, this) } } @@ -40,11 +41,12 @@ class Connection { * @template {Record} T * @template {API.Tuple>} I * @param {API.Connection} connection - * @param {I} workflow - * @returns {Promise>} + * @param {I} invocations + * @returns {Promise>} */ -export const execute = async (workflow, connection) => { - const request = await connection.codec.encode(workflow, connection) +export const execute = async (invocations, connection) => { + const input = await Message.build({ invocations }) + const request = await connection.codec.encode(input, connection) const response = await connection.channel.request(request) // We may fail to decode the response if content type is not supported // or if data was corrupted. We do not want to throw in such case however, @@ -52,16 +54,17 @@ export const execute = async (workflow, connection) => { // consistent client API with two kinds of errors we encode caught error as // a receipts per workflow invocation. try { - return await connection.codec.decode(response) + const output = await connection.codec.decode(response) + const receipts = input.invocationLinks.map(link => output.get(link)) + return /** @type {API.InferReceipts} */ (receipts) } catch (error) { // No third party code is run during decode and we know // we only throw an Error const { message, ...cause } = /** @type {Error} */ (error) const receipts = [] - for await (const invocation of workflow) { - const { cid } = await invocation.delegate() + for await (const ran of input.invocationLinks) { const receipt = await Receipt.issue({ - ran: cid, + ran, result: { error: { ...cause, message } }, // @ts-expect-error - we can not really sign a receipt without having // an access to a signer which client does not have. In the future @@ -80,6 +83,6 @@ export const execute = async (workflow, connection) => { receipts.push(receipt) } - return /** @type {any} */ (receipts) + return /** @type {API.InferReceipts} */ (receipts) } } diff --git a/packages/client/test/client.spec.js b/packages/client/test/client.spec.js index 95af5dc0..0baea5a0 100644 --- a/packages/client/test/client.spec.js +++ b/packages/client/test/client.spec.js @@ -3,7 +3,7 @@ import * as Client from '../src/lib.js' import * as HTTP from '@ucanto/transport/http' import { CAR, Codec } from '@ucanto/transport' import * as Service from './service.js' -import { Receipt, CBOR } from '@ucanto/core' +import { Receipt, Message, CBOR } from '@ucanto/core' import { alice, bob, mallory, service as w3 } from './fixtures.js' import fetch from '@web-std/fetch' @@ -30,18 +30,19 @@ test('encode invocation', async () => { proofs: [], }) - const payload = await connection.codec.encode([add]) + const message = await Message.build({ invocations: [add] }) + const payload = await connection.codec.encode(message) assert.deepEqual(payload.headers, { - 'content-type': 'application/car', - accept: 'application/car', + 'content-type': 'application/vnd.ipld.car', + accept: 'application/vnd.ipld.car', }) assert.ok(payload.body instanceof Uint8Array) - const request = await CAR.decode(payload) + const request = await CAR.request.decode(payload) - const [invocation] = request - assert.equal(request.length, 1) + const [invocation] = request.invocations + assert.equal(request.invocations.length, 1) assert.equal(invocation.issuer.did(), alice.did()) assert.equal(invocation.audience.did(), w3.did()) assert.deepEqual(invocation.proofs, []) @@ -98,11 +99,12 @@ test('encode delegated invocation', async () => { }, }) - const payload = await connection.codec.encode([add, remove]) - const request = await CAR.decode(payload) + const message = await Message.build({ invocations: [add, remove] }) + const payload = await connection.codec.encode(message) + const request = await CAR.request.decode(payload) { - const [add, remove] = request - assert.equal(request.length, 2) + const [add, remove] = request.invocations + assert.equal(request.invocations.length, 2) assert.equal(add.issuer.did(), bob.did()) assert.equal(add.audience.did(), w3.did()) @@ -125,13 +127,16 @@ test('encode delegated invocation', async () => { assert.equal(remove.issuer.did(), alice.did()) assert.equal(remove.audience.did(), w3.did()) assert.deepEqual(remove.proofs, []) - assert.deepEqual(remove.capabilities, [ - { - can: 'store/remove', - with: alice.did(), - link: car.cid, - }, - ]) + assert.deepEqual( + [ + Object({ + can: 'store/remove', + with: alice.did(), + link: car.cid, + }), + ], + remove.capabilities + ) } }) @@ -140,8 +145,7 @@ const service = Service.create() const channel = HTTP.open({ url: new URL('about:blank'), fetch: async (url, input) => { - /** @type {Client.Tuple} */ - const invocations = await CAR.request.decode(input) + const { invocations } = await CAR.request.decode(input) const promises = invocations.map(async invocation => { const [capability] = invocation.capabilities switch (capability.can) { @@ -172,7 +176,9 @@ const channel = HTTP.open({ await Promise.all(promises) ) - const { headers, body } = await CAR.response.encode(receipts) + const message = await Message.build({ receipts }) + + const { headers, body } = await CAR.response.encode(message) return { ok: true, @@ -317,7 +323,7 @@ test('decode error', async () => { error: { error: true, message: - "Can not decode response with content-type 'application/car' because no matching transport decoder is configured.", + "Can not decode response with content-type 'application/vnd.ipld.car' because no matching transport decoder is configured.", }, }) }) diff --git a/packages/core/src/car.js b/packages/core/src/car.js index 80e980df..4b79695d 100644 --- a/packages/core/src/car.js +++ b/packages/core/src/car.js @@ -5,22 +5,23 @@ import { base32 } from 'multiformats/bases/base32' import { create as createLink } from './link.js' import { sha256 } from 'multiformats/hashes/sha2' +// @see https://www.iana.org/assignments/media-types/application/vnd.ipld.car +export const contentType = 'application/vnd.ipld.car' export const name = 'CAR' /** @type {API.MulticodecCode<0x0202, 'CAR'>} */ export const code = 0x0202 /** - * @typedef {API.Block} Block * @typedef {{ - * roots: Block[] - * blocks: Map + * roots: API.IPLDBlock[] + * blocks: Map * }} Model */ class Writer { /** - * @param {Block[]} blocks + * @param {API.IPLDBlock[]} blocks * @param {number} byteLength */ constructor(blocks = [], byteLength = 0) { @@ -29,7 +30,7 @@ class Writer { this.byteLength = byteLength } /** - * @param {Block[]} blocks + * @param {API.IPLDBlock[]} blocks */ write(...blocks) { for (const block of blocks) { @@ -37,7 +38,7 @@ class Writer { if (!this.written.has(id)) { this.blocks.push(block) this.byteLength += CarBufferWriter.blockLength( - /** @type {CarBufferWriter.Block} */ (block) + /** @type {any} */ (block) ) this.written.add(id) } @@ -45,7 +46,7 @@ class Writer { return this } /** - * @param {Block[]} rootBlocks + * @param {API.IPLDBlock[]} rootBlocks */ flush(...rootBlocks) { const roots = [] @@ -99,11 +100,12 @@ export const encode = ({ roots = [], blocks }) => { */ export const decode = bytes => { const reader = CarBufferReader.fromBytes(bytes) + /** @type {API.IPLDBlock[]} */ const roots = [] const blocks = new Map() for (const root of reader.getRoots()) { - const block = reader.get(root) + const block = /** @type {API.IPLDBlock} */ (reader.get(root)) if (block) { roots.push(block) } diff --git a/packages/core/src/cbor.js b/packages/core/src/cbor.js index cf1c4754..b19d8b4e 100644 --- a/packages/core/src/cbor.js +++ b/packages/core/src/cbor.js @@ -4,6 +4,9 @@ export { code, name, decode } from '@ipld/dag-cbor' import { sha256 } from 'multiformats/hashes/sha2' import { create as createLink, isLink } from 'multiformats/link' +// @see https://www.iana.org/assignments/media-types/application/vnd.ipld.dag-cbor +export const contentType = 'application/vnd.ipld.dag-cbor' + /** * @param {unknown} data * @param {Set} seen diff --git a/packages/core/src/dag.js b/packages/core/src/dag.js index b929989e..2ebf67ab 100644 --- a/packages/core/src/dag.js +++ b/packages/core/src/dag.js @@ -5,6 +5,8 @@ import * as MF from 'multiformats/interface' import * as CBOR from './cbor.js' import { identity } from 'multiformats/hashes/identity' +export { CBOR, sha256, identity } + /** * Function takes arbitrary value and if it happens to be an `IPLDView` * it will iterate over it's blocks. It is just a convenience for traversing @@ -27,15 +29,20 @@ export const iterate = function* (value) { } /** - * @template T - * @typedef {Map, API.Block>} BlockStore + * @template [T=unknown] + * @typedef {Map, API.Block|API.Block>} BlockStore */ /** * @template [T=unknown] + * @param {API.Block[]} blocks * @returns {BlockStore} */ -export const createStore = () => new Map() +export const createStore = (blocks = []) => { + const store = new Map() + addEveryInto(blocks, store) + return store +} /** @type {API.MulticodecCode} */ const EMBED_CODE = identity.code @@ -45,13 +52,16 @@ const EMBED_CODE = identity.code * contain the block, `fallback` is returned. If `fallback` is not provided, it * will throw an error. * + * @template {0|1} V * @template {T} U * @template T + * @template {API.MulticodecCode} Format + * @template {API.MulticodecCode} Alg * @template [E=never] - * @param {API.Link} cid + * @param {API.Link} cid * @param {BlockStore} store * @param {E} [fallback] - * @returns {API.Block|E} + * @returns {API.Block|E} */ export const get = (cid, store, fallback) => { // If CID uses identity hash, we can return the block data directly @@ -59,7 +69,9 @@ export const get = (cid, store, fallback) => { return { cid, bytes: cid.multihash.digest } } - const block = /** @type {API.Block|undefined} */ (store.get(`${cid}`)) + const block = /** @type {API.Block|undefined} */ ( + store.get(`${cid}`) + ) return block ? block : fallback === undefined ? notFound(cid) : fallback } @@ -84,10 +96,10 @@ export const embed = (source, { codec } = {}) => { } /** - * @param {API.Link} link + * @param {API.Link<*, *, *, *>} link * @returns {never} */ -const notFound = link => { +export const notFound = link => { throw new Error(`Block for the ${link} is not found`) } diff --git a/packages/core/src/delegation.js b/packages/core/src/delegation.js index 6ebfe553..24416da6 100644 --- a/packages/core/src/delegation.js +++ b/packages/core/src/delegation.js @@ -156,7 +156,7 @@ const matchAbility = (provided, claimed) => { export class Delegation { /** * @param {API.UCANBlock} root - * @param {Map} [blocks] + * @param {DAG.BlockStore} [blocks] */ constructor(root, blocks = new Map()) { this.root = root @@ -364,14 +364,14 @@ export const delegate = async ( /** * @template {API.Capabilities} C * @param {API.UCANBlock} root - * @param {Map} blocks + * @param {DAG.BlockStore} blocks * @returns {IterableIterator} */ export const exportDAG = function* (root, blocks) { for (const link of decode(root).proofs) { // Check if block is included in this delegation - const root = /** @type {UCAN.Block} */ (blocks.get(link.toString())) + const root = /** @type {UCAN.Block} */ (blocks.get(`${link}`)) if (root) { yield* exportDAG(root, blocks) } @@ -409,7 +409,7 @@ export const importDAG = dag => { * @template {API.Capabilities} C * @param {object} dag * @param {API.UCANBlock} dag.root - * @param {Map>} [dag.blocks] + * @param {DAG.BlockStore} [dag.blocks] * @returns {API.Delegation} */ export const create = ({ root, blocks }) => new Delegation(root, blocks) @@ -419,7 +419,7 @@ export const create = ({ root, blocks }) => new Delegation(root, blocks) * @template [T=undefined] * @param {object} dag * @param {API.UCANLink} dag.root - * @param {Map} dag.blocks + * @param {DAG.BlockStore} dag.blocks * @param {T} [fallback] * @returns {API.Delegation|T} */ diff --git a/packages/core/src/invocation.js b/packages/core/src/invocation.js index bd8bfa36..b9c7b5a4 100644 --- a/packages/core/src/invocation.js +++ b/packages/core/src/invocation.js @@ -13,7 +13,7 @@ export const invoke = options => new IssuedInvocation(options) * @template {API.Capability} C * @param {object} dag * @param {API.UCANBlock<[C]>} dag.root - * @param {Map>} [dag.blocks] + * @param {DAG.BlockStore} [dag.blocks] * @returns {API.Invocation} */ export const create = ({ root, blocks }) => new Invocation(root, blocks) @@ -25,21 +25,22 @@ export const create = ({ root, blocks }) => new Invocation(root, blocks) * If root points to wrong block (that is not an invocation) it will misbehave * and likely throw some errors on field access. * + * @template {API.Capability} C * @template {API.Invocation} Invocation - * @template [T=undefined] + * @template [T=never] * @param {object} dag - * @param {ReturnType} dag.root - * @param {Map} dag.blocks + * @param {API.UCANLink<[C]>} dag.root + * @param {DAG.BlockStore} dag.blocks * @param {T} [fallback] - * @returns {Invocation|T} + * @returns {API.Invocation|T} */ export const view = ({ root, blocks }, fallback) => { const block = DAG.get(root, blocks, null) - const view = block - ? /** @type {Invocation} */ (create({ root: block, blocks })) - : /** @type {T} */ (fallback) + if (block == null) { + return fallback !== undefined ? fallback : DAG.notFound(root) + } - return view + return /** @type {API.Invocation} */ (create({ root: block, blocks })) } /** @@ -93,7 +94,7 @@ class IssuedInvocation { /** * @template {API.InvocationService} Service * @param {API.ConnectionView} connection - * @returns {Promise>} + * @returns {Promise>} */ async execute(connection) { /** @type {API.ServiceInvocation} */ diff --git a/packages/core/src/lib.js b/packages/core/src/lib.js index 0dcb8d38..2a299393 100644 --- a/packages/core/src/lib.js +++ b/packages/core/src/lib.js @@ -1,6 +1,7 @@ export * as API from '@ucanto/interface' export * as Delegation from './delegation.js' export * as Invocation from './invocation.js' +export * as Message from './message.js' export * as Receipt from './receipt.js' export * as DAG from './dag.js' export * as CBOR from './cbor.js' diff --git a/packages/core/src/message.js b/packages/core/src/message.js new file mode 100644 index 00000000..57b60126 --- /dev/null +++ b/packages/core/src/message.js @@ -0,0 +1,238 @@ +import * as API from '@ucanto/interface' +import * as DAG from './dag.js' +import { Invocation, panic } from './lib.js' +import * as Receipt from './receipt.js' +import * as Schema from './schema.js' + +export const MessageSchema = Schema.variant({ + 'ucanto/message@0.6.0': Schema.struct({ + execute: Schema.link().array().optional(), + delegate: Schema.dictionary({ + key: Schema.string(), + value: /** @type {API.Reader>} */ ( + Schema.link() + ), + }) + .array() + .optional(), + }), +}) + +/** + * @template {API.Tuple} I + * @template {API.Tuple} R + * @param {object} source + * @param {I} [source.invocations] + * @param {R} [source.receipts] + * @returns {Promise, Out: R }>>} + */ +export const build = ({ invocations, receipts }) => + new MessageBuilder({ invocations, receipts }).buildIPLDView() + +/** + * @template [E=never] + * @param {object} source + * @param {API.Link} source.root + * @param {DAG.BlockStore} source.store + * @param {E} [fallback] + * @returns {API.AgentMessage|E} + */ +export const view = ({ root, store }, fallback) => { + const block = DAG.get(root, store, null) + if (block === null) { + return fallback !== undefined ? fallback : DAG.notFound(root) + } + const data = DAG.CBOR.decode(block.bytes) + const [branch, value] = MessageSchema.match(data, fallback) + switch (branch) { + case 'ucanto/message@0.6.0': + return new Message({ root: { ...block, data }, store }) + default: + return value + } +} + +/** + * @template {API.Tuple} I + * @template {API.Tuple} R + * @implements {API.AgentMessageBuilder<{In: API.InferInvocations, Out: R }>} + * + */ +class MessageBuilder { + /** + * @param {object} source + * @param {I} [source.invocations] + * @param {R} [source.receipts] + */ + constructor({ invocations, receipts }) { + this.invocations = invocations + this.receipts = receipts + } + /** + * + * @param {API.BuildOptions} [options] + * @returns {Promise, Out: R }>>} + */ + async buildIPLDView(options) { + const store = new Map() + + const { invocations, ...executeField } = await writeInvocations( + this.invocations || [], + store + ) + + const { receipts, ...receiptsField } = await writeReceipts( + this.receipts || [], + store + ) + + const root = await DAG.writeInto( + /** @type {API.AgentMessageModel<{ In: API.InferInvocations, Out: R }>} */ + ({ + 'ucanto/message@0.6.0': { + ...executeField, + ...receiptsField, + }, + }), + store, + options + ) + + return new Message({ root, store }, { receipts, invocations }) + } +} + +/** + * + * @param {API.IssuedInvocation[]} run + * @param {Map} store + */ +const writeInvocations = async (run, store) => { + const invocations = [] + const execute = [] + for (const invocation of run) { + const view = await invocation.buildIPLDView() + execute.push(view.link()) + invocations.push(view) + for (const block of view.iterateIPLDBlocks()) { + store.set(`${block.cid}`, block) + } + } + + return { invocations, ...(execute.length > 0 ? { execute } : {}) } +} + +/** + * @param {API.Receipt[]} source + * @param {Map} store + */ +const writeReceipts = async (source, store) => { + if (source.length === 0) { + return {} + } + + const receipts = new Map() + /** @type {Record, API.Link>} */ + const report = {} + + for (const [n, receipt] of source.entries()) { + const view = await receipt.buildIPLDView() + for (const block of view.iterateIPLDBlocks()) { + store.set(`${block.cid}`, block) + } + + const key = `${view.ran.link()}` + if (!(key in report)) { + report[key] = view.root.cid + receipts.set(key, view) + } else { + // In theory we could have gotten the same invocation twice and both + // should get same receipt. In legacy code we send tuple of results + // as opposed to a map keyed by invocation to keep old clients working + // we just stick the receipt in the map with a unique key so that when + // legacy encoder maps entries to array it will get both receipts in + // the right order. + receipts.set(`${key}@${n}`, view) + } + } + + return { receipts, report } +} + +/** + * @template {{ In: API.Invocation[], Out: API.Receipt[] }} T + * @implements {API.AgentMessage} + */ +class Message { + /** + * @param {object} source + * @param {Required>>} source.root + * @param {DAG.BlockStore} source.store + * @param {object} build + * @param {API.Invocation[]} [build.invocations] + * @param {Map} [build.receipts] + */ + constructor({ root, store }, { invocations, receipts } = {}) { + this.root = root + this.store = store + this._invocations = invocations + this._receipts = receipts + } + *iterateIPLDBlocks() { + for (const invocation of this.invocations) { + yield* invocation.iterateIPLDBlocks() + } + + for (const receipt of this.receipts.values()) { + yield* receipt.iterateIPLDBlocks() + } + + yield this.root + } + /** + * @template [E=never] + * @param {API.Link} link + * @param {E} [fallback] + * @returns {API.Receipt|E} + */ + get(link, fallback) { + const receipts = this.root.data['ucanto/message@0.6.0'].report || {} + const receipt = receipts[`${link}`] + if (receipt) { + return Receipt.view({ root: receipt, blocks: this.store }) + } else { + return fallback !== undefined + ? fallback + : panic(`Message does not include receipt for ${link}`) + } + } + + get invocationLinks() { + return this.root.data['ucanto/message@0.6.0'].execute || [] + } + + get invocations() { + let invocations = this._invocations + if (!invocations) { + invocations = this.invocationLinks.map(link => { + return Invocation.view({ root: link, blocks: this.store }) + }) + } + + return invocations + } + + get receipts() { + let receipts = this._receipts + if (!receipts) { + receipts = new Map() + const report = this.root.data['ucanto/message@0.6.0'].report || {} + for (const [key, link] of Object.entries(report)) { + const receipt = Receipt.view({ root: link, blocks: this.store }) + receipts.set(`${receipt.ran.link()}`, receipt) + } + } + + return receipts + } +} diff --git a/packages/core/src/receipt.js b/packages/core/src/receipt.js index dd4870e5..e9cd4dbf 100644 --- a/packages/core/src/receipt.js +++ b/packages/core/src/receipt.js @@ -11,15 +11,20 @@ import { sha256 } from 'multiformats/hashes/sha2' * @template {{}} Ok * @template {{}} Error * @template {API.Invocation} Ran + * @template [E=never] * @param {object} input * @param {API.Link>} input.root - * @param {Map} input.blocks + * @param {DAG.BlockStore} input.blocks + * @param {E} [fallback] */ -export const view = ({ root, blocks }) => { - const { bytes, cid } = DAG.get(root, blocks) - const data = CBOR.decode(bytes) +export const view = ({ root, blocks }, fallback) => { + const block = DAG.get(root, blocks, null) + if (block == null) { + return fallback !== undefined ? fallback : DAG.notFound(root) + } + const data = CBOR.decode(block.bytes) - return new Receipt({ root: { bytes, cid, data }, store: blocks }) + return new Receipt({ root: { ...block, data }, store: blocks }) } /** @@ -38,7 +43,7 @@ class Receipt { /** * @param {object} input * @param {Required>>} input.root - * @param {Map} input.store + * @param {DAG.BlockStore} input.store * @param {API.Meta} [input.meta] * @param {Ran|ReturnType} [input.ran] * @param {API.EffectsModel} [input.fx] @@ -62,12 +67,14 @@ class Receipt { get ran() { const ran = this._ran if (!ran) { - const ran = Invocation.view( - { - root: this.root.data.ocm.ran, - blocks: this.store, - }, - this.root.data.ocm.ran + const ran = /** @type {Ran} */ ( + Invocation.view( + { + root: this.root.data.ocm.ran, + blocks: this.store, + }, + this.root.data.ocm.ran + ) ) this._ran = ran return ran diff --git a/packages/core/src/result.js b/packages/core/src/result.js index 2990ab45..0f4f4c9f 100644 --- a/packages/core/src/result.js +++ b/packages/core/src/result.js @@ -35,6 +35,17 @@ export const error = cause => { } } +/** + * Crash the program with a given `message`. This function is + * intended to be used in places where it is impossible to + * recover from an error. It is similar to `panic` function in + * Rust. + * + * @param {string} message + */ +export const panic = message => { + throw new Failure(message) +} /** * Creates the failing result containing an error with a given * `message`. Unlike `error` function it creates a very generic diff --git a/packages/core/src/schema/schema.js b/packages/core/src/schema/schema.js index dcccb479..3ea979dd 100644 --- a/packages/core/src/schema/schema.js +++ b/packages/core/src/schema/schema.js @@ -1,5 +1,5 @@ import * as Schema from './type.js' -import { ok } from '../result.js' +import { ok, Failure } from '../result.js' export * from './type.js' export { ok } @@ -1324,26 +1324,14 @@ export const variant = variants => new Variant(variants) */ export const error = message => ({ error: new SchemaError(message) }) -class SchemaError extends Error { +class SchemaError extends Failure { get name() { return 'SchemaError' } - /** @type {true} */ - get error() { - return true - } /* c8 ignore next 3 */ describe() { return this.name } - get message() { - return this.describe() - } - - toJSON() { - const { error, name, message, stack } = this - return { error, name, message, stack } - } } class TypeError extends SchemaError { diff --git a/packages/core/test/invocation.spec.js b/packages/core/test/invocation.spec.js index 51d91d20..32f88b75 100644 --- a/packages/core/test/invocation.spec.js +++ b/packages/core/test/invocation.spec.js @@ -160,3 +160,25 @@ test('execute invocation', async () => { // @ts-expect-error assert.deepEqual(result, { hello: 'world' }) }) + +test('receipt view fallback', async () => { + const invocation = await invoke({ + issuer: alice, + audience: w3, + capability: { + can: 'test/echo', + with: alice.did(), + }, + }).delegate() + + assert.throws( + () => Invocation.view({ root: invocation.cid, blocks: new Map() }), + /not found/ + ) + + assert.deepEqual( + Invocation.view({ root: invocation.cid, blocks: new Map() }, null), + null, + 'returns fallback' + ) +}) diff --git a/packages/core/test/lib.spec.js b/packages/core/test/lib.spec.js index 2a0bca66..52b80de8 100644 --- a/packages/core/test/lib.spec.js +++ b/packages/core/test/lib.spec.js @@ -144,7 +144,7 @@ test('create delegation with attached proof', async () => { const root = await UCAN.write(data) const delegation = Delegation.create({ root, - blocks: new Map([[proof.cid.toString(), proof.root]]), + blocks: new Map([[`${proof.cid}`, proof.root]]), }) assert.deepNestedInclude(delegation, { @@ -219,7 +219,7 @@ test('create delegation chain', async () => { const { cid, bytes } = await UCAN.write(data) delegation = Delegation.create({ root: { cid, bytes }, - blocks: new Map([[proof.cid.toString(), proof.root]]), + blocks: new Map([[`${proof.cid}`, proof.root]]), }) } @@ -241,7 +241,7 @@ test('create delegation chain', async () => { { const invocation = Delegation.create({ root, - blocks: new Map([[delegation.cid.toString(), delegation.root]]), + blocks: new Map([[`${delegation.cid}`, delegation.root]]), }) assert.equal(invocation.issuer.did(), mallory.did()) @@ -281,7 +281,7 @@ test('create delegation chain', async () => { const invocation = Delegation.create({ root, blocks: new Map([ - [delegation.cid.toString(), delegation.root], + [`${delegation.cid}`, delegation.root], [proof.cid.toString(), proof.root], ]), }) diff --git a/packages/core/test/message.spec.js b/packages/core/test/message.spec.js new file mode 100644 index 00000000..f929aa00 --- /dev/null +++ b/packages/core/test/message.spec.js @@ -0,0 +1,315 @@ +import { + Message, + Receipt, + invoke, + API, + delegate, + DAG, + UCAN, +} 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('build empty message', async () => { + const message = await Message.build({}) + assert.deepEqual(message.invocations, []) + assert.deepEqual(message.receipts.size, 0) + + assert.deepEqual(message.invocationLinks, []) +}) + +test('build message with an invocation', async () => { + const echo = await build({ + run: { + can: 'test/echo', + message: 'hello', + }, + result: { + ok: { message: 'hello' }, + }, + }) + + const message = await Message.build({ + invocations: [echo.invocation], + }) + + assert.deepEqual(message.root.data, { + 'ucanto/message@0.6.0': { + execute: [echo.delegation.cid], + }, + }) + assert.deepEqual(message.invocationLinks, [echo.delegation.cid]) + + const store = DAG.createStore([...message.iterateIPLDBlocks()]) + + const view = Message.view({ + root: message.root.cid, + store, + }) + + assert.deepEqual(view.invocations, [echo.delegation]) + assert.deepEqual( + view.invocations[0].proofs, + [echo.proof], + 'proofs are included' + ) + + assert.deepEqual(view.receipts.size, 0) + assert.deepEqual([...view.iterateIPLDBlocks()].length, store.size) +}) + +test('Message.view', async () => { + const hi = await build({ run: { can: 'test/hi' } }) + const bye = await build({ run: { can: 'test/bye' } }) + + const store = DAG.createStore([ + ...hi.delegation.iterateIPLDBlocks(), + ...bye.delegation.iterateIPLDBlocks(), + ]) + + const buildHi = await Message.build({ + invocations: [hi.invocation], + }) + + assert.throws( + () => + Message.view({ + root: buildHi.root.cid, + store, + }), + /Block for the bafy.* not found/ + ) + + assert.deepEqual( + Message.view( + { + root: buildHi.root.cid, + store, + }, + null + ), + null + ) + + DAG.addInto(buildHi.root, store) + const viewHi = Message.view({ + root: buildHi.root.cid, + store, + }) + + assert.deepEqual(buildHi.invocations, viewHi.invocations) + assert.deepEqual([...viewHi.iterateIPLDBlocks()].length < store.size, true) + + assert.throws( + () => + Message.view({ + root: hi.delegation.cid, + store, + }), + /Expected an object with a single key/, + 'throws if message does not match schema' + ) + + assert.deepEqual( + Message.view( + { + root: hi.delegation.cid, + store, + }, + { another: 'one' } + ), + { another: 'one' } + ) +}) + +test('empty receipts are omitted', async () => { + const hi = await build({ run: { can: 'test/hi' } }) + const message = await Message.build({ + invocations: [hi.delegation], + // @ts-expect-error - requires at least on item + receipts: [], + }) + + assert.deepEqual( + message.root.data, + { + 'ucanto/message@0.6.0': { + execute: [hi.delegation.cid], + }, + }, + 'receipts are omitted' + ) + + assert.equal(message.get(hi.delegation.cid, null), null) +}) + +test('message with receipts', async () => { + const hi = await build({ run: { can: 'test/hi' } }) + const message = await Message.build({ + receipts: [hi.receipt], + }) + + assert.deepEqual( + message.root.data, + { + 'ucanto/message@0.6.0': { + report: { + [`${hi.delegation.cid}`]: hi.receipt.root.cid, + }, + }, + }, + 'includes passed receipt' + ) +}) + +test('handles duplicate receipts', async () => { + const hi = await build({ run: { can: 'test/hi' } }) + const message = await Message.build({ + receipts: [hi.receipt, hi.receipt], + }) + + assert.deepEqual( + message.root.data, + { + 'ucanto/message@0.6.0': { + report: { + [`${hi.delegation.cid}`]: hi.receipt.root.cid, + }, + }, + }, + 'includes passed receipt' + ) + + assert.deepEqual([...message.receipts.values()], [hi.receipt, hi.receipt]) +}) + +test('empty invocations are omitted', async () => { + const hi = await build({ run: { can: 'test/hi' } }) + const message = await Message.build({ + receipts: [hi.receipt], + // @ts-expect-error - requires non empty invocations + invocations: [], + }) + + assert.deepEqual( + message.root.data, + { + 'ucanto/message@0.6.0': { + report: { + [`${hi.delegation.cid}`]: hi.receipt.root.cid, + }, + }, + }, + 'empty invocations are omitted' + ) + + const receipt = message.get(hi.delegation.cid) + assert.deepEqual(receipt.root, hi.receipt.root) + + assert.throws( + () => message.get(receipt.root.cid), + /does not include receipt for/ + ) +}) + +test('message with invocations & receipts', async () => { + const hi = await build({ run: { can: 'test/hi' } }) + const message = await Message.build({ + receipts: [hi.receipt], + invocations: [hi.invocation], + }) + + assert.deepEqual( + message.root.data, + { + 'ucanto/message@0.6.0': { + execute: [hi.delegation.cid], + report: { + [`${hi.delegation.cid}`]: hi.receipt.root.cid, + }, + }, + }, + 'contains invocations and receipts' + ) + + const cids = new Set( + [...message.iterateIPLDBlocks()].map($ => $.cid.toString()) + ) + + assert.deepEqual(cids.has(hi.delegation.cid.toString()), true) + assert.deepEqual(cids.has(hi.proof.cid.toString()), true) + assert.deepEqual(cids.has(hi.receipt.root.cid.toString()), true) + assert.deepEqual(cids.has(message.root.cid.toString()), true) +}) + +test('Message.view with receipts', async () => { + const hi = await build({ run: { can: 'test/hi' } }) + const bye = await build({ run: { can: 'test/bye' } }) + + const message = await Message.build({ + invocations: [hi.invocation], + receipts: [hi.receipt, bye.receipt], + }) + + const store = DAG.createStore([...message.iterateIPLDBlocks()]) + + const view = Message.view({ + root: message.root.cid, + store, + }) + + assert.deepEqual( + [...view.receipts.entries()] + .sort(([a], [b]) => (a > b ? 1 : -1)) + .map(([key, $]) => [key, $.root]), + [...message.receipts.entries()] + .sort(([a], [b]) => (a > b ? 1 : -1)) + .map(([key, $]) => [key, $.root]) + ) + + assert.deepEqual(view.invocations, message.invocations) +}) + +/** + * @template {Omit} I + * @param {object} source + * @param {I} source.run + * @param {Record} [source.meta] + * @param {API.Result<{}, {}>} [source.result] + */ +const build = async ({ + run, + result = { ok: {} }, + meta = { test: 'metadata' }, +}) => { + const proof = await delegate({ + issuer: alice, + audience: bob, + capabilities: [{ with: alice.did(), can: run.can }], + expiration: UCAN.now() + 1000, + }) + + const invocation = invoke({ + issuer: alice, + audience: w3, + capability: { + ...run, + with: alice.did(), + }, + proofs: [proof], + }) + + const delegation = await invocation.buildIPLDView() + const receipt = await Receipt.issue({ + issuer: w3, + result, + ran: delegation.link(), + meta, + fx: { + fork: [], + }, + }) + + return { proof, invocation, delegation, receipt } +} diff --git a/packages/core/test/receipt.spec.js b/packages/core/test/receipt.spec.js index 8b325b37..854cdbe7 100644 --- a/packages/core/test/receipt.spec.js +++ b/packages/core/test/receipt.spec.js @@ -236,6 +236,37 @@ test('receipt with fx.join', async () => { await assertRoundtrip(receipt) }) +test('receipt view fallback', 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' }, + fx: { + fork: [], + }, + }) + + assert.throws( + () => Receipt.view({ root: receipt.root.cid, blocks: new Map() }), + /not found/ + ) + + assert.deepEqual( + Receipt.view({ root: receipt.root.cid, blocks: new Map() }, null), + null, + 'returns fallback' + ) +}) /** * @template {API.Receipt} Receipt * @param {Receipt} receipt diff --git a/packages/core/test/schema.spec.js b/packages/core/test/schema.spec.js index d6520a71..989b170c 100644 --- a/packages/core/test/schema.spec.js +++ b/packages/core/test/schema.spec.js @@ -496,7 +496,6 @@ test('errors', () => { assert.deepInclude(json, { name: 'SchemaError', message: 'boom!', - error: true, stack: error.stack, }) diff --git a/packages/interface/src/lib.ts b/packages/interface/src/lib.ts index 66208f1d..b56ff009 100644 --- a/packages/interface/src/lib.ts +++ b/packages/interface/src/lib.ts @@ -213,7 +213,7 @@ export interface Delegation * view / selection over the CAR which may contain bunch of other blocks. * @deprecated */ - readonly blocks: Map + readonly blocks: Map> readonly cid: UCANLink readonly bytes: ByteView> @@ -477,6 +477,11 @@ export type Result = Variant<{ error: X }> +/** + * @see {@link https://en.wikipedia.org/wiki/Unit_type|Unit type - Wikipedia} + */ +export interface Unit {} + /** * Utility type for defining a [keyed union] type as in IPLD Schema. In practice * this just works around typescript limitation that requires discriminant field @@ -548,12 +553,14 @@ export type ServiceInvocation< export type InferInvocation = T extends ServiceInvocation ? Invocation : never -export type InferInvocations = T extends [] - ? [] - : T extends [ServiceInvocation, ...infer Rest] - ? [Invocation, ...InferInvocations] - : T extends Array> - ? Invocation[] +export type InferInvocations = T extends [ + ServiceInvocation, + infer Next, + ...infer Rest +] + ? [Invocation, ...InferInvocations<[Next, ...Rest]>] + : T extends [ServiceInvocation] + ? [Invocation] : never /** @@ -629,7 +636,7 @@ export type InferServiceInvocationReturn< > : never -export type InferServiceInvocationReceipt< +export type InferReceipt< C extends Capability, S extends Record > = ResolveServiceMethod extends ServiceMethod< @@ -656,21 +663,73 @@ export type InferServiceInvocations< ? [InferServiceInvocationReturn, ...InferServiceInvocations] : never -export type InferWorkflowReceipts< +export type InferReceipts< I extends unknown[], T extends Record > = I extends [] ? [] : I extends [ServiceInvocation, ...infer Rest] - ? [InferServiceInvocationReceipt, ...InferWorkflowReceipts] + ? [InferReceipt, ...InferReceipts] : never +/** + * Describes messages send across ucanto agents. + */ +export type AgentMessageModel = Variant<{ + 'ucanto/message@0.6.0': AgentMessageData +}> + +/** + * Describes ucanto@0.6 message format send between (client/server) agents. + * + * @template T - Phantom type capturing types of the payload for the inference. + */ +export interface AgentMessageData extends Phantom { + /** + * Set of (invocation) delegation links to be executed by the agent. + */ + execute?: Tuple>> + + /** + * Map of receipts keyed by the (invocation) delegation. + */ + report?: Record, Link> +} + +export interface AgentMessageBuilder + extends IPLDViewBuilder> {} + +export interface AgentMessage + extends IPLDView> { + invocationLinks: Tuple>> | [] + receipts: Map, Receipt> + invocations: Invocation[] + get(link: Link, fallback?: E): Receipt | E +} + +export interface ReportModel extends Phantom { + receipts: Record< + ToString>, + Link + > +} + +export interface ReportBuilder + extends IPLDViewBuilder>> { + set(link: Link, receipt: Receipt): void + entries(): IterableIterator<[ToString>, Receipt]> +} + +export interface Report extends Phantom { + get(link: Link, fallback: E): Receipt | E +} + export interface IssuedInvocationView extends IssuedInvocation { delegate(): Await> execute>( service: ConnectionView - ): Await> + ): Await> } export type ServiceInvocations = IssuedInvocation & @@ -754,7 +813,7 @@ export interface ConnectionView> I extends Transport.Tuple> >( ...invocations: I - ): Await> + ): Await> } export interface InboundAcceptCodec { @@ -835,6 +894,9 @@ export interface ServerView> Transport.Channel { context: InvocationContext catch: (err: HandlerExecutionError) => void + run( + invocation: ServiceInvocation + ): Await> } /** diff --git a/packages/interface/src/transport.ts b/packages/interface/src/transport.ts index 63901d54..6a370dfb 100644 --- a/packages/interface/src/transport.ts +++ b/packages/interface/src/transport.ts @@ -6,11 +6,14 @@ import type { import type { Phantom, Await } from '@ipld/dag-ucan' import * as UCAN from '@ipld/dag-ucan' import type { + Capability, ServiceInvocation, - InferWorkflowReceipts, + InferReceipts, InferInvocations, Receipt, + ByteView, Invocation, + AgentMessage, } from './lib.js' /** @@ -31,48 +34,50 @@ export interface RequestEncodeOptions extends EncodeOptions { accept?: string } -export interface Channel> extends Phantom { - request>>( - request: HTTPRequest - ): Await & Tuple>> +export interface Channel> extends Phantom { + request>>( + request: HTTPRequest< + AgentMessage<{ In: InferInvocations; Out: Tuple }> + > + ): Await< + HTTPResponse< + AgentMessage<{ Out: InferReceipts; In: Tuple }> + > + > } export interface RequestEncoder { - encode>( - invocations: I, + encode( + message: T, options?: RequestEncodeOptions - ): Await> + ): Await> } export interface RequestDecoder { - decode>( - request: HTTPRequest - ): Await> + decode(request: HTTPRequest): Await } export interface ResponseEncoder { - encode>>( - result: I, + encode( + message: T, options?: EncodeOptions - ): Await> + ): Await> } export interface ResponseDecoder { - decode>>( - response: HTTPResponse - ): Await + decode(response: HTTPResponse): Await } -export interface HTTPRequest extends Phantom { +export interface HTTPRequest { method?: string headers: Readonly> - body: Uint8Array + body: ByteView } -export interface HTTPResponse extends Phantom { +export interface HTTPResponse { status?: number headers: Readonly> - body: Uint8Array + body: ByteView } /** diff --git a/packages/server/src/server.js b/packages/server/src/server.js index a91a68b1..1c8f4ce6 100644 --- a/packages/server/src/server.js +++ b/packages/server/src/server.js @@ -7,7 +7,7 @@ export { Failure, MalformedCapability, } from '@ucanto/validator' -import { Receipt, ok, fail } from '@ucanto/core' +import { Receipt, ok, fail, Message, Failure } from '@ucanto/core' export { ok, fail } /** @@ -20,12 +20,12 @@ export { ok, fail } export const create = options => new Server(options) /** - * @template {Record} Service - * @implements {API.ServerView} + * @template {Record} S + * @implements {API.ServerView} */ class Server { /** - * @param {API.Server} options + * @param {API.Server} options */ constructor({ id, service, codec, principal = Verifier, ...rest }) { const { catch: fail, ...context } = rest @@ -39,18 +39,32 @@ class Server { } /** - * @type {API.Channel['request']} + * @template {API.Tuple>} I + * @param {API.HTTPRequest, Out: API.Tuple }>>} request + * @returns {Promise, In: API.Tuple }>>>} */ request(request) { return handle(this, request) } + + /** + * @template {API.Capability} C + * @param {API.ServiceInvocation} invocation + * @returns {Promise>} + */ + async run(invocation) { + const receipt = /** @type {API.InferReceipt} */ ( + await invoke(await invocation.buildIPLDView(), this) + ) + return receipt + } } /** - * @template {Record} T - * @template {API.Tuple>} I - * @param {API.ServerView} server - * @param {API.HTTPRequest} request + * @template {Record} S + * @template {API.Tuple>} I + * @param {API.ServerView} server + * @param {API.HTTPRequest, Out: API.Tuple }>>} request */ export const handle = async (server, request) => { const selection = server.codec.accept(request) @@ -63,39 +77,34 @@ export const handle = async (server, request) => { } } else { const { encoder, decoder } = selection.ok - const workflow = await decoder.decode(request) - const result = await execute(workflow, server) + const message = await decoder.decode(request) + const result = await execute(message, server) const response = await encoder.encode(result) return response } } /** - * @template {Record} Service - * @template {API.Capability} C - * @template {API.Tuple>} I - * @param {API.InferInvocations} workflow - * @param {API.ServerView} server - * @returns {Promise & API.Tuple>} + * @template {Record} S + * @template {API.Tuple} I + * @param {API.AgentMessage<{ In: API.InferInvocations, Out: API.Tuple }>} input + * @param {API.ServerView} server + * @returns {Promise, In: API.Tuple }>>} */ -export const execute = async (workflow, server) => { - const input = - /** @type {API.InferInvocation>[]} */ ( - workflow - ) +export const execute = async (input, server) => { + const promises = input.invocations.map($ => invoke($, server)) - const promises = input.map(invocation => invoke(invocation, server)) - const results = await Promise.all(promises) - - return /** @type {API.InferWorkflowReceipts & API.Tuple} */ ( - results + const receipts = /** @type {API.InferReceipts} */ ( + await Promise.all(promises) ) + + return Message.build({ receipts }) } /** * @template {Record} Service * @template {API.Capability} C - * @param {API.InferInvocation>} invocation + * @param {API.Invocation} invocation * @param {API.ServerView} server * @returns {Promise} */ @@ -134,6 +143,7 @@ export const invoke = async (invocation, server) => { result, }) } catch (cause) { + /** @type {API.HandlerExecutionError} */ const error = new HandlerExecutionError( capability, /** @type {Error} */ (cause) @@ -184,9 +194,9 @@ export class HandlerNotFound extends RangeError { } } -class HandlerExecutionError extends Error { +class HandlerExecutionError extends Failure { /** - * @param {API.ParsedCapability} capability + * @param {API.Capability} capability * @param {Error} cause */ constructor(capability, cause) { diff --git a/packages/server/test/server.spec.js b/packages/server/test/server.spec.js index eff6ecab..8451d356 100644 --- a/packages/server/test/server.spec.js +++ b/packages/server/test/server.spec.js @@ -1,4 +1,5 @@ import * as Client from '@ucanto/client' +import { invoke, Schema } from '@ucanto/core' import * as Server from '../src/lib.js' import * as CAR from '@ucanto/transport/car' import * as CBOR from '@ucanto/core/cbor' @@ -6,7 +7,6 @@ import * as Transport from '@ucanto/transport' import { alice, bob, mallory, service as w3 } from './fixtures.js' import * as Service from '../../client/test/service.js' import { test, assert } from './test.js' -import { Schema } from '@ucanto/validator' const storeAdd = Server.capability({ can: 'store/add', @@ -33,6 +33,7 @@ const storeAdd = Server.capability({ } }, }) + const storeRemove = Server.capability({ can: 'store/remove', with: Server.URI.match({ protocol: 'did:' }), @@ -417,3 +418,53 @@ test('falsy errors are turned into {}', async () => { ok: {}, }) }) + +test('run invocation without encode / decode', async () => { + const server = Server.create({ + service: Service.create(), + codec: CAR.inbound, + id: w3, + }) + + const identify = invoke({ + issuer: alice, + audience: w3, + capability: { + can: 'access/identify', + with: 'did:email:alice@mail.com', + }, + }) + + const register = await server.run(identify) + assert.deepEqual(register.out, { + ok: {}, + }) + + const car = await CAR.codec.write({ + roots: [await CBOR.write({ hello: 'world ' })], + }) + + const add = Client.invoke({ + issuer: alice, + audience: w3, + capability: { + can: 'store/add', + with: alice.did(), + nb: { + link: car.cid, + }, + }, + proofs: [], + }) + + const receipt = await server.run(add) + + assert.deepEqual(receipt.out, { + ok: { + link: car.cid, + status: 'upload', + url: 'http://localhost:9090/', + with: alice.did(), + }, + }) +}) diff --git a/packages/transport/package.json b/packages/transport/package.json index 851ac77e..44826461 100644 --- a/packages/transport/package.json +++ b/packages/transport/package.json @@ -23,7 +23,7 @@ "scripts": { "test:web": "playwright-test test/**/*.spec.js --cov && nyc report", "test:node": "c8 --check-coverage --branches 100 --functions 100 --lines 100 mocha test/**/*.spec.js", - "test": "npm run test:node", + "test": "c8 --check-coverage --branches 100 --functions 100 --lines 100 mocha --bail test/**/*.spec.js", "coverage": "c8 --reporter=html mocha test/**/*.spec.js && npm_config_yes=true npx st -d coverage -p 8080", "check": "tsc --build", "build": "tsc --build" diff --git a/packages/transport/src/car.js b/packages/transport/src/car.js index 11b63950..1cf92cd1 100644 --- a/packages/transport/src/car.js +++ b/packages/transport/src/car.js @@ -1,4 +1,3 @@ -import * as API from '@ucanto/interface' import { CAR } from '@ucanto/core' import * as request from './car/request.js' import * as response from './car/response.js' @@ -6,41 +5,22 @@ import * as Codec from './codec.js' export { CAR as codec, request, response } -export const contentType = 'application/car' - -const HEADERS = Object.freeze({ - 'content-type': 'application/car', -}) - -/** - * @deprecated - * @template {API.Tuple} I - * @param {I} invocations - * @param {API.EncodeOptions & { headers?: Record }} [options] - * @returns {Promise>} - */ -export const encode = (invocations, options) => - request.encode(invocations, { headers: HEADERS, ...options }) - -/** - * @deprecated - */ -export const decode = request.decode +export const contentType = CAR.contentType export const inbound = Codec.inbound({ decoders: { - 'application/car': request, + [request.contentType]: request, }, encoders: { - 'application/car': response, + [response.contentType]: response, }, }) export const outbound = Codec.outbound({ encoders: { - 'application/car': request, + [request.contentType]: request, }, decoders: { - 'application/car': response, + [response.contentType]: response, }, }) diff --git a/packages/transport/src/car/request.js b/packages/transport/src/car/request.js index 65cbd32b..36f1dd16 100644 --- a/packages/transport/src/car/request.js +++ b/packages/transport/src/car/request.js @@ -1,35 +1,40 @@ import * as API from '@ucanto/interface' -import { CAR } from '@ucanto/core' -import { Delegation } from '@ucanto/core' +import { CAR, Message } from '@ucanto/core' export { CAR as codec } +export const contentType = CAR.contentType + const HEADERS = Object.freeze({ - 'content-type': 'application/car', + 'content-type': contentType, // We will signal that we want to receive a CAR file in the response - accept: 'application/car', + accept: contentType, }) /** - * Encodes invocation batch into an HTTPRequest. + * Encodes `AgentMessage` into an `HTTPRequest`. * - * @template {API.Tuple} I - * @param {I} invocations + * @template {API.AgentMessage} Message + * @param {Message} message * @param {API.EncodeOptions & { headers?: Record }} [options] - * @returns {Promise>} + * @returns {API.HTTPRequest} */ -export const encode = async (invocations, options) => { - const roots = [] +export const encode = (message, options) => { 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()) + for (const block of message.iterateIPLDBlocks()) { + blocks.set(`${block.cid}`, block) } - const body = CAR.encode({ roots, blocks }) + + /** + * Cast to Uint8Array to remove phantom type set by the + * CAR encoder which is too specific. + * + * @type {Uint8Array} + */ + const body = CAR.encode({ + roots: [message.root], + blocks, + }) return { headers: options?.headers || HEADERS, @@ -38,32 +43,14 @@ export const encode = async (invocations, options) => { } /** - * Decodes HTTPRequest to an invocation batch. + * Decodes `AgentMessage` from the received `HTTPRequest`. * - * @template {API.Tuple} Invocations - * @param {API.HTTPRequest} request - * @returns {Promise>} + * @template {API.AgentMessage} Message + * @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) + const { roots, blocks } = CAR.decode(/** @type {Uint8Array} */ (body)) + const message = Message.view({ root: roots[0].cid, store: blocks }) + return /** @type {Message} */ (message) } diff --git a/packages/transport/src/car/response.js b/packages/transport/src/car/response.js index 855e4c07..80b12906 100644 --- a/packages/transport/src/car/response.js +++ b/packages/transport/src/car/response.js @@ -1,32 +1,37 @@ import * as API from '@ucanto/interface' -import { CAR } from '@ucanto/core' -import { Receipt } from '@ucanto/core' - +import { CAR, Message } from '@ucanto/core' export { CAR as codec } +export const contentType = CAR.contentType + const HEADERS = Object.freeze({ - 'content-type': 'application/car', + 'content-type': contentType, }) /** - * Encodes invocation batch into an HTTPRequest. + * Encodes `AgentMessage` into an `HTTPRequest`. * - * @template {API.Tuple} I - * @param {I} receipts + * @template {API.AgentMessage} Message + * @param {Message} message * @param {API.EncodeOptions} [options] - * @returns {Promise>} + * @returns {API.HTTPResponse} */ -export const encode = async (receipts, options) => { - const roots = [] +export const encode = (message, options) => { 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) - } + for (const block of message.iterateIPLDBlocks()) { + blocks.set(`${block.cid}`, block) } - const body = CAR.encode({ roots, blocks }) + + /** + * Cast to Uint8Array to remove phantom type set by the + * CAR encoder which is too specific. + * + * @type {Uint8Array} + */ + const body = CAR.encode({ + roots: [message.root], + blocks, + }) return { headers: HEADERS, @@ -35,32 +40,14 @@ export const encode = async (receipts, options) => { } /** - * Decodes HTTPRequest to an invocation batch. + * Decodes `AgentMessage` from the received `HTTPResponse`. * - * @template {API.Tuple} I - * @param {API.HTTPRequest} request - * @returns {I} + * @template {API.AgentMessage} Message + * @param {API.HTTPResponse} response + * @returns {Promise} */ -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.Block[]} */ (roots)) { - receipts.push( - Receipt.view({ - root: root.cid, - blocks: /** @type {Map} */ (blocks), - }) - ) - } - - return /** @type {I} */ (receipts) +export const decode = async ({ headers, body }) => { + const { roots, blocks } = CAR.decode(/** @type {Uint8Array} */ (body)) + const message = Message.view({ root: roots[0].cid, store: blocks }) + return /** @type {Message} */ (message) } diff --git a/packages/transport/src/codec.js b/packages/transport/src/codec.js index 1a593d9e..0d10ad80 100644 --- a/packages/transport/src/codec.js +++ b/packages/transport/src/codec.js @@ -125,18 +125,18 @@ class Outbound { } /** - * @template {API.Tuple} I - * @param {I} workflow + * @template {API.AgentMessage} Message + * @param {Message} message */ - encode(workflow) { - return this.encoder.encode(workflow, { + encode(message) { + return this.encoder.encode(message, { accept: this.acceptType, }) } /** - * @template {API.Tuple} I - * @param {API.HTTPResponse} response - * @returns {API.Await} + * @template {API.AgentMessage} Message + * @param {API.HTTPResponse} response + * @returns {API.Await} */ decode(response) { const { headers } = response @@ -148,7 +148,6 @@ class Outbound { throw Object.assign( new RangeError(new TextDecoder().decode(response.body)), { - error: true, status: response.status, headers: response.headers, } diff --git a/packages/transport/src/http.js b/packages/transport/src/http.js index 97db4834..fb953032 100644 --- a/packages/transport/src/http.js +++ b/packages/transport/src/http.js @@ -11,15 +11,15 @@ import * as API from '@ucanto/interface' * statusText?: string * url?: string * }} FetchResponse - * @typedef {(url:string, init:API.HTTPRequest>) => API.Await} Fetcher + * @typedef {(url:string, init:API.HTTPRequest) => API.Await} Fetcher */ /** - * @template T + * @template S * @param {object} options * @param {URL} options.url - * @param {(url:string, init:API.HTTPRequest>) => API.Await} [options.fetch] + * @param {(url:string, init:API.HTTPRequest) => API.Await} [options.fetch] * @param {string} [options.method] - * @returns {API.Channel} + * @returns {API.Channel} */ export const open = ({ url, method = 'POST', fetch }) => { /* c8 ignore next 9 */ @@ -34,6 +34,11 @@ export const open = ({ url, method = 'POST', fetch }) => { } return new Channel({ url, method, fetch }) } + +/** + * @template {Record} S + * @implements {API.Channel} + */ class Channel { /** * @param {object} options @@ -47,8 +52,9 @@ class Channel { this.url = url } /** - * @param {API.HTTPRequest} request - * @returns {Promise} + * @template {API.Tuple>} I + * @param {API.HTTPRequest, Out: API.Tuple }>>} request + * @returns {Promise, In: API.Tuple }>>>} */ async request({ headers, body }) { const response = await this.fetch(this.url.href, { diff --git a/packages/transport/src/jwt.js b/packages/transport/src/jwt.js deleted file mode 100644 index 195e9a6b..00000000 --- a/packages/transport/src/jwt.js +++ /dev/null @@ -1,97 +0,0 @@ -import * as API from '@ucanto/interface' -import * as UTF8 from './utf8.js' -import { Delegation, UCAN } from '@ucanto/core' - -const HEADER_PREFIX = 'x-auth-' - -const HEADERS = Object.freeze({ - 'content-type': 'application/json', -}) - -/** - * Encodes invocation batch into an HTTPRequest. - * - * @template {API.Tuple} I - * @param {I} batch - * @returns {Promise>} - */ -export const encode = async batch => { - /** @type {Record} */ - const headers = { ...HEADERS } - /** @type {string[]} */ - const body = [] - for (const invocation of batch) { - const delegation = await invocation.delegate() - - body.push(`${delegation.cid}`) - for (const proof of iterate(delegation)) { - headers[`${HEADER_PREFIX}${proof.cid}`] = UCAN.format(proof.data) - } - headers[`${HEADER_PREFIX}${delegation.cid}`] = UCAN.format(delegation.data) - } - - return { - headers, - body: UTF8.encode(JSON.stringify(body)), - } -} - -/** - * @param {API.Delegation} delegation - * @return {IterableIterator} - */ -const iterate = function* (delegation) { - for (const proof of delegation.proofs) { - if (!Delegation.isLink(proof)) { - yield* iterate(proof) - yield proof - } - } -} - -/** - * Decodes HTTPRequest to an invocation batch. - * - * @template {API.Tuple} I - * @param {API.HTTPRequest} request - * @returns {Promise>} - */ -export const decode = async ({ headers, body }) => { - const contentType = headers['content-type'] || headers['Content-Type'] - if (contentType !== 'application/json') { - throw TypeError( - `Only 'content-type: application/json' is supported, instead got '${contentType}'` - ) - } - /** @type {API.Block[]} */ - const invocations = [] - const blocks = new Map() - for (const [name, value] of Object.entries(headers)) { - if (name.startsWith(HEADER_PREFIX)) { - const key = name.slice(HEADER_PREFIX.length) - const data = UCAN.parse(/** @type {UCAN.JWT} */ (value)) - const { cid, bytes } = await UCAN.write(data) - - if (cid.toString() != key) { - throw TypeError( - `Invalid request, proof with key ${key} has mismatching cid ${cid}` - ) - } - blocks.set(cid.toString(), { cid, bytes }) - } - } - - for (const cid of JSON.parse(UTF8.decode(body))) { - const root = blocks.get(cid.toString()) - if (!root) { - throw TypeError( - `Invalid request proof of invocation ${cid} is not provided` - ) - } else { - invocations.push(Delegation.create({ root, blocks })) - blocks.delete(cid.toString()) - } - } - - return /** @type {API.InferInvocations} */ (invocations) -} diff --git a/packages/transport/src/legacy.js b/packages/transport/src/legacy.js index 6c679407..eb31f987 100644 --- a/packages/transport/src/legacy.js +++ b/packages/transport/src/legacy.js @@ -1,36 +1,7 @@ -import * as API from '@ucanto/interface' import * as Codec from './codec.js' import * as CAR from './car.js' -import { encode as encodeCBOR } from '@ucanto/core/cbor' - -export const CBOR = { - /** - * Encodes receipts into a legacy CBOR representation. - * - * @template {API.Tuple} I - * @param {I} receipts - * @param {API.EncodeOptions} [options] - * @returns {API.HTTPResponse} - */ - encode(receipts, options) { - const legacyResults = [] - for (const receipt of receipts) { - const result = receipt.out - if (result.ok) { - legacyResults.push(result.ok) - } else { - legacyResults.push({ - ...result.error, - error: true, - }) - } - } - return /** @type {API.HTTPResponse} */ ({ - headers: { 'content-type': 'application/cbor' }, - body: encodeCBOR(legacyResults), - }) - }, -} +import * as response from './legacy/response.js' +import * as request from './legacy/request.js' /** * This is an inbound codec designed to support legacy clients and encode @@ -38,18 +9,19 @@ export const CBOR = { */ export const inbound = Codec.inbound({ decoders: { - 'application/car': CAR.request, + [request.contentType]: request, + [CAR.contentType]: CAR.request, }, encoders: { // Here we configure encoders such that if accept header is `*/*` (which is // the default if omitted) we will encode the response in CBOR. If - // `application/car` is set we will encode the response in current format - // is CAR. + // `application/vnd.ipld.car` is set we will encode the response in current + // format. // Here we exploit the fact that legacy clients do not send an accept header // and therefore will get response in legacy format. New clients on the other - // hand will send `application/car` and consequently get response in current - // format. - '*/*;q=0.1': CBOR, - 'application/car': CAR.response, + // hand will send `application/vnd.ipld.car` and consequently get response + // in current format. + '*/*;q=0.1': response, + [CAR.contentType]: CAR.response, }, }) diff --git a/packages/transport/src/legacy/request.js b/packages/transport/src/legacy/request.js new file mode 100644 index 00000000..20c46508 --- /dev/null +++ b/packages/transport/src/legacy/request.js @@ -0,0 +1,29 @@ +import * as CAR from '@ucanto/core/car' +import * as API from '@ucanto/interface' +import { Invocation, Message } from '@ucanto/core' + +export const contentType = 'application/car' + +/** + * @template {API.AgentMessage} Message + * @param {API.HTTPRequest} request + */ +export const decode = async ({ body }) => { + const { roots, blocks } = CAR.decode(/** @type {Uint8Array} */ (body)) + /** @type {API.IssuedInvocation[]} */ + const run = [] + for (const { cid } of roots) { + // We don't have a way to know if the root matches a ucan link. + const invocation = Invocation.view({ + root: /** @type {API.Link} */ (cid), + blocks, + }) + run.push(invocation) + } + + const message = await Message.build({ + invocations: /** @type {API.Tuple} */ (run), + }) + + return /** @type {Message} */ (message) +} diff --git a/packages/transport/src/legacy/response.js b/packages/transport/src/legacy/response.js new file mode 100644 index 00000000..af9e1982 --- /dev/null +++ b/packages/transport/src/legacy/response.js @@ -0,0 +1,38 @@ +import * as API from '@ucanto/interface' +import * as CBOR from '@ucanto/core/cbor' +export const contentType = 'application/cbor' + +const HEADERS = Object.freeze({ + 'content-type': contentType, +}) + +/** + * Encodes `AgentMessage` into a legacy CBOR representation. + * + * @template {API.AgentMessage} Message + * @param {Message} message + * @param {API.EncodeOptions} [options] + * @returns {API.HTTPResponse} + */ +export const encode = (message, options) => { + const legacyResults = [] + for (const receipt of message.receipts.values()) { + const result = receipt.out + if (result.ok) { + legacyResults.push(result.ok) + } else { + legacyResults.push({ + ...result.error, + error: true, + }) + } + } + + /** @type {Uint8Array} */ + const body = CBOR.encode(legacyResults) + + return /** @type {API.HTTPResponse} */ ({ + headers: HEADERS, + body, + }) +} diff --git a/packages/transport/src/lib.js b/packages/transport/src/lib.js index 3705784a..000ffa5f 100644 --- a/packages/transport/src/lib.js +++ b/packages/transport/src/lib.js @@ -1,5 +1,4 @@ export * as CAR from './car.js' -export * as JWT from './jwt.js' export * as HTTP from './http.js' export * as UTF8 from './utf8.js' export * as Legacy from './legacy.js' diff --git a/packages/transport/src/utf8.js b/packages/transport/src/utf8.js index 452f062b..ce0797f1 100644 --- a/packages/transport/src/utf8.js +++ b/packages/transport/src/utf8.js @@ -5,11 +5,11 @@ export const decoder = new TextDecoder() * @param {string} text * @returns {Uint8Array} */ -export const encode = (text) => encoder.encode(text) +export const encode = text => encoder.encode(text) /** * * @param {Uint8Array} bytes * @returns {string} */ -export const decode = (bytes) => decoder.decode(bytes) +export const decode = bytes => decoder.decode(bytes) diff --git a/packages/transport/test/car.spec.js b/packages/transport/test/car.spec.js index 61ad25b9..b1e7c9c4 100644 --- a/packages/transport/test/car.spec.js +++ b/packages/transport/test/car.spec.js @@ -1,119 +1,48 @@ import { test, assert } from './test.js' import * as CAR from '../src/car.js' -import { - delegate, - invoke, - Receipt, - Delegation, - UCAN, - parseLink, -} from '@ucanto/core' +import { delegate, invoke, Receipt, Message, UCAN } from '@ucanto/core' import { alice, bob, service } from './fixtures.js' -import { collect } from './util.js' +import * as API from '@ucanto/interface' test('encode / decode', async () => { - const cid = parseLink( - 'bafyreiaxnmoptsqiehdff2blpptvdbenxcz6xgrbojw5em36xovn2xea4y' - ) - const expiration = 1654298135 - - const request = await CAR.encode([ - invoke({ - issuer: alice, - audience: bob, - capability: { - can: 'store/add', - with: alice.did(), - }, - expiration, - proofs: [], - }), - ]) + const { message, delegation, outgoing, incoming } = await setup() - assert.deepEqual(request.headers, { - 'content-type': 'application/car', + assert.deepEqual(outgoing.headers, { + 'content-type': 'application/vnd.ipld.car', + accept: 'application/vnd.ipld.car', }) - const expect = await Delegation.delegate({ - issuer: alice, - audience: bob, - capabilities: [ - { - can: 'store/add', - with: alice.did(), + assertDecode(incoming, { + root: message.root, + data: { + 'ucanto/message@0.6.0': { + execute: [delegation.cid], }, - ], - expiration, - proofs: [], + }, }) - - assert.deepEqual([expect], await CAR.decode(request), 'roundtrips') }) -test('decode requires application/car content type', async () => { - const { body } = await CAR.encode([ - invoke({ - issuer: alice, - audience: bob, - capability: { - can: 'store/add', - with: alice.did(), - }, - proofs: [], - }), - ]) +test('accepts Content-Type as well', async () => { + const { message, delegation, outgoing } = await setup() - try { - await CAR.decode({ - body, + assertDecode( + await CAR.request.decode({ + ...outgoing, headers: { - 'content-type': 'application/octet-stream', + 'Content-Type': 'application/car', }, - }) - assert.fail('expected to fail') - } catch (error) { - assert.match(String(error), /content-type: application\/car/) - } -}) - -test('accepts Content-Type as well', async () => { - const expiration = UCAN.now() + 90 - const request = await CAR.encode([ - invoke({ - issuer: alice, - audience: bob, - capability: { - can: 'store/add', - with: alice.did(), - }, - proofs: [], - expiration, }), - ]) - - const [invocation] = await CAR.decode({ - ...request, - headers: { - 'Content-Type': 'application/car', - }, - }) - - const delegation = await delegate({ - issuer: alice, - audience: bob, - capabilities: [ - { - can: 'store/add', - with: alice.did(), + { + root: message.root, + data: { + 'ucanto/message@0.6.0': { + execute: [delegation.cid], + }, }, - ], - proofs: [], - expiration, - }) - - assert.deepEqual({ ...request }, { ...(await CAR.encode([delegation])) }) + } + ) - assert.deepEqual(invocation.bytes, delegation.bytes) + assert.deepEqual(message.invocations[0].bytes, delegation.bytes) }) test('delegated proofs', async () => { @@ -128,39 +57,12 @@ test('delegated proofs', async () => { ], }) - const expiration = UCAN.now() + 90 + const { invocation, incoming } = await setup({ proofs: [proof] }) - const outgoing = await CAR.encode([ - invoke({ - issuer: bob, - audience: service, - capability: { - can: 'store/add', - with: alice.did(), - }, - proofs: [proof], - expiration, - }), - ]) + const { invocations } = incoming + assert.deepEqual(invocations, [await invocation.delegate()]) - const incoming = await CAR.decode(outgoing) - - assert.deepEqual(incoming, [ - await delegate({ - issuer: bob, - audience: service, - capabilities: [ - { - can: 'store/add', - with: alice.did(), - }, - ], - expiration, - proofs: [proof], - }), - ]) - - assert.deepEqual(incoming[0].proofs, [proof]) + assert.deepEqual(invocations[0].proofs, [proof]) }) test('omit proof', async () => { @@ -175,24 +77,10 @@ test('omit proof', async () => { ], }) - const expiration = UCAN.now() + 90 + const { incoming } = await setup({ proofs: [proof.cid] }) - const outgoing = await CAR.encode([ - invoke({ - issuer: bob, - audience: service, - capability: { - can: 'store/add', - with: alice.did(), - }, - proofs: [proof.cid], - expiration, - }), - ]) - - const incoming = await CAR.decode(outgoing) - - assert.deepEqual(incoming, [ + const { invocations } = incoming + assert.deepEqual(invocations, [ await delegate({ issuer: bob, audience: service, @@ -207,47 +95,25 @@ test('omit proof', async () => { }), ]) - assert.deepEqual(incoming[0].proofs, [proof.cid]) + assert.deepEqual(invocations[0].proofs, [proof.cid]) }) 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: [], - }), - ]) + const { outgoing, incoming, message, delegation } = await setup() - assert.deepEqual(request.headers, { - 'content-type': 'application/car', - accept: 'application/car', + assert.deepEqual(outgoing.headers, { + 'content-type': 'application/vnd.ipld.car', + accept: 'application/vnd.ipld.car', }) - const expect = await Delegation.delegate({ - issuer: alice, - audience: bob, - capabilities: [ - { - can: 'store/add', - with: alice.did(), + assertDecode(incoming, { + root: message.root, + data: { + 'ucanto/message@0.6.0': { + execute: [delegation.cid], }, - ], - expiration, - proofs: [], + }, }) - - assert.deepEqual([expect], await CAR.request.decode(request), 'roundtrips') }) test('CAR.response encode/decode', async () => { @@ -269,24 +135,59 @@ test('CAR.response encode/decode', async () => { meta: { test: 'run' }, }) - const message = await CAR.response.encode([receipt]) - assert.deepEqual(message.headers, { - 'content-type': 'application/car', + const message = await Message.build({ receipts: [receipt] }) + const request = CAR.response.encode(message) + assert.deepEqual(request.headers, { + 'content-type': 'application/vnd.ipld.car', }) - const [received, ...other] = await CAR.response.decode(message) - assert.equal(other.length, 0) + const replica = await CAR.response.decode(request) + const [received, ...receipts] = replica.receipts.values() + + assert.equal(receipts.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.deepEqual( + received.signature, + /** @type {object} */ (receipt.signature) + ) +}) + +const expiration = UCAN.now() + 90 - assert.throws(() => { - CAR.response.decode({ - headers: {}, - body: message.body, - }) +/** + * @param {object} source + * @param {API.Proof[]} [source.proofs] + */ +const setup = async ({ proofs = [] } = {}) => { + const invocation = invoke({ + issuer: bob, + audience: service, + capability: { + can: 'store/add', + with: alice.did(), + }, + expiration, + proofs, }) -}) + const delegation = await invocation.delegate() + const message = await Message.build({ invocations: [invocation] }) + const outgoing = await CAR.request.encode(message) + const incoming = await CAR.request.decode(outgoing) + + return { invocation, delegation, message, outgoing, incoming } +} + +/** + * @param {API.AgentMessage} actual + * @param {object} expect + * @param {API.Block} expect.root + * @param {API.AgentMessageModel<*>} expect.data + */ +const assertDecode = (actual, expect) => { + assert.deepEqual(actual.root, expect.root, 'roundtrips') + assert.deepEqual(actual.root.data, expect.data) +} diff --git a/packages/transport/test/codec.spec.js b/packages/transport/test/codec.spec.js index 82d555c1..0f973da6 100644 --- a/packages/transport/test/codec.spec.js +++ b/packages/transport/test/codec.spec.js @@ -2,11 +2,11 @@ import { test, assert } from './test.js' import * as CAR from '../src/car.js' import * as Transport from '../src/lib.js' import { alice, bob } from './fixtures.js' -import { invoke, delegate, parseLink, Receipt } from '@ucanto/core' +import { invoke, delegate, parseLink, Receipt, Message } from '@ucanto/core' test('unsupported inbound content-type', async () => { const accept = CAR.inbound.accept({ - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/car' }, body: new Uint8Array(), }) @@ -15,7 +15,7 @@ test('unsupported inbound content-type', async () => { status: 415, message: `The server cannot process the request because the payload format is not supported. Please check the content-type header and try again with a supported media type.`, headers: { - accept: `application/car`, + accept: 'application/vnd.ipld.car', }, }, }) @@ -23,7 +23,10 @@ test('unsupported inbound content-type', async () => { test('unsupported inbound accept type', async () => { const accept = CAR.inbound.accept({ - headers: { 'content-type': 'application/car', accept: 'application/cbor' }, + headers: { + 'content-type': 'application/vnd.ipld.car', + accept: 'application/car', + }, body: new Uint8Array(), }) @@ -32,7 +35,7 @@ test('unsupported inbound accept type', async () => { status: 406, message: `The requested resource cannot be served in the requested content type. Please specify a supported content type using the Accept header.`, headers: { - accept: `application/car`, + accept: `application/vnd.ipld.car`, }, }, }) @@ -64,64 +67,52 @@ test(`requires encoders / decoders`, async () => { }) test('outbound encode', async () => { - const cid = parseLink( - 'bafyreiaxnmoptsqiehdff2blpptvdbenxcz6xgrbojw5em36xovn2xea4y' - ) const expiration = 1654298135 + const message = await Message.build({ + invocations: [ + invoke({ + issuer: alice, + audience: bob, + capability: { + can: 'store/add', + with: alice.did(), + }, + expiration, + proofs: [], + }), + ], + }) - const request = await CAR.outbound.encode([ - invoke({ - issuer: alice, - audience: bob, - capability: { - can: 'store/add', - with: alice.did(), - }, - expiration, - proofs: [], - }), - ]) + const request = await CAR.outbound.encode(message) assert.deepEqual(request.headers, { - 'content-type': 'application/car', - accept: 'application/car', - }) - - const expect = await delegate({ - issuer: alice, - audience: bob, - capabilities: [ - { - can: 'store/add', - with: alice.did(), - }, - ], - expiration, - proofs: [], + 'content-type': 'application/vnd.ipld.car', + accept: 'application/vnd.ipld.car', }) assert.deepEqual( - [expect], - await CAR.inbound.accept(request).ok?.decoder.decode(request), + message.root, + (await CAR.inbound.accept(request).ok?.decoder.decode(request))?.root, 'roundtrips' ) }) test('outbound decode', async () => { - const { success, failure } = await buildPayload() + const { success, failure } = await setup() + const message = await Message.build({ receipts: [success, failure] }) - const response = await CAR.response.encode([success, failure]) - const receipts = await CAR.outbound.decode(response) + const response = await CAR.response.encode(message) + const replica = await CAR.outbound.decode(response) assert.deepEqual( - receipts.map($ => $.root), - [success.root, failure.root] + [...replica.receipts.keys()], + [success.ran.link().toString(), failure.ran.link().toString()] ) }) test('inbound supports Content-Type header', async () => { const accept = await CAR.inbound.accept({ - headers: { 'Content-Type': 'application/car' }, + headers: { 'Content-Type': 'application/vnd.ipld.car' }, body: new Uint8Array(), }) @@ -129,15 +120,16 @@ test('inbound supports Content-Type header', async () => { }) test('outbound supports Content-Type header', async () => { - const { success } = await buildPayload() - const { body } = await CAR.response.encode([success]) + const { success } = await setup() + const message = await Message.build({ receipts: [success] }) + const { body } = await CAR.response.encode(message) - const receipts = await CAR.outbound.decode({ - headers: { 'Content-Type': 'application/car' }, + const replica = await CAR.outbound.decode({ + headers: { 'Content-Type': 'application/vnd.ipld.car' }, body, }) - assert.deepEqual(receipts[0].root, success.root) + assert.deepEqual(replica.get(success.ran.link()).root, success.root) }) test('inbound encode preference', async () => { @@ -162,9 +154,10 @@ test('inbound encode preference', async () => { }) test('unsupported response content-type', async () => { - const { success } = await buildPayload() + const { success } = await setup() + const message = await Message.build({ receipts: [success] }) - const response = await CAR.response.encode([success]) + const response = await CAR.response.encode(message) const badContentType = await wait(() => CAR.outbound.decode({ @@ -202,9 +195,9 @@ test('format media type', async () => { ) }) -const buildPayload = async () => { +const setup = async () => { const expiration = 1654298135 - const ran = await delegate({ + const hi = await delegate({ issuer: alice, audience: bob, capabilities: [ @@ -217,19 +210,32 @@ const buildPayload = async () => { proofs: [], }) + const boom = await delegate({ + issuer: alice, + audience: bob, + capabilities: [ + { + can: 'debug/error', + with: alice.did(), + }, + ], + expiration, + proofs: [], + }) + const success = await Receipt.issue({ - ran: ran.cid, + ran: hi.cid, issuer: bob, result: { ok: { hello: 'message' } }, }) const failure = await Receipt.issue({ - ran: ran.cid, + ran: boom.cid, issuer: bob, result: { error: { message: 'Boom' } }, }) - return { ran, success, failure } + return { hi, boom, success, failure } } /** diff --git a/packages/transport/test/jwt.spec.js b/packages/transport/test/jwt.spec.js deleted file mode 100644 index 14d91432..00000000 --- a/packages/transport/test/jwt.spec.js +++ /dev/null @@ -1,271 +0,0 @@ -import { test, assert } from './test.js' -import * as JWT from '../src/jwt.js' -import { delegate, invoke, Delegation, UCAN } from '@ucanto/core' -import * as UTF8 from '../src/utf8.js' -import { alice, bob, service } from './fixtures.js' - -const NOW = 1654298135 - -const fixtures = { - basic: { - cid: 'bafyreiaxnmoptsqiehdff2blpptvdbenxcz6xgrbojw5em36xovn2xea4y', - jwt: 'eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsInVjdiI6IjAuOS4xIn0.eyJhdHQiOlt7ImNhbiI6InN0b3JlL2FkZCIsIndpdGgiOiJkaWQ6a2V5Ono2TWtrODliQzNKclZxS2llNzFZRWNjNU0xU01WeHVDZ054NnpMWjhTWUpzeEFMaSJ9XSwiYXVkIjoiZGlkOmtleTp6Nk1rZmZEWkNrQ1RXcmVnODg2OGZHMUZHRm9nY0pqNVg2UFk5M3BQY1dEbjlib2IiLCJleHAiOjE2NTQyOTgxMzUsImlzcyI6ImRpZDprZXk6ejZNa2s4OWJDM0pyVnFLaWU3MVlFY2M1TTFTTVZ4dUNnTng2ekxaOFNZSnN4QUxpIiwicHJmIjpbXX0.amtDCzx4xzI28w8M4gKCOBWuhREPPAh8cdoXfi4JDTMy5wxy-4VYYM4AC7lXufsgdiT6thaBtq3AAIv1P87lAA', - }, -} - -test('encode / decode', async () => { - const { cid, jwt } = fixtures.basic - - const request = await JWT.encode([ - invoke({ - issuer: alice, - audience: bob, - capability: { - can: 'store/add', - with: alice.did(), - }, - expiration: NOW, - proofs: [], - }), - ]) - - const expect = { - body: UTF8.encode(JSON.stringify([cid])), - headers: { - 'content-type': 'application/json', - [`x-auth-${cid}`]: jwt, - }, - } - - assert.deepEqual(request, expect) - - assert.deepEqual( - await JWT.decode(request), - [ - await Delegation.delegate({ - issuer: alice, - audience: bob, - capabilities: [ - { - can: 'store/add', - with: alice.did(), - }, - ], - expiration: NOW, - proofs: [], - }), - ], - 'roundtrips' - ) -}) - -test('decode requires application/json contet type', async () => { - const { cid, jwt } = fixtures.basic - - try { - await JWT.decode({ - body: UTF8.encode(JSON.stringify([cid])), - headers: { - [`x-auth-${cid}`]: jwt, - }, - }) - assert.fail('expected to fail') - } catch (error) { - assert.match(String(error), /content-type: application\/json/) - } -}) - -test('delegated proofs', async () => { - const proof = await delegate({ - issuer: alice, - audience: bob, - capabilities: [ - { - can: 'store/add', - with: alice.did(), - }, - ], - }) - - const expiration = UCAN.now() + 90 - - const outgoing = await JWT.encode([ - invoke({ - issuer: bob, - audience: service, - capability: { - can: 'store/add', - with: alice.did(), - }, - proofs: [proof], - expiration, - }), - ]) - - assert.equal(Object.keys(outgoing.headers).length, 3) - - const incoming = await JWT.decode(outgoing) - - assert.deepEqual(incoming, [ - await delegate({ - issuer: bob, - audience: service, - capabilities: [ - { - can: 'store/add', - with: alice.did(), - }, - ], - expiration, - proofs: [proof], - }), - ]) - - assert.deepEqual(incoming[0].proofs, [proof]) -}) - -test('omit proof', async () => { - const proof = await delegate({ - issuer: alice, - audience: bob, - capabilities: [ - { - can: 'store/add', - with: alice.did(), - }, - ], - }) - - const expiration = UCAN.now() + 90 - - const outgoing = await JWT.encode([ - invoke({ - issuer: bob, - audience: service, - capability: { - can: 'store/add', - with: alice.did(), - }, - proofs: [proof.cid], - expiration, - }), - ]) - - assert.equal(Object.keys(outgoing.headers).length, 2) - - const incoming = await JWT.decode(outgoing) - - assert.deepEqual(incoming, [ - await delegate({ - issuer: bob, - audience: service, - capabilities: [ - { - can: 'store/add', - with: alice.did(), - }, - ], - expiration, - proofs: [proof.cid], - }), - ]) - - assert.deepEqual(incoming[0].proofs, [proof.cid]) -}) - -test('thorws on invalid heard', async () => { - const proof = await delegate({ - issuer: alice, - audience: bob, - capabilities: [ - { - can: 'store/add', - with: alice.did(), - }, - ], - }) - - const expiration = UCAN.now() + 90 - - const request = await JWT.encode([ - invoke({ - issuer: bob, - audience: service, - capability: { - can: 'store/add', - with: alice.did(), - }, - proofs: [proof], - expiration, - }), - ]) - - const { [`x-auth-${proof.cid}`]: jwt, ...headers } = request.headers - - try { - await JWT.decode({ - ...request, - headers: { - ...headers, - [`x-auth-bafyreigw75rhf7gf7eubwmrhovcrdu4mfy6pfbi4wgbzlfieq2wlfsza5i`]: - request.headers[`x-auth-${proof.cid}`], - }, - }) - assert.fail('expected to fail') - } catch (error) { - assert.match(String(error), /has mismatching cid/) - } -}) - -test('leaving out root throws', async () => { - const proof = await delegate({ - issuer: alice, - audience: bob, - capabilities: [ - { - can: 'store/add', - with: alice.did(), - }, - ], - }) - - const expiration = UCAN.now() + 90 - - const request = await JWT.encode([ - invoke({ - issuer: bob, - audience: service, - capability: { - can: 'store/add', - with: alice.did(), - }, - proofs: [proof], - expiration, - }), - ]) - - const { cid } = await delegate({ - issuer: bob, - audience: service, - capabilities: [ - { - can: 'store/add', - with: alice.did(), - }, - ], - proofs: [proof], - expiration, - }) - - const { [`x-auth-${cid}`]: jwt, ...headers } = request.headers - - try { - await JWT.decode({ - ...request, - headers, - }) - assert.fail('expected to fail') - } catch (error) { - assert.match(String(error), /invocation .* is not provided/) - } -}) diff --git a/packages/transport/test/legacy.spec.js b/packages/transport/test/legacy.spec.js index 834f4467..7989149f 100644 --- a/packages/transport/test/legacy.spec.js +++ b/packages/transport/test/legacy.spec.js @@ -1,27 +1,30 @@ +import * as API from '@ucanto/interface' import { test, assert } from './test.js' import * as CAR from '../src/car.js' import * as Legacy from '../src/legacy.js' -import { invoke, Receipt, Delegation, CBOR } from '@ucanto/core' -import { alice, bob } from './fixtures.js' +import { invoke, Receipt, Message, CBOR } from '@ucanto/core' +import { alice, bob, service } from './fixtures.js' test('Legacy decode / encode', async () => { const expiration = 1654298135 + const invocation = invoke({ + issuer: alice, + audience: bob, + capability: { + can: 'store/add', + with: alice.did(), + }, + expiration, + proofs: [], + }) + const message = await Message.build({ + invocations: [invocation], + }) - const source = await CAR.outbound.encode([ - invoke({ - issuer: alice, - audience: bob, - capability: { - can: 'store/add', - with: alice.did(), - }, - expiration, - proofs: [], - }), - ]) + const source = await CAR.outbound.encode(message) const accept = await Legacy.inbound.accept({ - headers: { 'content-type': 'application/car' }, + headers: { 'content-type': 'application/vnd.ipld.car' }, body: source.body, }) if (accept.error) { @@ -29,22 +32,14 @@ test('Legacy decode / encode', async () => { } const { encoder, decoder } = accept.ok - const workflow = await decoder.decode(source) + const request = await decoder.decode(source) - const expect = await Delegation.delegate({ - issuer: alice, - audience: bob, - capabilities: [ - { - can: 'store/add', - with: alice.did(), - }, - ], - expiration, - proofs: [], - }) - - assert.deepEqual([expect], workflow, 'roundtrips') + const expect = await invocation.delegate() + assert.deepEqual( + [expect.link()], + request.invocations.map($ => $.link()), + 'roundtrips' + ) const success = await Receipt.issue({ ran: expect.cid, @@ -58,13 +53,60 @@ test('Legacy decode / encode', async () => { result: { error: { message: 'Boom' } }, }) - const response = await encoder.encode([success, failure]) + const output = await Message.build({ receipts: [success, failure] }) + const response = await encoder.encode(output) const results = await CBOR.decode(response.body) assert.deepEqual( results, - // we want to return to old clients. + // @ts-expect-error - we want to return to old clients. [{ hello: 'message' }, { error: true, message: 'Boom' }], 'roundtrips' ) }) + +test('decode legacy invocation format', async () => { + const expiration = 1654298135 + const add = await invoke({ + issuer: alice, + audience: service, + capability: { + can: 'store/add', + with: alice.did(), + }, + expiration, + proofs: [], + }).buildIPLDView() + + const greet = await invoke({ + issuer: alice, + audience: service, + capability: { + can: 'test/echo', + with: 'data:hello', + }, + expiration, + proofs: [], + }).buildIPLDView() + + const roots = [add, greet] + const blocks = new Map() + for (const root of roots) { + for (const block of root.iterateIPLDBlocks()) { + blocks.set(`${block.cid}`, block) + } + } + + const request = { + headers: { 'content-type': 'application/car' }, + body: /** @type {Uint8Array} */ (await CAR.codec.encode({ roots, blocks })), + } + + const codec = Legacy.inbound.accept(request) + if (codec.error) { + return assert.fail('expected to accept legacy invocation') + } + const message = await codec.ok.decoder.decode(request) + + assert.deepEqual(message.invocations, roots) +}) diff --git a/packages/transport/test/utf8.spec.js b/packages/transport/test/utf8.spec.js new file mode 100644 index 00000000..4ed69867 --- /dev/null +++ b/packages/transport/test/utf8.spec.js @@ -0,0 +1,6 @@ +import { test, assert } from './test.js' +import * as UTF8 from '../src/utf8.js' + +test('encode <-> decode', async () => { + assert.deepEqual(UTF8.decode(UTF8.encode('hello')), 'hello') +})