Isomorphic UCAN primitives for delegations, invocations, proof storage, and typed RPC.
UCAN is a local-first authorization model where permissions are cryptographically signed, delegated, and verified without relying on a central authority. See ucan.xyz for more background.
iso-ucan provides:
iso-kv drivers.iso-signatures.iso-signatures EIP191Signer.pnpm install iso-ucan
import { Capability } from 'iso-ucan/capability'
import { Store } from 'iso-ucan/store'
import { MemoryDriver } from 'iso-kv/drivers/memory'
import { EdDSASigner } from 'iso-signatures/signers/eddsa.js'
import { z } from 'zod'
const store = new Store(new MemoryDriver())
const AccountCreateCap = Capability.from({
schema: z.object({
type: z.string(),
properties: z
.object({
name: z.string(),
})
.strict(),
}),
cmd: '/account/create',
})
const AccountCap = Capability.from({
schema: z.never(),
cmd: '/account',
})
const owner = await EdDSASigner.generate()
const bob = await EdDSASigner.generate()
const invoker = await EdDSASigner.generate()
const nowInSeconds = Math.floor(Date.now() / 1000)
const ownerDelegation = await AccountCap.delegate({
iss: owner,
aud: bob,
sub: owner,
pol: [],
exp: nowInSeconds + 1000,
})
await store.set(ownerDelegation)
const bobDelegation = await AccountCap.delegate({
iss: bob,
aud: invoker,
sub: owner,
pol: [],
exp: nowInSeconds + 1000,
})
await store.set(bobDelegation)
const invocation = await AccountCreateCap.invoke({
iss: invoker,
sub: owner,
args: {
type: 'account',
properties: {
name: 'John Doe',
},
},
store,
exp: nowInSeconds + 1000,
})
The UCAN spec requires executors to reject replayed invocations. Pass a
replayStore to Invocation.from so an invocation is accepted only once, and
set maxTtl so invocations must expire soon, which keeps the store bounded:
import { Invocation } from 'iso-ucan/invocation'
import { KVReplayStore } from 'iso-ucan/replay'
const replayStore = new KVReplayStore() // in memory by default
const invocation = await Invocation.from({
bytes,
audience: executor,
verifierResolver,
resolveProof: (cid) => store.resolveProof(cid),
replayStore,
maxTtl: 300,
})
The store is keyed on invocation.replayKey, the CID of the signed payload.
Do not deduplicate on invocation.cid: it also covers the signature, and
some signature algorithms (such as ECDSA) accept more than one valid signature
for the same payload.
KVReplayStore is atomic within one process only. When several processes or
instances execute invocations, implement the ReplayStore interface on a
shared store with an atomic set-if-absent, such as Redis SET NX or SQL
INSERT ... ON CONFLICT DO NOTHING.
Pass isRevoked to Delegation.from, Delegation#validate or
Invocation.from to reject revoked delegations by CID. Because ECDSA and
EIP-191 signatures can be re-encoded into another valid signature, a revoked
delegation can reappear under a different CID. iso-ucan therefore calls
isRevoked with every CID the delegation could have been issued under (up to
2 for ECDSA and 4 for EIP-191), and rejects it if any is revoked.
iso-ucan can use EIP-191 wallet signatures through EIP191Signer from
iso-signatures. That lets UCAN delegations and invocations be issued by an
Ethereum account exposed through an EIP-1193 provider, such as an injected
MetaMask wallet, while verification is handled by the matching EIP-191 verifier
from iso-signatures.
examples/eip191 shows this flow in a browser web
app. It uses wagmi to connect the wallet, wraps the provider in
EIP191Signer, then creates and verifies UCAN delegations and invocations
directly in the browser.
The example uses filsnap-adapter to install or enable
Filsnap, the Filecoin Wallet
MetaMask Snap. Filsnap supports UCAN signature insights for EIP-191 signers, so
when the app asks MetaMask to sign a UCAN delegation, the MetaMask signature
popup can show a readable summary of the issuer, audience, subject, command,
expiration, and raw payload.
iso-ucan/rpc is a small request/response layer on top of UCAN
invocations. You declare a protocol once as a record of commands
(defineCommand), then build matching client and server pairs from it
(defineClient / defineServer). Every command's success and error shapes
are described by a single receipt schema (receipt, receiptResult,
receiptError) which is shared by both ends, so the call sites are fully
type-safe and discriminated unions just work on the client.
Internal server failures (invalid invocation, unknown command, handler
threw, …) are surfaced as a generic SERVER_ERROR variant that
receipt(...) adds implicitly, so clients can always handle them with one
narrow.
import {
defineCommand,
receipt,
receiptError,
receiptResult,
} from 'iso-ucan/rpc'
import { z } from 'zod'
const TodoSchema = z.object({
id: z.string(),
text: z.string(),
done: z.boolean(),
})
export const Protocol = {
TodoList: defineCommand({
cmd: '/todo/list',
args: z.object({}),
receipt: receipt(receiptResult(z.object({ todos: z.array(TodoSchema) }))),
}),
TodoAdd: defineCommand({
cmd: '/todo/add',
args: z.object({ text: z.string().min(1) }),
receipt: receipt(receiptResult(z.object({ todo: TodoSchema }))),
}),
TodoComplete: defineCommand({
cmd: '/todo/complete',
args: z.object({ id: z.string() }),
receipt: receipt(
receiptResult(z.object({ todo: TodoSchema })),
receiptError('NOT_FOUND', 'Todo not found.', z.object({ id: z.string() }))
),
}),
} as const
@hono/node-server)import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { MemoryDriver } from 'iso-kv/drivers/memory.js'
import { defineServer, type ServerHandlers } from 'iso-ucan/rpc'
import { Store } from 'iso-ucan/store'
import { Protocol } from './protocol.ts'
const store = new Store(new MemoryDriver())
// ...seed `store` with the delegations that authorise your clients...
const handlers: ServerHandlers<typeof Protocol> = {
'/todo/list': ({ invocation }) => ({
cid: invocation.cid,
result: { todos: [/* ... */] },
}),
'/todo/add': ({ args, invocation }) => ({
cid: invocation.cid,
result: { todo: { id: '1', text: args.text, done: false } },
}),
'/todo/complete': ({ args, invocation }) => {
// return either { cid, result: ... } or { cid, error: { code, message, data } }
return {
cid: invocation.cid,
error: {
code: 'NOT_FOUND',
message: 'Todo not found.',
data: { id: args.id },
},
}
},
}
const rpc = defineServer(Protocol, {
signer: serverSigner,
store,
verifierResolver,
handlers,
})
const app = new Hono()
app.post('/rpc', (c) => rpc(c.req.raw))
serve({ fetch: app.fetch, port: 3000 })
defineServer rejects replayed invocations with an in-memory KVReplayStore
by default. Pass replayStore to share it between server instances.
It also rejects invocations that never expire or expire more than maxTtl
seconds from now (default 600). Pass a different maxTtl to change the
limit, or null to accept any expiration. See
Replay protection.
import { defineClient } from 'iso-ucan/rpc'
const client = defineClient(Protocol, {
url: 'http://localhost:3000/rpc',
issuer: cliSigner,
audience: serverSigner.didObject,
store,
verifierResolver,
})
Invocations created by the client expire after ttl seconds (default 300).
// `args` and the returned receipt are typed from the protocol entry for `cmd`.
const r = await client.request({ cmd: '/todo/complete', args: { id: '42' } })
if ('result' in r) {
console.log(r.result.todo)
} else if (r.error.code === 'NOT_FOUND') {
console.error(r.error.message, r.error.data) // data: { id: string }
} else {
// r.error.code === 'SERVER_ERROR' — always present in every receipt union.
console.error('server error:', r.error.message)
}
A complete runnable example (Hono server + a small CLI client) lives in
examples/rpc-todo.
MIT © Hugo Dias