diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index 54512ae4..cb8d7ee4 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -11,8 +11,6 @@ on: - 'packages/transport/**' - 'packages/client/**' pull_request: - branches: - - main paths: - 'packages/interface/**' - 'packages/core/**' diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index dbc65abb..2b9b443e 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -9,8 +9,6 @@ on: - 'packages/core/**' - 'packages/interface/**' pull_request: - branches: - - main paths: - 'packages/core/**' - 'packages/interface/**' diff --git a/.github/workflows/interface.yml b/.github/workflows/interface.yml index c7c9fbf6..027ef592 100644 --- a/.github/workflows/interface.yml +++ b/.github/workflows/interface.yml @@ -8,8 +8,6 @@ on: paths: - 'packages/interface/**' pull_request: - branches: - - main paths: - 'packages/interface/**' - '.github/workflows/interface.yml' diff --git a/.github/workflows/principal.yml b/.github/workflows/principal.yml index c3255cdf..8397096d 100644 --- a/.github/workflows/principal.yml +++ b/.github/workflows/principal.yml @@ -9,8 +9,6 @@ on: - 'packages/interface/**' - 'packages/principal/**' pull_request: - branches: - - main paths: - 'packages/interface/**' - 'packages/principal/**' diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index 560b5f00..d3437e2d 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -13,8 +13,6 @@ on: - 'packages/validator/**' - 'packages/server/**' pull_request: - branches: - - main paths: - 'packages/interface/**' - 'packages/core/**' diff --git a/.github/workflows/transport.yml b/.github/workflows/transport.yml index 2c5e321c..c907c300 100644 --- a/.github/workflows/transport.yml +++ b/.github/workflows/transport.yml @@ -10,8 +10,6 @@ on: - 'packages/interface/**' - 'packages/transport/**' pull_request: - branches: - - main paths: - 'packages/core/**' - 'packages/interface/**' diff --git a/.github/workflows/validator.yml b/.github/workflows/validator.yml index 61a66579..0ac39039 100644 --- a/.github/workflows/validator.yml +++ b/.github/workflows/validator.yml @@ -12,8 +12,6 @@ on: - 'packages/client/**' - 'packages/validator/**' pull_request: - branches: - - main paths: - 'packages/interface/**' - 'packages/core/**' diff --git a/packages/client/test/client.spec.js b/packages/client/test/client.spec.js index a45f2817..abad8c1f 100644 --- a/packages/client/test/client.spec.js +++ b/packages/client/test/client.spec.js @@ -6,8 +6,6 @@ import * as Service from './service.js' import { Receipt, Message, CBOR } from '@ucanto/core' import { alice, bob, mallory, service as w3 } from './fixtures.js' import fetch from '@web-std/fetch' -import * as Server from '@ucanto/server' -import { Schema } from '@ucanto/validator' test('encode invocation', async () => { /** @type {Client.ConnectionView} */ @@ -142,90 +140,7 @@ test('encode delegated invocation', async () => { } }) -// Create the service instance -const service = Service.create() - -// Define capabilities -const storeAddCapability = Server.capability({ - can: 'store/add', - with: Server.URI.match({ protocol: 'did:' }), - nb: Schema.struct({ - link: Server.Link.match().optional(), - }), - derives: (claimed, delegated) => { - if (claimed.with !== delegated.with) { - return Server.fail( - `Expected 'with: "${delegated.with}"' instead got '${claimed.with}'` - ) - } else if ( - delegated.nb.link && - `${delegated.nb.link}` !== `${claimed.nb.link}` - ) { - return Server.fail( - `Link ${ - claimed.nb.link == null ? '' : `${claimed.nb.link} ` - }violates imposed ${delegated.nb.link} constraint` - ) - } else { - return Server.ok({}) - } - }, -}) - -const storeRemoveCapability = Server.capability({ - can: 'store/remove', - with: Server.URI.match({ protocol: 'did:' }), - nb: Schema.struct({ - link: Server.Link.match().optional(), - }), - derives: (claimed, delegated) => { - if (claimed.with !== delegated.with) { - return Server.fail( - `Expected 'with: "${delegated.with}"' instead got '${claimed.with}'` - ) - } else if ( - delegated.nb.link && - `${delegated.nb.link}` !== `${claimed.nb.link}` - ) { - return Server.fail( - `Link ${ - claimed.nb.link == null ? '' : `${claimed.nb.link} ` - }violates imposed ${delegated.nb.link} constraint` - ) - } else { - return Server.ok({}) - } - }, -}) - -// Create server with service handlers using Server.provide -const server = Server.create({ - id: w3, - service: { - store: { - add: Server.provide(storeAddCapability, async ({ capability, invocation }) => { - // Call the existing service method with the invocation - return await service.store.add(/** @type {Client.Invocation} */ (invocation)) - }), - remove: Server.provide(storeRemoveCapability, async ({ capability, invocation }) => { - // Call the existing service method with the invocation - return await service.store.remove(/** @type {Client.Invocation} */ (invocation)) - }), - }, - }, - codec: CAR.inbound, - validateAuthorization: () => ({ ok: {} }), -}) - -// Use server directly as channel (no HTTP, no mock fetch!) -/** @type {Client.ConnectionView} */ -const connection = Client.connect({ - id: w3, - channel: server, // 🎯 Server directly as channel - validates delegation chains! - codec: CAR.outbound, -}) - -test('execute', async () => { +test('execute invocation', async () => { const car = await CAR.codec.write({ roots: [await CBOR.write({ hello: 'world ' })], }) @@ -241,34 +156,38 @@ test('execute', async () => { proofs: [], }) - const remove = Client.invoke({ - issuer: alice, - audience: w3, - capability: { - can: 'store/remove', - with: alice.did(), - nb: { link: car.cid }, + const channel = { + request: async (/** @type {any} */ input) => { + const { invocations } = await CAR.request.decode(input) + const receipts = await Promise.all( + invocations.map(invocation => + Receipt.issue({ + ran: invocation.cid, + issuer: w3, + result: { + ok: { + with: invocation.capabilities[0].with, + link: car.cid, + status: 'upload', + url: 'http://localhost:9090/', + }, + }, + }) + ) + ) + const message = await Message.build({ + receipts: /** @type {any} */ (receipts), + }) + return CAR.response.encode(message) }, - }) - - const e1 = await add.execute(connection) + } - assert.deepEqual(e1.out, { - error: { - // @ts-expect-error - name: 'UnknownDIDError', - message: `DID ${alice.did()} has no account`, - did: alice.did(), - }, + const connection = Client.connect({ + id: w3, + channel: /** @type {any} */ (channel), + codec: CAR.outbound, }) - // fake register alice - service.access.accounts.register( - alice.did(), - 'did:email:alice@web.mail', - car.cid - ) - const [r1] = await connection.execute(add) assert.deepEqual(r1.out, { ok: { @@ -280,51 +199,44 @@ test('execute', async () => { }) }) -test('execute with delegations', async () => { +test('decode error', async () => { const car = await CAR.codec.write({ roots: [await CBOR.write({ hello: 'world ' })], }) const add = Client.invoke({ - issuer: bob, + issuer: alice, audience: w3, capability: { can: 'store/add', - with: bob.did(), + with: alice.did(), nb: { link: car.cid }, }, proofs: [], }) - const [e1] = await connection.execute(await add.delegate()) - - assert.deepEqual(e1.out, { - error: { - // @ts-expect-error - name: 'UnknownDIDError', - message: `DID ${bob.did()} has no account`, - did: bob.did(), - }, - }) - - // fake register alice - service.access.accounts.register(bob.did(), 'did:email:bob@web.mail', car.cid) - - const [r1] = await connection.execute(await add.delegate()) - assert.deepEqual(r1.out, { - ok: { - with: bob.did(), - link: car.cid, - status: 'upload', - url: 'http://localhost:9090/', + const channel = { + request: async (/** @type {any} */ input) => { + const { invocations } = await CAR.request.decode(input) + const receipts = await Promise.all( + invocations.map(invocation => + Receipt.issue({ + ran: invocation.cid, + issuer: w3, + result: { ok: {} }, + }) + ) + ) + const message = await Message.build({ + receipts: /** @type {any} */ (receipts), + }) + return CAR.response.encode(message) }, - }) -}) + } -test('decode error', async () => { const client = Client.connect({ id: w3, - channel: server, + channel: /** @type {any} */ (channel), codec: Codec.outbound({ encoders: { 'application/car': CAR.request, @@ -335,22 +247,7 @@ test('decode error', async () => { }), }) - 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 [e1] = await client.execute(await add.delegate()) + const [e1] = await client.execute(add) assert.deepEqual( { diff --git a/packages/server/test/server-channel.spec.js b/packages/server/test/server-channel.spec.js new file mode 100644 index 00000000..f178ae3b --- /dev/null +++ b/packages/server/test/server-channel.spec.js @@ -0,0 +1,222 @@ +import * as Client from '@ucanto/client' +import * as Server from '../src/lib.js' +import * as CAR from '@ucanto/transport/car' +import { Codec } from '@ucanto/transport' +import * as CBOR from '@ucanto/core/cbor' +import { Schema } from '@ucanto/core' +import * as Service from '../../client/test/service.js' +import { alice, bob, service as w3 } from './fixtures.js' +import { test, assert } from './test.js' + +const service = Service.create() + +const storeAddCapability = Server.capability({ + can: 'store/add', + with: Server.URI.match({ protocol: 'did:' }), + nb: Schema.struct({ + link: Server.Link.match().optional(), + }), + derives: (claimed, delegated) => { + if (claimed.with !== delegated.with) { + return Server.fail( + `Expected 'with: "${delegated.with}"' instead got '${claimed.with}'` + ) + } else if ( + delegated.nb.link && + `${delegated.nb.link}` !== `${claimed.nb.link}` + ) { + return Server.fail( + `Link ${ + claimed.nb.link == null ? '' : `${claimed.nb.link} ` + }violates imposed ${delegated.nb.link} constraint` + ) + } else { + return Server.ok({}) + } + }, +}) + +const storeRemoveCapability = Server.capability({ + can: 'store/remove', + with: Server.URI.match({ protocol: 'did:' }), + nb: Schema.struct({ + link: Server.Link.match().optional(), + }), + derives: (claimed, delegated) => { + if (claimed.with !== delegated.with) { + return Server.fail( + `Expected 'with: "${delegated.with}"' instead got '${claimed.with}'` + ) + } else if ( + delegated.nb.link && + `${delegated.nb.link}` !== `${claimed.nb.link}` + ) { + return Server.fail( + `Link ${ + claimed.nb.link == null ? '' : `${claimed.nb.link} ` + }violates imposed ${delegated.nb.link} constraint` + ) + } else { + return Server.ok({}) + } + }, +}) + +const server = Server.create({ + id: w3, + service: { + store: { + add: Server.provide(storeAddCapability, async ({ invocation }) => + service.store.add(/** @type {Client.Invocation} */ (invocation)) + ), + remove: Server.provide(storeRemoveCapability, async ({ invocation }) => + service.store.remove(/** @type {Client.Invocation} */ (invocation)) + ), + }, + }, + codec: CAR.inbound, + validateAuthorization: () => ({ ok: {} }), +}) + +/** + * @typedef {{ + * store: { + * add: Client.ServiceMethod + * remove: Client.ServiceMethod + * } + * }} StoreService + */ + +/** @type {Client.ConnectionView} */ +const connection = Client.connect({ + id: w3, + channel: server, + codec: CAR.outbound, +}) + +test('execute with server as channel', async () => { + 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 e1 = await add.execute(connection) + + assert.deepEqual(e1.out, { + error: { + name: 'UnknownDIDError', + message: `DID ${alice.did()} has no account`, + did: alice.did(), + }, + }) + + service.access.accounts.register( + alice.did(), + 'did:email:alice@web.mail', + car.cid + ) + + const [r1] = await connection.execute(add) + assert.deepEqual(r1.out, { + ok: { + with: alice.did(), + link: car.cid, + status: 'upload', + url: 'http://localhost:9090/', + }, + }) +}) + +test('execute with delegations and server as channel', async () => { + const car = await CAR.codec.write({ + roots: [await CBOR.write({ hello: 'world ' })], + }) + + const add = Client.invoke({ + issuer: bob, + audience: w3, + capability: { + can: 'store/add', + with: bob.did(), + nb: { link: car.cid }, + }, + proofs: [], + }) + + const [e1] = await connection.execute(await add.delegate()) + + assert.deepEqual(e1.out, { + error: { + name: 'UnknownDIDError', + message: `DID ${bob.did()} has no account`, + did: bob.did(), + }, + }) + + service.access.accounts.register(bob.did(), 'did:email:bob@web.mail', car.cid) + + const [r1] = await connection.execute(await add.delegate()) + assert.deepEqual(r1.out, { + ok: { + with: bob.did(), + link: car.cid, + status: 'upload', + url: 'http://localhost:9090/', + }, + }) +}) + +test('decode error with server as channel', async () => { + const client = Client.connect({ + id: w3, + channel: server, + codec: Codec.outbound({ + encoders: { + 'application/car': CAR.request, + }, + decoders: { + 'application/car+receipt': CAR.response, + }, + }), + }) + + 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 [e1] = await client.execute(await add.delegate()) + + assert.deepEqual( + { + error: { + message: + "Can not decode response with content-type 'application/vnd.ipld.car' because no matching transport decoder is configured.", + // @ts-expect-error + name: 'TypeError', + error: true, + }, + }, + e1.out + ) +})