iso-ucan 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/src/capability.d.ts +0 -3
  2. package/dist/src/capability.d.ts.map +1 -1
  3. package/dist/src/delegation.d.ts +4 -1
  4. package/dist/src/delegation.d.ts.map +1 -1
  5. package/dist/src/invocation.d.ts.map +1 -1
  6. package/dist/src/rpc/client.d.ts +24 -0
  7. package/dist/src/rpc/client.d.ts.map +1 -0
  8. package/dist/src/rpc/command.d.ts +19 -0
  9. package/dist/src/rpc/command.d.ts.map +1 -0
  10. package/dist/src/rpc/index.d.ts +5 -0
  11. package/dist/src/rpc/index.d.ts.map +1 -0
  12. package/dist/src/rpc/receipt.d.ts +114 -0
  13. package/dist/src/rpc/receipt.d.ts.map +1 -0
  14. package/dist/src/rpc/server.d.ts +18 -0
  15. package/dist/src/rpc/server.d.ts.map +1 -0
  16. package/dist/src/rpc/types.d.ts +112 -0
  17. package/dist/src/rpc/types.d.ts.map +1 -0
  18. package/dist/src/store.d.ts +4 -2
  19. package/dist/src/store.d.ts.map +1 -1
  20. package/dist/src/types.d.ts +15 -3
  21. package/dist/src/types.d.ts.map +1 -1
  22. package/dist/src/utils.d.ts +4 -2
  23. package/dist/src/utils.d.ts.map +1 -1
  24. package/package.json +17 -24
  25. package/readme.md +130 -0
  26. package/src/capability.js +1 -6
  27. package/src/delegation.js +15 -13
  28. package/src/invocation.js +7 -4
  29. package/src/rpc/client.js +85 -0
  30. package/src/rpc/command.js +28 -0
  31. package/src/rpc/index.js +10 -0
  32. package/src/rpc/receipt.js +136 -0
  33. package/src/rpc/server.js +155 -0
  34. package/src/rpc/types.js +9 -0
  35. package/src/rpc/types.ts +153 -0
  36. package/src/store.js +41 -16
  37. package/src/types.ts +15 -6
  38. package/src/utils.js +20 -15
  39. package/dist/src/client.d.ts +0 -41
  40. package/dist/src/client.d.ts.map +0 -1
  41. package/dist/src/server.d.ts +0 -38
  42. package/dist/src/server.d.ts.map +0 -1
  43. package/src/client.js +0 -52
  44. package/src/server.js +0 -47
package/readme.md CHANGED
@@ -76,6 +76,136 @@ exp: nowInSeconds + 1000,
76
76
  })
77
77
  ```
78
78
 
79
+ ## RPC
80
+
81
+ `iso-ucan/rpc` is a small request/response layer on top of UCAN
82
+ invocations. You declare a protocol once as a record of commands
83
+ (`defineCommand`), then build matching client and server pairs from it
84
+ (`defineClient` / `defineServer`). Every command's success and error shapes
85
+ are described by a single receipt schema (`receipt`, `receiptResult`,
86
+ `receiptError`) which is shared by both ends, so the call sites are fully
87
+ type-safe and discriminated unions just work on the client.
88
+
89
+ Internal server failures (invalid invocation, unknown command, handler
90
+ threw, …) are surfaced as a generic `SERVER_ERROR` variant that
91
+ `receipt(...)` adds implicitly, so clients can always handle them with one
92
+ narrow.
93
+
94
+ ### Define a shared protocol
95
+
96
+ ```ts
97
+ import {
98
+ defineCommand,
99
+ receipt,
100
+ receiptError,
101
+ receiptResult,
102
+ } from 'iso-ucan/rpc'
103
+ import { z } from 'zod'
104
+
105
+ const TodoSchema = z.object({
106
+ id: z.string(),
107
+ text: z.string(),
108
+ done: z.boolean(),
109
+ })
110
+
111
+ export const Protocol = {
112
+ TodoList: defineCommand({
113
+ cmd: '/todo/list',
114
+ args: z.object({}),
115
+ receipt: receipt(receiptResult(z.object({ todos: z.array(TodoSchema) }))),
116
+ }),
117
+ TodoAdd: defineCommand({
118
+ cmd: '/todo/add',
119
+ args: z.object({ text: z.string().min(1) }),
120
+ receipt: receipt(receiptResult(z.object({ todo: TodoSchema }))),
121
+ }),
122
+ TodoComplete: defineCommand({
123
+ cmd: '/todo/complete',
124
+ args: z.object({ id: z.string() }),
125
+ receipt: receipt(
126
+ receiptResult(z.object({ todo: TodoSchema })),
127
+ receiptError('NOT_FOUND', 'Todo not found.', z.object({ id: z.string() }))
128
+ ),
129
+ }),
130
+ } as const
131
+ ```
132
+
133
+ ### Server (e.g. Hono + `@hono/node-server`)
134
+
135
+ ```ts
136
+ import { serve } from '@hono/node-server'
137
+ import { Hono } from 'hono'
138
+ import { MemoryDriver } from 'iso-kv/drivers/memory.js'
139
+ import { defineServer, type ServerHandlers } from 'iso-ucan/rpc'
140
+ import { Store } from 'iso-ucan/store'
141
+ import { Protocol } from './protocol.ts'
142
+
143
+ const store = new Store(new MemoryDriver())
144
+ // ...seed `store` with the delegations that authorise your clients...
145
+
146
+ const handlers: ServerHandlers<typeof Protocol> = {
147
+ '/todo/list': ({ invocation }) => ({
148
+ cid: invocation.cid,
149
+ result: { todos: [/* ... */] },
150
+ }),
151
+ '/todo/add': ({ args, invocation }) => ({
152
+ cid: invocation.cid,
153
+ result: { todo: { id: '1', text: args.text, done: false } },
154
+ }),
155
+ '/todo/complete': ({ args, invocation }) => {
156
+ // return either { cid, result: ... } or { cid, error: { code, message, data } }
157
+ return {
158
+ cid: invocation.cid,
159
+ error: {
160
+ code: 'NOT_FOUND',
161
+ message: 'Todo not found.',
162
+ data: { id: args.id },
163
+ },
164
+ }
165
+ },
166
+ }
167
+
168
+ const rpc = defineServer(Protocol, {
169
+ signer: serverSigner,
170
+ store,
171
+ verifierResolver,
172
+ handlers,
173
+ })
174
+
175
+ const app = new Hono()
176
+ app.post('/rpc', (c) => rpc(c.req.raw))
177
+ serve({ fetch: app.fetch, port: 3000 })
178
+ ```
179
+
180
+ ### Client
181
+
182
+ ```ts
183
+ import { defineClient } from 'iso-ucan/rpc'
184
+
185
+ const client = defineClient(Protocol, {
186
+ url: 'http://localhost:3000/rpc',
187
+ issuer: cliSigner,
188
+ audience: serverSigner.didObject,
189
+ store,
190
+ verifierResolver,
191
+ })
192
+
193
+ // `args` and the returned receipt are typed from the protocol entry for `cmd`.
194
+ const r = await client.request({ cmd: '/todo/complete', args: { id: '42' } })
195
+
196
+ if ('result' in r) {
197
+ console.log(r.result.todo)
198
+ } else if (r.error.code === 'NOT_FOUND') {
199
+ console.error(r.error.message, r.error.data) // data: { id: string }
200
+ } else {
201
+ // r.error.code === 'SERVER_ERROR' — always present in every receipt union.
202
+ console.error('server error:', r.error.message)
203
+ }
204
+ ```
205
+
206
+ A complete runnable example (Hono server + a small CLI client) lives in
207
+ [`examples/rpc-todo`](../../examples/rpc-todo).
208
+
79
209
  ## License
80
210
 
81
211
  MIT © [Hugo Dias](http://hugodias.me)
package/src/capability.js CHANGED
@@ -19,9 +19,6 @@ export class Capability {
19
19
  constructor(options) {
20
20
  this.schema = options.schema
21
21
  this.cmd = options.cmd
22
- this.isRevoked = options.isRevoked
23
- this.didResolver = options.didResolver
24
- this.verifierResolver = options.verifierResolver
25
22
  }
26
23
 
27
24
  /**
@@ -64,14 +61,12 @@ export class Capability {
64
61
  args: result.value,
65
62
  })
66
63
  }
64
+
67
65
  return await Invocation.create({
68
66
  ...options,
69
67
  args: result.value,
70
68
  cmd: this.cmd,
71
69
  prf: proofs,
72
- verifierResolver: this.verifierResolver,
73
- didResolver: this.didResolver,
74
- isRevoked: this.isRevoked,
75
70
  })
76
71
  }
77
72
 
package/src/delegation.js CHANGED
@@ -1,4 +1,4 @@
1
- import { base64pad } from 'iso-base/rfc4648'
1
+ import { base64 } from 'iso-base/rfc4648'
2
2
  import { parse as didParse } from 'iso-did'
3
3
  import { randomBytes } from 'iso-web/crypto'
4
4
  import * as Envelope from './envelope.js'
@@ -84,7 +84,7 @@ export class Delegation {
84
84
  )
85
85
  }
86
86
 
87
- assertStructure(envelope.payload)
87
+ assertStructure(envelope.payload, options.now)
88
88
 
89
89
  await verifySignature(envelope, verifierResolver, didResolveOptions)
90
90
 
@@ -98,9 +98,10 @@ export class Delegation {
98
98
  * Create a delegation from a base64 encoded string
99
99
  *
100
100
  * @param {string} token
101
+ * @param { {now?: number}} [options]
101
102
  */
102
- static async fromString(token) {
103
- const bytes = base64pad.decode(token)
103
+ static async fromString(token, options = {}) {
104
+ const bytes = base64.decode(token)
104
105
  const envelope = /** @type {typeof Envelope.decode<"dlg">} */ (
105
106
  Envelope.decode
106
107
  )({ envelope: bytes })
@@ -111,7 +112,7 @@ export class Delegation {
111
112
  )
112
113
  }
113
114
 
114
- assertStructure(envelope.payload)
115
+ assertStructure(envelope.payload, options.now)
115
116
  const _cid = await cid(envelope)
116
117
  return new Delegation(envelope, bytes, _cid)
117
118
  }
@@ -143,7 +144,7 @@ export class Delegation {
143
144
  payload.meta = options.meta
144
145
  }
145
146
 
146
- assertStructure(payload)
147
+ assertStructure(payload, options.now)
147
148
 
148
149
  const { signature, signaturePayload } = await Envelope.sign({
149
150
  spec: 'dlg',
@@ -174,8 +175,8 @@ export class Delegation {
174
175
  * @param {DelegationValidateOptions} options
175
176
  */
176
177
  async validate(options) {
177
- assertStructure(this.envelope.payload)
178
- assertNotBefore(this.envelope.payload.nbf)
178
+ assertStructure(this.envelope.payload, options.now)
179
+ assertNotBefore(this.envelope.payload.nbf, options.now)
179
180
  await verifySignature(
180
181
  this.envelope,
181
182
  options.verifierResolver,
@@ -187,7 +188,7 @@ export class Delegation {
187
188
  }
188
189
 
189
190
  toString() {
190
- return base64pad.encode(this.bytes)
191
+ return base64.encode(this.bytes)
191
192
  }
192
193
 
193
194
  toJSON() {
@@ -203,10 +204,10 @@ export class Delegation {
203
204
  pol: this.pol,
204
205
  exp: this.exp,
205
206
  nbf: this.nbf,
206
- nonce: base64pad.encode(this.nonce),
207
+ nonce: base64.encode(this.nonce),
207
208
  meta: this.meta,
208
209
  },
209
- signature: base64pad.encode(this.envelope.signature),
210
+ signature: base64.encode(this.envelope.signature),
210
211
  alg: this.envelope.alg,
211
212
  enc: this.envelope.enc,
212
213
  spec: this.envelope.spec,
@@ -219,8 +220,9 @@ export class Delegation {
219
220
  /**
220
221
  *
221
222
  * @param {import('./types.js').DelegationPayload} payload
223
+ * @param {number} [now]
222
224
  */
223
- function assertStructure(payload) {
225
+ function assertStructure(payload, now) {
224
226
  didParse(payload.iss)
225
227
  didParse(payload.aud)
226
228
 
@@ -229,7 +231,7 @@ function assertStructure(payload) {
229
231
  }
230
232
 
231
233
  assertIsValidCommand(payload.cmd)
232
- assertExpiration(payload.exp)
234
+ assertExpiration(payload.exp, now)
233
235
  assertNonce(payload.nonce)
234
236
 
235
237
  // assertPolicy(payload.pol)
package/src/invocation.js CHANGED
@@ -65,6 +65,7 @@ export class Invocation {
65
65
  }
66
66
  // Audience or subject must match receiver
67
67
  if (
68
+ audience &&
68
69
  envelope.payload.aud !== audience.did &&
69
70
  envelope.payload.sub !== audience.did
70
71
  ) {
@@ -192,14 +193,15 @@ export function assertProofs(payload, proofs) {
192
193
  throw new Error("UCAN Invocation couldn't resolve all proofs CIDs")
193
194
  }
194
195
 
195
- const lastProof = proofs[proofs.length - 1]
196
- const issuerDid = didParse(lastProof.envelope.payload.iss).did
197
- if (issuerDid !== lastProof.envelope.payload.sub) {
196
+ const rootProof = proofs[0]
197
+ const issuerDid = didParse(rootProof.iss).did
198
+ if (issuerDid !== rootProof.sub) {
198
199
  throw new Error('UCAN Invocation root proof is not self-signed')
199
200
  }
200
201
 
201
202
  for (let index = 0; index < proofs.length; index++) {
202
203
  const current = proofs[index].envelope.payload
204
+
203
205
  const currentAudience = didParse(current.aud)
204
206
  const currentSubject = didParse(current.sub ?? payload.sub)
205
207
 
@@ -210,12 +212,13 @@ export function assertProofs(payload, proofs) {
210
212
  }
211
213
 
212
214
  const nextProof = proofs[index + 1]
215
+
213
216
  /** @type {import("./types.js").Payload} */
214
217
  let next
215
218
  if (nextProof) {
216
219
  next = nextProof.envelope.payload
217
220
  } else {
218
- next = payload
221
+ next = payload // checks against the invocation payload
219
222
  }
220
223
 
221
224
  const nextIssuer = didParse(next.iss)
@@ -0,0 +1,85 @@
1
+ import { SchemaError } from '@standard-schema/utils'
2
+
3
+ /**
4
+ * @import {
5
+ * CommandsRecord,
6
+ * DefineClientOptions,
7
+ * CommandClient,
8
+ * } from './types.js'
9
+ */
10
+
11
+ /**
12
+ * Define a type-safe client from a record of commands.
13
+ *
14
+ * The returned client exposes a `request(cmd, args)` method whose `cmd`
15
+ * parameter is constrained to the union of `cmd` values declared by
16
+ * `commands`, and whose `args` parameter is typed accordingly.
17
+ *
18
+ * @template {CommandsRecord} Commands
19
+ * @param {Commands} commands
20
+ * @param {DefineClientOptions} options
21
+ * @returns {CommandClient<Commands>}
22
+ */
23
+ export function defineClient(commands, options) {
24
+ /** @type {Record<string, CommandsRecord[string]>} */
25
+ const commandMap = {}
26
+ for (const command of Object.values(commands)) {
27
+ commandMap[command.capability.cmd] = command
28
+ }
29
+
30
+ return {
31
+ request: async ({ cmd, args }) => {
32
+ const command = commandMap[cmd]
33
+ if (!command) {
34
+ throw new Error(`Command not found: ${cmd}`)
35
+ }
36
+
37
+ const inv = await command.capability.invoke({
38
+ args,
39
+ iss: options.issuer,
40
+ sub: options.audience.did,
41
+ store: options.store,
42
+ exp: null,
43
+ verifierResolver: options.verifierResolver,
44
+ })
45
+
46
+ const response = await fetch(options.url, {
47
+ method: 'POST',
48
+ body: /** @type {BufferSource} */ (inv.bytes),
49
+ })
50
+
51
+ const data = await response.json()
52
+ if (
53
+ typeof data !== 'object' ||
54
+ data === null ||
55
+ (!('result' in data) && !('error' in data))
56
+ ) {
57
+ throw new Error(
58
+ 'Invalid receipt: response must include a `result` or `error` field'
59
+ )
60
+ }
61
+
62
+ const validated = await command.receipt['~standard'].validate(data)
63
+ if (validated.issues) {
64
+ throw new SchemaError(validated.issues)
65
+ }
66
+ return validated.value
67
+ },
68
+
69
+ invoke: async ({ cmd, args }) => {
70
+ const command = commandMap[cmd]
71
+ if (!command) {
72
+ throw new Error(`Command not found: ${cmd}`)
73
+ }
74
+
75
+ return await command.capability.invoke({
76
+ args,
77
+ iss: options.issuer,
78
+ sub: options.audience.did,
79
+ store: options.store,
80
+ exp: null,
81
+ verifierResolver: options.verifierResolver,
82
+ })
83
+ },
84
+ }
85
+ }
@@ -0,0 +1,28 @@
1
+ import { Capability } from '../capability.js'
2
+
3
+ /**
4
+ * @import {
5
+ * CommandOptions,
6
+ * CommandOutput,
7
+ * } from './types.js'
8
+ * @import {StandardSchemaV1} from '@standard-schema/spec'
9
+ */
10
+
11
+ /**
12
+ * @template {StandardSchemaV1} Args
13
+ * @template {string} Cmd
14
+ * @template {StandardSchemaV1} ReceiptSchema
15
+ * @param {CommandOptions<Args, Cmd, ReceiptSchema>} options
16
+ * @returns {CommandOutput<Args, Cmd, ReceiptSchema>}
17
+ */
18
+ export function defineCommand(options) {
19
+ return {
20
+ capability: /** @type {Capability<Args, Cmd>} */ (
21
+ Capability.from({
22
+ schema: options.args,
23
+ cmd: options.cmd,
24
+ })
25
+ ),
26
+ receipt: options.receipt,
27
+ }
28
+ }
@@ -0,0 +1,10 @@
1
+ export { defineClient } from './client.js'
2
+ export { defineCommand } from './command.js'
3
+ export {
4
+ cidSchema,
5
+ receipt,
6
+ receiptError,
7
+ receiptResult,
8
+ receiptServerError,
9
+ } from './receipt.js'
10
+ export { defineServer } from './server.js'
@@ -0,0 +1,136 @@
1
+ import { CID } from 'multiformats/cid'
2
+ import { z } from 'zod/v4'
3
+
4
+ /**
5
+ * @import {z as zType} from 'zod/v4'
6
+ */
7
+
8
+ /**
9
+ * Schema for an Invocation CID.
10
+ *
11
+ * Accepts a {@link CID} instance (server-side construction), a CID string,
12
+ * or the dag-json `{ "/": "<cid>" }` form (which is what `CID.toJSON()`
13
+ * produces when serialized through `JSON.stringify`) and normalizes to a
14
+ * {@link CID}.
15
+ */
16
+ export const cidSchema = z.union([
17
+ z.instanceof(CID),
18
+ z
19
+ .union([z.string(), z.object({ '/': z.string() }).transform((v) => v['/'])])
20
+ .transform((value, ctx) => {
21
+ try {
22
+ return CID.parse(value)
23
+ } catch (error) {
24
+ ctx.addIssue({
25
+ code: 'custom',
26
+ message: `Invalid CID: ${
27
+ error instanceof Error ? error.message : String(error)
28
+ }`,
29
+ })
30
+ return z.NEVER
31
+ }
32
+ }),
33
+ ])
34
+
35
+ /**
36
+ * Schema for a successful receipt: `{ cid, result }`.
37
+ *
38
+ * @template {zType.ZodType} ResultSchema
39
+ * @param {ResultSchema} result Schema for the `result` payload.
40
+ * @returns {zType.ZodObject<{ cid: typeof cidSchema, result: ResultSchema }>}
41
+ */
42
+ export function receiptResult(result) {
43
+ return z.object({
44
+ cid: cidSchema,
45
+ result,
46
+ })
47
+ }
48
+
49
+ /**
50
+ * Schema for an error receipt: `{ cid, error: { code, message, data } }`.
51
+ *
52
+ * `code` and `message` are encoded as Zod literals so each error variant is
53
+ * distinguishable in the resulting union.
54
+ *
55
+ * @template {string} Code
56
+ * @template {string} Message
57
+ * @template {zType.ZodType} DataSchema
58
+ * @param {Code} code
59
+ * @param {Message} message
60
+ * @param {DataSchema} data Schema for the structured error `data` payload.
61
+ * @returns {zType.ZodObject<{
62
+ * cid: typeof cidSchema,
63
+ * error: zType.ZodObject<{
64
+ * code: zType.ZodLiteral<Code>,
65
+ * message: zType.ZodLiteral<Message>,
66
+ * data: DataSchema,
67
+ * }>,
68
+ * }>}
69
+ */
70
+ export function receiptError(code, message, data) {
71
+ return z.object({
72
+ cid: cidSchema,
73
+ error: z.object({
74
+ code: z.literal(code),
75
+ message: z.literal(message),
76
+ data,
77
+ }),
78
+ })
79
+ }
80
+
81
+ /**
82
+ * Generic schema for server-emitted internal errors.
83
+ *
84
+ * Unlike {@link receiptError} (which is part of the per-command receipt
85
+ * schema with literal `code`/`message`), this schema is emitted by the
86
+ * server for any error that happens outside of normal handler execution
87
+ * (invalid invocation, command not found, args validation failure, handler
88
+ * threw, etc.).
89
+ *
90
+ * `cid` is optional because some errors happen before the invocation can
91
+ * be decoded.
92
+ *
93
+ * Automatically included as a variant in every command receipt produced by
94
+ * {@link receipt}, so the client always knows how to validate and discriminate
95
+ * a server error from a normal receipt.
96
+ */
97
+ export const receiptServerError = z.object({
98
+ error: z.object({
99
+ code: z.literal('SERVER_ERROR'),
100
+ message: z.string(),
101
+ cid: cidSchema.optional(),
102
+ data: z.unknown().optional(),
103
+ }),
104
+ })
105
+
106
+ /**
107
+ * @typedef {zType.infer<typeof receiptServerError>} ReceiptServerError
108
+ */
109
+
110
+ /**
111
+ * Combine one {@link receiptResult} schema and zero or more
112
+ * {@link receiptError} schemas into a single discriminated receipt schema.
113
+ *
114
+ * The {@link receiptServerError} schema is always appended as the last
115
+ * variant of the union so every command receipt accepts a server-emitted
116
+ * internal error in addition to its declared result/error variants.
117
+ *
118
+ * The resulting schema validates one of:
119
+ * - `{ cid, result }`
120
+ * - `{ cid, error: { code, message, data } }` (one of the declared command errors)
121
+ * - `{ error: { code, message, cid?, data? } }` (a generic server error)
122
+ *
123
+ * Consumers discriminate via `'result' in receipt` / `'error' in receipt`,
124
+ * and within the error branch via the `code` field.
125
+ *
126
+ * @template {zType.ZodType} Result
127
+ * @template {zType.ZodType} Error
128
+ * @param {Result} result
129
+ * @param {...Error} errors
130
+ * @returns {zType.ZodUnion<readonly [Result, ...Error[], typeof receiptServerError]>}
131
+ */
132
+ export function receipt(result, ...errors) {
133
+ return /** @type {any} */ (
134
+ z.union(/** @type {any} */ ([result, ...errors, receiptServerError]))
135
+ )
136
+ }