@ixo/ucan 1.0.0 → 1.1.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.
@@ -0,0 +1,419 @@
1
+ # Server Example
2
+
3
+ Complete Express.js server using `@ixo/ucan` for authorization.
4
+
5
+ ## Setup
6
+
7
+ ```bash
8
+ npm install express @ixo/ucan
9
+ ```
10
+
11
+ ## Full Example
12
+
13
+ ```typescript
14
+ import express, { Request, Response } from 'express';
15
+ import {
16
+ createUCANValidator,
17
+ createIxoDIDResolver,
18
+ defineCapability,
19
+ Schema,
20
+ generateKeypair,
21
+ createDelegation,
22
+ serializeDelegation,
23
+ parseSigner,
24
+ SupportedDID,
25
+ } from '@ixo/ucan';
26
+
27
+ const app = express();
28
+ app.use(express.json());
29
+
30
+ // =============================================================================
31
+ // Configuration
32
+ // =============================================================================
33
+
34
+ // Server identity (use did:key for simplicity, or did:ixo for production)
35
+ const SERVER_DID = 'did:ixo:ixo1abc...'; // Your server's DID
36
+ const ROOT_DID = 'did:ixo:ixo1admin...'; // Admin who can delegate
37
+ const ROOT_PRIVATE_KEY = 'MgCY...'; // Admin's private key
38
+
39
+ // =============================================================================
40
+ // Define Capabilities
41
+ // =============================================================================
42
+
43
+ /**
44
+ * Capability with caveat validation (limit)
45
+ *
46
+ * The `derives` function enforces attenuation:
47
+ * - A delegation with limit: 100 can create sub-delegations with limit ≤ 100
48
+ * - An invocation can only request up to the delegated limit
49
+ */
50
+ const EmployeesRead = defineCapability({
51
+ can: 'employees/read',
52
+ protocol: 'myapp:',
53
+ nb: { limit: Schema.integer().optional() },
54
+ derives: (claimed, delegated) => {
55
+ const claimedLimit = claimed.nb?.limit ?? Infinity;
56
+ const delegatedLimit = delegated.nb?.limit ?? Infinity;
57
+
58
+ if (claimedLimit > delegatedLimit) {
59
+ return {
60
+ error: new Error(
61
+ `Cannot request limit=${claimedLimit}, delegation only allows limit=${delegatedLimit}`,
62
+ ),
63
+ };
64
+ }
65
+ return { ok: {} };
66
+ },
67
+ });
68
+
69
+ /**
70
+ * Simple capability without caveats
71
+ */
72
+ const EmployeesWrite = defineCapability({
73
+ can: 'employees/write',
74
+ protocol: 'myapp:',
75
+ });
76
+
77
+ // =============================================================================
78
+ // Initialize Validator
79
+ // =============================================================================
80
+
81
+ let validator: Awaited<ReturnType<typeof createUCANValidator>>;
82
+
83
+ async function initializeServer() {
84
+ // Create DID resolver for did:ixo
85
+ const didResolver = createIxoDIDResolver({
86
+ indexerUrl: 'https://blocksync.ixo.earth/graphql',
87
+ });
88
+
89
+ // Create validator (async to resolve non-did:key DIDs at startup)
90
+ validator = await createUCANValidator({
91
+ serverDid: SERVER_DID,
92
+ rootIssuers: [ROOT_DID],
93
+ didResolver,
94
+ });
95
+
96
+ console.log('Server DID:', SERVER_DID);
97
+ console.log('Root Issuer:', ROOT_DID);
98
+ }
99
+
100
+ // =============================================================================
101
+ // Helper Functions
102
+ // =============================================================================
103
+
104
+ function buildResourceUri(serverDid: string): `myapp:${string}` {
105
+ return `myapp:${serverDid}`;
106
+ }
107
+
108
+ function buildEmployees(limit: number) {
109
+ return Array.from({ length: limit }, (_, i) => ({
110
+ id: i + 1,
111
+ name: `Employee ${i + 1}`,
112
+ }));
113
+ }
114
+
115
+ // =============================================================================
116
+ // Routes
117
+ // =============================================================================
118
+
119
+ /**
120
+ * Health check
121
+ */
122
+ app.get('/health', (_req, res) => {
123
+ res.json({ status: 'ok' });
124
+ });
125
+
126
+ /**
127
+ * Server info and available capabilities
128
+ */
129
+ app.get('/info', (_req, res) => {
130
+ res.json({
131
+ serverDid: SERVER_DID,
132
+ rootIssuers: [ROOT_DID],
133
+ capabilities: {
134
+ 'employees/read': {
135
+ description: 'Read employee data',
136
+ caveats: { limit: 'Maximum number of employees to return' },
137
+ },
138
+ 'employees/write': {
139
+ description: 'Write employee data',
140
+ caveats: null,
141
+ },
142
+ },
143
+ });
144
+ });
145
+
146
+ /**
147
+ * Protected route - requires valid UCAN invocation
148
+ *
149
+ * Send: POST /protected
150
+ * Body: { "invocation": "<base64 CAR>" }
151
+ */
152
+ app.post('/protected', async (req: Request, res: Response) => {
153
+ const invocationBase64 = req.body?.invocation;
154
+
155
+ if (!invocationBase64) {
156
+ res.status(400).json({
157
+ error: 'Missing invocation in request body',
158
+ hint: 'Send { "invocation": "<base64 CAR>" }',
159
+ });
160
+ return;
161
+ }
162
+
163
+ // Validate with capability (includes caveat validation!)
164
+ const result = await validator.validate(
165
+ invocationBase64,
166
+ EmployeesRead,
167
+ buildResourceUri(SERVER_DID),
168
+ );
169
+
170
+ if (!result.ok) {
171
+ res.status(403).json({
172
+ error: 'Unauthorized',
173
+ details: result.error,
174
+ });
175
+ return;
176
+ }
177
+
178
+ // Extract limit from validated capability
179
+ const limit = (result.capability?.nb?.limit as number) ?? 10;
180
+
181
+ res.json({
182
+ message: 'Access granted!',
183
+ invoker: result.invoker,
184
+ capability: result.capability,
185
+ employees: buildEmployees(limit),
186
+ });
187
+ });
188
+
189
+ /**
190
+ * Create delegation for a user
191
+ *
192
+ * Send: POST /delegate
193
+ * Body: {
194
+ * "audience": "did:key:z6Mk...",
195
+ * "capabilities": [{ "can": "employees/read", "nb": { "limit": 50 } }],
196
+ * "expiration": 1735689600 // Optional, defaults to 24h
197
+ * }
198
+ */
199
+ app.post('/delegate', async (req: Request, res: Response) => {
200
+ const { audience, capabilities, expiration } = req.body;
201
+
202
+ if (!audience) {
203
+ res.status(400).json({
204
+ error: 'Missing audience DID',
205
+ hint: 'Send { "audience": "did:key:..." }',
206
+ });
207
+ return;
208
+ }
209
+
210
+ if (!capabilities || !Array.isArray(capabilities)) {
211
+ res.status(400).json({
212
+ error: 'Missing or invalid capabilities',
213
+ hint: 'Send { "capabilities": [{ "can": "employees/read", "nb": { "limit": 50 } }] }',
214
+ });
215
+ return;
216
+ }
217
+
218
+ try {
219
+ const rootSigner = parseSigner(ROOT_PRIVATE_KEY, ROOT_DID as SupportedDID);
220
+
221
+ // Build full capabilities with resource URI
222
+ const fullCapabilities = capabilities.map((cap: any) => ({
223
+ can: cap.can,
224
+ with: buildResourceUri(SERVER_DID),
225
+ nb: cap.nb,
226
+ }));
227
+
228
+ // Default expiration: 24 hours
229
+ const exp = expiration ?? Math.floor(Date.now() / 1000) + 86400;
230
+
231
+ const delegation = await createDelegation({
232
+ issuer: rootSigner,
233
+ audience,
234
+ capabilities: fullCapabilities as any,
235
+ expiration: exp,
236
+ });
237
+
238
+ const serialized = await serializeDelegation(delegation);
239
+
240
+ res.json({
241
+ success: true,
242
+ delegation: serialized,
243
+ details: {
244
+ cid: delegation.cid.toString(),
245
+ issuer: delegation.issuer.did(),
246
+ audience: delegation.audience.did(),
247
+ expiration: exp,
248
+ },
249
+ });
250
+ } catch (error: any) {
251
+ res.status(500).json({
252
+ error: 'Failed to create delegation',
253
+ details: error.message,
254
+ });
255
+ }
256
+ });
257
+
258
+ // =============================================================================
259
+ // Start Server
260
+ // =============================================================================
261
+
262
+ initializeServer().then(() => {
263
+ const PORT = process.env.PORT || 3000;
264
+ app.listen(PORT, () => {
265
+ console.log(`Server listening on http://localhost:${PORT}`);
266
+ console.log('\nEndpoints:');
267
+ console.log(' GET /health - Health check');
268
+ console.log(' GET /info - Server info');
269
+ console.log(
270
+ ' POST /protected - Protected endpoint (requires invocation)',
271
+ );
272
+ console.log(' POST /delegate - Create delegation for a user');
273
+ });
274
+ });
275
+ ```
276
+
277
+ ## Testing with cURL
278
+
279
+ ### 1. Get server info
280
+
281
+ ```bash
282
+ curl http://localhost:3000/info
283
+ ```
284
+
285
+ ### 2. Create a delegation
286
+
287
+ ```bash
288
+ curl -X POST http://localhost:3000/delegate \
289
+ -H "Content-Type: application/json" \
290
+ -d '{
291
+ "audience": "did:key:z6MkUserKey...",
292
+ "capabilities": [{ "can": "employees/read", "nb": { "limit": 50 } }]
293
+ }'
294
+ ```
295
+
296
+ ### 3. Use the delegation (invocation)
297
+
298
+ The client needs to create and sign an invocation. See [CLIENT.md](./CLIENT.md) for how to do this.
299
+
300
+ ```bash
301
+ curl -X POST http://localhost:3000/protected \
302
+ -H "Content-Type: application/json" \
303
+ -d '{ "invocation": "<base64 CAR from client>" }'
304
+ ```
305
+
306
+ ## Validation Results
307
+
308
+ ### Success Response
309
+
310
+ ```json
311
+ {
312
+ "message": "Access granted!",
313
+ "invoker": "did:key:z6MkUserKey...",
314
+ "capability": {
315
+ "can": "employees/read",
316
+ "with": "myapp:did:ixo:ixo1abc...",
317
+ "nb": { "limit": 25 }
318
+ },
319
+ "employees": [...]
320
+ }
321
+ ```
322
+
323
+ ### Error Responses
324
+
325
+ **Missing invocation:**
326
+
327
+ ```json
328
+ {
329
+ "error": "Missing invocation in request body",
330
+ "hint": "Send { \"invocation\": \"<base64 CAR>\" }"
331
+ }
332
+ ```
333
+
334
+ **Invalid signature:**
335
+
336
+ ```json
337
+ {
338
+ "error": "Unauthorized",
339
+ "details": { "code": "INVALID_SIGNATURE", "message": "..." }
340
+ }
341
+ ```
342
+
343
+ **Caveat violation:**
344
+
345
+ ```json
346
+ {
347
+ "error": "Unauthorized",
348
+ "details": {
349
+ "code": "CAVEAT_VIOLATION",
350
+ "message": "Cannot request limit=100, delegation only allows limit=50"
351
+ }
352
+ }
353
+ ```
354
+
355
+ **Replay attack:**
356
+
357
+ ```json
358
+ {
359
+ "error": "Unauthorized",
360
+ "details": { "code": "REPLAY", "message": "Invocation has already been used" }
361
+ }
362
+ ```
363
+
364
+ ## Using with Other Frameworks
365
+
366
+ The validator is framework-agnostic. Here's how to use it with other frameworks:
367
+
368
+ ### Fastify
369
+
370
+ ```typescript
371
+ fastify.post('/protected', async (request, reply) => {
372
+ const result = await validator.validate(
373
+ request.body.invocation,
374
+ EmployeesRead,
375
+ 'myapp:server'
376
+ );
377
+
378
+ if (!result.ok) {
379
+ return reply.code(403).send({ error: result.error });
380
+ }
381
+
382
+ return { employees: [...] };
383
+ });
384
+ ```
385
+
386
+ ### Hono
387
+
388
+ ```typescript
389
+ app.post('/protected', async (c) => {
390
+ const { invocation } = await c.req.json();
391
+ const result = await validator.validate(invocation, EmployeesRead, 'myapp:server');
392
+
393
+ if (!result.ok) {
394
+ return c.json({ error: result.error }, 403);
395
+ }
396
+
397
+ return c.json({ employees: [...] });
398
+ });
399
+ ```
400
+
401
+ ### NestJS
402
+
403
+ ```typescript
404
+ @Controller('employees')
405
+ export class EmployeesController {
406
+ constructor(private readonly ucanService: UCANService) {}
407
+
408
+ @Post()
409
+ async getEmployees(@Body('invocation') invocation: string) {
410
+ const result = await this.ucanService.validate(invocation, EmployeesRead);
411
+
412
+ if (!result.ok) {
413
+ throw new ForbiddenException(result.error);
414
+ }
415
+
416
+ return { employees: [...] };
417
+ }
418
+ }
419
+ ```
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ixo/ucan",
3
3
  "description": "UCAN authorization for any service - built on ucanto",
4
- "version": "1.0.0",
4
+ "version": "1.1.0",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -38,15 +38,13 @@
38
38
  "devDependencies": {
39
39
  "@cosmjs/crypto": "0.32.4",
40
40
  "@cosmjs/encoding": "0.32.4",
41
- "@types/jest": "^29.5.14",
42
41
  "@types/node": "^22.10.5",
43
- "jest": "^29.7.0",
44
- "ts-jest": "^29.2.5",
45
42
  "tsx": "^4.7.0",
46
43
  "typescript": "^5.5.4",
47
- "@ixo/eslint-config": "0.0.0",
48
- "@ixo/jest-config": "0.0.0",
49
- "@ixo/typescript-config": "0.0.0"
44
+ "vitest": "^3.2.4",
45
+ "@ixo/eslint-config": "2.0.0",
46
+ "@ixo/typescript-config": "1.0.0",
47
+ "@ixo/vitest-config": "1.0.0"
50
48
  },
51
49
  "dependencies": {
52
50
  "@ucanto/client": "9.0.2",
@@ -72,7 +70,7 @@
72
70
  },
73
71
  "scripts": {
74
72
  "build": "tsc",
75
- "test": "jest",
73
+ "test": "vitest run",
76
74
  "test:ucan": "tsx scripts/test-ucan.ts"
77
75
  }
78
76
  }
@@ -1,3 +1,4 @@
1
+ /* eslint-disable no-console */
1
2
  /**
2
3
  * UCAN Test Script
3
4
  *
@@ -23,11 +24,8 @@ import {
23
24
  // CONFIGURATION - Change this to test different scenarios
24
25
  // ============================================================================
25
26
 
26
- const ACTION:
27
- | 'generate-keys'
28
- | 'create-delegation'
29
- | 'full-flow'
30
- | 'validate' = 'full-flow';
27
+ const ACTION: 'generate-keys' | 'create-delegation' | 'full-flow' | 'validate' =
28
+ 'full-flow';
31
29
 
32
30
  // ============================================================================
33
31
  // CAPABILITY DEFINITION
@@ -66,7 +64,9 @@ function log(title: string, data?: unknown) {
66
64
  console.log(`│ ${title}`);
67
65
  console.log('─'.repeat(70));
68
66
  if (data !== undefined) {
69
- console.log(typeof data === 'string' ? data : JSON.stringify(data, null, 2));
67
+ console.log(
68
+ typeof data === 'string' ? data : JSON.stringify(data, null, 2),
69
+ );
70
70
  }
71
71
  }
72
72
 
@@ -97,11 +97,17 @@ async function generateKeys() {
97
97
  const did = signer.did();
98
98
  const privateKey = ed25519.Signer.format(signer);
99
99
 
100
- console.log(JSON.stringify({
101
- did,
102
- privateKey,
103
- note: 'Save the privateKey securely! The DID is public.',
104
- }, null, 2));
100
+ console.log(
101
+ JSON.stringify(
102
+ {
103
+ did,
104
+ privateKey,
105
+ note: 'Save the privateKey securely! The DID is public.',
106
+ },
107
+ null,
108
+ 2,
109
+ ),
110
+ );
105
111
 
106
112
  return { signer, did, privateKey };
107
113
  }
@@ -233,7 +239,7 @@ async function fullFlow() {
233
239
  });
234
240
 
235
241
  success(`Delegation created: ${aliceToBob.cid.toString().slice(0, 20)}...`);
236
- info('Bob can now read up to 25 employees (attenuated from Alice\'s 50)');
242
+ info("Bob can now read up to 25 employees (attenuated from Alice's 50)");
237
243
 
238
244
  // ─────────────────────────────────────────────────────────────────────────
239
245
  // STEP 4: Alice invokes with limit: 50 (should succeed)
@@ -261,10 +267,12 @@ async function fullFlow() {
261
267
  );
262
268
 
263
269
  if (aliceResult.ok) {
264
- success('Alice\'s invocation PASSED');
270
+ success("Alice's invocation PASSED");
265
271
  console.log(` Requested: ${aliceResult.capability?.nb?.limit} employees`);
266
272
  } else {
267
- fail(`Alice's invocation failed unexpectedly: ${aliceResult.error?.message}`);
273
+ fail(
274
+ `Alice's invocation failed unexpectedly: ${aliceResult.error?.message}`,
275
+ );
268
276
  }
269
277
 
270
278
  // ─────────────────────────────────────────────────────────────────────────
@@ -293,11 +301,11 @@ async function fullFlow() {
293
301
  );
294
302
 
295
303
  if (!bobBadResult.ok) {
296
- success('Bob\'s excessive request correctly REJECTED');
304
+ success("Bob's excessive request correctly REJECTED");
297
305
  console.log(` Error: ${bobBadResult.error?.message}`);
298
306
  console.log(` Code: ${bobBadResult.error?.code}`);
299
307
  } else {
300
- fail('Bob\'s excessive request should have been rejected!');
308
+ fail("Bob's excessive request should have been rejected!");
301
309
  }
302
310
 
303
311
  // ─────────────────────────────────────────────────────────────────────────
@@ -326,11 +334,15 @@ async function fullFlow() {
326
334
  );
327
335
 
328
336
  if (bobGoodResult.ok) {
329
- success('Bob\'s valid request PASSED');
330
- console.log(` Requested: ${bobGoodResult.capability?.nb?.limit} employees`);
337
+ success("Bob's valid request PASSED");
338
+ console.log(
339
+ ` Requested: ${bobGoodResult.capability?.nb?.limit} employees`,
340
+ );
331
341
  console.log(` Invoker: ${bobGoodResult.invoker?.slice(0, 40)}...`);
332
342
  } else {
333
- fail(`Bob's valid request failed unexpectedly: ${bobGoodResult.error?.message}`);
343
+ fail(
344
+ `Bob's valid request failed unexpectedly: ${bobGoodResult.error?.message}`,
345
+ );
334
346
  }
335
347
 
336
348
  // ─────────────────────────────────────────────────────────────────────────
@@ -23,7 +23,9 @@ export { Schema };
23
23
  * Extracts the output type O from a Reader/Schema
24
24
  * A Reader<O, I> has a read method that returns { ok: O } | { error: ... }
25
25
  */
26
- type Infer<T> = T extends { read(input: unknown): { ok: infer O } | { error: unknown } }
26
+ type Infer<T> = T extends {
27
+ read(input: unknown): { ok: infer O } | { error: unknown };
28
+ }
27
29
  ? O
28
30
  : never;
29
31
 
@@ -66,8 +68,7 @@ type InferStruct<U extends Record<string, unknown>> = {
66
68
  */
67
69
  export interface DefineCapabilityOptions<
68
70
  // NBSchema is the schema SHAPE, e.g., { limit: Schema<number | undefined> }
69
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
70
- NBSchema extends Record<string, any> = Record<string, never>,
71
+ NBSchema extends Record<string, unknown> = Record<string, never>,
71
72
  > {
72
73
  /**
73
74
  * The action this capability authorizes
@@ -166,10 +167,10 @@ export interface DefineCapabilityOptions<
166
167
  * });
167
168
  * ```
168
169
  */
169
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
170
- export function defineCapability<NBSchema extends Record<string, any> = Record<string, never>>(
171
- options: DefineCapabilityOptions<NBSchema>,
172
- ) {
170
+
171
+ export function defineCapability<
172
+ NBSchema extends Record<string, unknown> = Record<string, never>,
173
+ >(options: DefineCapabilityOptions<NBSchema>) {
173
174
  const protocol = (options.protocol ?? 'urn:') as `${string}:`;
174
175
  const supportWildcards = options.supportWildcards ?? true;
175
176