diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index 1dfc6167..54512ae4 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -77,6 +77,12 @@ jobs: with: run_install: true + - name: Install Playwright browsers + run: npx playwright install + + - name: Install Playwright system dependencies + run: npx playwright install-deps + - name: Test (Node) run: pnpm run --if-present --dir packages/${{matrix.project}} test:node diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index 3113b1d7..dbc65abb 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -73,6 +73,12 @@ jobs: with: run_install: true + - name: Install Playwright browsers + run: npx playwright install + + - name: Install Playwright system dependencies + run: npx playwright install-deps + - name: Test (Node) run: pnpm run --if-present --dir packages/${{matrix.project}} test:node diff --git a/.github/workflows/interface.yml b/.github/workflows/interface.yml index 2c6c3b56..c7c9fbf6 100644 --- a/.github/workflows/interface.yml +++ b/.github/workflows/interface.yml @@ -71,6 +71,12 @@ jobs: with: run_install: true + - name: Install Playwright browsers + run: npx playwright install + + - name: Install Playwright system dependencies + run: npx playwright install-deps + - name: Test (Node) run: pnpm run --if-present --dir packages/${{matrix.project}} test:node diff --git a/.github/workflows/principal.yml b/.github/workflows/principal.yml index 15d95b33..c3255cdf 100644 --- a/.github/workflows/principal.yml +++ b/.github/workflows/principal.yml @@ -72,6 +72,12 @@ jobs: with: run_install: true + - name: Install Playwright browsers + run: npx playwright install + + - name: Install Playwright system dependencies + run: npx playwright install-deps + - name: Test (Node) run: pnpm run --if-present --dir packages/${{matrix.project}} test:node diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index 3300a8f3..560b5f00 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -81,6 +81,12 @@ jobs: with: run_install: true + - name: Install Playwright browsers + run: npx playwright install + + - name: Install Playwright system dependencies + run: npx playwright install-deps + - name: Test (Node) run: pnpm run --if-present --dir packages/${{matrix.project}} test:node diff --git a/.github/workflows/transport.yml b/.github/workflows/transport.yml index e2d49c99..2c5e321c 100644 --- a/.github/workflows/transport.yml +++ b/.github/workflows/transport.yml @@ -75,6 +75,12 @@ jobs: with: run_install: true + - name: Install Playwright browsers + run: npx playwright install + + - name: Install Playwright system dependencies + run: npx playwright install-deps + - name: Test (Node) run: pnpm run --if-present --dir packages/${{matrix.project}} test:node diff --git a/.github/workflows/validator.yml b/.github/workflows/validator.yml index 58c086e5..61a66579 100644 --- a/.github/workflows/validator.yml +++ b/.github/workflows/validator.yml @@ -79,6 +79,12 @@ jobs: with: run_install: true + - name: Install Playwright browsers + run: npx playwright install + + - name: Install Playwright system dependencies + run: npx playwright install-deps + - name: Test (Node) run: pnpm run --if-present --dir packages/${{matrix.project}} test:node diff --git a/Readme.md b/Readme.md index 67ed269c..3511b025 100644 --- a/Readme.md +++ b/Readme.md @@ -1,286 +1,286 @@ -# ucanto - -(u)canto is a library for [UCAN][] based [RPC][] that provides: - -1. A declarative system for defining capabilities (roughly equivalent to HTTP - routes in REST). -1. A system for binding [capability][] handles (a.k.a providers) to form services with built-in routing. -1. A UCAN validation system. -1. A runtime for executing UCAN capability [invocations][]. -1. A pluggable transport layer. -1. A client supporting batched invocations and full type inference. - -> the name ucanto is a word play on UCAN and canto (one of the major divisions of a long poem) - -## Quick sample - -To get a taste of the libary we will build up a "filesystem" service, in which: - -1. Top level paths are [did:key][] identifiers, here on referred to as (user) drives. -1. Drives are owned by users holding a private key corresponding to the [did:key][] of the drive. -1. Drive owners can mutate the filesystem within their drive's path and delegate that ability to others. - -### Capabilities - -The very first thing we want to do is define the set of capabilities our service will provide. Each (cap)[ability][] MUST: - -1. Have a `can` field denoting an _action_ it can perform. -2. Have a `with` URI denoting the _resource_ it can perform that action on. -3. Be comparable to other capabilities _(with set semantics, as in does capability `a` include capability `b` ?)_ - -Let's define the `file/link` capability, where resources are identified via `file:` URLs and MAY contain a `link` to be mapped to a given path. - -```ts -import { capability, URI, Link, Failure } from '@ucanto/server' - -const Add = capability({ - can: 'file/link', - with: URI.match({ protocol: 'file:' }), - nb: { link: Link }, - derives: (claimed, delegated) => - // Can be derived if claimed capability path is contained in the delegated - // capability path. - claimed.uri.href.startsWith(ensureTrailingDelimiter(delegated.uri.href)) || - new Failure(`Notebook ${claimed.uri} is not included in ${delegaed.uri}`), -}) - -const ensureTrailingDelimiter = uri => (uri.endsWith('/') ? uri : `${uri}/`) -``` - -> Please note that the library guarantees that both `claimed` and `delegated` capabilities will have `{can: "file/link", with: string nb: { link?: CID }}` -> type inferred from the definition. -> -> We will explore more complicated cases later where a capability may be derived from a different capability or even a set. - -### Services - -Now that we have a `file/link` capability we can define a service providing it: - -```ts -import { provide, Failure, MalformedCapability } from '@ucanto/server' - -const service = (context: { store: Map }) => { - const add = provide(Add, ({ capability, invocation }) => { - store.set(capability.uri.href, capability.nb.link) - return { - with: capability.with, - link: capability.nb.link, - } - }) - - return { file: { add } } -} -``` - -The `provide` building block used above will take care of associating a handler to the -given capability and performing necessary UCAN validation steps when `add` is -invoked. - -### Transport - -The library provides a pluggable transport architecture so you can expose a service in various content encodings. To do so you have to provide: - -1. `decoder` that will take `{ headers: Record, body: Uint8Array }` object and decode it into `{ invocations: Invocation[] }`. -2. `encoder` that will take `unknown[]` (corresponding to values returned by handlers) and encode it into `{ headers: Record, body: Uint8Array }`. - -> Note that the actual encoder / decoder types are more complicated as they capture capability types, the number of invocations, and corresponding return types. This allows them to provide good type inference. But ignoring those details, that is what they are in a nutshell. - -The library comes with several transport layer codecs you can pick from, but you can also bring one yourself. Below we will take invocations encoded in [CAR][] format and produce responses encoded in [DAG-CBOR][] format: - -```ts -import * as Server from "@ucanto/server" -import * as CAR from "@ucanto/transport/car" -import * as CBOR from "@ucanto/transport/cbor" -import { ed25519 } from "@ucanto/principal" -import * as HTTP from "node:http" -import * as Buffer from "node:buffer" - -export const server = (context { store = new Map() } : { store: Map }) => - Server.create({ - id: ed25519.Signer.parse(process.env.SERVICE_SECRET), - service: service(context), - decoder: CAR, - encoder: CBOR, - - // We tell server that capability can be self-issued by a drive owner - canIssue: (capability, issuer) => { - if (capability.uri.protocol === "file:") { - const [did] = capability.uri.pathname.split("/") - return did === issuer - } - return false - }, - }) -``` - -> Please note that server does not do HTTP as bindings may differ across runtimes, so it is up to you to plug one in. - -In nodejs we could expose our service as follows: - -```ts -export const listen = ({ port = 8080, context = new Map() }) => { - - HTTP.createServer(async (request, response) => { - const chunks = [] - for await (const chunk of request) { - chunks.push(chunk) - } - - const { headers, body } = await fileServer.request({ - headers: request.headers, - body: Buffer.concat(chunks), - }) - - response.writeHead(200, headers) - response.write(body) - response.end() - }).listen(port) -} -``` - -## Client - -Client can be used to issue and execute UCAN invocations. Here is an example of -invoking the `file/link` capability we've defined earlier: - -```ts -import * as Client from '@ucanto/client' -import { ed25519 } from '@ucanto/principal' -import { CID } from 'multiformats' - -// Service will have a well known DID -const service = ed25519.Verifier.parse(process.env.SERVICE_ID) -// Client keypair -const issuer = ed25519.Signer.parse(process.env.MY_KEPAIR) - -const demo1 = async connection => { - const me = await Client.invoke({ - issuer: alice, - audience: service, - capability: { - can: 'file/link', - with: `file://${issuer.did()}/me/about`, - link: CID.parse(process.env.ME_CID), - }, - }) - - const result = await connection.execute(me) - if (result.error) { - console.error('oops', result) - } else { - console.log('file got linked', result.link.toString()) - } -} -``` - -> Note that the client will get full type inference on when `connection` captures a type of the service on the other side of the wire. - -### Connection - -Just like the server, the client has a pluggable transport layer which you provide when you create a connection. We could create an in-process connection with our service simply by providing service as a channel: - -```ts -import * as CAR from "@ucanto/transport/car" -import * as CBOR from "@ucanto/transport/cbor" - -const connection = Client.connect({ - encoder: CAR, // encode as CAR because server decodes from car - decoder: CBOR, // decode as CBOR because server encodes as CBOR - channel: server(), // simply pass the server -}) -``` - -In practice you probably would want client/server communication to happen across the wire, or at least across processes. You can bring your own transport channel, or choose an existing one. For example: - -```ts -import * as CAR from "@ucanto/transport/car" -import * as CBOR from "@ucanto/transport/cbor" -import * as HTTP from "@ucanto/transport/http" - -const connection = Client.connect({ - encoder: CAR, // encode as CAR because server decodes from car - decoder: CBOR, // decode as CBOR because server encodes as CBOR - /** @type {Transport.Channel>} */ - channel: HTTP.open({ url: new URL(process.env.SERVICE_URL) }), -}) -``` - -> Note: That in the second example you need to provide a type annotations, so that client can infer what capabilities can be invoked and what the return types it will correspond to. - -### Batching & Proof chains - -The library supports batch invocations and takes care of all the nitty gritty details when it comes to UCAN delegation chains, specifically taking chains apart to encode as blocks in CAR and putting them back together into a chain on the other side. All you need to do is provide a delegation in the proofs: - -```ts -import { ed25519 } from '@ucanto/principal' -import * as Client from '@ucanto/client' -import { CID } from 'multiformats' - -const service = ed25519.Verifier.parse(process.env.SERVICE_DID) -const alice = ed25519.Signer.parse(process.env.ALICE_KEYPAIR) -const bob = ed25519.Signer.parse(process.env.BOB_KEYPAIR) - -const demo2 = async connection => { - // Alice delegates capability to mutate FS under bob's namespace - const proof = await Client.delegate({ - issuer: alice, - audience: bob.principal, - capabilities: [ - { - can: 'file/link', - with: `file://${alice.did()}/friends/${bob.did()}/`, - }, - ], - }) - - const aboutBob = Client.invoke({ - issuer: bob, - audience: service, - capability: { - can: 'file/link', - with: `file://${alice.did()}/friends/${bob.did()}/about`, - link: CID.parse(process.env.BOB_CID), - }, - }) - - const aboutMallory = Client.invoke({ - issuer: bob, - audience: service, - capability: { - can: 'file/link', - with: `file://${alice.did()}/friends/${MALLORY_DID}/about`, - link: CID.parse(process.env.MALLORY_CID), - }, - }) - - const [bobResult, malloryResult] = connection.execute([ - aboutBob, - aboutMallory, - ]) - - if (bobResult.error) { - console.error('oops', r1) - } else { - console.log('about bob is linked', r1) - } - - if (malloryResult.error) { - console.log('oops', r2) - } else { - console.log('about mallory is linked', r2) - } -} -``` - -> In the example above, the first invocation will succeed, but the second one will not because Bob has not been granted a capability to mutate Mallory's namespace. Also note that both invocations are sent in a single request. - -[ucan]: https://github.com/ucan-wg/spec/ -[rpc]: https://en.wikipedia.org/wiki/Remote_procedure_call -[capability]: https://github.com/ucan-wg/spec/#23-capability -[invocations]: https://github.com/ucan-wg/spec/#28-invocation -[ability]: https://github.com/ucan-wg/spec/#3242-ability -[type union]: https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#union-types -[car]: https://ipld.io/specs/transport/car/carv1/ -[dag-cbor]: https://ipld.io/specs/codecs/dag-cbor/ -[cid]: https://docs.ipfs.io/concepts/content-addressing/ -[did:key]: https://w3c-ccg.github.io/did-method-key/ +# ucanto + +[![Core Tests](https://github.com/storacha/ucanto/actions/workflows/core.yml/badge.svg)](https://github.com/storacha/ucanto/actions/workflows/core.yml) +[![Principal Tests](https://github.com/storacha/ucanto/actions/workflows/principal.yml/badge.svg)](https://github.com/storacha/ucanto/actions/workflows/principal.yml) +[![Transport Tests](https://github.com/storacha/ucanto/actions/workflows/transport.yml/badge.svg)](https://github.com/storacha/ucanto/actions/workflows/transport.yml) +[![Interface Tests](https://github.com/storacha/ucanto/actions/workflows/interface.yml/badge.svg)](https://github.com/storacha/ucanto/actions/workflows/interface.yml) +[![Server Tests](https://github.com/storacha/ucanto/actions/workflows/server.yml/badge.svg)](https://github.com/storacha/ucanto/actions/workflows/server.yml) +[![Client Tests](https://github.com/storacha/ucanto/actions/workflows/client.yml/badge.svg)](https://github.com/storacha/ucanto/actions/workflows/client.yml) +[![Validator Tests](https://github.com/storacha/ucanto/actions/workflows/validator.yml/badge.svg)](https://github.com/storacha/ucanto/actions/workflows/validator.yml) + +(u)canto is a library for [UCAN][] based [RPC][] that provides: + +1. A declarative system for defining [capabilities][] and [abilities][] (roughly equivalent to HTTP + routes in REST). +2. A system for binding [capability][] handles (a.k.a providers) to form services with built-in routing. +3. A UCAN validation system. +4. A runtime for executing UCAN capability [invocations][]. +5. A pluggable transport layer. +6. A client supporting batched invocations and full [type][] inference. + +> the name ucanto is a word play on UCAN and canto (one of the major divisions of a long poem) + +## Quick Start - Using a UCAN Service + +Most developers will use ucanto to **connect to existing UCAN services**. Here's how to get started: + +### Installation + +```sh +npm install @ucanto/client @ucanto/principal @ucanto/transport +``` + +### Basic Usage + +```ts +import * as Client from '@ucanto/client' +import * as HTTP from '@ucanto/transport/http' +import { CAR } from '@ucanto/transport' +import { ed25519 } from '@ucanto/principal' + +// Connect to a UCAN service (e.g., w3up, your company's API, etc.) +const connection = Client.connect({ + id: { did: () => 'did:web:api.example.com' }, // Service's public DID + codec: CAR.outbound, + channel: HTTP.open({ url: new URL('https://api.example.com') }), +}) + +// Generate or load your client keys +const agent = await ed25519.generate() + +// Invoke a capability on the service +const invocation = Client.invoke({ + issuer: agent, + audience: connection.id, + capability: { + can: 'store/add', + with: agent.did(), + nb: { + link: 'bafybeigwflfnv7tjgpuy52ep45cbbgkkb2makd3bwhbj3ueabvt3eq43ca' + } + } +}) + +// Execute the invocation +const result = await invocation.execute(connection) +if (result.error) { + console.error('Operation failed:', result.error) +} else { + console.log('Success:', result.out) +} +``` + +> 📝 **Tested in**: [`packages/client/test/client.spec.js:22`](./packages/client/test/client.spec.js#L22) - Client invocation and execution + +### Working with Delegations + +UCAN services often require **delegated permissions**. Here's how to use them: + +```ts +// Example 1: Using a DID (identity-based resource) +const delegation = await Client.delegate({ + issuer: serviceAgent, // Who granted the permission + audience: agent, // You (the recipient) + capabilities: [{ + can: 'store/add', + with: 'did:key:zAlice' // Resource: Alice's storage (must match serviceAgent.did()) + }] +}) + +// Example 2: Using a resource URI (file-based resource) +const fileDelegation = await Client.delegate({ + issuer: alice, // Alice owns the file + audience: bob, // Bob gets access + capabilities: [{ + can: 'file/write', + with: 'file:///home/alice/documents/important.txt' // Specific file resource + }] +}) + +// Use the delegation as proof in your invocation +const invocation = Client.invoke({ + issuer: agent, + audience: connection.id, + capability: { + can: 'store/add', + with: 'did:key:zAlice', // Must match the delegated resource + nb: { link: 'bafybeig...' } + }, + proofs: [delegation] // Proof you have permission +}) + +const result = await invocation.execute(connection) +``` + +> 📝 **Tested in**: +> - [`packages/client/test/client.spec.js:70`](./packages/client/test/client.spec.js#L70) - Delegation creation and usage +> - [`packages/server/test/readme-integration.spec.js:160`](./packages/server/test/readme-integration.spec.js#L160) - Delegation with server validation + +### Batch Operations + +You can send multiple invocations in a single request: + +```ts +const uploadFile = Client.invoke({ + issuer: agent, + audience: connection.id, + capability: { can: 'store/add', with: agent.did(), nb: { link: fileCID } } +}) + +const deleteFile = Client.invoke({ + issuer: agent, + audience: connection.id, + capability: { can: 'store/remove', with: agent.did(), nb: { link: oldFileCID } } +}) + +// Execute both operations together +const [uploadResult, deleteResult] = await connection.execute([uploadFile, deleteFile]) +``` + +> 📝 **Tested in**: [`packages/client/test/client.spec.js:102`](./packages/client/test/client.spec.js#L102) - Batch invocation execution + +### Advanced Delegation Patterns + +UCAN supports complex delegation scenarios where users can grant permissions to others: + +```ts +// Alice delegates capability to Bob for a specific namespace +const proof = await Client.delegate({ + issuer: alice, + audience: bob, + capabilities: [ + { + can: 'file/link', + with: `file:///tmp/${alice.did()}/friends/${bob.did()}/`, + }, + ], +}) + +// Bob can now use the delegated permission +const aboutBob = Client.invoke({ + issuer: bob, + audience: serviceKey, + capability: { + can: 'file/link', + with: `file:///tmp/${alice.did()}/friends/${bob.did()}/about`, + nb: { link: testCID }, + }, + proofs: [proof], // Include the delegation proof +}) + +// Bob tries to access Mallory's namespace (should fail) +const aboutMallory = Client.invoke({ + issuer: bob, + audience: serviceKey, + capability: { + can: 'file/link', + with: `file:///tmp/${alice.did()}/friends/${MALLORY_DID}/about`, + nb: { link: malloryCID }, + }, + proofs: [proof], // Same proof, but wrong namespace +}) + +// Execute both operations +const [bobResult, malloryResult] = await connection.execute([ + aboutBob, + aboutMallory, +]) + +// Bob's operation succeeds, Mallory's fails +if (bobResult.error) { + console.error('Bob operation failed:', bobResult.error) +} else { + console.log('Bob operation succeeded:', bobResult.out) +} + +if (malloryResult.error) { + console.log('Mallory operation failed (expected):', malloryResult.error) +} else { + console.log('Mallory operation succeeded (unexpected)') +} +``` + +This demonstrates how UCAN's delegation system provides fine-grained access control where: +- ✅ **Bob succeeds** - He has delegated permission for his namespace +- ❌ **Mallory fails** - Bob doesn't have permission for Mallory's namespace +- 🔒 **Security** - The service validates the delegation chain and resource ownership + +> 📝 **Tested in**: [`packages/server/test/readme-integration.spec.js:99`](./packages/server/test/readme-integration.spec.js#L99) - Advanced delegation patterns with namespace validation + +## Service-Specific Examples + +Different UCAN services will have different capabilities. Check their documentation for specifics: + +- **w3up (Web3.Storage)**: [w3up documentation](https://github.com/web3-storage/w3up) +- **Custom Services**: See your service's API documentation + +## Building Your Own Service + +To create your own UCAN service, see the **[@ucanto/server documentation](./packages/server/README.md)**. This covers: + +- Defining capabilities +- Creating service handlers +- Setting up transport layers +- Deployment and security + +## Advanced Topics + +### Custom Transport + +```ts +import * as Transport from '@ucanto/transport' + +const connection = Client.connect({ + id: service, + codec: Transport.outbound({ + encoders: { 'application/car': CAR.request }, + decoders: { 'application/dag-cbor': CBOR.response } + }), + channel: yourCustomChannel +}) +``` + +### Key Management + +```ts +import { ed25519 } from '@ucanto/principal' + +// Generate new keys +const agent = await ed25519.generate() + +// Save keys (browser) +localStorage.setItem('agent', agent.toString()) + +// Load keys (browser) +const savedAgent = ed25519.parse(localStorage.getItem('agent')) + +// Save keys (Node.js) +import fs from 'fs/promises' +await fs.writeFile('agent.key', agent.toString()) + +// Load keys (Node.js) +const keyData = await fs.readFile('agent.key', 'utf-8') +const loadedAgent = ed25519.parse(keyData) +``` + +> 📝 **Tested in**: [`packages/server/test/readme-examples.spec.js:54`](./packages/server/test/readme-examples.spec.js#L54) - Key generation, formatting, and parsing + +## Package Overview + +- [`@ucanto/client`](./packages/client/README.md) - Connect to and invoke UCAN services +- [`@ucanto/server`](./packages/server/README.md) - Build your own UCAN services +- [`@ucanto/transport`](./packages/transport/README.md) - Transport layer implementations +- [`@ucanto/principal`](./packages/principal/README.md) - Cryptographic identity management +- [`@ucanto/core`](./packages/core/README.md) - Core UCAN primitives +- [`@ucanto/validator`](./packages/validator/README.md) - UCAN validation logic + +[ucan]: https://github.com/ucan-wg/spec/ +[rpc]: https://en.wikipedia.org/wiki/Remote_procedure_call +[capability]: https://github.com/ucan-wg/spec/#23-capability +[invocations]: https://github.com/ucan-wg/spec/#28-invocation +[ability]: https://github.com/ucan-wg/spec/#3242-ability +[type]: https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#union-types +[car]: https://ipld.io/specs/transport/car/carv1/ +[dag-cbor]: https://ipld.io/specs/codecs/dag-cbor/ +[cid]: https://docs.ipfs.io/concepts/content-addressing/ +[did:key]: https://w3c-ccg.github.io/did-method-key/ \ No newline at end of file diff --git a/packages/client/README.md b/packages/client/README.md index 800a37e2..5b9cdae2 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -3,12 +3,14 @@ `@ucanto/client` provides the tools necessary to create, sign, and send UCAN-based RPC invocations. It enables secure communication with UCAN-compliant services while ensuring proper authorization and delegation handling. ## What It Provides + - **UCAN Invocation Handling**: Creates and signs capability invocations. +- **Connection Management**: Manages communication with UCAN services. - **Batch Invocation Support**: Enables multiple invocations in a single request. -- **Secure Communication**: Ensures interactions are cryptographically signed and verified. +- **Delegation Support**: Creates and uses authorization delegations. ## How It Fits with Other Modules -- [`@ucanto/core`](../core/README.md): Defines capability structures and execution logic. +- [`@ucanto/core`](../core/README.md): Provides capability invocation and delegation logic. - [`@ucanto/server`](../server/README.md): Processes invocations received from the client. - [`@ucanto/interface`](../interface/README.md): Provides shared types for request and response handling. - [`@ucanto/principal`](../principal/README.md): Manages cryptographic signing for invocations. @@ -22,28 +24,103 @@ npm install @ucanto/client ``` ## Example Usage -```ts -import * as Client from '@ucanto/client'; -import { ed25519 } from '@ucanto/principal'; -const service = ed25519.Verifier.parse(process.env.SERVICE_ID); -const issuer = ed25519.Signer.parse(process.env.CLIENT_KEYPAIR); +### Connecting to a UCAN Service + +```js +import * as Client from '@ucanto/client' +import * as HTTP from '@ucanto/transport/http' +import { CAR } from '@ucanto/transport' +import { ed25519 } from '@ucanto/principal' +import { DID } from '@ucanto/core' + +// Parse the service DID +// SERVICE_DID should be a DID like: did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi +const service = DID.parse(process.env.SERVICE_DID) +// Parse the agent's private key +// AGENT_PRIVATE_KEY should be a base64 private key like: Mg.. +const issuer = ed25519.parse(process.env.AGENT_PRIVATE_KEY) + +// Connect to a UCAN service +const connection = Client.connect({ + id: service, + channel: HTTP.open({ + url: new URL(process.env.SERVICE_URL || 'https://api.example.com') + }), + codec: CAR.outbound, +}) -const invocation = await Client.invoke({ +// Create and execute invocation +const invocation = Client.invoke({ issuer, audience: service, capability: { - can: 'file/read', - with: 'file://example.txt' + can: 'store/add', + with: issuer.did(), + nb: { link: 'bafybeigwflfnv7tjgpuy52ep45cbbgkkb2makd3bwhbj3ueabvt3eq43ca' } } -}); +}) + +const [receipt] = await connection.execute(invocation) +// A receipt is a signed result from the service proving the invocation was processed. +console.log(receipt.out.error ? 'Failed:' : 'Success:', receipt.out) +``` + +### Using Server as Channel (for Testing) + +For testing or local development, you can use a UCAN server directly as a channel without HTTP. See the [`@ucanto/server` README](../server/README.md) for details on setting up a server. + +## Setup Instructions + +### Environment Variables + +**AGENT_PRIVATE_KEY** +Set the key your client should use to sign UCAN invocations. You can generate Ed25519 keys with the ucanto library. + +#### Usage + +Create a file called `generate-keys.js`: + +```javascript +import { ed25519 } from '@ucanto/principal' -const response = await client.execute(invocation); -if (response.error) { - console.error('Invocation failed:', response.error); -} else { - console.log('Invocation succeeded:', response.result); +async function generateKeys() { + const keypair = await ed25519.generate() + + const privateKey = ed25519.format(keypair) + + console.log('AGENT_PRIVATE_KEY=' + privateKey) } + +generateKeys().catch(console.error) +``` + +Then run it: + +```bash +node generate-keys.js ``` +**SERVICE_DID** +Set the DID of the service you want to connect to. Check the service's documentation for their public DID. + +**SERVICE_URL** (Optional) +If you're connecting to a custom service, set both `SERVICE_DID` and `SERVICE_URL` environment variables. + + +For example, Storacha has following `SERVICE_DID` and `SERVICE_URL`: + +```bash +# Storacha uses these default values: +SERVICE_DID="did:web:up.storacha.network" +SERVICE_URL="https://up.storacha.network" +``` + +Set your environment variables like so: +```bash +AGENT_PRIVATE_KEY="your_generated_private_key_here" \ +SERVICE_DID="did:key:service_provider_did_here" \ +``` + + For more details, see the [`ucanto` documentation](https://github.com/storacha/ucanto). \ No newline at end of file diff --git a/packages/client/package.json b/packages/client/package.json index fdc498cb..56e6a3e7 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -36,7 +36,9 @@ "@types/chai": "^4.3.3", "@types/mocha": "^10.0.1", "@ucanto/principal": "workspace:^", + "@ucanto/server": "workspace:^", "@ucanto/transport": "workspace:^", + "@ucanto/validator": "workspace:^", "@web-std/fetch": "^4.1.0", "@web-std/file": "^3.0.2", "c8": "^7.13.0", diff --git a/packages/client/test/client.spec.js b/packages/client/test/client.spec.js index 23eb324c..a45f2817 100644 --- a/packages/client/test/client.spec.js +++ b/packages/client/test/client.spec.js @@ -6,6 +6,8 @@ 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} */ @@ -140,58 +142,86 @@ test('encode delegated invocation', async () => { } }) +// Create the service instance const service = Service.create() -const channel = HTTP.open({ - url: new URL('about:blank'), - fetch: async (url, input) => { - const { invocations } = await CAR.request.decode(input) - const promises = invocations.map(async invocation => { - const [capability] = invocation.capabilities - switch (capability.can) { - case 'store/add': { - const result = await service.store.add( - /** @type {Client.Invocation} */ (invocation) - ) - return Receipt.issue({ - ran: invocation.cid, - issuer: w3, - result, - }) - } - case 'store/remove': { - const result = await service.store.remove( - /** @type {Client.Invocation} */ (invocation) - ) - return Receipt.issue({ - ran: invocation.cid, - issuer: w3, - result, - }) - } - } - }) - - const receipts = /** @type {Client.Tuple} */ ( - await Promise.all(promises) - ) - - const message = await Message.build({ receipts }) - - const { headers, body } = await CAR.response.encode(message) +// 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({}) + } + }, +}) - return { - ok: true, - headers: new Map(Object.entries(headers)), - arrayBuffer: () => body, +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, + channel: server, // 🎯 Server directly as channel - validates delegation chains! codec: CAR.outbound, }) @@ -294,7 +324,7 @@ test('execute with delegations', async () => { test('decode error', async () => { const client = Client.connect({ id: w3, - channel, + channel: server, codec: Codec.outbound({ encoders: { 'application/car': CAR.request, diff --git a/packages/server/README.md b/packages/server/README.md index 9931a620..483141c4 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -3,12 +3,14 @@ `@ucanto/server` provides the necessary components to build a UCAN-based RPC server. It enables services to define capabilities, validate UCANs, and process invocations securely and efficiently. This package builds on `ucanto/core` and integrates seamlessly with other `ucanto` modules. ## What It Provides + - **UCAN-Based Authorization**: Ensures that all invocations are securely verified before execution. - **Capability Handling**: Allows services to define and manage capabilities with fine-grained access control. - **Pluggable Transport Layer**: Supports multiple encoding and transport options. - **Batch Invocation Processing**: Enables efficient handling of multiple invocations in a single request. ## How It Fits with Other Modules + - [`@ucanto/core`](../core/README.md): Provides the fundamental capability execution and validation logic. - [`@ucanto/interface`](../interface/README.md): Defines shared type definitions and contracts. - [`@ucanto/transport`](../transport/README.md): Implements encoding and transport mechanisms. @@ -17,11 +19,15 @@ For an overview and detailed usage information, refer to the [main `ucanto` README](../../Readme.md). ## Installation + ```sh npm install @ucanto/server ``` ## Example Usage + +### Basic Example + ```ts import * as Server from '@ucanto/server'; import * as CAR from '@ucanto/transport/car'; @@ -40,7 +46,7 @@ export const createServer = () => { }); return Server.create({ - id: ed25519.Signer.parse(process.env.SERVICE_SECRET), + id: ed25519.parse(process.env.SERVICE_SECRET), service: { file: { read } }, decoder: CAR, encoder: CBOR @@ -48,4 +54,178 @@ export const createServer = () => { }; ``` +### Complete Filesystem Service Example + +Here's a comprehensive example of building a filesystem service with UCAN capabilities: + +```ts +import * as Server from '@ucanto/server'; +import * as Client from '@ucanto/client'; +import * as CAR from '@ucanto/transport/car'; +import * as CBOR from '@ucanto/transport/cbor'; +import { ed25519 } from '@ucanto/principal'; +import { capability, URI, Link, Schema, Failure } from '@ucanto/core'; + +// 1. Define the file/link capability +const ensureTrailingDelimiter = uri => (uri.endsWith('/') ? uri : `${uri}/`) + +const Add = capability({ + can: 'file/link', + with: URI.match({ protocol: 'file:' }), + nb: Schema.struct({ + link: Link, + }), + derives: (claimed, delegated) => + claimed.with.startsWith(ensureTrailingDelimiter(delegated.with)) || + new Failure(`Resource ${claimed.with} is not contained by ${delegated.with}`), +}) + +// 2. Create service with context +const context = { store: new Map() } +const service = { + file: { + link: Server.provide(Add, ({ capability, invocation }) => { + context.store.set(capability.with, capability.nb.link) + return { + with: capability.with, + link: capability.nb.link, + } + }) + } +} + +// 3. Create server with validation +const serviceKey = await ed25519.generate() + +const server = Server.create({ + id: serviceKey, + service, + codec: CAR.inbound, + canIssue: (capability, issuer) => { + if (capability.with.startsWith("file:")) { + // Extract the DID from the file URI: file:///tmp/did:key:zABC.../path + const url = new URL(capability.with) + const pathParts = url.pathname.split("/") + const did = pathParts[2] // Skip empty string and "tmp" + return did === issuer + } + return false + }, +}) + +// 4. Create client connection +const connection = Client.connect({ + id: serviceKey, + codec: CAR.outbound, + channel: server, // Server directly as channel +}) + +// 5. Use the service +const issuerKey = await ed25519.generate() +const testCID = parseLink('bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy') + +const invocation = await Client.invoke({ + issuer: issuerKey, + audience: serviceKey, + capability: { + can: 'file/link', + with: `file:///tmp/${issuerKey.did()}/me/about`, + nb: { link: testCID }, + }, +}) + +const result = await invocation.execute(connection) +console.log('File linked:', result.out) +``` + +### Key Features Demonstrated + +- **Capability Definition**: How to define `file/link` capabilities with validation +- **Service Implementation**: Complete service with handlers and context +- **Authorization Logic**: `canIssue` function for resource ownership validation +- **Client Integration**: How clients connect and use the service +- **Resource Management**: File URI handling and DID extraction + +### Advanced Examples + +#### HTTP Server Setup + +To expose your service over HTTP, you can wrap it with a Node.js HTTP server: + +```ts +import * as HTTP from "node:http" +import * as Buffer from "node:buffer" + +export const listen = ({ port = 8080, context = new Map() }) => { + const fileServer = Server.create({ + id: ed25519.parse(process.env.SERVICE_SECRET), + service: service(context), + decoder: CAR, + encoder: CBOR, + canIssue: (capability, issuer) => { + if (capability.with.startsWith("file:")) { + const url = new URL(capability.with) + const pathParts = url.pathname.split("/") + const did = pathParts[2] + return did === issuer + } + return false + }, + }) + + HTTP.createServer(async (request, response) => { + const chunks = [] + for await (const chunk of request) { + chunks.push(chunk) + } + + const { headers, body } = await fileServer.request({ + headers: request.headers, + body: Buffer.concat(chunks), + }) + + response.writeHead(200, headers) + response.write(body) + response.end() + }).listen(port) +} +``` + +#### Delegation and Proof Chains + +The server supports complex delegation scenarios: + +```ts +// Alice delegates capability to Bob +const proof = await Client.delegate({ + issuer: alice, + audience: bob, + capabilities: [ + { + can: 'file/link', + with: `file:///tmp/${alice.did()}/friends/${bob.did()}/`, + }, + ], +}) + +// Bob uses the delegation +const aboutBob = Client.invoke({ + issuer: bob, + audience: serviceKey, + capability: { + can: 'file/link', + with: `file:///tmp/${alice.did()}/friends/${bob.did()}/about`, + nb: { link: testCID }, + }, + proofs: [proof], // Include the delegation proof +}) +``` + +### Testing + +This example is tested in the integration tests: + +- **Complete workflow test**: [`readme-integration.spec.js:19`](../test/readme-integration.spec.js#L19) - End-to-end integration test +- **Component tests**: [`readme-examples.spec.js:11`](../test/readme-examples.spec.js#L11) - Individual capability and service tests + For more details, see the [`ucanto` documentation](https://github.com/storacha/ucanto). diff --git a/packages/server/test/handler.spec.js b/packages/server/test/handler.spec.js index 48ca6f29..6889bb01 100644 --- a/packages/server/test/handler.spec.js +++ b/packages/server/test/handler.spec.js @@ -248,13 +248,6 @@ test('test access/claim provider', async () => { validateAuthorization: () => ({ ok: {} }), }) - /** - * @type {Client.ConnectionView<{ - * access: { - * claim: API.ServiceMethod, never[], API.Failure> - * } - * }>} - */ const client = Client.connect({ id: w3, codec: CAR.outbound, diff --git a/packages/server/test/readme-examples.spec.js b/packages/server/test/readme-examples.spec.js new file mode 100644 index 00000000..5ad2033f --- /dev/null +++ b/packages/server/test/readme-examples.spec.js @@ -0,0 +1,64 @@ +/** + * Integration tests for README examples - simplified version + * Addresses issue #387: test all examples and code snippets in READMEs + */ + +import { test, assert } from './test.js' +import { capability, URI, Link, Failure, provide, Schema, ok, fail } from '../src/lib.js' +import { ed25519 } from '@ucanto/principal' + +// Test that we can create the README capability definition +test('README capability definition works', async () => { + /** @param {string} uri */ + const ensureTrailingDelimiter = uri => (uri.endsWith('/') ? uri : `${uri}/`) + + const Add = capability({ + can: 'file/link', + with: URI.match({ protocol: 'file:' }), + nb: Schema.struct({ link: Link }), + derives: (claimed, delegated) => + claimed.with.startsWith(ensureTrailingDelimiter(delegated.with)) ? + ok({}) : + fail(`Resource ${claimed.with} is not contained by ${delegated.with}`), + }) + + // Test that capability was created successfully with correct 'can' field + assert.ok(Add) + assert.equal(Add.can, 'file/link') +}) + +// Test that we can create a service with provide +test('README service definition works', async () => { + const Add = capability({ + can: 'file/link', + with: URI.match({ protocol: 'file:' }), + nb: Schema.struct({ link: Link }), + }) + + const service = (context = { store: new Map() }) => { + const add = provide(Add, ({ capability, invocation }) => { + context.store.set(capability.with, capability.nb.link) + return ok({ + with: capability.with, + link: capability.nb.link, + }) + }) + + return { file: { add } } + } + + const testService = service() + assert.ok(testService.file) + assert.ok(testService.file.add) +}) + +// Test that ed25519.parse works +test('README uses correct ed25519.parse API', async () => { + // This should work with the current API (not the old ed25519.Signer.parse) + const key = await ed25519.generate() + + // Test that we can format and parse keys correctly + const formatted = ed25519.format(key) + const parsed = ed25519.parse(formatted) + assert.equal(parsed.did(), key.did()) +}) diff --git a/packages/server/test/readme-integration.spec.js b/packages/server/test/readme-integration.spec.js new file mode 100644 index 00000000..3b27c013 --- /dev/null +++ b/packages/server/test/readme-integration.spec.js @@ -0,0 +1,198 @@ +/** + * Integration tests for README examples using server-as-channel pattern + */ + +import { test, assert } from './test.js' +import { capability, URI, Link, Failure, provide, Schema, ok, fail } from '../src/lib.js' +import * as Server from '../src/lib.js' +import * as CAR from '@ucanto/transport/car' +import { ed25519 } from '@ucanto/principal' +import * as Client from '@ucanto/client' +import { parseLink } from '@ucanto/core' + +test('README workflow integration with server-as-channel', async () => { + // 1. Define capability (from README) + /** @param {string} uri */ + const ensureTrailingDelimiter = uri => (uri.endsWith('/') ? uri : `${uri}/`) + + const Add = capability({ + can: 'file/link', + with: URI.match({ protocol: 'file:' }), + nb: Schema.struct({ + link: Link, + }), + derives: (claimed, delegated) => + claimed.with.startsWith(ensureTrailingDelimiter(delegated.with)) ? + ok({}) : + fail(`Resource ${claimed.with} is not contained by ${delegated.with}`), + }) + + // 2. Define service (from README) using proper Server.provide pattern + const context = { store: new Map() } + const service = { + file: { + link: provide(Add, ({ capability, invocation }) => { + context.store.set(capability.with, capability.nb.link) + return ok({ + with: capability.with, + link: capability.nb.link, + }) + }) + } + } + + // 3. Create server (from README) + const serviceKey = await ed25519.generate() + + const server = Server.create({ + id: serviceKey, + service, + codec: CAR.inbound, + validateAuthorization: () => ({ ok: {} }), + canIssue: (capability, issuer) => { + if (capability.with.startsWith("file:")) { + // Extract the DID from the file URI: file:///tmp/did:key:zABC.../path + const url = new URL(capability.with) + const pathParts = url.pathname.split("/") + const did = pathParts[2] // Skip empty string and "tmp" + return did === issuer + } + return false + }, + }) + + // 4. Create client connection using server-as-channel (RECOMMENDED PATTERN) + const connection = Client.connect({ + id: serviceKey, + codec: CAR.outbound, + channel: server, // 🎯 Server directly as channel - no HTTP needed! + }) + + // 5. Create and execute invocation (from README) + const issuerKey = await ed25519.generate() + const testCID = parseLink('bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy') + + const me = await Client.invoke({ + issuer: issuerKey, + audience: serviceKey, + capability: { + can: 'file/link', + with: `file:///tmp/${issuerKey.did()}/me/about`, + nb: { link: testCID }, + }, + }) + + const result = await me.execute(connection) + + // 6. Test that the full workflow completed successfully + assert.ok(result) + assert.ok(!result.out.error, `Expected no error, got: ${result.out.error?.message}`) + assert.ok(result.out, 'Expected successful result') + assert.ok(result.out.ok, 'Expected successful result in ok field') + assert.ok(!result.out.error, 'Expected no error in result') + assert.equal(result.out.ok.with, `file:///tmp/${issuerKey.did()}/me/about`) + assert.equal(result.out.ok.link.toString(), testCID.toString()) + + // 7. Verify the store was updated (proves the service handler actually ran) + const storedLink = context.store.get(`file:///tmp/${issuerKey.did()}/me/about`) + assert.ok(storedLink, 'Expected link to be stored') + assert.equal(storedLink.toString(), testCID.toString()) +}) + +// Test delegation example with server-as-channel +test('README delegation example with server-as-channel', async () => { + // 1. Define the ensureTrailingDelimiter helper + /** @param {string} uri */ + const ensureTrailingDelimiter = uri => (uri.endsWith('/') ? uri : `${uri}/`) + + // Create the same service setup + const Add = capability({ + can: 'file/link', + with: URI.match({ protocol: 'file:' }), + nb: Schema.struct({ + link: Link, + }), + derives: (claimed, delegated) => + claimed.with.startsWith(ensureTrailingDelimiter(delegated.with)) ? + ok({}) : + fail(`Resource ${claimed.with} is not contained by ${delegated.with}`), + }) + + const context = { store: new Map() } + const service = { + file: { + link: provide(Add, ({ capability, invocation }) => { + context.store.set(capability.with, capability.nb.link) + return ok({ + with: capability.with, + link: capability.nb.link, + }) + }) + } + } + + const serviceKey = await ed25519.generate() + + const server = Server.create({ + id: serviceKey, + service, + codec: CAR.inbound, + validateAuthorization: () => ({ ok: {} }), + canIssue: (capability, issuer) => { + if (capability.with.startsWith("file:")) { + // Extract the DID from the file URI: file:///tmp/did:key:zABC.../path + const url = new URL(capability.with) + const pathParts = url.pathname.split("/") + const did = pathParts[2] // Skip empty string and "tmp" + return did === issuer + } + return false + }, + }) + + // Server-as-channel connection + const connection = Client.connect({ + id: serviceKey, + codec: CAR.outbound, + channel: server, // 🎯 Direct server channel + }) + + // Generate test keys (like README) + const alice = await ed25519.generate() + const bob = await ed25519.generate() + + // Alice delegates capability to Bob (like README) + const proof = await Client.delegate({ + issuer: alice, + audience: bob, + capabilities: [ + { + can: 'file/link', + with: `file:///tmp/${alice.did()}/friends/${bob.did()}/`, + }, + ], + }) + + // Bob uses the delegation (like README) + const testCID = parseLink('bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy') + const aboutBob = Client.invoke({ + issuer: bob, + audience: serviceKey, + capability: { + can: 'file/link', + with: `file:///tmp/${alice.did()}/friends/${bob.did()}/about`, + nb: { link: testCID }, + }, + proofs: [proof], + }) + + const result = await aboutBob.execute(connection) + + // This should succeed because Bob has delegated permission from Alice + assert.ok(result) + assert.ok(!result.out.error, `Expected no error, got: ${result.out.error?.message}`) + assert.ok(result.out, 'Expected successful result') + assert.ok(result.out.ok, 'Expected successful result in ok field') + assert.ok(!result.out.error, 'Expected no error in result') + assert.equal(result.out.ok.with, `file:///tmp/${alice.did()}/friends/${bob.did()}/about`) +}) \ No newline at end of file diff --git a/packages/server/test/server.spec.js b/packages/server/test/server.spec.js index a516d78a..17f43b91 100644 --- a/packages/server/test/server.spec.js +++ b/packages/server/test/server.spec.js @@ -204,8 +204,7 @@ test('unknown handler', async () => { }, }) - // @ts-expect-error - reporst that service has no such capability - const error = await register.execute(connection) + const error = await register.execute(/** @type {any} */ (connection)) assert.containSubset(error, { out: { @@ -230,8 +229,7 @@ test('unknown handler', async () => { }, }) - // @ts-expect-error - reporst that service has no such capability - const error2 = await boom.execute(connection) + const error2 = await boom.execute(/** @type {any} */ (connection)) assert.containSubset(error2, { out: { error: { diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 3e549c1a..46437e9f 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -4,7 +4,7 @@ /* Projects */ "incremental": true /* Enable incremental compilation */, - "composite": true /* Enable constraints that allow a TypeScript project to be used with project references. */, + // "composite": true /* Enable constraints that allow a TypeScript project to be used with project references. */, // "tsBuildInfoFile": "./dist", /* Specify the folder for .tsbuildinfo incremental compilation files. */ // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ @@ -12,7 +12,7 @@ /* Language and Environment */ "target": "ES2020" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, - // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + "lib": ["ES2020", "DOM"] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, // "jsx": "preserve", /* Specify what JSX code is generated. */ // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ @@ -42,9 +42,9 @@ // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ /* Emit */ - // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ "declarationMap": true /* Create sourcemaps for d.ts files. */, - "emitDeclarationOnly": true /* Only output d.ts files and not JavaScript files. */, + // "emitDeclarationOnly": true /* Only output d.ts files and not JavaScript files. */, // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ "outDir": "./dist/" /* Specify an output folder for all emitted files. */, @@ -68,7 +68,7 @@ /* Interop Constraints */ // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ - // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, @@ -95,10 +95,11 @@ // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ /* Completeness */ - // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ }, "include": ["src", "test"], + "exclude": ["node_modules", "dist", "**/dist", "**/dist/**/*", "../**/dist/**/*"], "references": [ { "path": "../interface" }, { "path": "../core" }, diff --git a/packages/transport/README.md b/packages/transport/README.md index e243cadb..1ce7242f 100644 --- a/packages/transport/README.md +++ b/packages/transport/README.md @@ -1,17 +1,18 @@ # @ucanto/transport -`@ucanto/transport` provides encoding, decoding, and transport mechanisms for UCAN-based RPC. It ensures reliable communication between clients and servers using standardized serialization formats. +`@ucanto/transport` provides encoding, decoding, and transport mechanisms for UCAN-based RPC. It handles the serialization and network communication needed for secure UCAN message exchange between clients and servers. ## What It Provides -- **Pluggable Transport Layer**: Supports multiple encoding formats like CAR and CBOR. -- **Standardized Encoding**: Ensures consistency in UCAN invocation serialization. -- **Extensible Communication**: Enables integration with various network protocols. +- **CAR Encoding/Decoding**: Serializes UCAN messages in Content Addressable Archive format. +- **HTTP Transport**: Enables UCAN communication over HTTP with proper content negotiation. +- **Pluggable Codec System**: Supports multiple encoding formats with inbound/outbound codecs. +- **Legacy Support**: Maintains compatibility with older CBOR-based UCAN message formats. ## How It Fits with Other Modules -- [`@ucanto/core`](../core/README.md): Uses transport mechanisms for executing capabilities. -- [`@ucanto/server`](../server/README.md): Relies on transport modules for request handling. -- [`@ucanto/interface`](../interface/README.md): Defines standard transport-related types. -- [`@ucanto/principal`](../principal/README.md): Facilitates secure communication using identity-based encryption. +- [`@ucanto/client`](../client/README.md): Uses transport to communicate with services. +- [`@ucanto/server`](../server/README.md): Uses transport to receive and respond to requests. +- [`@ucanto/core`](../core/README.md): Provides the UCAN message structures that get transported. +- [`@ucanto/interface`](../interface/README.md): Defines transport-related types and interfaces. For an overview and detailed usage information, refer to the [main `ucanto` README](../../Readme.md). @@ -21,12 +22,126 @@ npm install @ucanto/transport ``` ## Example Usage -```ts -import * as CAR from '@ucanto/transport/car'; -import * as CBOR from '@ucanto/transport/cbor'; -const encoded = CAR.encode({ invocations: [] }); -const decoded = CBOR.decode(encoded); +### HTTP Transport + +```js +import * as HTTP from '@ucanto/transport/http' +import { CAR } from '@ucanto/transport' +import { ed25519 } from '@ucanto/principal' +import { invoke, Message } from '@ucanto/core' +import { DID } from '@ucanto/core' + +// Parse the service DID +// SERVICE_DID should be a DID like: did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi +const service = DID.parse(process.env.SERVICE_DID) +// Parse the agent's private key +// AGENT_PRIVATE_KEY should be a base64 private key starting with: Mg.. +const issuer = ed25519.parse(process.env.AGENT_PRIVATE_KEY) + +// Create UCAN invocation +const invocation = invoke({ + issuer, + audience: service, + capability: { + can: 'store/add', + with: issuer.did(), + nb: { link: 'bafybeigwflfnv7tjgpuy52ep45cbbgkkb2makd3bwhbj3ueabvt3eq43ca' } + } +}) + +// Package for transport +const message = await Message.build({ invocations: [invocation] }) +const request = await CAR.request.encode(message) + +// Create HTTP channel and send to a UCAN service +const channel = HTTP.open({ + url: new URL(process.env.SERVICE_URL || 'https://api.example.com') +}) +const response = await channel.request(request) + +// Unpack response +const replyMessage = await CAR.response.decode(response) +console.log('Received:', replyMessage.receipts.size, 'receipts') +``` + +### Server as Channel (for Testing) + +For testing, you can use a UCAN server directly as a channel without HTTP. See the [`@ucanto/server` README](../server/README.md) for examples of using a server as a channel. + +## Setup Instructions + +### Environment Variables + +**AGENT_PRIVATE_KEY** +Set the key your client should use to sign UCAN invocations. You can generate Ed25519 keys with the ucanto library. + +#### Usage + +Create a file called `generate-keys.js`: + +```javascript +import { ed25519 } from '@ucanto/principal' + +async function generateKeys() { + const keypair = await ed25519.generate() + + const privateKey = ed25519.format(keypair) + + console.log('AGENT_PRIVATE_KEY=' + privateKey) +} + +generateKeys().catch(console.error) +``` + +Then run it: + +```bash +node generate-keys.js +``` + +**SERVICE_DID** +Set the DID of the service you want to connect to. Check the service's documentation for their public DID. + +**SERVICE_URL** (Optional) +If you're connecting to a custom service, set both `SERVICE_DID` and `SERVICE_URL` environment variables. + + +For example, Storacha has following `SERVICE_DID` and `SERVICE_URL`: + +```bash +# Storacha uses these default values: +SERVICE_DID="did:web:up.storacha.network" +SERVICE_URL="https://up.storacha.network" +``` + +Set your environment variables like so: +```bash +AGENT_PRIVATE_KEY="your_generated_private_key_here" \ +SERVICE_DID="did:key:service_provider_did_here" \ ``` + +### Advanced: Pluggable Codecs + +Transport provides a codec system for different encoding strategies: + +```js +import { Codec, CAR } from '@ucanto/transport' + +// Outbound codec (client-side) +const outbound = Codec.outbound({ + encoders: { 'application/vnd.ipld.car': CAR.request }, + decoders: { 'application/vnd.ipld.car': CAR.response }, +}) + +// Inbound codec (server-side) +const inbound = Codec.inbound({ + decoders: { 'application/vnd.ipld.car': CAR.request }, + encoders: { 'application/vnd.ipld.car': CAR.response }, +}) +``` + +**What's happening:** Transport handles the low-level details of UCAN communication - encoding messages into CAR format, managing HTTP headers, content negotiation, and error handling. Most developers use `@ucanto/client` which handles this automatically. + For more details, see the [`ucanto` documentation](https://github.com/storacha/ucanto). \ No newline at end of file diff --git a/packages/validator/src/error.js b/packages/validator/src/error.js index 16b26762..9df79130 100644 --- a/packages/validator/src/error.js +++ b/packages/validator/src/error.js @@ -1,6 +1,6 @@ import * as API from '@ucanto/interface' import { the } from './util.js' -import { isLink } from '@ucanto/core/link' +import { isLink } from '@ucanto/core' import { fail, Failure } from '@ucanto/core/result' export { Failure, fail } diff --git a/packages/validator/src/lib.js b/packages/validator/src/lib.js index 353d96cf..8f8638a3 100644 --- a/packages/validator/src/lib.js +++ b/packages/validator/src/lib.js @@ -1,5 +1,5 @@ import * as API from '@ucanto/interface' -import { isDelegation, UCAN, ok, fail } from '@ucanto/core' +import { isDelegation, ok, fail, UCAN } from '@ucanto/core' import { capability } from './capability.js' import * as Schema from '@ucanto/core/schema' import * as Authorization from './authorization.js' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66542d75..3fe927d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,9 +36,15 @@ importers: '@ucanto/principal': specifier: workspace:^ version: link:../principal + '@ucanto/server': + specifier: workspace:^ + version: link:../server '@ucanto/transport': specifier: workspace:^ version: link:../transport + '@ucanto/validator': + specifier: workspace:^ + version: link:../validator '@web-std/fetch': specifier: ^4.1.0 version: 4.2.1 @@ -793,6 +799,7 @@ packages: chai-subset@1.6.0: resolution: {integrity: sha512-K3d+KmqdS5XKW5DWPd5sgNffL3uxdDe+6GdnJh3AYPhwnBGRY5urfvfcbRtWIvvpz+KxkL9FeBB6MZewLUNwug==} engines: {node: '>=4'} + deprecated: 'functionality of this lib is built-in to chai now. see more details here: https://github.com/debitoor/chai-subset/pull/85' chai@4.5.0: resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} @@ -3937,8 +3944,6 @@ snapshots: typescript@5.0.4: {} - typescript@5.7.3: {} - unbox-primitive@1.1.0: dependencies: call-bound: 1.0.3