From 038b3c4499248ee8c27896b1f87b81d781782ab4 Mon Sep 17 00:00:00 2001 From: Dhruv-Varshney-developer Date: Tue, 19 Aug 2025 07:57:15 +0530 Subject: [PATCH 01/22] docs: Update Readme and code snippets for @ucanto/transport --- packages/transport/README.md | 113 +++++++++++++++++++++++++++++++---- 1 file changed, 100 insertions(+), 13 deletions(-) diff --git a/packages/transport/README.md b/packages/transport/README.md index e243cadb..75ca49a1 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,98 @@ npm install @ucanto/transport ``` ## Example Usage -```ts -import * as CAR from '@ucanto/transport/car'; -import * as CBOR from '@ucanto/transport/cbor'; +```js +import * as HTTP from '@ucanto/transport/http' +import { CAR } from '@ucanto/transport' +import { ed25519 } from '@ucanto/principal' +import { invoke, Message, Receipt } from '@ucanto/core' -const encoded = CAR.encode({ invocations: [] }); -const decoded = CBOR.decode(encoded); +const service = ed25519.parse(process.env.SERVICE_ID) +const issuer = ed25519.parse(process.env.CLIENT_KEYPAIR) + +// Mock fetch that simulates a UCAN service +const mockFetch = async (url, init) => { + console.log('Sending request to:', url) + console.log('Request headers:', init.headers) + + // Simulate a service response with a receipt + const { invocations } = await CAR.request.decode(init) + const receipts = await Promise.all( + invocations.map(inv => Receipt.issue({ + ran: inv.cid, + issuer: service, + result: { ok: { status: 'added' } } + })) + ) + + const responseMessage = await Message.build({ receipts }) + const response = await CAR.response.encode(responseMessage) + + return { + ok: true, + headers: new Map(Object.entries(response.headers)), + arrayBuffer: () => response.body, + } +} + +// 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 +const channel = HTTP.open({ + url: new URL('https://api.example.com'), + fetch: mockFetch +}) +const response = await channel.request(request) + +// Unpack response +const replyMessage = await CAR.response.decode(response) +console.log('Received:', replyMessage.receipts.size, 'receipts') ``` +### Run it: +⚠️ These are test keys. Replace them with your own service ID and client keypair before using in production. + +```bash +SERVICE_ID="MgCYKXoHVy7Vk4/QjcEGi+MCqjntUiasxXJ8uJKY0qh11e+0Bs8WsdqGK7xothgrDzzWD0ME7ynPjz2okXDh8537lId8=" \ +CLIENT_KEYPAIR="MgCZT5vOnYZoVAeyjnzuJIVY9J4LNtJ+f8Js0cTPuKUpFne0BVEDJjEu6quFIU8yp91/TY/+MYK8GvlKoTDnqOCovCVM=" \ +node example.js +``` + + + +### 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 From 6fd66a30a4972371d1a9e3224e9cbf623a08609c Mon Sep 17 00:00:00 2001 From: Dhruv-Varshney-developer Date: Tue, 19 Aug 2025 07:57:28 +0530 Subject: [PATCH 02/22] docs: Update Readme and code snippets for @ucanto/client --- packages/client/README.md | 87 +++++++++++++++++++++++++++++++-------- 1 file changed, 70 insertions(+), 17 deletions(-) diff --git a/packages/client/README.md b/packages/client/README.md index 800a37e2..7d9822ec 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -4,11 +4,12 @@ ## 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 +23,80 @@ npm install @ucanto/client ``` ## Example Usage -```ts -import * as Client from '@ucanto/client'; -import { ed25519 } from '@ucanto/principal'; +```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 { Receipt, Message } from '@ucanto/core' -const service = ed25519.Verifier.parse(process.env.SERVICE_ID); -const issuer = ed25519.Signer.parse(process.env.CLIENT_KEYPAIR); +const service = ed25519.parse(process.env.SERVICE_ID) +const issuer = ed25519.parse(process.env.CLIENT_KEYPAIR) -const invocation = await Client.invoke({ +// Mock fetch that simulates a UCAN service +const mockFetch = async (url, input) => { + const { invocations } = await CAR.request.decode(input) + + const receipts = await Promise.all( + invocations.map(inv => Receipt.issue({ + ran: inv.cid, // Link to the invocation + issuer: service, // Service signs the receipt + result: { ok: { status: 'success' } } // Fake success + })) + ) + + const message = await Message.build({ receipts }) + const response = await CAR.response.encode(message) + + return { + ok: true, + headers: new Map(Object.entries(response.headers)), + arrayBuffer: () => response.body, + } +} + +// Connect to mock service +const connection = Client.connect({ + id: service, + channel: HTTP.open({ url: new URL('https://api.example.com'), fetch: mockFetch }), + codec: CAR.outbound, +}) + +// 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 response = await client.execute(invocation); -if (response.error) { - console.error('Invocation failed:', response.error); -} else { - console.log('Invocation succeeded:', response.result); -} +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) +``` + +### Run it: + +⚠️ These are test keys. Replace them with your own service ID and client keypair before using in production. + +```bash +SERVICE_ID="MgCYKXoHVy7Vk4/QjcEGi+MCqjntUiasxXJ8uJKY0qh11e+0Bs8WsdqGK7xothgrDzzWD0ME7ynPjz2okXDh8537lId8=" \ +CLIENT_KEYPAIR="MgCZT5vOnYZoVAeyjnzuJIVY9J4LNtJ+f8Js0cTPuKUpFne0BVEDJjEu6quFIU8yp91/TY/+MYK8GvlKoTDnqOCovCVM=" \ +node example.js ``` + +### Connecting to a Real Service + +Replace the `mockFetch` in the example with the real `fetch` and a valid service URL: + +```js +channel: HTTP.open({ url: new URL('https://api.example.com'), fetch }) +``` + +**What's happening:** UCAN services expect CAR-encoded requests and return CAR-encoded receipts with cryptographic signatures. The mock simulates this entire flow so the example works without a real service. + For more details, see the [`ucanto` documentation](https://github.com/storacha/ucanto). \ No newline at end of file From d80540e723ea87a47749fa52d09fdd33e61c1df4 Mon Sep 17 00:00:00 2001 From: Dhruv Varshney Date: Tue, 26 Aug 2025 02:18:14 +0530 Subject: [PATCH 03/22] refactor: use native Response Constructor instead of custom constructor object Co-authored-by: ash --- packages/client/README.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/client/README.md b/packages/client/README.md index 7d9822ec..cde21cd0 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -48,11 +48,7 @@ const mockFetch = async (url, input) => { const message = await Message.build({ receipts }) const response = await CAR.response.encode(message) - return { - ok: true, - headers: new Map(Object.entries(response.headers)), - arrayBuffer: () => response.body, - } + return new Response(response.body, { headers: response.headers }) } // Connect to mock service From 4a0a38db292485c7e57de53e50dbce4f2db1540f Mon Sep 17 00:00:00 2001 From: Dhruv-Varshney-developer Date: Tue, 26 Aug 2025 02:30:33 +0530 Subject: [PATCH 04/22] refactor: use native Response constructor instead of custom response object --- packages/transport/README.md | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/transport/README.md b/packages/transport/README.md index 75ca49a1..e86143ae 100644 --- a/packages/transport/README.md +++ b/packages/transport/README.md @@ -47,13 +47,8 @@ const mockFetch = async (url, init) => { ) const responseMessage = await Message.build({ receipts }) - const response = await CAR.response.encode(responseMessage) - - return { - ok: true, - headers: new Map(Object.entries(response.headers)), - arrayBuffer: () => response.body, - } + const response = await CAR.response.encode(responseMessage) + return new Response(response.body, { headers: response.headers }) } // Create UCAN invocation From 2a896485b2ab2a9d202a27e1441b2efd539917a6 Mon Sep 17 00:00:00 2001 From: Dhruv-Varshney-developer Date: Tue, 26 Aug 2025 05:45:06 +0530 Subject: [PATCH 05/22] refactor: rename from CLIENT_KEYPAIR to AGENT_PRIVATE_KEY. --- packages/client/README.md | 4 ++-- packages/transport/README.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client/README.md b/packages/client/README.md index cde21cd0..df1d4a00 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -31,7 +31,7 @@ import { ed25519 } from '@ucanto/principal' import { Receipt, Message } from '@ucanto/core' const service = ed25519.parse(process.env.SERVICE_ID) -const issuer = ed25519.parse(process.env.CLIENT_KEYPAIR) +const issuer = ed25519.parse(process.env.AGENT_PRIVATE_KEY) // Mock fetch that simulates a UCAN service const mockFetch = async (url, input) => { @@ -80,7 +80,7 @@ console.log(receipt.out.error ? 'Failed:' : 'Success:', receipt.out) ```bash SERVICE_ID="MgCYKXoHVy7Vk4/QjcEGi+MCqjntUiasxXJ8uJKY0qh11e+0Bs8WsdqGK7xothgrDzzWD0ME7ynPjz2okXDh8537lId8=" \ -CLIENT_KEYPAIR="MgCZT5vOnYZoVAeyjnzuJIVY9J4LNtJ+f8Js0cTPuKUpFne0BVEDJjEu6quFIU8yp91/TY/+MYK8GvlKoTDnqOCovCVM=" \ +AGENT_PRIVATE_KEY="MgCZT5vOnYZoVAeyjnzuJIVY9J4LNtJ+f8Js0cTPuKUpFne0BVEDJjEu6quFIU8yp91/TY/+MYK8GvlKoTDnqOCovCVM=" \ node example.js ``` diff --git a/packages/transport/README.md b/packages/transport/README.md index e86143ae..9066b9cf 100644 --- a/packages/transport/README.md +++ b/packages/transport/README.md @@ -29,7 +29,7 @@ import { ed25519 } from '@ucanto/principal' import { invoke, Message, Receipt } from '@ucanto/core' const service = ed25519.parse(process.env.SERVICE_ID) -const issuer = ed25519.parse(process.env.CLIENT_KEYPAIR) +const issuer = ed25519.parse(process.env.AGENT_PRIVATE_KEY) // Mock fetch that simulates a UCAN service const mockFetch = async (url, init) => { @@ -83,7 +83,7 @@ console.log('Received:', replyMessage.receipts.size, 'receipts') ```bash SERVICE_ID="MgCYKXoHVy7Vk4/QjcEGi+MCqjntUiasxXJ8uJKY0qh11e+0Bs8WsdqGK7xothgrDzzWD0ME7ynPjz2okXDh8537lId8=" \ -CLIENT_KEYPAIR="MgCZT5vOnYZoVAeyjnzuJIVY9J4LNtJ+f8Js0cTPuKUpFne0BVEDJjEu6quFIU8yp91/TY/+MYK8GvlKoTDnqOCovCVM=" \ +AGENT_PRIVATE_KEY="MgCZT5vOnYZoVAeyjnzuJIVY9J4LNtJ+f8Js0cTPuKUpFne0BVEDJjEu6quFIU8yp91/TY/+MYK8GvlKoTDnqOCovCVM=" \ node example.js ``` From 7abf3c3666da7d70b1027d231900d218dcf6305d Mon Sep 17 00:00:00 2001 From: Dhruv-Varshney-developer Date: Tue, 26 Aug 2025 07:24:37 +0530 Subject: [PATCH 06/22] fix: Replace Service private key with SERVICE DID. --- packages/client/README.md | 6 +++++- packages/transport/README.md | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/client/README.md b/packages/client/README.md index df1d4a00..abd5b980 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -30,7 +30,11 @@ import { CAR } from '@ucanto/transport' import { ed25519 } from '@ucanto/principal' import { Receipt, Message } from '@ucanto/core' -const service = ed25519.parse(process.env.SERVICE_ID) +// Parse the service DID (public key) +// SERVICE_DID should be a DID like: did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi +const service = ed25519.Verifier.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) // Mock fetch that simulates a UCAN service diff --git a/packages/transport/README.md b/packages/transport/README.md index 9066b9cf..0b902188 100644 --- a/packages/transport/README.md +++ b/packages/transport/README.md @@ -28,7 +28,11 @@ import { CAR } from '@ucanto/transport' import { ed25519 } from '@ucanto/principal' import { invoke, Message, Receipt } from '@ucanto/core' -const service = ed25519.parse(process.env.SERVICE_ID) +// Parse the service DID (public key) +// SERVICE_DID should be a DID like: did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi +const service = ed25519.Verifier.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) // Mock fetch that simulates a UCAN service From 5dc05d33f190086481fd4a63f850e12e4100c446 Mon Sep 17 00:00:00 2001 From: Dhruv-Varshney-developer Date: Tue, 26 Aug 2025 15:23:17 +0530 Subject: [PATCH 07/22] fix: Replace .env variables with setup instructions. --- packages/client/README.md | 52 ++++++++++++++++++++++++++++------ packages/transport/README.md | 54 ++++++++++++++++++++++++++++++++---- 2 files changed, 91 insertions(+), 15 deletions(-) diff --git a/packages/client/README.md b/packages/client/README.md index abd5b980..30926d15 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -78,25 +78,59 @@ const [receipt] = await connection.execute(invocation) console.log(receipt.out.error ? 'Failed:' : 'Success:', receipt.out) ``` -### Run it: +## Setup Instructions -⚠️ These are test keys. Replace them with your own service ID and client keypair before using in production. +### 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 -SERVICE_ID="MgCYKXoHVy7Vk4/QjcEGi+MCqjntUiasxXJ8uJKY0qh11e+0Bs8WsdqGK7xothgrDzzWD0ME7ynPjz2okXDh8537lId8=" \ -AGENT_PRIVATE_KEY="MgCZT5vOnYZoVAeyjnzuJIVY9J4LNtJ+f8Js0cTPuKUpFne0BVEDJjEu6quFIU8yp91/TY/+MYK8GvlKoTDnqOCovCVM=" \ -node example.js +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. -### Connecting to a Real Service +**SERVICE_URL** (Optional) +If you're connecting to a custom service, set both `SERVICE_DID` and `SERVICE_URL` environment variables. -Replace the `mockFetch` in the example with the real `fetch` and a valid service URL: -```js -channel: HTTP.open({ url: new URL('https://api.example.com'), fetch }) +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" \ +``` + + **What's happening:** UCAN services expect CAR-encoded requests and return CAR-encoded receipts with cryptographic signatures. The mock simulates this entire flow so the example works without a real service. For more details, see the [`ucanto` documentation](https://github.com/storacha/ucanto). \ No newline at end of file diff --git a/packages/transport/README.md b/packages/transport/README.md index 0b902188..31fc0b47 100644 --- a/packages/transport/README.md +++ b/packages/transport/README.md @@ -32,7 +32,7 @@ import { invoke, Message, Receipt } from '@ucanto/core' // SERVICE_DID should be a DID like: did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi const service = ed25519.Verifier.parse(process.env.SERVICE_DID) // Parse the agent's private key -// AGENT_PRIVATE_KEY should be a base64 private key like: Mg.. +// AGENT_PRIVATE_KEY should be a base64 private key starting with: Mg.. const issuer = ed25519.parse(process.env.AGENT_PRIVATE_KEY) // Mock fetch that simulates a UCAN service @@ -82,15 +82,57 @@ const replyMessage = await CAR.response.decode(response) console.log('Received:', replyMessage.receipts.size, 'receipts') ``` -### Run it: -⚠️ These are test keys. Replace them with your own service ID and client keypair before using in production. +## 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 -SERVICE_ID="MgCYKXoHVy7Vk4/QjcEGi+MCqjntUiasxXJ8uJKY0qh11e+0Bs8WsdqGK7xothgrDzzWD0ME7ynPjz2okXDh8537lId8=" \ -AGENT_PRIVATE_KEY="MgCZT5vOnYZoVAeyjnzuJIVY9J4LNtJ+f8Js0cTPuKUpFne0BVEDJjEu6quFIU8yp91/TY/+MYK8GvlKoTDnqOCovCVM=" \ -node example.js +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 From 2c7ee88c3c2b3bdaa4131d5466d27d79e08d5d11 Mon Sep 17 00:00:00 2001 From: Nico Krause Date: Sat, 11 Oct 2025 15:17:31 +0200 Subject: [PATCH 08/22] added playwright setup --- .github/workflows/client.yml | 6 ++++++ .github/workflows/core.yml | 6 ++++++ .github/workflows/interface.yml | 6 ++++++ .github/workflows/principal.yml | 6 ++++++ .github/workflows/server.yml | 6 ++++++ .github/workflows/transport.yml | 6 ++++++ .github/workflows/validator.yml | 6 ++++++ 7 files changed, 42 insertions(+) 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 From d31624a2afe99ac61e85fb1ce8d68e2c9b2d43f1 Mon Sep 17 00:00:00 2001 From: Nico Krause Date: Sat, 11 Oct 2025 15:19:19 +0200 Subject: [PATCH 09/22] fixed and improved readme according to #387 and PR #388 --- Readme.md | 552 +++++++++--------- packages/client/README.md | 6 +- packages/server/README.md | 182 +++++- packages/server/test/readme-examples.spec.js | 62 ++ .../server/test/readme-integration.spec.js | 192 ++++++ 5 files changed, 704 insertions(+), 290 deletions(-) create mode 100644 packages/server/test/readme-examples.spec.js create mode 100644 packages/server/test/readme-integration.spec.js diff --git a/Readme.md b/Readme.md index 67ed269c..9e50ddaf 100644 --- a/Readme.md +++ b/Readme.md @@ -1,286 +1,266 @@ -# 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 + +(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) +} +``` + +### 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) +``` + +### 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]) +``` + +### 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 + +## 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) +``` + +## 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..f1dcc009 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -3,11 +3,13 @@ `@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. - **Batch Invocation Support**: Enables multiple invocations in a single request. - **Secure Communication**: Ensures interactions are cryptographically signed and verified. ## How It Fits with Other Modules + - [`@ucanto/core`](../core/README.md): Defines capability structures and execution 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. @@ -26,8 +28,8 @@ npm install @ucanto/client 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); +const service = ed25519.parse(process.env.SERVICE_ID); +const issuer = ed25519.parse(process.env.CLIENT_KEYPAIR); const invocation = await Client.invoke({ issuer, diff --git a/packages/server/README.md b/packages/server/README.md index c070738d..08f1bfc0 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,176 @@ export const createServer = () => { }; ``` -For more details, see the [`ucanto` documentation](https://github.com/storacha/ucanto). \ No newline at end of file +### 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 diff --git a/packages/server/test/readme-examples.spec.js b/packages/server/test/readme-examples.spec.js new file mode 100644 index 00000000..62326b9e --- /dev/null +++ b/packages/server/test/readme-examples.spec.js @@ -0,0 +1,62 @@ +/** + * 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 } from '../src/lib.js' +import { ed25519 } from '@ucanto/principal' + +// Test that we can create the README capability definition +test('README capability definition works', async () => { + const ensureTrailingDelimiter = uri => (uri.endsWith('/') ? uri : `${uri}/`) + + const Add = capability({ + can: 'file/link', + with: URI.match({ protocol: 'file:' }), + nb: { link: Link }, + derives: (claimed, delegated) => + claimed.uri.href.startsWith(ensureTrailingDelimiter(delegated.uri.href)) || + new Failure(`Notebook ${claimed.uri} is not included in ${delegated.uri}`), + }) + + // 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: { link: Link }, + }) + + const service = (context = { store: new Map() }) => { + const add = provide(Add, ({ capability, invocation }) => { + context.store.set(capability.uri.href, capability.nb.link) + return { + 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..5798a4c7 --- /dev/null +++ b/packages/server/test/readme-integration.spec.js @@ -0,0 +1,192 @@ +/** + * Integration tests for README examples using server-as-channel pattern + */ + +import { test, assert } from './test.js' +import { capability, URI, Link, Failure, provide, Schema } 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) + 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. 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 { + 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.error, `Expected no error, got: ${result.error?.message}`) + assert.ok(result.out, 'Expected successful result') + assert.ok(!result.out.error, 'Expected no error in result') + assert.equal(result.out.with, `file:///tmp/${issuerKey.did()}/me/about`) + assert.equal(result.out.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 + 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)) || + new Failure(`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 { + 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.error, `Expected no error, got: ${result.error?.message}`) + assert.ok(result.out, 'Expected successful result') + assert.ok(!result.out.error, 'Expected no error in result') + assert.equal(result.out.with, `file:///tmp/${alice.did()}/friends/${bob.did()}/about`) +}) \ No newline at end of file From f80b1f688060db869790f720f0c8a59c092e4fde Mon Sep 17 00:00:00 2001 From: Nico Krause Date: Sat, 11 Oct 2025 15:32:15 +0200 Subject: [PATCH 10/22] adding test badges --- Readme.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Readme.md b/Readme.md index 9e50ddaf..d1b2f9fb 100644 --- a/Readme.md +++ b/Readme.md @@ -1,5 +1,13 @@ # ucanto +[![Core Tests](https://github.com/{{ github.repository }}/actions/workflows/core.yml/badge.svg)](https://github.com/{{ github.repository }}/actions/workflows/core.yml) +[![Principal Tests](https://github.com/{{ github.repository }}/actions/workflows/principal.yml/badge.svg)](https://github.com/{{ github.repository }}/actions/workflows/principal.yml) +[![Transport Tests](https://github.com/{{ github.repository }}/actions/workflows/transport.yml/badge.svg)](https://github.com/{{ github.repository }}/actions/workflows/transport.yml) +[![Interface Tests](https://github.com/{{ github.repository }}/actions/workflows/interface.yml/badge.svg)](https://github.com/{{ github.repository }}/actions/workflows/interface.yml) +[![Server Tests](https://github.com/{{ github.repository }}/actions/workflows/server.yml/badge.svg)](https://github.com/{{ github.repository }}/actions/workflows/server.yml) +[![Client Tests](https://github.com/{{ github.repository }}/actions/workflows/client.yml/badge.svg)](https://github.com/{{ github.repository }}/actions/workflows/client.yml) +[![Validator Tests](https://github.com/{{ github.repository }}/actions/workflows/validator.yml/badge.svg)](https://github.com/{{ github.repository }}/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 From ddb4540e4b9c6f5ece23e313a9d005f5fb282f00 Mon Sep 17 00:00:00 2001 From: Nico Krause Date: Sat, 11 Oct 2025 15:39:59 +0200 Subject: [PATCH 11/22] changing badge url --- Readme.md | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/Readme.md b/Readme.md index d1b2f9fb..de1e5642 100644 --- a/Readme.md +++ b/Readme.md @@ -1,12 +1,12 @@ # ucanto -[![Core Tests](https://github.com/{{ github.repository }}/actions/workflows/core.yml/badge.svg)](https://github.com/{{ github.repository }}/actions/workflows/core.yml) -[![Principal Tests](https://github.com/{{ github.repository }}/actions/workflows/principal.yml/badge.svg)](https://github.com/{{ github.repository }}/actions/workflows/principal.yml) -[![Transport Tests](https://github.com/{{ github.repository }}/actions/workflows/transport.yml/badge.svg)](https://github.com/{{ github.repository }}/actions/workflows/transport.yml) -[![Interface Tests](https://github.com/{{ github.repository }}/actions/workflows/interface.yml/badge.svg)](https://github.com/{{ github.repository }}/actions/workflows/interface.yml) -[![Server Tests](https://github.com/{{ github.repository }}/actions/workflows/server.yml/badge.svg)](https://github.com/{{ github.repository }}/actions/workflows/server.yml) -[![Client Tests](https://github.com/{{ github.repository }}/actions/workflows/client.yml/badge.svg)](https://github.com/{{ github.repository }}/actions/workflows/client.yml) -[![Validator Tests](https://github.com/{{ github.repository }}/actions/workflows/validator.yml/badge.svg)](https://github.com/{{ github.repository }}/actions/workflows/validator.yml) +[![Core Tests](https://github.com/NiKrause/ucanto/actions/workflows/core.yml/badge.svg)](https://github.com/NiKrause/ucanto/actions/workflows/core.yml) +[![Principal Tests](https://github.com/NiKrause/ucanto/actions/workflows/principal.yml/badge.svg)](https://github.com/NiKrause/ucanto/actions/workflows/principal.yml) +[![Transport Tests](https://github.com/NiKrause/ucanto/actions/workflows/transport.yml/badge.svg)](https://github.com/NiKrause/ucanto/actions/workflows/transport.yml) +[![Interface Tests](https://github.com/NiKrause/ucanto/actions/workflows/interface.yml/badge.svg)](https://github.com/NiKrause/ucanto/actions/workflows/interface.yml) +[![Server Tests](https://github.com/NiKrause/ucanto/actions/workflows/server.yml/badge.svg)](https://github.com/NiKrause/ucanto/actions/workflows/server.yml) +[![Client Tests](https://github.com/NiKrause/ucanto/actions/workflows/client.yml/badge.svg)](https://github.com/NiKrause/ucanto/actions/workflows/client.yml) +[![Validator Tests](https://github.com/NiKrause/ucanto/actions/workflows/validator.yml/badge.svg)](https://github.com/NiKrause/ucanto/actions/workflows/validator.yml) (u)canto is a library for [UCAN][] based [RPC][] that provides: @@ -70,6 +70,8 @@ if (result.error) { } ``` +> 📝 **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: @@ -110,6 +112,10 @@ const invocation = Client.invoke({ 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: @@ -131,6 +137,8 @@ const deleteFile = Client.invoke({ 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: @@ -197,6 +205,8 @@ This demonstrates how UCAN's delegation system provides fine-grained access cont - ❌ **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: @@ -253,6 +263,8 @@ 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 From 47430b80ffcf03c3a733690fd42eb7c5c33e0bf7 Mon Sep 17 00:00:00 2001 From: Nico Krause Date: Sat, 11 Oct 2025 16:10:04 +0200 Subject: [PATCH 12/22] fixing server tests --- packages/server/test/handler.spec.js | 2 +- packages/server/test/readme-examples.spec.js | 18 ++++++----- .../server/test/readme-integration.spec.js | 30 +++++++++++-------- packages/server/test/server.spec.js | 4 --- packages/validator/src/error.js | 2 +- packages/validator/src/lib.js | 2 +- 6 files changed, 31 insertions(+), 27 deletions(-) diff --git a/packages/server/test/handler.spec.js b/packages/server/test/handler.spec.js index 48ca6f29..9d3c1763 100644 --- a/packages/server/test/handler.spec.js +++ b/packages/server/test/handler.spec.js @@ -249,7 +249,7 @@ test('test access/claim provider', async () => { }) /** - * @type {Client.ConnectionView<{ + * @type {API.ConnectionView<{ * access: { * claim: API.ServiceMethod, never[], API.Failure> * } diff --git a/packages/server/test/readme-examples.spec.js b/packages/server/test/readme-examples.spec.js index 62326b9e..5ad2033f 100644 --- a/packages/server/test/readme-examples.spec.js +++ b/packages/server/test/readme-examples.spec.js @@ -4,20 +4,22 @@ */ import { test, assert } from './test.js' -import { capability, URI, Link, Failure, provide } from '../src/lib.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: { link: Link }, + nb: Schema.struct({ link: Link }), derives: (claimed, delegated) => - claimed.uri.href.startsWith(ensureTrailingDelimiter(delegated.uri.href)) || - new Failure(`Notebook ${claimed.uri} is not included in ${delegated.uri}`), + 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 @@ -30,16 +32,16 @@ test('README service definition works', async () => { const Add = capability({ can: 'file/link', with: URI.match({ protocol: 'file:' }), - nb: { link: Link }, + nb: Schema.struct({ link: Link }), }) const service = (context = { store: new Map() }) => { const add = provide(Add, ({ capability, invocation }) => { - context.store.set(capability.uri.href, capability.nb.link) - return { + context.store.set(capability.with, capability.nb.link) + return ok({ with: capability.with, link: capability.nb.link, - } + }) }) return { file: { add } } diff --git a/packages/server/test/readme-integration.spec.js b/packages/server/test/readme-integration.spec.js index 5798a4c7..1be9964a 100644 --- a/packages/server/test/readme-integration.spec.js +++ b/packages/server/test/readme-integration.spec.js @@ -3,7 +3,7 @@ */ import { test, assert } from './test.js' -import { capability, URI, Link, Failure, provide, Schema } from '../src/lib.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' @@ -12,6 +12,7 @@ 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({ @@ -21,8 +22,9 @@ test('README workflow integration with server-as-channel', async () => { link: Link, }), derives: (claimed, delegated) => - claimed.with.startsWith(ensureTrailingDelimiter(delegated.with)) || - new Failure(`Resource ${claimed.with} is not contained by ${delegated.with}`), + 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 @@ -31,10 +33,10 @@ test('README workflow integration with server-as-channel', async () => { file: { link: provide(Add, ({ capability, invocation }) => { context.store.set(capability.with, capability.nb.link) - return { + return ok({ with: capability.with, link: capability.nb.link, - } + }) }) } } @@ -86,9 +88,10 @@ test('README workflow integration with server-as-channel', async () => { assert.ok(result) assert.ok(!result.error, `Expected no error, got: ${result.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.with, `file:///tmp/${issuerKey.did()}/me/about`) - assert.equal(result.out.link.toString(), testCID.toString()) + 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`) @@ -99,6 +102,7 @@ test('README workflow integration with server-as-channel', async () => { // 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 @@ -109,8 +113,9 @@ test('README delegation example with server-as-channel', async () => { link: Link, }), derives: (claimed, delegated) => - claimed.with.startsWith(ensureTrailingDelimiter(delegated.with)) || - new Failure(`Resource ${claimed.with} is not contained by ${delegated.with}`), + claimed.with.startsWith(ensureTrailingDelimiter(delegated.with)) ? + ok({}) : + fail(`Resource ${claimed.with} is not contained by ${delegated.with}`), }) const context = { store: new Map() } @@ -118,10 +123,10 @@ test('README delegation example with server-as-channel', async () => { file: { link: provide(Add, ({ capability, invocation }) => { context.store.set(capability.with, capability.nb.link) - return { + return ok({ with: capability.with, link: capability.nb.link, - } + }) }) } } @@ -187,6 +192,7 @@ test('README delegation example with server-as-channel', async () => { assert.ok(result) assert.ok(!result.error, `Expected no error, got: ${result.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.with, `file:///tmp/${alice.did()}/friends/${bob.did()}/about`) + 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..2e9d26b6 100644 --- a/packages/server/test/server.spec.js +++ b/packages/server/test/server.spec.js @@ -125,7 +125,6 @@ test('encode delegated invocation', async () => { assert.deepEqual(r1.out, { error: { - // @ts-expect-error name: 'UnknownDIDError', did: alice.did(), message: `DID ${alice.did()} has no account`, @@ -134,7 +133,6 @@ test('encode delegated invocation', async () => { assert.deepEqual(r2.out, { error: { - // @ts-expect-error name: 'UnknownDIDError', did: alice.did(), message: `DID ${alice.did()} has no account`, @@ -204,7 +202,6 @@ test('unknown handler', async () => { }, }) - // @ts-expect-error - reporst that service has no such capability const error = await register.execute(connection) assert.containSubset(error, { @@ -230,7 +227,6 @@ test('unknown handler', async () => { }, }) - // @ts-expect-error - reporst that service has no such capability const error2 = await boom.execute(connection) assert.containSubset(error2, { out: { 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 1b863d56..91128706 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' From f8da1ca341851549b9fb5e755dbda98820518c72 Mon Sep 17 00:00:00 2001 From: Nico Krause Date: Sat, 11 Oct 2025 16:15:59 +0200 Subject: [PATCH 13/22] fixing typecheck --- packages/server/test/handler.spec.js | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/server/test/handler.spec.js b/packages/server/test/handler.spec.js index 9d3c1763..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 {API.ConnectionView<{ - * access: { - * claim: API.ServiceMethod, never[], API.Failure> - * } - * }>} - */ const client = Client.connect({ id: w3, codec: CAR.outbound, From 38541bdaf20c03db3956a627358734770631bd0c Mon Sep 17 00:00:00 2001 From: Nico Krause Date: Sat, 11 Oct 2025 16:25:23 +0200 Subject: [PATCH 14/22] fixing server tests --- packages/server/test/readme-integration.spec.js | 4 ++-- packages/server/tsconfig.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/server/test/readme-integration.spec.js b/packages/server/test/readme-integration.spec.js index 1be9964a..3b27c013 100644 --- a/packages/server/test/readme-integration.spec.js +++ b/packages/server/test/readme-integration.spec.js @@ -86,7 +86,7 @@ test('README workflow integration with server-as-channel', async () => { // 6. Test that the full workflow completed successfully assert.ok(result) - assert.ok(!result.error, `Expected no error, got: ${result.error?.message}`) + 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') @@ -190,7 +190,7 @@ test('README delegation example with server-as-channel', async () => { // This should succeed because Bob has delegated permission from Alice assert.ok(result) - assert.ok(!result.error, `Expected no error, got: ${result.error?.message}`) + 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') diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 3e549c1a..12bcbfc2 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -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. */ @@ -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. */, From 1b724adce84f3f8a1de7073863789c03a0ffdae0 Mon Sep 17 00:00:00 2001 From: Nico Krause Date: Sat, 11 Oct 2025 17:51:58 +0200 Subject: [PATCH 15/22] ts improvements --- packages/server/test/server.spec.js | 6 ++++-- packages/server/tsconfig.json | 9 +++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/server/test/server.spec.js b/packages/server/test/server.spec.js index 2e9d26b6..17f43b91 100644 --- a/packages/server/test/server.spec.js +++ b/packages/server/test/server.spec.js @@ -125,6 +125,7 @@ test('encode delegated invocation', async () => { assert.deepEqual(r1.out, { error: { + // @ts-expect-error name: 'UnknownDIDError', did: alice.did(), message: `DID ${alice.did()} has no account`, @@ -133,6 +134,7 @@ test('encode delegated invocation', async () => { assert.deepEqual(r2.out, { error: { + // @ts-expect-error name: 'UnknownDIDError', did: alice.did(), message: `DID ${alice.did()} has no account`, @@ -202,7 +204,7 @@ test('unknown handler', async () => { }, }) - const error = await register.execute(connection) + const error = await register.execute(/** @type {any} */ (connection)) assert.containSubset(error, { out: { @@ -227,7 +229,7 @@ test('unknown handler', async () => { }, }) - 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 12bcbfc2..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. */ @@ -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. */, @@ -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" }, From 570a04c85e5013584779ee389b9a53dd0decd4be Mon Sep 17 00:00:00 2001 From: Nico Krause Date: Wed, 15 Oct 2025 13:51:35 +0200 Subject: [PATCH 16/22] Update README badge URLs to point to original storacha/ucanto repository --- Readme.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Readme.md b/Readme.md index de1e5642..3511b025 100644 --- a/Readme.md +++ b/Readme.md @@ -1,12 +1,12 @@ # ucanto -[![Core Tests](https://github.com/NiKrause/ucanto/actions/workflows/core.yml/badge.svg)](https://github.com/NiKrause/ucanto/actions/workflows/core.yml) -[![Principal Tests](https://github.com/NiKrause/ucanto/actions/workflows/principal.yml/badge.svg)](https://github.com/NiKrause/ucanto/actions/workflows/principal.yml) -[![Transport Tests](https://github.com/NiKrause/ucanto/actions/workflows/transport.yml/badge.svg)](https://github.com/NiKrause/ucanto/actions/workflows/transport.yml) -[![Interface Tests](https://github.com/NiKrause/ucanto/actions/workflows/interface.yml/badge.svg)](https://github.com/NiKrause/ucanto/actions/workflows/interface.yml) -[![Server Tests](https://github.com/NiKrause/ucanto/actions/workflows/server.yml/badge.svg)](https://github.com/NiKrause/ucanto/actions/workflows/server.yml) -[![Client Tests](https://github.com/NiKrause/ucanto/actions/workflows/client.yml/badge.svg)](https://github.com/NiKrause/ucanto/actions/workflows/client.yml) -[![Validator Tests](https://github.com/NiKrause/ucanto/actions/workflows/validator.yml/badge.svg)](https://github.com/NiKrause/ucanto/actions/workflows/validator.yml) +[![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: From f884a18431fcac7a14af5e21d10d54814c20583f Mon Sep 17 00:00:00 2001 From: Nico Krause Date: Tue, 18 Nov 2025 16:56:13 +0500 Subject: [PATCH 17/22] refactor: address alanshaw's recommendations from PR #388 - Remove mock fetch from READMEs, show real server usage - Use DID.parse() instead of ed25519.Verifier.parse() for service DIDs - Convert client tests to use server-as-channel pattern - Add @ucanto/server and @ucanto/validator to client devDependencies --- packages/client/README.md | 39 ++++------ packages/client/package.json | 2 + packages/client/test/client.spec.js | 116 +++++++++++++++++----------- packages/transport/README.md | 39 ++++------ pnpm-lock.yaml | 9 ++- 5 files changed, 109 insertions(+), 96 deletions(-) diff --git a/packages/client/README.md b/packages/client/README.md index 24f749b4..5b9cdae2 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -24,42 +24,29 @@ npm install @ucanto/client ``` ## Example Usage + +### 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 { Receipt, Message } from '@ucanto/core' +import { DID } from '@ucanto/core' -// Parse the service DID (public key) +// Parse the service DID // SERVICE_DID should be a DID like: did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi -const service = ed25519.Verifier.parse(process.env.SERVICE_DID) +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) -// Mock fetch that simulates a UCAN service -const mockFetch = async (url, input) => { - const { invocations } = await CAR.request.decode(input) - - const receipts = await Promise.all( - invocations.map(inv => Receipt.issue({ - ran: inv.cid, // Link to the invocation - issuer: service, // Service signs the receipt - result: { ok: { status: 'success' } } // Fake success - })) - ) - - const message = await Message.build({ receipts }) - const response = await CAR.response.encode(message) - - return new Response(response.body, { headers: response.headers }) -} - -// Connect to mock service +// Connect to a UCAN service const connection = Client.connect({ id: service, - channel: HTTP.open({ url: new URL('https://api.example.com'), fetch: mockFetch }), + channel: HTTP.open({ + url: new URL(process.env.SERVICE_URL || 'https://api.example.com') + }), codec: CAR.outbound, }) @@ -79,6 +66,10 @@ const [receipt] = await connection.execute(invocation) 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 @@ -132,6 +123,4 @@ SERVICE_DID="did:key:service_provider_did_here" \ ``` -**What's happening:** UCAN services expect CAR-encoded requests and return CAR-encoded receipts with cryptographic signatures. The mock simulates this entire flow so the example works without a real service. - 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/transport/README.md b/packages/transport/README.md index 31fc0b47..1ce7242f 100644 --- a/packages/transport/README.md +++ b/packages/transport/README.md @@ -22,39 +22,23 @@ npm install @ucanto/transport ``` ## Example Usage + +### HTTP Transport + ```js import * as HTTP from '@ucanto/transport/http' import { CAR } from '@ucanto/transport' import { ed25519 } from '@ucanto/principal' -import { invoke, Message, Receipt } from '@ucanto/core' +import { invoke, Message } from '@ucanto/core' +import { DID } from '@ucanto/core' -// Parse the service DID (public key) +// Parse the service DID // SERVICE_DID should be a DID like: did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi -const service = ed25519.Verifier.parse(process.env.SERVICE_DID) +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) -// Mock fetch that simulates a UCAN service -const mockFetch = async (url, init) => { - console.log('Sending request to:', url) - console.log('Request headers:', init.headers) - - // Simulate a service response with a receipt - const { invocations } = await CAR.request.decode(init) - const receipts = await Promise.all( - invocations.map(inv => Receipt.issue({ - ran: inv.cid, - issuer: service, - result: { ok: { status: 'added' } } - })) - ) - - const responseMessage = await Message.build({ receipts }) - const response = await CAR.response.encode(responseMessage) - return new Response(response.body, { headers: response.headers }) -} - // Create UCAN invocation const invocation = invoke({ issuer, @@ -70,10 +54,9 @@ const invocation = invoke({ const message = await Message.build({ invocations: [invocation] }) const request = await CAR.request.encode(message) -// Create HTTP channel and send +// Create HTTP channel and send to a UCAN service const channel = HTTP.open({ - url: new URL('https://api.example.com'), - fetch: mockFetch + url: new URL(process.env.SERVICE_URL || 'https://api.example.com') }) const response = await channel.request(request) @@ -82,6 +65,10 @@ 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 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 From 7027542c2865189deecf58d4f512cf1a62486fe9 Mon Sep 17 00:00:00 2001 From: Nico Krause Date: Wed, 11 Feb 2026 11:42:04 +0100 Subject: [PATCH 18/22] feat(principal): use webcrypto ed25519 non-extractable keys by default --- packages/principal/src/ed25519/signer.js | 125 ++++++++++++++++++++- packages/principal/src/ed25519/type.ts | 3 +- packages/principal/src/ed25519/verifier.js | 37 +++++- packages/principal/test/ed25519.spec.js | 90 ++++++++++++--- 4 files changed, 234 insertions(+), 21 deletions(-) diff --git a/packages/principal/src/ed25519/signer.js b/packages/principal/src/ed25519/signer.js index b19fa836..235746b3 100644 --- a/packages/principal/src/ed25519/signer.js +++ b/packages/principal/src/ed25519/signer.js @@ -1,4 +1,5 @@ import * as ED25519 from '@noble/ed25519' +import { webcrypto } from 'one-webcrypto' import { varint } from 'multiformats' import * as API from './type.js' import * as Verifier from './verifier.js' @@ -23,9 +24,33 @@ export const PUB_KEY_OFFSET = PRIVATE_TAG_SIZE + KEY_SIZE /** * Generates new issuer by generating underlying ED25519 keypair. + * @param {{extractable?: boolean}} [options] * @returns {Promise} */ -export const generate = () => derive(ED25519.utils.randomPrivateKey()) +export const generate = async ({ extractable = false } = {}) => { + if (extractable) { + return derive(ED25519.utils.randomPrivateKey()) + } + + const keypair = /** @type {CryptoKeyPair} */ ( + await webcrypto.subtle.generateKey({ name: 'Ed25519' }, false, [ + 'sign', + 'verify', + ]) + ) + + const raw = new Uint8Array( + await webcrypto.subtle.exportKey('raw', keypair.publicKey) + ) + const bytes = new Uint8Array(PUBLIC_TAG_SIZE + KEY_SIZE) + varint.encodeTo(Verifier.code, bytes, 0) + bytes.set(raw, PUBLIC_TAG_SIZE) + + return new UnextractableEd25519Signer({ + privateKey: keypair.privateKey, + verifier: Verifier.decode(bytes), + }) +} /** * Derives issuer from 32 byte long secret key. @@ -57,9 +82,20 @@ export const derive = async secret => { */ export const from = ({ id, keys }) => { if (id.startsWith('did:key:')) { - const key = keys[/** @type {API.DIDKey} */ (id)] + const did = /** @type {API.DIDKey} */ (id) + const key = keys[did] if (key instanceof Uint8Array) { return decode(key) + } else if ( + key && + key.type === 'private' && + key.algorithm && + key.algorithm.name === 'Ed25519' + ) { + return new UnextractableEd25519Signer({ + privateKey: key, + verifier: /** @type {API.EdVerifier} */ (Verifier.parse(did)), + }) } } throw new TypeError(`Unsupported archive format`) @@ -222,3 +258,88 @@ class Ed25519Signer extends Uint8Array { } } } + +/** + * @implements {API.EdSigner} + */ +class UnextractableEd25519Signer { + /** + * @param {object} options + * @param {CryptoKey} options.privateKey + * @param {API.EdVerifier} options.verifier + */ + constructor({ privateKey, verifier }) { + this.privateKey = privateKey + this.verifier = verifier + } + + /** @type {typeof code} */ + get code() { + return code + } + + get signer() { + return this + } + + did() { + return this.verifier.did() + } + + toDIDKey() { + return this.verifier.toDIDKey() + } + + /** + * @template {API.DID} ID + * @param {ID} id + * @returns {API.Signer} + */ + withDID(id) { + return Signer.withDID(this, id) + } + + /** + * @template T + * @param {API.ByteView} payload + * @returns {Promise>} + */ + async sign(payload) { + const raw = new Uint8Array( + await webcrypto.subtle.sign({ name: 'Ed25519' }, this.privateKey, payload) + ) + return Signature.create(this.signatureCode, raw) + } + + /** + * @template T + * @param {API.ByteView} payload + * @param {API.Signature} signature + */ + verify(payload, signature) { + return this.verifier.verify(payload, signature) + } + + get signatureAlgorithm() { + return signatureAlgorithm + } + + get signatureCode() { + return Signature.EdDSA + } + + /** + * @returns {API.ByteView} + */ + encode() { + throw new TypeError('Unextractable ed25519 key can not be encoded') + } + + toArchive() { + const id = this.did() + return { + id, + keys: { [id]: this.privateKey }, + } + } +} diff --git a/packages/principal/src/ed25519/type.ts b/packages/principal/src/ed25519/type.ts index 965b96af..d8f86f5e 100644 --- a/packages/principal/src/ed25519/type.ts +++ b/packages/principal/src/ed25519/type.ts @@ -4,6 +4,7 @@ import { MulticodecCode, ByteView, DIDKey, + KeyArchive, } from '@ucanto/interface' import * as Signature from '@ipld/dag-ucan/signature' @@ -44,7 +45,7 @@ export interface EdSigner extends SignerKey { */ toArchive(): { id: DIDKey - keys: { [Key: DIDKey]: ByteView & CryptoKey> } + keys: { [Key: DIDKey]: KeyArchive } } } diff --git a/packages/principal/src/ed25519/verifier.js b/packages/principal/src/ed25519/verifier.js index e15018eb..d6bf64e6 100644 --- a/packages/principal/src/ed25519/verifier.js +++ b/packages/principal/src/ed25519/verifier.js @@ -1,5 +1,5 @@ import * as DID from '@ipld/dag-ucan/did' -import * as ED25519 from '@noble/ed25519' +import { webcrypto } from 'one-webcrypto' import { varint } from 'multiformats' import * as API from './type.js' import * as Signature from '@ipld/dag-ucan/signature' @@ -109,9 +109,38 @@ class Ed25519Verifier extends Uint8Array { * @returns {API.Await} */ verify(payload, signature) { - return ( - signature.code === signatureCode && - ED25519.verify(signature.raw, payload, this.publicKey) + if (signature.code !== signatureCode) { + return false + } + + return this.verifyWithWebCrypto(payload, signature) + } + + /** + * @template T + * @param {API.ByteView} payload + * @param {API.Signature} signature + * @returns {Promise} + */ + async verifyWithWebCrypto(payload, signature) { + const state = /** @type {{cryptoKey?: Promise}} */ (this) + const key = + state.cryptoKey || + webcrypto.subtle.importKey( + 'raw', + this.publicKey, + { name: 'Ed25519' }, + true, + ['verify'] + ) + + state.cryptoKey = key + + return webcrypto.subtle.verify( + { name: 'Ed25519' }, + await key, + signature.raw, + payload ) } diff --git a/packages/principal/test/ed25519.spec.js b/packages/principal/test/ed25519.spec.js index 59a6ffdf..b818e788 100644 --- a/packages/principal/test/ed25519.spec.js +++ b/packages/principal/test/ed25519.spec.js @@ -2,6 +2,7 @@ import { ed25519, ed25519 as Lib } from '../src/lib.js' import { assert } from 'chai' import { sha256 } from 'multiformats/hashes/sha2' import { varint } from 'multiformats' +import { webcrypto } from 'one-webcrypto' describe('signing principal', () => { const { Signer } = Lib @@ -45,8 +46,19 @@ describe('signing principal', () => { assert.equal(signer.did(), verifier.did()) }) + it('generate non extractable by default', async () => { + const signer = await Lib.generate() + const { id, keys } = signer.toArchive() + const key = /** @type {CryptoKey} */ (keys[id]) + + assert.equal(key.type, 'private') + assert.deepEqual(Object(key.algorithm), { name: 'Ed25519' }) + assert.equal(key.extractable, false) + assert.deepEqual(key.usages, ['sign']) + }) + it('derive', async () => { - const original = await Lib.generate() + const original = await Lib.generate({ extractable: true }) // @ts-expect-error - secret is not defined by interface const derived = await Lib.derive(original.secret) @@ -57,7 +69,7 @@ describe('signing principal', () => { it('derive throws on bad input', async () => { // @ts-expect-error - secret is not defined by interface - const { secret } = await Lib.generate() + const { secret } = await Lib.generate({ extractable: true }) try { await Lib.derive(secret.subarray(1)) assert.fail('Expected to throw') @@ -67,22 +79,26 @@ describe('signing principal', () => { }) it('SigningPrincipal.decode', async () => { - const signer = await Lib.generate() + const signer = await Lib.generate({ extractable: true }) const bytes = Signer.encode(signer) const { id, keys } = signer.toArchive() + const key = keys[id] + if (!(key instanceof Uint8Array)) { + return assert.fail('Expected archive key to be Uint8Array') + } - assert.deepEqual(Signer.decode(keys[id]), signer) + assert.deepEqual(Signer.decode(key), signer) - const invalid = new Uint8Array(keys[id]) + const invalid = new Uint8Array(key) varint.encodeTo(4, invalid, 0) assert.throws(() => Signer.decode(invalid), /must be a multiformat with/) assert.throws( - () => Signer.decode(keys[id].slice(0, 32)), + () => Signer.decode(key.slice(0, 32)), /Expected Uint8Array with byteLength/ ) - const malformed = new Uint8Array(keys[id]) + const malformed = new Uint8Array(key) // @ts-ignore varint.encodeTo(4, malformed, Signer.PUB_KEY_OFFSET) @@ -90,22 +106,35 @@ describe('signing principal', () => { }) it('SigningPrincipal decode encode roundtrip', async () => { - const signer = await Lib.generate() + const signer = await Lib.generate({ extractable: true }) assert.deepEqual(Signer.decode(Signer.encode(signer)), signer) }) it('SigningPrincipal.format', async () => { - const signer = await Lib.generate() + const signer = await Lib.generate({ extractable: true }) assert.deepEqual(Signer.parse(Signer.format(signer)), signer) }) it('SigningPrincipal.did', async () => { - const signer = await Lib.generate() + const signer = await Lib.generate({ extractable: true }) assert.equal(signer.did().startsWith('did:key:'), true) }) + + it('extractable signer supports Signer interface helpers', async () => { + const signer = await Lib.generate({ extractable: true }) + const alias = signer.withDID('did:web:example.com') + const payload = new TextEncoder().encode('hello world') + const signature = await signer.sign(payload) + + assert.equal(signer.code, 0x1300) + assert.equal(signer.signer, signer) + assert.equal(signer.toDIDKey(), signer.did()) + assert.equal(signer.signatureAlgorithm, 'EdDSA') + assert.equal(await alias.verify(payload, signature), true) + }) }) describe('principal', () => { @@ -118,10 +147,13 @@ describe('principal', () => { }) it('Verifier.parse', async () => { - const signer = await Lib.generate() + const signer = await Lib.generate({ extractable: true }) const verifier = Verifier.parse(signer.did()) const { id, keys } = signer.toArchive() const bytes = keys[id] + if (!(bytes instanceof Uint8Array)) { + return assert.fail('Expected archive key to be Uint8Array') + } assert.deepEqual( new Uint8Array(bytes.buffer, bytes.byteOffset + Signer.PUB_KEY_OFFSET), @@ -131,9 +163,12 @@ describe('principal', () => { }) it('Verifier.decode', async () => { - const signer = await Lib.generate() + const signer = await Lib.generate({ extractable: true }) const { id, keys } = signer.toArchive() const bytes = keys[id] + if (!(bytes instanceof Uint8Array)) { + return assert.fail('Expected archive key to be Uint8Array') + } const verifier = new Uint8Array( bytes.buffer, @@ -149,7 +184,7 @@ describe('principal', () => { }) it('Verifier.format', async () => { - const signer = await Lib.generate() + const signer = await Lib.generate({ extractable: true }) const verifier = Verifier.parse(signer.did()) assert.deepEqual(Verifier.format(verifier), signer.did()) @@ -163,7 +198,7 @@ describe('principal', () => { }) it('signer toArchive', async () => { - const signer = await Lib.generate() + const signer = await Lib.generate({ extractable: true }) assert.deepEqual( { @@ -192,4 +227,31 @@ describe('principal', () => { const payload = new TextEncoder().encode('hello world') assert.equal(await ed.verify(payload, await ed.sign(payload)), true) }) + + it('can archive and restore non extractable key', async () => { + const signer = await Lib.generate() + const archive = signer.toArchive() + const restored = Signer.from(archive) + const payload = new TextEncoder().encode('hello world') + + const signature = await restored.sign(payload) + assert.equal(await signer.verify(payload, signature), true) + assert.equal(await restored.verify(payload, signature), true) + + const key = /** @type {CryptoKey} */ (archive.keys[archive.id]) + try { + await webcrypto.subtle.exportKey('pkcs8', key) + assert.fail('Expected exportKey(pkcs8) to fail for non extractable key') + } catch (error) { + assert.match(String(error), /extractable/i) + } + }) + + it('can not encode non extractable key', async () => { + const signer = await Lib.generate() + assert.throws( + () => Signer.encode(signer), + /Unextractable ed25519 key can not be encoded/ + ) + }) }) From 8890008bcb8d36f582625c1f2d0e4346ed738584 Mon Sep 17 00:00:00 2001 From: Nico Krause Date: Wed, 11 Feb 2026 11:43:50 +0100 Subject: [PATCH 19/22] chore: apply prettier formatting across workspace files --- .github/workflows/release.yml | 3 +- packages/client/test/services/util.js | 6 ++-- packages/core/src/schema/did.js | 2 +- packages/core/test/cbor.spec.js | 2 +- packages/core/test/delegation.spec.js | 12 ++++---- packages/core/test/extra-schema.spec.js | 41 +++++++++++++++++++------ packages/core/test/utils.js | 2 +- packages/interface/src/lib.ts | 8 +++-- packages/server/src/handler.js | 3 +- packages/server/src/server.js | 11 ++++--- packages/server/test/server.spec.js | 30 ++++++++++-------- packages/transport/src/http.js | 5 ++- packages/transport/test/https.spec.js | 7 +++-- packages/transport/test/util.js | 2 +- packages/validator/src/error.js | 2 +- packages/validator/src/lib.js | 14 ++++----- packages/validator/test/session.spec.js | 23 ++++++++------ 17 files changed, 108 insertions(+), 65 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37a8a29d..7c6865da 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: if: ${{needs.release.outputs.releases_created}} runs-on: ubuntu-latest permissions: - id-token: write # Required for OIDC + id-token: write # Required for OIDC steps: # The logic below handles the npm publication: - name: Checkout @@ -56,4 +56,3 @@ jobs: - name: Publish run: pnpm -r publish --access=public - diff --git a/packages/client/test/services/util.js b/packages/client/test/services/util.js index 9ec88e01..9b9f7c02 100644 --- a/packages/client/test/services/util.js +++ b/packages/client/test/services/util.js @@ -1,6 +1,6 @@ export const ok = /** @type {(...args:Args) => Args extends [T] ? {ok:true, value:T extends undefined ? null : T} : {ok:true, value:null}}} */ ( - (value) => (value == undefined ? Ok : { ok: true, value }) + value => (value == undefined ? Ok : { ok: true, value }) ) const Ok = { ok: true, value: null } @@ -9,13 +9,13 @@ const Ok = { ok: true, value: null } * @param {T} value * @returns {T} */ -export const the = (value) => value +export const the = value => value /** * @param {string} reason * @returns {never} */ -export const panic = (reason) => { +export const panic = reason => { throw new Error(reason) } diff --git a/packages/core/src/schema/did.js b/packages/core/src/schema/did.js index 6853eb25..a8d8b92b 100644 --- a/packages/core/src/schema/did.js +++ b/packages/core/src/schema/did.js @@ -68,7 +68,7 @@ class DIDBytesSchema extends Schema.API { return Schema.error(`Expected a ${prefix} but got "${did}" instead`) } else { return { ok: /** @type {API.DID} */ (did) } - } + } } } diff --git a/packages/core/test/cbor.spec.js b/packages/core/test/cbor.spec.js index bf870ae8..9110a9e9 100644 --- a/packages/core/test/cbor.spec.js +++ b/packages/core/test/cbor.spec.js @@ -69,7 +69,7 @@ test('encode / decode', async () => { const o = {} const data = { a: o, - b: o + b: o, } assert.doesNotThrow(() => transcode(data)) diff --git a/packages/core/test/delegation.spec.js b/packages/core/test/delegation.spec.js index 84576149..4b612ad4 100644 --- a/packages/core/test/delegation.spec.js +++ b/packages/core/test/delegation.spec.js @@ -437,8 +437,8 @@ test('delegation.attach block in capabiliy', async () => { can: 'store/add', with: alice.did(), nb: { - inlineBlock: block.cid.link() - } + inlineBlock: block.cid.link(), + }, }, ], }) @@ -467,8 +467,8 @@ test('delegation.attach block in facts', async () => { facts: [ { [`${block.cid.link()}`]: block.cid.link() }, // @ts-expect-error Link has fact entry - block.cid.link() - ] + block.cid.link(), + ], }) ucan.attach(block) @@ -488,11 +488,11 @@ test('delegation.attach fails to attach block with not attached link', async () capabilities: [ { can: 'store/add', - with: alice.did() + with: alice.did(), }, ], }) const block = await getBlock({ test: 'inlineBlock' }) assert.throws(() => ucan.attach(block)) -}) \ No newline at end of file +}) diff --git a/packages/core/test/extra-schema.spec.js b/packages/core/test/extra-schema.spec.js index a00bffdd..4132b18d 100644 --- a/packages/core/test/extra-schema.spec.js +++ b/packages/core/test/extra-schema.spec.js @@ -418,7 +418,11 @@ test('URI.from', () => { Uint8Array.from([1, 2, 3]), /Unable to parse bytes as did:/, ], - [{ method: 'echo' }, DIDTools.parse('did:echo:hello'), { ok: 'did:echo:hello' }], + [ + { method: 'echo' }, + DIDTools.parse('did:echo:hello'), + { ok: 'did:echo:hello' }, + ], [ { method: 'foo' }, DIDTools.parse('did:echo:hello'), @@ -500,11 +504,16 @@ test('URI.from', () => { [undefined, /Expected value of type Uint8Array instead got undefined/], [null, /Expected value of type Uint8Array instead got null/], [Uint8Array.from([1, 2, 3]), /Unable to decode bytes as DID:/], - [DIDTools.parse('did:echo:1'), { ok: new Uint8Array([157, 26, 101, 99, 104, 111, 58, 49]) }], + [ + DIDTools.parse('did:echo:1'), + { ok: new Uint8Array([157, 26, 101, 99, 104, 111, 58, 49]) }, + ], ] for (const [input, out] of dataset) { - test(`Principal.read(${input == null ? input : `Uint8Array([${input}])`})`, () => { + test(`Principal.read(${ + input == null ? input : `Uint8Array([${input}])` + })`, () => { matchResult(Principal.read(input), out) }) } @@ -531,7 +540,11 @@ test('URI.from', () => { [ { method: 'echo' }, DIDTools.parse('did:echo:hello'), - { ok: new Uint8Array([157, 26, 101, 99, 104, 111, 58, 104, 101, 108, 108, 111]) } + { + ok: new Uint8Array([ + 157, 26, 101, 99, 104, 111, 58, 104, 101, 108, 108, 111, + ]), + }, ], [ { method: 'foo' }, @@ -541,7 +554,9 @@ test('URI.from', () => { ] for (const [options, input, out] of dataset) { - test(`Principal.match({ method: ${options.method == null ? options.method : `"${options.method}"`} }).read(${input == null ? input : `Uint8Array([${input}])`})`, () => { + test(`Principal.match({ method: ${ + options.method == null ? options.method : `"${options.method}"` + } }).read(${input == null ? input : `Uint8Array([${input}])`})`, () => { matchResult(Principal.match(options).read(input), out) }) } @@ -555,7 +570,7 @@ test('URI.from', () => { [ {}, DIDTools.parse('did:echo:bar'), - { ok: new Uint8Array([157, 26, 101, 99, 104, 111, 58, 98, 97, 114]) } + { ok: new Uint8Array([157, 26, 101, 99, 104, 111, 58, 98, 97, 114]) }, ], [{ method: 'echo' }, undefined, { ok: undefined }], [ @@ -576,8 +591,14 @@ test('URI.from', () => { ] for (const [options, input, out] of dataset) { - test(`Principal.match({ method: ${options.method == null ? options.method : `"${options.method}"`} }).optional().read(${input == null ? input : `Uint8Array([${input}])`})`, () => { - const schema = options.method ? Principal.match(options) : Principal.principal() + test(`Principal.match({ method: ${ + options.method == null ? options.method : `"${options.method}"` + } }).optional().read(${ + input == null ? input : `Uint8Array([${input}])` + })`, () => { + const schema = options.method + ? Principal.match(options) + : Principal.principal() matchResult(schema.optional().read(input), out) }) } @@ -596,7 +617,9 @@ test('URI.from', () => { ], ] for (const [did, errorExpectation] of dataset) { - test(`Principal.from(${did == null ? did : `Uint8Array([${did}])`})`, () => { + test(`Principal.from(${ + did == null ? did : `Uint8Array([${did}])` + })`, () => { let error try { Principal.from(did) diff --git a/packages/core/test/utils.js b/packages/core/test/utils.js index e0d105ba..7324a739 100644 --- a/packages/core/test/utils.js +++ b/packages/core/test/utils.js @@ -9,6 +9,6 @@ export async function getBlock(value) { return await Block.encode({ value, codec, - hasher + hasher, }) } diff --git a/packages/interface/src/lib.ts b/packages/interface/src/lib.ts index c4b7b905..3fa6ba18 100644 --- a/packages/interface/src/lib.ts +++ b/packages/interface/src/lib.ts @@ -45,7 +45,7 @@ import { Revoked, InferCapability, Authorization, - Reader + Reader, } from './capability.js' import type * as Transport from './transport.js' import type { Tuple, Block } from './transport.js' @@ -978,7 +978,9 @@ export interface HTTPError { /** * Options for UCAN validation. */ -export interface ValidatorOptions extends PrincipalResolver, Partial { +export interface ValidatorOptions + extends PrincipalResolver, + Partial { /** * Schema allowing invocations to be accepted for audiences other than the * service itself. @@ -1092,7 +1094,7 @@ export interface PrincipalParser { */ export interface PrincipalResolver { resolveDIDKey?: ( - did: UCAN.DID, + did: UCAN.DID ) => Await> } diff --git a/packages/server/src/handler.js b/packages/server/src/handler.js index 2d97ffc3..1accd141 100644 --- a/packages/server/src/handler.js +++ b/packages/server/src/handler.js @@ -52,7 +52,8 @@ export const provideAdvanced = // If audience schema is not provided we expect the audience to match // the server id. Users could pass `schema.string()` if they want to accept // any audience. - const audienceSchema = audience || options.audience || Schema.literal(options.id.did()) + const audienceSchema = + audience || options.audience || Schema.literal(options.id.did()) const result = audienceSchema.read(invocation.audience.did()) if (result.error) { return { error: new InvalidAudience({ cause: result.error }) } diff --git a/packages/server/src/server.js b/packages/server/src/server.js index 43e71a0b..d7a06e46 100644 --- a/packages/server/src/server.js +++ b/packages/server/src/server.js @@ -79,15 +79,18 @@ export const handle = async (server, request) => { } } else { const { encoder, decoder } = selection.ok - let message; + let message try { message = await decoder.decode(request) } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Unable to decode request' + const errorMessage = + err instanceof Error ? err.message : 'Unable to decode request' return { status: 400, headers: { 'Content-Type': 'text/plain' }, - body: new TextEncoder().encode(`Bad request: Malformed payload - ${errorMessage}`), + body: new TextEncoder().encode( + `Bad request: Malformed payload - ${errorMessage}` + ), } } const result = await execute(message, server) @@ -198,4 +201,4 @@ export const resolve = (service, path) => { } } return target -} \ No newline at end of file +} diff --git a/packages/server/test/server.spec.js b/packages/server/test/server.spec.js index a516d78a..b9f032af 100644 --- a/packages/server/test/server.spec.js +++ b/packages/server/test/server.spec.js @@ -350,9 +350,10 @@ test('did:web principal resolve', async () => { }, codec: CAR.inbound, id: w3, - resolveDIDKey: did => did === account.did() - ? Server.ok([bob.did()]) - : Server.error(new DIDResolutionError(did)), + resolveDIDKey: did => + did === account.did() + ? Server.ok([bob.did()]) + : Server.error(new DIDResolutionError(did)), validateAuthorization: () => ({ ok: {} }), }) @@ -404,7 +405,7 @@ test('alternative audience', async () => { id: service, audience: Schema.or( Schema.literal('did:web:web3.storage'), - Schema.literal(alias.did()), + Schema.literal(alias.did()) ), validateAuthorization: () => ({ ok: {} }), }) @@ -606,9 +607,9 @@ test('should return 400 Bad Request for malformed payloads', async () => { const malformedRequest = { headers: { 'content-type': CAR.contentType, - 'accept': CAR.contentType + accept: CAR.contentType, }, - body: new Uint8Array([1, 2, 3]) + body: new Uint8Array([1, 2, 3]), } const response = await server.request(malformedRequest) @@ -630,10 +631,10 @@ test('should return 400 Bad Request for non-Error decoder failures', async () => decoder: { decode: async () => { throw 'Not an Error instance' - } - } - } - }) + }, + }, + }, + }), }, validateAuthorization: () => ({ ok: {} }), }) @@ -641,9 +642,9 @@ test('should return 400 Bad Request for non-Error decoder failures', async () => const malformedRequest = { headers: { 'content-type': CAR.contentType, - 'accept': CAR.contentType + accept: CAR.contentType, }, - body: new Uint8Array([1, 2, 3]) + body: new Uint8Array([1, 2, 3]), } const response = await server.request(malformedRequest) @@ -651,5 +652,8 @@ test('should return 400 Bad Request for non-Error decoder failures', async () => assert.equal(response.status, 400) assert.deepEqual(response.headers, { 'Content-Type': 'text/plain' }) const errorMessage = new TextDecoder().decode(response.body) - assert.match(errorMessage, /Bad request: Malformed payload - Unable to decode request/) + assert.match( + errorMessage, + /Bad request: Malformed payload - Unable to decode request/ + ) }) diff --git a/packages/transport/src/http.js b/packages/transport/src/http.js index b4df7730..5cfce071 100644 --- a/packages/transport/src/http.js +++ b/packages/transport/src/http.js @@ -68,7 +68,10 @@ class Channel { const buffer = response.ok ? await response.arrayBuffer() - : HTTPError.throw(`HTTP Request failed. ${this.method} ${this.url.href} → ${response.status}`, response) + : HTTPError.throw( + `HTTP Request failed. ${this.method} ${this.url.href} → ${response.status}`, + response + ) return { headers: response.headers.entries diff --git a/packages/transport/test/https.spec.js b/packages/transport/test/https.spec.js index fb93c308..0f17f695 100644 --- a/packages/transport/test/https.spec.js +++ b/packages/transport/test/https.spec.js @@ -100,8 +100,11 @@ test('headers from http channel are passed to fetch along with the request heade headers: { 'x-client': 'abc' }, }) - const requestHeaders = { 'x-test': 'test-value', 'content-type': 'text/plain' } - + const requestHeaders = { + 'x-test': 'test-value', + 'content-type': 'text/plain', + } + await channel.request({ headers: requestHeaders, body: UTF8.encode('ping'), diff --git a/packages/transport/test/util.js b/packages/transport/test/util.js index 60df4a58..9c805589 100644 --- a/packages/transport/test/util.js +++ b/packages/transport/test/util.js @@ -3,7 +3,7 @@ * @param {AsyncIterable|Iterable} iterable * @returns {Promise} */ -export const collect = async (iterable) => { +export const collect = async iterable => { const result = [] for await (const item of iterable) { result.push(item) diff --git a/packages/validator/src/error.js b/packages/validator/src/error.js index 16b26762..642cb419 100644 --- a/packages/validator/src/error.js +++ b/packages/validator/src/error.js @@ -334,7 +334,7 @@ export class Unauthorized extends Failure { failedProofs, }) { super() - this.name = /** @type {const} */ ('Unauthorized') + this.name = /** @type {const} */ ('Unauthorized') this.capability = capability this.delegationErrors = delegationErrors this.unknownCapabilities = unknownCapabilities diff --git a/packages/validator/src/lib.js b/packages/validator/src/lib.js index 353d96cf..d3b0b4c1 100644 --- a/packages/validator/src/lib.js +++ b/packages/validator/src/lib.js @@ -108,7 +108,7 @@ const resolveProofs = async (proofs, config) => { } } catch (error) { errors.push( - new UnavailableProof(proof, /** @type {Error} */(error)) + new UnavailableProof(proof, /** @type {Error} */ (error)) ) } @@ -175,7 +175,7 @@ const resolveSources = async ({ delegation }, config) => { // track which proof in which capability the are from. for (const capability of proof.capabilities) { sources.push( - /** @type {API.Source} */({ + /** @type {API.Source} */ ({ capability, delegation: proof, }) @@ -263,7 +263,7 @@ export const claim = async ( if (validation.ok) { for (const capability of validation.ok.capabilities.values()) { sources.push( - /** @type {API.Source} */({ + /** @type {API.Source} */ ({ capability, delegation: validation.ok, }) @@ -450,7 +450,7 @@ const validate = async (delegation, proofs, config) => { if (UCAN.isExpired(delegation.data)) { return { error: new Expired( - /** @type {API.Delegation & {expiration: number}} */(delegation) + /** @type {API.Delegation & {expiration: number}} */ (delegation) ), } } @@ -458,7 +458,7 @@ const validate = async (delegation, proofs, config) => { if (UCAN.isTooEarly(delegation.data)) { return { error: new NotValidBefore( - /** @type {API.Delegation & {notBefore: number}} */(delegation) + /** @type {API.Delegation & {notBefore: number}} */ (delegation) ), } } @@ -525,7 +525,7 @@ const verifyAuthorization = async (delegation, proofs, config) => { verificationErrResults.push(verificationResult.error) } } - + // If no verifiers were found, there is no way to verify the signature if (verificationErrResults.length === 0) { return { error: new DIDKeyResolutionError(issuer) } @@ -535,7 +535,7 @@ const verifyAuthorization = async (delegation, proofs, config) => { const combinedMessage = verificationErrResults .map(err => err.message) .join('\n ') - + // @ts-expect-error - both error types have describe method, override it to return the concatenated message combinedError.describe = () => combinedMessage diff --git a/packages/validator/test/session.spec.js b/packages/validator/test/session.spec.js index efdeb32d..b2e2f69a 100644 --- a/packages/validator/test/session.spec.js +++ b/packages/validator/test/session.spec.js @@ -130,8 +130,8 @@ test('validate mailto attested by another service', async () => { await attest.delegate({ issuer: w3, audience: other, - with: w3.did() - }) + with: w3.did(), + }), ], }) @@ -405,7 +405,7 @@ test('fail unknown ucan/attest proof', async () => { return Schema.ok([otherService.toDIDKey()]) } return { error: new DIDKeyResolutionError(did) } - } + }, }) assert.containSubset(result, { @@ -660,7 +660,7 @@ test('fail when no verifiers found', async () => { capability: echo, principal: Verifier, validateAuthorization: () => ({ ok: {} }), - resolveDIDKey: () => ({ ok: [] }) + resolveDIDKey: () => ({ ok: [] }), }) assert.match( @@ -702,7 +702,7 @@ test('succeed with single valid verifier', async () => { capability: echo, principal: Verifier, validateAuthorization: () => ({ ok: {} }), - resolveDIDKey: () => ({ ok: [alice.toDIDKey()] }) + resolveDIDKey: () => ({ ok: [alice.toDIDKey()] }), }) assert.ok(result.ok) @@ -742,7 +742,12 @@ test('succeed with multiple verifiers and one valid', async () => { capability: echo, principal: Verifier, validateAuthorization: () => ({ ok: {} }), - resolveDIDKey: () => ({ ok: [`did:key:${other.did().split(':')[2]}`, `did:key:${alice.did().split(':')[2]}`] }) + resolveDIDKey: () => ({ + ok: [ + `did:key:${other.did().split(':')[2]}`, + `did:key:${alice.did().split(':')[2]}`, + ], + }), }) assert.ok(result.ok) @@ -776,13 +781,13 @@ test('fail with multiple invalid verifiers', async () => { capability: echo, principal: Verifier, validateAuthorization: () => ({ ok: {} }), - resolveDIDKey: (did) => { + resolveDIDKey: did => { if (did === account.did()) { // Return verifiers that don't match the account's actual key return { ok: [other1.toDIDKey(), other2.toDIDKey()] } } return { error: new DIDKeyResolutionError(did) } - } + }, }) console.log('Result:', result) @@ -792,4 +797,4 @@ test('fail with multiple invalid verifiers', async () => { `${result.error}`, /Proof .* does not has a valid signature from did:key:/ ) -}) \ No newline at end of file +}) From 0d96d5ce937af19c7b39d8c6bab870b2f578155c Mon Sep 17 00:00:00 2001 From: Nico Krause Date: Wed, 11 Feb 2026 12:26:32 +0100 Subject: [PATCH 20/22] refactor(principal): remove noble ed25519 signer path --- packages/principal/package.json | 1 - packages/principal/src/ed25519/signer.js | 125 +++++++++++++++++++---- packages/principal/test/ed25519.spec.js | 83 +++++++++++++++ pnpm-lock.yaml | 11 +- 4 files changed, 187 insertions(+), 33 deletions(-) diff --git a/packages/principal/package.json b/packages/principal/package.json index 69396ce4..c58f4a71 100644 --- a/packages/principal/package.json +++ b/packages/principal/package.json @@ -29,7 +29,6 @@ "dependencies": { "@ipld/dag-ucan": "^3.4.5", "@noble/curves": "^1.2.0", - "@noble/ed25519": "^1.7.3", "@noble/hashes": "^1.3.2", "@ucanto/interface": "workspace:^", "multiformats": "^13.3.1", diff --git a/packages/principal/src/ed25519/signer.js b/packages/principal/src/ed25519/signer.js index 235746b3..1f181d80 100644 --- a/packages/principal/src/ed25519/signer.js +++ b/packages/principal/src/ed25519/signer.js @@ -1,9 +1,8 @@ -import * as ED25519 from '@noble/ed25519' import { webcrypto } from 'one-webcrypto' import { varint } from 'multiformats' import * as API from './type.js' import * as Verifier from './verifier.js' -import { base64pad } from 'multiformats/bases/base64' +import { base64pad, base64url } from 'multiformats/bases/base64' import * as Signature from '@ipld/dag-ucan/signature' import * as Signer from '../signer.js' export * from './type.js' @@ -19,6 +18,11 @@ const PRIVATE_TAG_SIZE = varint.encodingLength(code) const PUBLIC_TAG_SIZE = varint.encodingLength(Verifier.code) const KEY_SIZE = 32 const SIZE = PRIVATE_TAG_SIZE + KEY_SIZE + PUBLIC_TAG_SIZE + KEY_SIZE +const ALG = { name: 'Ed25519' } +const PKCS8_PREFIX = Uint8Array.from([ + 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, + 0x22, 0x04, 0x20, +]) export const PUB_KEY_OFFSET = PRIVATE_TAG_SIZE + KEY_SIZE @@ -28,17 +32,21 @@ export const PUB_KEY_OFFSET = PRIVATE_TAG_SIZE + KEY_SIZE * @returns {Promise} */ export const generate = async ({ extractable = false } = {}) => { - if (extractable) { - return derive(ED25519.utils.randomPrivateKey()) - } - const keypair = /** @type {CryptoKeyPair} */ ( - await webcrypto.subtle.generateKey({ name: 'Ed25519' }, false, [ - 'sign', - 'verify', - ]) + await webcrypto.subtle.generateKey(ALG, extractable, ['sign', 'verify']) ) + if (extractable) { + const pkcs8 = new Uint8Array( + await webcrypto.subtle.exportKey('pkcs8', keypair.privateKey) + ) + const secret = decodePKCS8(pkcs8) + const publicKey = new Uint8Array( + await webcrypto.subtle.exportKey('raw', keypair.publicKey) + ) + return createSigner({ secret, publicKey }) + } + const raw = new Uint8Array( await webcrypto.subtle.exportKey('raw', keypair.publicKey) ) @@ -64,16 +72,16 @@ export const derive = async secret => { ) } - const publicKey = await ED25519.getPublicKey(secret) - const signer = new Ed25519Signer(SIZE) - - varint.encodeTo(code, signer, 0) - signer.set(secret, PRIVATE_TAG_SIZE) - - varint.encodeTo(Verifier.code, signer, PRIVATE_TAG_SIZE + KEY_SIZE) - signer.set(publicKey, PRIVATE_TAG_SIZE + KEY_SIZE + PUBLIC_TAG_SIZE) - - return signer + const privateKey = await webcrypto.subtle.importKey( + 'pkcs8', + encodePKCS8(secret), + ALG, + true, + ['sign'] + ) + const jwk = await webcrypto.subtle.exportKey('jwk', privateKey) + const publicKey = decodePublicKey(jwk) + return createSigner({ secret, publicKey }) } /** @@ -225,7 +233,18 @@ class Ed25519Signer extends Uint8Array { * @returns {Promise>} */ async sign(payload) { - const raw = await ED25519.sign(payload, this.secret) + const state = /** @type {{privateKey?: Promise}} */ (this) + const privateKey = + state.privateKey || + webcrypto.subtle.importKey('pkcs8', encodePKCS8(this.secret), ALG, true, [ + 'sign', + ]) + + state.privateKey = privateKey + + const raw = new Uint8Array( + await webcrypto.subtle.sign(ALG, await privateKey, payload) + ) return Signature.create(this.signatureCode, raw) } @@ -306,7 +325,7 @@ class UnextractableEd25519Signer { */ async sign(payload) { const raw = new Uint8Array( - await webcrypto.subtle.sign({ name: 'Ed25519' }, this.privateKey, payload) + await webcrypto.subtle.sign(ALG, this.privateKey, payload) ) return Signature.create(this.signatureCode, raw) } @@ -343,3 +362,65 @@ class UnextractableEd25519Signer { } } } + +/** + * @param {object} options + * @param {Uint8Array} options.secret + * @param {Uint8Array} options.publicKey + */ +const createSigner = ({ secret, publicKey }) => { + const signer = new Ed25519Signer(SIZE) + + varint.encodeTo(code, signer, 0) + signer.set(secret, PRIVATE_TAG_SIZE) + + varint.encodeTo(Verifier.code, signer, PRIVATE_TAG_SIZE + KEY_SIZE) + signer.set(publicKey, PRIVATE_TAG_SIZE + KEY_SIZE + PUBLIC_TAG_SIZE) + + return signer +} + +/** + * @param {Uint8Array} secret + */ +const encodePKCS8 = secret => { + const bytes = new Uint8Array(PKCS8_PREFIX.length + KEY_SIZE) + bytes.set(PKCS8_PREFIX, 0) + bytes.set(secret, PKCS8_PREFIX.length) + return bytes +} + +/** + * @param {Uint8Array} pkcs8 + */ +const decodePKCS8 = pkcs8 => { + if (pkcs8.byteLength !== PKCS8_PREFIX.length + KEY_SIZE) { + throw new TypeError('Unsupported ed25519 pkcs8 key length') + } + + for (let i = 0; i < PKCS8_PREFIX.length; i += 1) { + if (pkcs8[i] !== PKCS8_PREFIX[i]) { + throw new TypeError('Unsupported ed25519 pkcs8 key format') + } + } + + return pkcs8.subarray(PKCS8_PREFIX.length) +} + +/** + * @param {JsonWebKey} jwk + */ +const decodePublicKey = jwk => { + if (typeof jwk.x !== 'string') { + throw new TypeError('Can not derive ed25519 public key from JWK') + } + + const bytes = base64url.baseDecode(jwk.x) + if (bytes.byteLength !== KEY_SIZE) { + throw new TypeError( + `Expected JWK public key with byteLength ${KEY_SIZE} instead not ${bytes.byteLength}` + ) + } + + return bytes +} diff --git a/packages/principal/test/ed25519.spec.js b/packages/principal/test/ed25519.spec.js index b818e788..5d75f7ac 100644 --- a/packages/principal/test/ed25519.spec.js +++ b/packages/principal/test/ed25519.spec.js @@ -2,6 +2,7 @@ import { ed25519, ed25519 as Lib } from '../src/lib.js' import { assert } from 'chai' import { sha256 } from 'multiformats/hashes/sha2' import { varint } from 'multiformats' +import { base64url } from 'multiformats/bases/base64' import { webcrypto } from 'one-webcrypto' describe('signing principal', () => { @@ -135,6 +136,88 @@ describe('signing principal', () => { assert.equal(signer.signatureAlgorithm, 'EdDSA') assert.equal(await alias.verify(payload, signature), true) }) + + it('generate extractable throws on unsupported pkcs8 key length', async () => { + const exportKey = /** @type {any} */ (webcrypto.subtle.exportKey) + webcrypto.subtle.exportKey = async function (format, key) { + if (format === 'pkcs8') { + return new Uint8Array(47) + } + return exportKey.call(this, format, key) + } + + try { + await Lib.generate({ extractable: true }) + assert.fail('Expected to throw') + } catch (error) { + assert.match(String(error), /Unsupported ed25519 pkcs8 key length/) + } finally { + webcrypto.subtle.exportKey = + /** @type {typeof webcrypto.subtle.exportKey} */ (exportKey) + } + }) + + it('generate extractable throws on unsupported pkcs8 key format', async () => { + const exportKey = /** @type {any} */ (webcrypto.subtle.exportKey) + webcrypto.subtle.exportKey = async function (format, key) { + if (format === 'pkcs8') { + const pkcs8 = new Uint8Array(await exportKey.call(this, format, key)) + pkcs8[0] = 0x00 + return pkcs8 + } + return exportKey.call(this, format, key) + } + + try { + await Lib.generate({ extractable: true }) + assert.fail('Expected to throw') + } catch (error) { + assert.match(String(error), /Unsupported ed25519 pkcs8 key format/) + } finally { + webcrypto.subtle.exportKey = + /** @type {typeof webcrypto.subtle.exportKey} */ (exportKey) + } + }) + + it('derive throws when JWK has no x', async () => { + const exportKey = /** @type {any} */ (webcrypto.subtle.exportKey) + webcrypto.subtle.exportKey = async function (format, key) { + if (format === 'jwk') { + return {} + } + return exportKey.call(this, format, key) + } + + try { + await Lib.derive(new Uint8Array(32)) + assert.fail('Expected to throw') + } catch (error) { + assert.match(String(error), /Can not derive ed25519 public key from JWK/) + } finally { + webcrypto.subtle.exportKey = + /** @type {typeof webcrypto.subtle.exportKey} */ (exportKey) + } + }) + + it('derive throws when JWK x has invalid size', async () => { + const exportKey = /** @type {any} */ (webcrypto.subtle.exportKey) + webcrypto.subtle.exportKey = async function (format, key) { + if (format === 'jwk') { + return { x: base64url.baseEncode(new Uint8Array(31)) } + } + return exportKey.call(this, format, key) + } + + try { + await Lib.derive(new Uint8Array(32)) + assert.fail('Expected to throw') + } catch (error) { + assert.match(String(error), /Expected JWK public key with byteLength 32/) + } finally { + webcrypto.subtle.exportKey = + /** @type {typeof webcrypto.subtle.exportKey} */ (exportKey) + } + }) }) describe('principal', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66542d75..666be282 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,9 +131,6 @@ importers: '@noble/curves': specifier: ^1.2.0 version: 1.8.1 - '@noble/ed25519': - specifier: ^1.7.3 - version: 1.7.3 '@noble/hashes': specifier: ^1.3.2 version: 1.7.1 @@ -586,9 +583,6 @@ packages: resolution: {integrity: sha512-warwspo+UYUPep0Q+vtdVB4Ugn8GGQj8iyB3gnRWsztmUHTI3S1nhdiWNsPUGL0vud7JlRRk1XEu7Lq1KGTnMQ==} engines: {node: ^14.21.3 || >=16} - '@noble/ed25519@1.7.3': - resolution: {integrity: sha512-iR8GBkDt0Q3GyaVcIu7mSsVIqnFbkbRzGLWlvhwunacoLwt4J3swfKhfaM6rN6WY+TBGoYT1GtT1mIh2/jGbRQ==} - '@noble/hashes@1.7.1': resolution: {integrity: sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==} engines: {node: ^14.21.3 || >=16} @@ -793,6 +787,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==} @@ -2318,8 +2313,6 @@ snapshots: dependencies: '@noble/hashes': 1.7.1 - '@noble/ed25519@1.7.3': {} - '@noble/hashes@1.7.1': {} '@nodelib/fs.scandir@2.1.5': @@ -3937,8 +3930,6 @@ snapshots: typescript@5.0.4: {} - typescript@5.7.3: {} - unbox-primitive@1.1.0: dependencies: call-bound: 1.0.3 From f5d2b8ff06e9abd4d8e60c5f3b8e12e24b0b0a32 Mon Sep 17 00:00:00 2001 From: Nico Krause Date: Wed, 11 Feb 2026 12:26:39 +0100 Subject: [PATCH 21/22] test(server): assert receipt issuer by did in execution error case --- packages/server/test/server.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/test/server.spec.js b/packages/server/test/server.spec.js index b9f032af..7e3ce473 100644 --- a/packages/server/test/server.spec.js +++ b/packages/server/test/server.spec.js @@ -280,9 +280,9 @@ test('execution error', async () => { }) const receipt = await boom.execute(connection) + assert.equal(receipt.issuer.did(), w3.did()) assert.containSubset(receipt, { - issuer: w3.verifier, out: { error: { error: true, From 169bc4ac9df967c2866b5a353d0728b8446d9840 Mon Sep 17 00:00:00 2001 From: Nico Krause Date: Wed, 11 Feb 2026 16:57:06 +0100 Subject: [PATCH 22/22] docs: align README snippets with tested API across packages --- Readme.md | 15 ++-- packages/client/README.md | 9 +- .../test/client-readme-snippets.spec.js | 73 ++++++++++++++++ packages/core/README.md | 24 ++++-- .../core/test/core-readme-snippets.spec.js | 18 ++++ packages/principal/README.md | 12 ++- .../test/principal-readme-snippets.spec.js | 20 +++++ packages/server/README.md | 4 +- ...c.js => server-readme-integration.spec.js} | 0 ...spec.js => server-readme-snippets.spec.js} | 2 +- packages/transport/README.md | 11 ++- .../test/transport-readme-snippets.spec.js | 84 +++++++++++++++++++ packages/validator/README.md | 17 ++-- .../test/validator-readme-snippets.spec.js | 51 +++++++++++ 14 files changed, 305 insertions(+), 35 deletions(-) create mode 100644 packages/client/test/client-readme-snippets.spec.js create mode 100644 packages/core/test/core-readme-snippets.spec.js create mode 100644 packages/principal/test/principal-readme-snippets.spec.js rename packages/server/test/{readme-integration.spec.js => server-readme-integration.spec.js} (100%) rename packages/server/test/{readme-examples.spec.js => server-readme-snippets.spec.js} (97%) create mode 100644 packages/transport/test/transport-readme-snippets.spec.js create mode 100644 packages/validator/test/validator-readme-snippets.spec.js diff --git a/Readme.md b/Readme.md index 3511b025..1cdd37d6 100644 --- a/Readme.md +++ b/Readme.md @@ -113,8 +113,9 @@ 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 +> - [`packages/server/test/server-readme-integration.spec.js:160`](./packages/server/test/server-readme-integration.spec.js#L160) - Delegation with server validation ### Batch Operations @@ -205,7 +206,7 @@ This demonstrates how UCAN's delegation system provides fine-grained access cont - ❌ **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 +> 📝 **Tested in**: [`packages/server/test/server-readme-integration.spec.js:99`](./packages/server/test/server-readme-integration.spec.js#L99) - Advanced delegation patterns with namespace validation ## Service-Specific Examples @@ -246,24 +247,24 @@ const connection = Client.connect({ import { ed25519 } from '@ucanto/principal' // Generate new keys -const agent = await ed25519.generate() +const agent = await ed25519.generate({ extractable: true }) // Save keys (browser) -localStorage.setItem('agent', agent.toString()) +localStorage.setItem('agent', ed25519.format(agent)) // 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()) +await fs.writeFile('agent.key', ed25519.format(agent)) // 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 +> 📝 **Tested in**: [`packages/server/test/server-readme-snippets.spec.js:54`](./packages/server/test/server-readme-snippets.spec.js#L54) - Key generation, formatting, and parsing ## Package Overview @@ -283,4 +284,4 @@ const loadedAgent = ed25519.parse(keyData) [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 +[did:key]: https://w3c-ccg.github.io/did-method-key/ diff --git a/packages/client/README.md b/packages/client/README.md index 5b9cdae2..e6bca67f 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -66,6 +66,8 @@ const [receipt] = await connection.execute(invocation) console.log(receipt.out.error ? 'Failed:' : 'Success:', receipt.out) ``` +> 📝 **Tested in**: [`client-readme-snippets.spec.js`](./test/client-readme-snippets.spec.js) + ### 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. @@ -85,7 +87,8 @@ Create a file called `generate-keys.js`: import { ed25519 } from '@ucanto/principal' async function generateKeys() { - const keypair = await ed25519.generate() + // Use extractable keys if you need to serialize for env vars. + const keypair = await ed25519.generate({ extractable: true }) const privateKey = ed25519.format(keypair) @@ -95,6 +98,8 @@ async function generateKeys() { generateKeys().catch(console.error) ``` +> 📝 **Tested in**: [`client-readme-snippets.spec.js`](./test/client-readme-snippets.spec.js) + Then run it: ```bash @@ -123,4 +128,4 @@ 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 +For more details, see the [`ucanto` documentation](https://github.com/storacha/ucanto). diff --git a/packages/client/test/client-readme-snippets.spec.js b/packages/client/test/client-readme-snippets.spec.js new file mode 100644 index 00000000..d4027175 --- /dev/null +++ b/packages/client/test/client-readme-snippets.spec.js @@ -0,0 +1,73 @@ +import { test, assert } from './test.js' +import * as Client from '../src/lib.js' +import * as HTTP from '@ucanto/transport/http' +import { CAR } from '@ucanto/transport' +import { ed25519 } from '@ucanto/principal' +import { DID, Message, Receipt } from '@ucanto/core' + +test('README connection and invocation example works', async () => { + const serviceSigner = ed25519.parse( + 'MgCYKXoHVy7Vk4/QjcEGi+MCqjntUiasxXJ8uJKY0qh11e+0Bs8WsdqGK7xothgrDzzWD0ME7ynPjz2okXDh8537lId8=' + ) + const service = DID.parse(serviceSigner.did()) + const issuer = ed25519.parse( + 'MgCZT5vOnYZoVAeyjnzuJIVY9J4LNtJ+f8Js0cTPuKUpFne0BVEDJjEu6quFIU8yp91/TY/+MYK8GvlKoTDnqOCovCVM=' + ) + + const channel = HTTP.open({ + url: new URL('about:blank'), + fetch: async (url, init) => { + assert.equal(url, 'about:blank') + const request = await CAR.request.decode({ + headers: /** @type {Record} */ (init.headers), + body: /** @type {Uint8Array} */ (init.body), + }) + const [invocation] = request.invocations + + const receipt = await Receipt.issue({ + issuer: serviceSigner, + ran: invocation.link(), + result: { ok: { accepted: true } }, + }) + const response = await CAR.response.encode( + await Message.build({ receipts: [receipt] }) + ) + + return { + ok: true, + arrayBuffer: () => response.body.buffer, + headers: new Map([['content-type', CAR.contentType]]), + } + }, + }) + + const connection = Client.connect({ + id: service, + channel, + codec: CAR.outbound, + }) + + const invocation = Client.invoke({ + issuer, + audience: service, + capability: { + can: 'store/add', + with: issuer.did(), + nb: { + link: 'bafybeigwflfnv7tjgpuy52ep45cbbgkkb2makd3bwhbj3ueabvt3eq43ca', + }, + }, + }) + + const [receipt] = await connection.execute(invocation) + assert.ok(!receipt.out.error, `Expected no error, got: ${receipt.out.error}`) + assert.deepEqual(receipt.out.ok, { accepted: true }) +}) + +test('README AGENT_PRIVATE_KEY generation snippet works', async () => { + const keypair = await ed25519.generate({ extractable: true }) + const privateKey = ed25519.format(keypair) + const parsed = ed25519.parse(privateKey) + + assert.equal(parsed.did(), keypair.did()) +}) diff --git a/packages/core/README.md b/packages/core/README.md index ad327e0c..b65621cc 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -22,13 +22,21 @@ npm install @ucanto/core ## Example Usage ```ts -import { capability, URI, Link } from '@ucanto/core'; - -const AddFile = capability({ - can: 'file/add', - with: URI.match({ protocol: 'file:' }), - nb: { link: Link } -}); +import { Schema, parseLink } from '@ucanto/core'; + +const AddFile = Schema.struct({ + with: Schema.uri({ protocol: 'file:' }), + nb: Schema.struct({ + link: Schema.link(), + }), +}) + +const parsed = AddFile.read({ + with: 'file:///tmp/example.txt', + nb: { link: parseLink('bafkqaaa') }, +}) ``` -For more details, see the [`ucanto` documentation](https://github.com/storacha/ucanto). \ No newline at end of file +> 📝 **Tested in**: [`core-readme-snippets.spec.js`](./test/core-readme-snippets.spec.js) + +For more details, see the [`ucanto` documentation](https://github.com/storacha/ucanto). diff --git a/packages/core/test/core-readme-snippets.spec.js b/packages/core/test/core-readme-snippets.spec.js new file mode 100644 index 00000000..eec73362 --- /dev/null +++ b/packages/core/test/core-readme-snippets.spec.js @@ -0,0 +1,18 @@ +import { test, assert } from './test.js' +import { Schema, parseLink } from '../src/lib.js' + +test('README schema example works', async () => { + const AddFile = Schema.struct({ + with: Schema.uri({ protocol: 'file:' }), + nb: Schema.struct({ + link: Schema.link(), + }), + }) + + const result = AddFile.read({ + with: 'file:///tmp/example.txt', + nb: { link: parseLink('bafkqaaa') }, + }) + + assert.ok(!result.error, `Expected no error, got: ${result.error?.message}`) +}) diff --git a/packages/principal/README.md b/packages/principal/README.md index 01a85b6a..c5e776f3 100644 --- a/packages/principal/README.md +++ b/packages/principal/README.md @@ -24,9 +24,15 @@ npm install @ucanto/principal ```ts import { ed25519 } from '@ucanto/principal'; -const keypair = ed25519.generate(); -const signature = keypair.sign(new Uint8Array([1, 2, 3])); -const isValid = keypair.verify(new Uint8Array([1, 2, 3]), signature); +const keypair = await ed25519.generate(); +const signature = await keypair.sign(new Uint8Array([1, 2, 3])); +const isValid = await keypair.verify(new Uint8Array([1, 2, 3]), signature); + +// If you need to serialize a private key, generate extractable keys: +const exportable = await ed25519.generate({ extractable: true }); +const privateKey = ed25519.format(exportable); ``` +> 📝 **Tested in**: [`principal-readme-snippets.spec.js`](./test/principal-readme-snippets.spec.js) + For more details, see the [`ucanto` documentation](https://github.com/storacha/ucanto). diff --git a/packages/principal/test/principal-readme-snippets.spec.js b/packages/principal/test/principal-readme-snippets.spec.js new file mode 100644 index 00000000..1d7fb493 --- /dev/null +++ b/packages/principal/test/principal-readme-snippets.spec.js @@ -0,0 +1,20 @@ +import { assert } from 'chai' +import { ed25519 } from '../src/lib.js' + +describe('principal README snippets', () => { + it('key generation, sign, verify, and serialization example works', async () => { + const payload = new Uint8Array([1, 2, 3]) + + const keypair = await ed25519.generate() + const signature = await keypair.sign(payload) + const isValid = await keypair.verify(payload, signature) + + assert.equal(isValid, true) + + const exportable = await ed25519.generate({ extractable: true }) + const privateKey = ed25519.format(exportable) + const parsed = ed25519.parse(privateKey) + + assert.equal(parsed.did(), exportable.did()) + }) +}) diff --git a/packages/server/README.md b/packages/server/README.md index 483141c4..9f56d518 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -225,7 +225,7 @@ const aboutBob = Client.invoke({ 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 +- **Complete workflow test**: [`server-readme-integration.spec.js:19`](./test/server-readme-integration.spec.js#L19) - End-to-end integration test +- **Component tests**: [`server-readme-snippets.spec.js:11`](./test/server-readme-snippets.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/readme-integration.spec.js b/packages/server/test/server-readme-integration.spec.js similarity index 100% rename from packages/server/test/readme-integration.spec.js rename to packages/server/test/server-readme-integration.spec.js diff --git a/packages/server/test/readme-examples.spec.js b/packages/server/test/server-readme-snippets.spec.js similarity index 97% rename from packages/server/test/readme-examples.spec.js rename to packages/server/test/server-readme-snippets.spec.js index 5ad2033f..d8c1decc 100644 --- a/packages/server/test/readme-examples.spec.js +++ b/packages/server/test/server-readme-snippets.spec.js @@ -55,7 +55,7 @@ test('README service definition works', async () => { // 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() + const key = await ed25519.generate({ extractable: true }) // Test that we can format and parse keys correctly const formatted = ed25519.format(key) diff --git a/packages/transport/README.md b/packages/transport/README.md index 1ce7242f..7ad72ec7 100644 --- a/packages/transport/README.md +++ b/packages/transport/README.md @@ -65,6 +65,8 @@ const replyMessage = await CAR.response.decode(response) console.log('Received:', replyMessage.receipts.size, 'receipts') ``` +> 📝 **Tested in**: [`transport-readme-snippets.spec.js`](./test/transport-readme-snippets.spec.js) + ### 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. @@ -84,7 +86,8 @@ Create a file called `generate-keys.js`: import { ed25519 } from '@ucanto/principal' async function generateKeys() { - const keypair = await ed25519.generate() + // Use extractable keys if you need to serialize for env vars. + const keypair = await ed25519.generate({ extractable: true }) const privateKey = ed25519.format(keypair) @@ -94,6 +97,8 @@ async function generateKeys() { generateKeys().catch(console.error) ``` +> 📝 **Tested in**: [`transport-readme-snippets.spec.js`](./test/transport-readme-snippets.spec.js) + Then run it: ```bash @@ -142,6 +147,8 @@ const inbound = Codec.inbound({ }) ``` +> 📝 **Tested in**: [`transport-readme-snippets.spec.js`](./test/transport-readme-snippets.spec.js) + **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 +For more details, see the [`ucanto` documentation](https://github.com/storacha/ucanto). diff --git a/packages/transport/test/transport-readme-snippets.spec.js b/packages/transport/test/transport-readme-snippets.spec.js new file mode 100644 index 00000000..373a2da5 --- /dev/null +++ b/packages/transport/test/transport-readme-snippets.spec.js @@ -0,0 +1,84 @@ +import { test, assert } from './test.js' +import * as HTTP from '../src/http.js' +import { CAR, Codec } from '../src/lib.js' +import { ed25519 } from '@ucanto/principal' +import { invoke, Message, DID, Receipt } from '@ucanto/core' + +test('README HTTP transport example works', async () => { + const serviceSigner = ed25519.parse( + 'MgCYKXoHVy7Vk4/QjcEGi+MCqjntUiasxXJ8uJKY0qh11e+0Bs8WsdqGK7xothgrDzzWD0ME7ynPjz2okXDh8537lId8=' + ) + const service = DID.parse(serviceSigner.did()) + const issuer = ed25519.parse( + 'MgCZT5vOnYZoVAeyjnzuJIVY9J4LNtJ+f8Js0cTPuKUpFne0BVEDJjEu6quFIU8yp91/TY/+MYK8GvlKoTDnqOCovCVM=' + ) + + const invocation = invoke({ + issuer, + audience: service, + capability: { + can: 'store/add', + with: issuer.did(), + nb: { + link: 'bafybeigwflfnv7tjgpuy52ep45cbbgkkb2makd3bwhbj3ueabvt3eq43ca', + }, + }, + }) + + const message = await Message.build({ invocations: [invocation] }) + const request = await CAR.request.encode(message) + + const channel = HTTP.open({ + url: new URL('about:blank'), + fetch: async (url, init) => { + assert.equal(url, 'about:blank') + const decoded = await CAR.request.decode({ + headers: /** @type {Record} */ (init.headers), + body: /** @type {Uint8Array} */ (init.body), + }) + const [received] = decoded.invocations + + const receipt = await Receipt.issue({ + issuer: serviceSigner, + ran: received.link(), + result: { ok: { accepted: true } }, + }) + const response = await CAR.response.encode( + await Message.build({ receipts: [receipt] }) + ) + + return { + ok: true, + arrayBuffer: () => response.body.buffer, + headers: new Map([['content-type', CAR.contentType]]), + } + }, + }) + + const response = await channel.request(request) + const replyMessage = await CAR.response.decode(response) + assert.equal(replyMessage.receipts.size, 1) +}) + +test('README pluggable codec example works', async () => { + const outbound = Codec.outbound({ + encoders: { 'application/vnd.ipld.car': CAR.request }, + decoders: { 'application/vnd.ipld.car': CAR.response }, + }) + + const inbound = Codec.inbound({ + decoders: { 'application/vnd.ipld.car': CAR.request }, + encoders: { 'application/vnd.ipld.car': CAR.response }, + }) + + assert.ok(outbound) + assert.ok(inbound) +}) + +test('README AGENT_PRIVATE_KEY generation snippet works', async () => { + const keypair = await ed25519.generate({ extractable: true }) + const privateKey = ed25519.format(keypair) + const parsed = ed25519.parse(privateKey) + + assert.equal(parsed.did(), keypair.did()) +}) diff --git a/packages/validator/README.md b/packages/validator/README.md index ddba2f08..093d81c6 100644 --- a/packages/validator/README.md +++ b/packages/validator/README.md @@ -49,15 +49,10 @@ const storeAdd = capability({ const proof = await storeAdd.delegate({ issuer: alice, audience: bob, - capabilities: [ - { - with: alice.did(), - can: 'store/add', - nb: { - link: Link.parse('bafkqaaa') - } - } - ] + with: alice.did(), + nb: { + link: Link.parse('bafkqaaa') + } }) // Bob tries to invoke the capability @@ -87,4 +82,6 @@ if (result.error) { } ``` -For more details, see the [`ucanto` documentation](https://github.com/storacha/ucanto). \ No newline at end of file +> 📝 **Tested in**: [`validator-readme-snippets.spec.js`](./test/validator-readme-snippets.spec.js) + +For more details, see the [`ucanto` documentation](https://github.com/storacha/ucanto). diff --git a/packages/validator/test/validator-readme-snippets.spec.js b/packages/validator/test/validator-readme-snippets.spec.js new file mode 100644 index 00000000..ca75e7fe --- /dev/null +++ b/packages/validator/test/validator-readme-snippets.spec.js @@ -0,0 +1,51 @@ +import { test, assert } from './test.js' +import { access, DID, capability, fail, Link, Schema } from '../src/lib.js' +import { Verifier, ed25519 } from '@ucanto/principal' + +test('README validator example works', async () => { + const alice = await ed25519.generate() + const bob = await ed25519.generate() + + const storeAdd = capability({ + can: 'store/add', + with: DID, + nb: Schema.struct({ + link: Link, + size: Schema.integer().optional(), + }), + derives: (claim, proof) => { + if (claim.with !== proof.with) { + return fail('with field does not match') + } + return { ok: {} } + }, + }) + + const proof = await storeAdd.delegate({ + issuer: alice, + audience: bob, + with: alice.did(), + nb: { + link: Link.parse('bafkqaaa'), + }, + }) + + const invocation = storeAdd.invoke({ + issuer: bob, + audience: alice, + with: alice.did(), + nb: { + link: Link.parse('bafkqaaa'), + }, + proofs: [proof], + }) + + const result = await access(await invocation.delegate(), { + authority: alice, + capability: storeAdd, + principal: Verifier, + validateAuthorization: () => ({ ok: {} }), + }) + + assert.ok(!result.error, `Expected no error, got: ${result.error?.message}`) +})