@ixo/ucan 1.0.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 (51) hide show
  1. package/.eslintrc.js +9 -0
  2. package/.prettierignore +3 -0
  3. package/.prettierrc.js +4 -0
  4. package/.turbo/turbo-build.log +4 -0
  5. package/CHANGELOG.md +0 -0
  6. package/README.md +189 -0
  7. package/dist/capabilities/capability.d.ts +33 -0
  8. package/dist/capabilities/capability.d.ts.map +1 -0
  9. package/dist/capabilities/capability.js +53 -0
  10. package/dist/capabilities/capability.js.map +1 -0
  11. package/dist/client/create-client.d.ts +33 -0
  12. package/dist/client/create-client.d.ts.map +1 -0
  13. package/dist/client/create-client.js +104 -0
  14. package/dist/client/create-client.js.map +1 -0
  15. package/dist/did/ixo-resolver.d.ts +8 -0
  16. package/dist/did/ixo-resolver.d.ts.map +1 -0
  17. package/dist/did/ixo-resolver.js +162 -0
  18. package/dist/did/ixo-resolver.js.map +1 -0
  19. package/dist/did/utils.d.ts +4 -0
  20. package/dist/did/utils.d.ts.map +1 -0
  21. package/dist/did/utils.js +85 -0
  22. package/dist/did/utils.js.map +1 -0
  23. package/dist/index.d.ts +13 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +12 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/store/memory.d.ts +25 -0
  28. package/dist/store/memory.d.ts.map +1 -0
  29. package/dist/store/memory.js +71 -0
  30. package/dist/store/memory.js.map +1 -0
  31. package/dist/tsconfig.tsbuildinfo +1 -0
  32. package/dist/types.d.ts +29 -0
  33. package/dist/types.d.ts.map +1 -0
  34. package/dist/types.js +2 -0
  35. package/dist/types.js.map +1 -0
  36. package/dist/validator/validator.d.ts +29 -0
  37. package/dist/validator/validator.d.ts.map +1 -0
  38. package/dist/validator/validator.js +179 -0
  39. package/dist/validator/validator.js.map +1 -0
  40. package/jest.config.js +3 -0
  41. package/package.json +78 -0
  42. package/scripts/test-ucan.ts +457 -0
  43. package/src/capabilities/capability.ts +244 -0
  44. package/src/client/create-client.ts +329 -0
  45. package/src/did/ixo-resolver.ts +325 -0
  46. package/src/did/utils.ts +141 -0
  47. package/src/index.ts +135 -0
  48. package/src/store/memory.ts +194 -0
  49. package/src/types.ts +108 -0
  50. package/src/validator/validator.ts +399 -0
  51. package/tsconfig.json +18 -0
@@ -0,0 +1,399 @@
1
+ /**
2
+ * @fileoverview Framework-agnostic UCAN validator
3
+ *
4
+ * This module provides a simple validator that can be used in any
5
+ * server framework (Express, Fastify, Hono, raw Node HTTP, etc.)
6
+ * to validate UCAN invocations.
7
+ *
8
+ * Uses ucanto's battle-tested validation under the hood.
9
+ *
10
+ * Supports any DID method (did:key, did:ixo, did:web, etc.) for the server identity.
11
+ * Non-did:key DIDs are resolved at startup using the provided didResolver.
12
+ */
13
+
14
+ import { ed25519 } from '@ucanto/principal';
15
+ import { Delegation } from '@ucanto/core';
16
+ import { claim } from '@ucanto/validator';
17
+ import { capability } from '@ucanto/validator';
18
+ import type { DIDKeyResolver, InvocationStore } from '../types.js';
19
+ import { InMemoryInvocationStore } from '../store/memory.js';
20
+
21
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
+ type CapabilityParser = ReturnType<typeof capability<any, any, any>>;
23
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
+ type Verifier = ReturnType<typeof ed25519.Verifier.parse>;
25
+
26
+ /**
27
+ * Options for creating a UCAN validator
28
+ */
29
+ export interface CreateValidatorOptions {
30
+ /**
31
+ * The server's DID (audience for invocations)
32
+ * Invocations must be addressed to this DID.
33
+ *
34
+ * Supports any DID method:
35
+ * - did:key:z6Mk... (parsed directly)
36
+ * - did:ixo:ixo1... (resolved using didResolver at startup)
37
+ * - did:web:example.com (resolved using didResolver at startup)
38
+ */
39
+ serverDid: string;
40
+
41
+ /**
42
+ * DIDs that are allowed to be root issuers
43
+ * These DIDs can self-issue capabilities without needing a delegation chain
44
+ */
45
+ rootIssuers: string[];
46
+
47
+ /**
48
+ * DID resolver for non-did:key DIDs.
49
+ * Required if serverDid or any issuer uses a non-did:key method.
50
+ *
51
+ * The resolver should return the did:key(s) associated with the DID.
52
+ */
53
+ didResolver?: DIDKeyResolver;
54
+
55
+ /**
56
+ * Optional invocation store for replay protection
57
+ * If not provided, an in-memory store is used
58
+ */
59
+ invocationStore?: InvocationStore;
60
+ }
61
+
62
+ /**
63
+ * Result of validating an invocation
64
+ */
65
+ export interface ValidateResult {
66
+ /** Whether validation succeeded */
67
+ ok: boolean;
68
+
69
+ /** The invoker's DID (if valid) */
70
+ invoker?: string;
71
+
72
+ /** The validated capability (if valid) */
73
+ capability?: {
74
+ can: string;
75
+ with: string;
76
+ nb?: Record<string, unknown>;
77
+ };
78
+
79
+ /** Error details (if invalid) */
80
+ error?: {
81
+ code:
82
+ | 'INVALID_FORMAT'
83
+ | 'INVALID_SIGNATURE'
84
+ | 'UNAUTHORIZED'
85
+ | 'REPLAY'
86
+ | 'EXPIRED'
87
+ | 'CAVEAT_VIOLATION';
88
+ message: string;
89
+ };
90
+ }
91
+
92
+ /**
93
+ * A framework-agnostic UCAN validator
94
+ */
95
+ export interface UCANValidator {
96
+ /**
97
+ * Validate an invocation against a capability definition
98
+ *
99
+ * @param invocationBase64 - Base64-encoded CAR containing the invocation
100
+ * @param capabilityDef - Capability definition from defineCapability()
101
+ * @param resource - The specific resource URI to validate against
102
+ * @returns Validation result
103
+ *
104
+ * @example
105
+ * ```typescript
106
+ * const result = await validator.validate(
107
+ * invocationBase64,
108
+ * EmployeesRead,
109
+ * 'myapp:company/acme'
110
+ * );
111
+ * ```
112
+ */
113
+ validate(
114
+ invocationBase64: string,
115
+ capabilityDef: CapabilityParser,
116
+ resource: string,
117
+ ): Promise<ValidateResult>;
118
+
119
+ /**
120
+ * The server's public DID (as provided in options)
121
+ */
122
+ readonly serverDid: string;
123
+ }
124
+
125
+ /**
126
+ * Create a UCAN validator (async to support DID resolution at startup)
127
+ *
128
+ * @param options - Validator configuration
129
+ * @returns A validator instance
130
+ *
131
+ * @example
132
+ * ```typescript
133
+ * import { createUCANValidator, defineCapability, Schema, createIxoDIDResolver } from '@ixo/ucan';
134
+ *
135
+ * // Define capability
136
+ * const EmployeesRead = defineCapability({
137
+ * can: 'employees/read',
138
+ * protocol: 'myapp:',
139
+ * nb: { limit: Schema.integer().optional() },
140
+ * derives: (claimed, delegated) => {
141
+ * const claimedLimit = claimed.nb?.limit ?? Infinity;
142
+ * const delegatedLimit = delegated.nb?.limit ?? Infinity;
143
+ * if (claimedLimit > delegatedLimit) {
144
+ * return { error: new Error(`Limit exceeds delegated`) };
145
+ * }
146
+ * return { ok: {} };
147
+ * }
148
+ * });
149
+ *
150
+ * // Create validator with did:ixo server identity
151
+ * const validator = await createUCANValidator({
152
+ * serverDid: 'did:ixo:ixo1abc...', // Any DID method supported
153
+ * rootIssuers: ['did:ixo:ixo1admin...'],
154
+ * didResolver: createIxoDIDResolver({ indexerUrl: '...' }),
155
+ * });
156
+ *
157
+ * // Validate invocations
158
+ * const result = await validator.validate(invocationBase64, EmployeesRead, 'myapp:server');
159
+ * ```
160
+ */
161
+ export async function createUCANValidator(
162
+ options: CreateValidatorOptions,
163
+ ): Promise<UCANValidator> {
164
+ const invocationStore =
165
+ options.invocationStore ?? new InMemoryInvocationStore();
166
+
167
+ // Resolve server DID to get verifier
168
+ // This supports any DID method - did:key is parsed directly, others use the resolver
169
+ let serverVerifier: Verifier;
170
+
171
+ if (options.serverDid.startsWith('did:key:')) {
172
+ // did:key can be parsed directly (contains the public key)
173
+ serverVerifier = ed25519.Verifier.parse(options.serverDid);
174
+ } else {
175
+ // Non-did:key requires resolution
176
+ if (!options.didResolver) {
177
+ throw new Error(
178
+ `Cannot use ${options.serverDid} as server DID without a didResolver. ` +
179
+ `Provide a didResolver to resolve non-did:key DIDs, or use a did:key directly.`,
180
+ );
181
+ }
182
+
183
+ const resolved = await options.didResolver(
184
+ options.serverDid as `did:${string}:${string}`,
185
+ );
186
+
187
+ if ('error' in resolved) {
188
+ throw new Error(
189
+ `Failed to resolve server DID ${options.serverDid}: ${resolved.error.message}`,
190
+ );
191
+ }
192
+
193
+ if (!resolved.ok || resolved.ok.length === 0) {
194
+ throw new Error(
195
+ `No keys found for server DID ${options.serverDid}. ` +
196
+ `The DID document must have at least one verification method.`,
197
+ );
198
+ }
199
+
200
+ // Use the first key (primary key)
201
+ const keyDid = resolved.ok[0];
202
+ if (!keyDid) {
203
+ throw new Error(`No valid key found for server DID ${options.serverDid}`);
204
+ }
205
+
206
+ serverVerifier = ed25519.Verifier.parse(keyDid);
207
+ }
208
+
209
+ // Create DID resolver for use during validation (for issuers in delegation chain)
210
+ const resolveDIDKey = async (did: `did:${string}:${string}`) => {
211
+ // Defensive: ensure did is a string
212
+ if (typeof did !== 'string') {
213
+ console.error('[resolveDIDKey] ERROR: did is not a string!', did);
214
+ return {
215
+ error: {
216
+ name: 'DIDKeyResolutionError' as const,
217
+ did: String(did),
218
+ message: `Expected DID string, got ${typeof did}`,
219
+ },
220
+ };
221
+ }
222
+
223
+ // did:key resolves to itself - return as array of DID strings (ucanto iterates over result.ok)
224
+ if (did.startsWith('did:key:')) {
225
+ return { ok: [did] };
226
+ }
227
+
228
+ // Try custom resolver for other DID methods (e.g., did:ixo)
229
+ if (options.didResolver) {
230
+ const result = await options.didResolver(
231
+ did as `did:${string}:${string}`,
232
+ );
233
+ if ('ok' in result && result.ok.length > 0) {
234
+ // Return the array of did:key strings (ucanto will parse them)
235
+ return { ok: result.ok };
236
+ }
237
+ if ('error' in result) {
238
+ return {
239
+ error: {
240
+ name: 'DIDKeyResolutionError' as const,
241
+ did,
242
+ message: result.error.message,
243
+ },
244
+ };
245
+ }
246
+ }
247
+
248
+ return {
249
+ error: {
250
+ name: 'DIDKeyResolutionError' as const,
251
+ did,
252
+ message: `Cannot resolve DID: ${did}`,
253
+ },
254
+ };
255
+ };
256
+
257
+ return {
258
+ serverDid: options.serverDid,
259
+
260
+ async validate(
261
+ invocationBase64,
262
+ capabilityDef,
263
+ resource,
264
+ ): Promise<ValidateResult> {
265
+ try {
266
+ // 1. Decode the invocation from base64 CAR
267
+ const carBytes = new Uint8Array(Buffer.from(invocationBase64, 'base64'));
268
+
269
+ // 2. Extract the invocation from CAR
270
+ const extracted = await Delegation.extract(carBytes);
271
+ if (extracted.error) {
272
+ return {
273
+ ok: false,
274
+ error: {
275
+ code: 'INVALID_FORMAT',
276
+ message: `Failed to decode: ${extracted.error?.message ?? 'unknown'}`,
277
+ },
278
+ };
279
+ }
280
+
281
+ const invocation = 'ok' in extracted ? extracted.ok : extracted;
282
+
283
+ // 3. Basic validation - check we have required fields
284
+ if (!invocation?.issuer?.did || !invocation?.audience?.did) {
285
+ return {
286
+ ok: false,
287
+ error: {
288
+ code: 'INVALID_FORMAT',
289
+ message: 'Invocation missing issuer or audience',
290
+ },
291
+ };
292
+ }
293
+
294
+ // 4. Check audience matches this server's public DID
295
+ const audienceDid = invocation.audience.did();
296
+ if (audienceDid !== options.serverDid) {
297
+ return {
298
+ ok: false,
299
+ error: {
300
+ code: 'UNAUTHORIZED',
301
+ message: `Invocation addressed to ${audienceDid}, not ${options.serverDid}`,
302
+ },
303
+ };
304
+ }
305
+
306
+ // 5. Check replay protection
307
+ const invocationCid = invocation.cid?.toString();
308
+ if (invocationCid && (await invocationStore.has(invocationCid))) {
309
+ return {
310
+ ok: false,
311
+ error: {
312
+ code: 'REPLAY',
313
+ message: 'Invocation has already been used',
314
+ },
315
+ };
316
+ }
317
+
318
+ // 6. Use ucanto's claim() to validate
319
+ // The serverVerifier was resolved at startup (supports any DID method)
320
+ const claimResult = claim(capabilityDef, [invocation], {
321
+ authority: serverVerifier,
322
+ principal: ed25519.Verifier,
323
+ resolveDIDKey: resolveDIDKey as any,
324
+ canIssue: (cap: any, issuer: string) => {
325
+ // Root issuers can issue any capability
326
+ if (options.rootIssuers.includes(issuer)) return true;
327
+ // Allow self-issued capabilities where resource contains issuer DID
328
+ if (typeof cap.with === 'string' && cap.with.includes(issuer))
329
+ return true;
330
+ return false;
331
+ },
332
+ validateAuthorization: () => ({ ok: {} }),
333
+ });
334
+
335
+ const accessResult = await claimResult;
336
+
337
+ if (accessResult.error) {
338
+ // Check if it's a caveat/derives error
339
+ const errorMsg = accessResult.error.message ?? 'Authorization failed';
340
+ const isCaveatError =
341
+ errorMsg.includes('limit') ||
342
+ errorMsg.includes('caveat') ||
343
+ errorMsg.includes('exceeds') ||
344
+ errorMsg.includes('violates');
345
+
346
+ return {
347
+ ok: false,
348
+ error: {
349
+ code: isCaveatError ? 'CAVEAT_VIOLATION' : 'UNAUTHORIZED',
350
+ message: errorMsg,
351
+ },
352
+ };
353
+ }
354
+
355
+ // 7. Verify the resource matches
356
+ const validatedCap = invocation.capabilities?.[0];
357
+ if (validatedCap && validatedCap.with !== resource) {
358
+ // Check if it's a wildcard match
359
+ const capWith = validatedCap.with as string;
360
+ const isWildcardMatch =
361
+ (capWith.endsWith('/*') &&
362
+ resource.startsWith(capWith.slice(0, -1))) ||
363
+ (capWith.endsWith(':*') &&
364
+ resource.startsWith(capWith.slice(0, -1)));
365
+
366
+ if (!isWildcardMatch) {
367
+ return {
368
+ ok: false,
369
+ error: {
370
+ code: 'UNAUTHORIZED',
371
+ message: `Resource ${validatedCap.with} does not match ${resource}`,
372
+ },
373
+ };
374
+ }
375
+ }
376
+
377
+ // 8. Success! Mark invocation as used for replay protection
378
+ if (invocationCid) {
379
+ await invocationStore.add(invocationCid);
380
+ }
381
+
382
+ return {
383
+ ok: true,
384
+ invoker: invocation.issuer.did(),
385
+ capability: validatedCap
386
+ ? {
387
+ can: validatedCap.can,
388
+ with: validatedCap.with as string,
389
+ nb: validatedCap.nb as Record<string, unknown> | undefined,
390
+ }
391
+ : undefined,
392
+ };
393
+ } catch (err) {
394
+ const message = err instanceof Error ? err.message : 'Unknown error';
395
+ return { ok: false, error: { code: 'INVALID_FORMAT', message } };
396
+ }
397
+ },
398
+ };
399
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "extends": "@ixo/typescript-config/nestjs.json",
3
+ "compilerOptions": {
4
+ "baseUrl": "./",
5
+ "outDir": "./dist",
6
+ "module": "NodeNext",
7
+ "moduleResolution": "NodeNext",
8
+ "target": "ES2022",
9
+ "declaration": true,
10
+ "declarationMap": true,
11
+ "sourceMap": true,
12
+ "strict": true,
13
+ "esModuleInterop": true,
14
+ "skipLibCheck": true
15
+ },
16
+ "include": ["src/**/*"],
17
+ "exclude": ["node_modules", "dist"]
18
+ }