@ixo/ucan 1.0.0 → 1.0.1

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,414 @@
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(' POST /protected - Protected endpoint (requires invocation)');
270
+ console.log(' POST /delegate - Create delegation for a user');
271
+ });
272
+ });
273
+ ```
274
+
275
+ ## Testing with cURL
276
+
277
+ ### 1. Get server info
278
+
279
+ ```bash
280
+ curl http://localhost:3000/info
281
+ ```
282
+
283
+ ### 2. Create a delegation
284
+
285
+ ```bash
286
+ curl -X POST http://localhost:3000/delegate \
287
+ -H "Content-Type: application/json" \
288
+ -d '{
289
+ "audience": "did:key:z6MkUserKey...",
290
+ "capabilities": [{ "can": "employees/read", "nb": { "limit": 50 } }]
291
+ }'
292
+ ```
293
+
294
+ ### 3. Use the delegation (invocation)
295
+
296
+ The client needs to create and sign an invocation. See [CLIENT.md](./CLIENT.md) for how to do this.
297
+
298
+ ```bash
299
+ curl -X POST http://localhost:3000/protected \
300
+ -H "Content-Type: application/json" \
301
+ -d '{ "invocation": "<base64 CAR from client>" }'
302
+ ```
303
+
304
+ ## Validation Results
305
+
306
+ ### Success Response
307
+
308
+ ```json
309
+ {
310
+ "message": "Access granted!",
311
+ "invoker": "did:key:z6MkUserKey...",
312
+ "capability": {
313
+ "can": "employees/read",
314
+ "with": "myapp:did:ixo:ixo1abc...",
315
+ "nb": { "limit": 25 }
316
+ },
317
+ "employees": [...]
318
+ }
319
+ ```
320
+
321
+ ### Error Responses
322
+
323
+ **Missing invocation:**
324
+ ```json
325
+ {
326
+ "error": "Missing invocation in request body",
327
+ "hint": "Send { \"invocation\": \"<base64 CAR>\" }"
328
+ }
329
+ ```
330
+
331
+ **Invalid signature:**
332
+ ```json
333
+ {
334
+ "error": "Unauthorized",
335
+ "details": { "code": "INVALID_SIGNATURE", "message": "..." }
336
+ }
337
+ ```
338
+
339
+ **Caveat violation:**
340
+ ```json
341
+ {
342
+ "error": "Unauthorized",
343
+ "details": {
344
+ "code": "CAVEAT_VIOLATION",
345
+ "message": "Cannot request limit=100, delegation only allows limit=50"
346
+ }
347
+ }
348
+ ```
349
+
350
+ **Replay attack:**
351
+ ```json
352
+ {
353
+ "error": "Unauthorized",
354
+ "details": { "code": "REPLAY", "message": "Invocation has already been used" }
355
+ }
356
+ ```
357
+
358
+ ## Using with Other Frameworks
359
+
360
+ The validator is framework-agnostic. Here's how to use it with other frameworks:
361
+
362
+ ### Fastify
363
+
364
+ ```typescript
365
+ fastify.post('/protected', async (request, reply) => {
366
+ const result = await validator.validate(
367
+ request.body.invocation,
368
+ EmployeesRead,
369
+ 'myapp:server'
370
+ );
371
+
372
+ if (!result.ok) {
373
+ return reply.code(403).send({ error: result.error });
374
+ }
375
+
376
+ return { employees: [...] };
377
+ });
378
+ ```
379
+
380
+ ### Hono
381
+
382
+ ```typescript
383
+ app.post('/protected', async (c) => {
384
+ const { invocation } = await c.req.json();
385
+ const result = await validator.validate(invocation, EmployeesRead, 'myapp:server');
386
+
387
+ if (!result.ok) {
388
+ return c.json({ error: result.error }, 403);
389
+ }
390
+
391
+ return c.json({ employees: [...] });
392
+ });
393
+ ```
394
+
395
+ ### NestJS
396
+
397
+ ```typescript
398
+ @Controller('employees')
399
+ export class EmployeesController {
400
+ constructor(private readonly ucanService: UCANService) {}
401
+
402
+ @Post()
403
+ async getEmployees(@Body('invocation') invocation: string) {
404
+ const result = await this.ucanService.validate(invocation, EmployeesRead);
405
+
406
+ if (!result.ok) {
407
+ throw new ForbiddenException(result.error);
408
+ }
409
+
410
+ return { employees: [...] };
411
+ }
412
+ }
413
+ ```
414
+
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.0.1",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -44,9 +44,9 @@
44
44
  "ts-jest": "^29.2.5",
45
45
  "tsx": "^4.7.0",
46
46
  "typescript": "^5.5.4",
47
- "@ixo/eslint-config": "0.0.0",
48
47
  "@ixo/jest-config": "0.0.0",
49
- "@ixo/typescript-config": "0.0.0"
48
+ "@ixo/typescript-config": "0.0.0",
49
+ "@ixo/eslint-config": "0.0.0"
50
50
  },
51
51
  "dependencies": {
52
52
  "@ucanto/client": "9.0.2",