iso-ucan 0.4.2 → 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.
- package/dist/src/capability.d.ts +0 -3
- package/dist/src/capability.d.ts.map +1 -1
- package/dist/src/delegation.d.ts +4 -1
- package/dist/src/delegation.d.ts.map +1 -1
- package/dist/src/invocation.d.ts.map +1 -1
- package/dist/src/rpc/client.d.ts +24 -0
- package/dist/src/rpc/client.d.ts.map +1 -0
- package/dist/src/rpc/command.d.ts +19 -0
- package/dist/src/rpc/command.d.ts.map +1 -0
- package/dist/src/rpc/index.d.ts +5 -0
- package/dist/src/rpc/index.d.ts.map +1 -0
- package/dist/src/rpc/receipt.d.ts +114 -0
- package/dist/src/rpc/receipt.d.ts.map +1 -0
- package/dist/src/rpc/server.d.ts +18 -0
- package/dist/src/rpc/server.d.ts.map +1 -0
- package/dist/src/rpc/types.d.ts +112 -0
- package/dist/src/rpc/types.d.ts.map +1 -0
- package/dist/src/store.d.ts +4 -2
- package/dist/src/store.d.ts.map +1 -1
- package/dist/src/types.d.ts +15 -3
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/utils.d.ts +4 -2
- package/dist/src/utils.d.ts.map +1 -1
- package/package.json +13 -20
- package/readme.md +130 -0
- package/src/capability.js +1 -6
- package/src/delegation.js +15 -13
- package/src/invocation.js +7 -4
- package/src/rpc/client.js +85 -0
- package/src/rpc/command.js +28 -0
- package/src/rpc/index.js +10 -0
- package/src/rpc/receipt.js +136 -0
- package/src/rpc/server.js +155 -0
- package/src/rpc/types.js +9 -0
- package/src/rpc/types.ts +153 -0
- package/src/store.js +41 -16
- package/src/types.ts +15 -6
- package/src/utils.js +20 -15
- package/dist/src/client.d.ts +0 -41
- package/dist/src/client.d.ts.map +0 -1
- package/dist/src/server.d.ts +0 -38
- package/dist/src/server.d.ts.map +0 -1
- package/src/client.js +0 -52
- 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 {
|
|
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 =
|
|
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
|
|
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:
|
|
207
|
+
nonce: base64.encode(this.nonce),
|
|
207
208
|
meta: this.meta,
|
|
208
209
|
},
|
|
209
|
-
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
|
|
196
|
-
const issuerDid = didParse(
|
|
197
|
-
if (issuerDid !==
|
|
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
|
+
}
|
package/src/rpc/index.js
ADDED
|
@@ -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
|
+
}
|