@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,457 @@
1
+ /**
2
+ * UCAN Test Script
3
+ *
4
+ * Run with: pnpm test:ucan
5
+ *
6
+ * Change the ACTION variable to test different things:
7
+ * - 'generate-keys' : Generate new keypairs for testing
8
+ * - 'create-delegation' : Create a delegation from Root to User
9
+ * - 'full-flow' : Full flow with caveat validation (Root -> Alice -> Bob)
10
+ * - 'validate' : Test the validator with a simple invocation
11
+ */
12
+
13
+ import { ed25519 } from '@ucanto/principal';
14
+ import * as Client from '@ucanto/client';
15
+ import {
16
+ defineCapability,
17
+ Schema,
18
+ createUCANValidator,
19
+ serializeInvocation,
20
+ } from '../src/index.js';
21
+
22
+ // ============================================================================
23
+ // CONFIGURATION - Change this to test different scenarios
24
+ // ============================================================================
25
+
26
+ const ACTION:
27
+ | 'generate-keys'
28
+ | 'create-delegation'
29
+ | 'full-flow'
30
+ | 'validate' = 'full-flow';
31
+
32
+ // ============================================================================
33
+ // CAPABILITY DEFINITION
34
+ // ============================================================================
35
+
36
+ /**
37
+ * EmployeesRead capability with limit caveat
38
+ * - The limit specifies max number of employees that can be read
39
+ * - Delegations can only attenuate (reduce) the limit, never increase it
40
+ */
41
+ const EmployeesRead = defineCapability({
42
+ can: 'employees/read',
43
+ protocol: 'myapp:',
44
+ nb: { limit: Schema.integer().optional() },
45
+ derives: (claimed, delegated) => {
46
+ const claimedLimit = claimed.nb?.limit ?? Infinity;
47
+ const delegatedLimit = delegated.nb?.limit ?? Infinity;
48
+
49
+ if (claimedLimit > delegatedLimit) {
50
+ return {
51
+ error: new Error(
52
+ `Cannot request limit=${claimedLimit}, delegation only allows limit=${delegatedLimit}`,
53
+ ),
54
+ };
55
+ }
56
+ return { ok: {} };
57
+ },
58
+ });
59
+
60
+ // ============================================================================
61
+ // HELPERS
62
+ // ============================================================================
63
+
64
+ function log(title: string, data?: unknown) {
65
+ console.log('\n' + '─'.repeat(70));
66
+ console.log(`│ ${title}`);
67
+ console.log('─'.repeat(70));
68
+ if (data !== undefined) {
69
+ console.log(typeof data === 'string' ? data : JSON.stringify(data, null, 2));
70
+ }
71
+ }
72
+
73
+ function success(msg: string) {
74
+ console.log(` ✅ ${msg}`);
75
+ }
76
+
77
+ function fail(msg: string) {
78
+ console.log(` ❌ ${msg}`);
79
+ }
80
+
81
+ function info(msg: string) {
82
+ console.log(` ℹ️ ${msg}`);
83
+ }
84
+
85
+ function buildResourceUri(serverDid: string): `myapp:${string}` {
86
+ return `myapp:${serverDid}`;
87
+ }
88
+
89
+ // ============================================================================
90
+ // ACTION: Generate Keys
91
+ // ============================================================================
92
+
93
+ async function generateKeys() {
94
+ log('Generating New Keypair');
95
+
96
+ const signer = await ed25519.Signer.generate();
97
+ const did = signer.did();
98
+ const privateKey = ed25519.Signer.format(signer);
99
+
100
+ console.log(JSON.stringify({
101
+ did,
102
+ privateKey,
103
+ note: 'Save the privateKey securely! The DID is public.',
104
+ }, null, 2));
105
+
106
+ return { signer, did, privateKey };
107
+ }
108
+
109
+ // ============================================================================
110
+ // ACTION: Create Single Delegation
111
+ // ============================================================================
112
+
113
+ async function createSingleDelegation() {
114
+ log('Creating Delegation: Root -> User');
115
+
116
+ const root = await ed25519.Signer.generate();
117
+ const user = await ed25519.Signer.generate();
118
+ const server = await ed25519.Signer.generate();
119
+
120
+ console.log('\nRoot (Admin):');
121
+ console.log(` DID: ${root.did()}`);
122
+ console.log(` Private Key: ${ed25519.Signer.format(root)}`);
123
+
124
+ console.log('\nUser (Delegate):');
125
+ console.log(` DID: ${user.did()}`);
126
+ console.log(` Private Key: ${ed25519.Signer.format(user)}`);
127
+
128
+ console.log('\nServer:');
129
+ console.log(` DID: ${server.did()}`);
130
+
131
+ // Create delegation
132
+ const delegation = await Client.delegate({
133
+ issuer: root,
134
+ audience: user,
135
+ capabilities: [
136
+ {
137
+ can: 'employees/read' as const,
138
+ with: buildResourceUri(server.did()),
139
+ nb: { limit: 100 },
140
+ },
141
+ ],
142
+ expiration: Math.floor(Date.now() / 1000) + 86400, // 24 hours
143
+ });
144
+
145
+ console.log('\nDelegation Created:');
146
+ console.log(` CID: ${delegation.cid.toString()}`);
147
+ console.log(` Issuer: ${delegation.issuer.did()}`);
148
+ console.log(` Audience: ${delegation.audience.did()}`);
149
+ console.log(` Capabilities: ${JSON.stringify(delegation.capabilities)}`);
150
+
151
+ // Serialize
152
+ const archive = await delegation.archive();
153
+ if ('error' in archive && archive.error) {
154
+ throw archive.error;
155
+ }
156
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
157
+ const serialized = Buffer.from((archive as any).ok).toString('base64');
158
+
159
+ console.log('\nSerialized (base64):');
160
+ console.log(` ${serialized.slice(0, 80)}...`);
161
+
162
+ return { root, user, server, delegation, serialized };
163
+ }
164
+
165
+ // ============================================================================
166
+ // ACTION: Full Flow with Caveat Validation
167
+ // ============================================================================
168
+
169
+ async function fullFlow() {
170
+ console.log('\n🔐 UCAN FULL FLOW TEST - With Caveat Validation\n');
171
+
172
+ // ─────────────────────────────────────────────────────────────────────────
173
+ // STEP 1: Setup - Generate all parties
174
+ // ─────────────────────────────────────────────────────────────────────────
175
+ log('STEP 1: Setup - Generate Parties');
176
+
177
+ const server = await ed25519.Signer.generate();
178
+ const root = await ed25519.Signer.generate();
179
+ const alice = await ed25519.Signer.generate();
180
+ const bob = await ed25519.Signer.generate();
181
+
182
+ console.log(` Server DID: ${server.did().slice(0, 40)}...`);
183
+ console.log(` Root DID: ${root.did().slice(0, 40)}...`);
184
+ console.log(` Alice DID: ${alice.did().slice(0, 40)}...`);
185
+ console.log(` Bob DID: ${bob.did().slice(0, 40)}...`);
186
+
187
+ // Create validator (async to support non-did:key server DIDs)
188
+ const validator = await createUCANValidator({
189
+ serverDid: server.did(),
190
+ rootIssuers: [root.did()],
191
+ });
192
+
193
+ info('Validator created with Root as the only root issuer');
194
+
195
+ // ─────────────────────────────────────────────────────────────────────────
196
+ // STEP 2: Root delegates to Alice with limit: 50
197
+ // ─────────────────────────────────────────────────────────────────────────
198
+ log('STEP 2: Root delegates to Alice (limit: 50)');
199
+
200
+ const rootToAlice = await Client.delegate({
201
+ issuer: root,
202
+ audience: alice,
203
+ capabilities: [
204
+ {
205
+ can: 'employees/read' as const,
206
+ with: buildResourceUri(server.did()),
207
+ nb: { limit: 50 },
208
+ },
209
+ ],
210
+ expiration: Math.floor(Date.now() / 1000) + 3600,
211
+ });
212
+
213
+ success(`Delegation created: ${rootToAlice.cid.toString().slice(0, 20)}...`);
214
+ info('Alice can now read up to 50 employees');
215
+
216
+ // ─────────────────────────────────────────────────────────────────────────
217
+ // STEP 3: Alice re-delegates to Bob with limit: 25 (attenuated)
218
+ // ─────────────────────────────────────────────────────────────────────────
219
+ log('STEP 3: Alice re-delegates to Bob (limit: 25 - attenuated)');
220
+
221
+ const aliceToBob = await Client.delegate({
222
+ issuer: alice,
223
+ audience: bob,
224
+ capabilities: [
225
+ {
226
+ can: 'employees/read' as const,
227
+ with: buildResourceUri(server.did()),
228
+ nb: { limit: 25 }, // Alice restricts Bob further
229
+ },
230
+ ],
231
+ expiration: Math.floor(Date.now() / 1000) + 3600,
232
+ proofs: [rootToAlice], // Include proof from Root
233
+ });
234
+
235
+ success(`Delegation created: ${aliceToBob.cid.toString().slice(0, 20)}...`);
236
+ info('Bob can now read up to 25 employees (attenuated from Alice\'s 50)');
237
+
238
+ // ─────────────────────────────────────────────────────────────────────────
239
+ // STEP 4: Alice invokes with limit: 50 (should succeed)
240
+ // ─────────────────────────────────────────────────────────────────────────
241
+ log('STEP 4: Alice invokes with limit: 50');
242
+ info('Alice tries to read 50 employees (her full allowance)');
243
+
244
+ const aliceInvocation = Client.invoke({
245
+ issuer: alice,
246
+ audience: server,
247
+ capability: {
248
+ can: 'employees/read' as const,
249
+ with: buildResourceUri(server.did()),
250
+ nb: { limit: 50 },
251
+ },
252
+ proofs: [rootToAlice],
253
+ });
254
+
255
+ const aliceSerialized = await serializeInvocation(aliceInvocation);
256
+
257
+ const aliceResult = await validator.validate(
258
+ aliceSerialized,
259
+ EmployeesRead,
260
+ buildResourceUri(server.did()),
261
+ );
262
+
263
+ if (aliceResult.ok) {
264
+ success('Alice\'s invocation PASSED');
265
+ console.log(` Requested: ${aliceResult.capability?.nb?.limit} employees`);
266
+ } else {
267
+ fail(`Alice's invocation failed unexpectedly: ${aliceResult.error?.message}`);
268
+ }
269
+
270
+ // ─────────────────────────────────────────────────────────────────────────
271
+ // STEP 5: Bob tries to invoke with limit: 30 (should FAIL - exceeds his 25)
272
+ // ─────────────────────────────────────────────────────────────────────────
273
+ log('STEP 5: Bob tries to invoke with limit: 30 (SHOULD FAIL)');
274
+ info('Bob tries to read 30 employees but only has allowance for 25');
275
+
276
+ const bobBadInvocation = Client.invoke({
277
+ issuer: bob,
278
+ audience: server,
279
+ capability: {
280
+ can: 'employees/read' as const,
281
+ with: buildResourceUri(server.did()),
282
+ nb: { limit: 30 }, // Exceeds his limit of 25!
283
+ },
284
+ proofs: [aliceToBob],
285
+ });
286
+
287
+ const bobBadSerialized = await serializeInvocation(bobBadInvocation);
288
+
289
+ const bobBadResult = await validator.validate(
290
+ bobBadSerialized,
291
+ EmployeesRead,
292
+ buildResourceUri(server.did()),
293
+ );
294
+
295
+ if (!bobBadResult.ok) {
296
+ success('Bob\'s excessive request correctly REJECTED');
297
+ console.log(` Error: ${bobBadResult.error?.message}`);
298
+ console.log(` Code: ${bobBadResult.error?.code}`);
299
+ } else {
300
+ fail('Bob\'s excessive request should have been rejected!');
301
+ }
302
+
303
+ // ─────────────────────────────────────────────────────────────────────────
304
+ // STEP 6: Bob invokes with limit: 20 (should succeed - within his 25)
305
+ // ─────────────────────────────────────────────────────────────────────────
306
+ log('STEP 6: Bob invokes with limit: 20 (should succeed)');
307
+ info('Bob tries to read 20 employees (within his allowance of 25)');
308
+
309
+ const bobGoodInvocation = Client.invoke({
310
+ issuer: bob,
311
+ audience: server,
312
+ capability: {
313
+ can: 'employees/read' as const,
314
+ with: buildResourceUri(server.did()),
315
+ nb: { limit: 20 }, // Within his limit
316
+ },
317
+ proofs: [aliceToBob],
318
+ });
319
+
320
+ const bobGoodSerialized = await serializeInvocation(bobGoodInvocation);
321
+
322
+ const bobGoodResult = await validator.validate(
323
+ bobGoodSerialized,
324
+ EmployeesRead,
325
+ buildResourceUri(server.did()),
326
+ );
327
+
328
+ if (bobGoodResult.ok) {
329
+ success('Bob\'s valid request PASSED');
330
+ console.log(` Requested: ${bobGoodResult.capability?.nb?.limit} employees`);
331
+ console.log(` Invoker: ${bobGoodResult.invoker?.slice(0, 40)}...`);
332
+ } else {
333
+ fail(`Bob's valid request failed unexpectedly: ${bobGoodResult.error?.message}`);
334
+ }
335
+
336
+ // ─────────────────────────────────────────────────────────────────────────
337
+ // SUMMARY
338
+ // ─────────────────────────────────────────────────────────────────────────
339
+ log('SUMMARY');
340
+
341
+ console.log(`
342
+ ┌───────────────────────────────────────────────────────────────────────┐
343
+ │ UCAN DELEGATION CHAIN │
344
+ ├───────────────────────────────────────────────────────────────────────┤
345
+ │ │
346
+ │ ROOT (Admin) │
347
+ │ └─ delegates to Alice: employees/read (limit: 50) │
348
+ │ │
349
+ │ ALICE (Team Lead) │
350
+ │ ├─ invokes: limit=50 ✅ PASSED (within her allowance) │
351
+ │ └─ re-delegates to Bob: employees/read (limit: 25) │
352
+ │ │
353
+ │ BOB (Employee) │
354
+ │ ├─ invokes: limit=30 ❌ REJECTED (exceeds his 25 allowance) │
355
+ │ └─ invokes: limit=20 ✅ PASSED (within his 25 allowance) │
356
+ │ │
357
+ ├───────────────────────────────────────────────────────────────────────┤
358
+ │ KEY INSIGHT: Caveats can only be attenuated (made stricter), │
359
+ │ never amplified. Bob cannot exceed Alice's restriction of 25, │
360
+ │ and Alice cannot exceed Root's restriction of 50. │
361
+ └───────────────────────────────────────────────────────────────────────┘
362
+ `);
363
+ }
364
+
365
+ // ============================================================================
366
+ // ACTION: Test Validator
367
+ // ============================================================================
368
+
369
+ async function testValidate() {
370
+ log('TEST VALIDATOR', 'Create invocation and validate it');
371
+
372
+ // Setup
373
+ const server = await ed25519.Signer.generate();
374
+ const root = await ed25519.Signer.generate();
375
+ const user = await ed25519.Signer.generate();
376
+
377
+ console.log('\nSetup:');
378
+ console.log(` Server: ${server.did().slice(0, 50)}...`);
379
+ console.log(` Root: ${root.did().slice(0, 50)}...`);
380
+ console.log(` User: ${user.did().slice(0, 50)}...`);
381
+
382
+ // Create validator (async to support non-did:key server DIDs)
383
+ const validator = await createUCANValidator({
384
+ serverDid: server.did(),
385
+ rootIssuers: [root.did()],
386
+ });
387
+
388
+ // Root delegates to user
389
+ const delegation = await Client.delegate({
390
+ issuer: root,
391
+ audience: user,
392
+ capabilities: [
393
+ {
394
+ can: 'employees/read' as const,
395
+ with: buildResourceUri(server.did()),
396
+ },
397
+ ],
398
+ });
399
+
400
+ console.log(`\nDelegation: ${delegation.cid.toString().slice(0, 30)}...`);
401
+
402
+ // User creates invocation
403
+ const invocation = Client.invoke({
404
+ issuer: user,
405
+ audience: server,
406
+ capability: {
407
+ can: 'employees/read' as const,
408
+ with: buildResourceUri(server.did()),
409
+ },
410
+ proofs: [delegation],
411
+ });
412
+
413
+ const serialized = await serializeInvocation(invocation);
414
+
415
+ console.log(`Invocation created`);
416
+ console.log(`Serialized length: ${serialized.length} bytes`);
417
+
418
+ // Validate it
419
+ const result = await validator.validate(
420
+ serialized,
421
+ EmployeesRead,
422
+ buildResourceUri(server.did()),
423
+ );
424
+
425
+ console.log('\nValidation Result:');
426
+ console.log(JSON.stringify(result, null, 2));
427
+ }
428
+
429
+ // ============================================================================
430
+ // MAIN
431
+ // ============================================================================
432
+
433
+ async function main() {
434
+ console.log('\n🔐 UCAN Test Script\n');
435
+ console.log(`Action: ${ACTION}`);
436
+
437
+ switch (ACTION) {
438
+ case 'generate-keys':
439
+ await generateKeys();
440
+ break;
441
+ case 'create-delegation':
442
+ await createSingleDelegation();
443
+ break;
444
+ case 'full-flow':
445
+ await fullFlow();
446
+ break;
447
+ case 'validate':
448
+ await testValidate();
449
+ break;
450
+ default:
451
+ console.error('Unknown action:', ACTION);
452
+ }
453
+
454
+ console.log('\n✅ Done!\n');
455
+ }
456
+
457
+ main().catch(console.error);
@@ -0,0 +1,244 @@
1
+ /**
2
+ * @fileoverview Generic capability definition helpers
3
+ *
4
+ * This module provides a simple way to define capabilities for any service.
5
+ * The capability definitions are used for both delegation creation and
6
+ * invocation validation, including custom caveat (nb) validation.
7
+ *
8
+ * Type inference follows ucanto's pattern - types flow automatically from
9
+ * schema definitions to callback parameters.
10
+ */
11
+
12
+ import { capability, URI, Schema } from '@ucanto/validator';
13
+ import type { Capability as UcantoCapability } from '@ucanto/interface';
14
+
15
+ // Re-export Schema for use in caveat definitions
16
+ export { Schema };
17
+
18
+ // =============================================================================
19
+ // Type Utilities (matching ucanto's pattern)
20
+ // =============================================================================
21
+
22
+ /**
23
+ * Extracts the output type O from a Reader/Schema
24
+ * A Reader<O, I> has a read method that returns { ok: O } | { error: ... }
25
+ */
26
+ type Infer<T> = T extends { read(input: unknown): { ok: infer O } | { error: unknown } }
27
+ ? O
28
+ : never;
29
+
30
+ /**
31
+ * Maps a struct shape to its output types
32
+ * { limit: Schema<number | undefined> } -> { limit?: number }
33
+ */
34
+ type InferStruct<U extends Record<string, unknown>> = {
35
+ [K in keyof U]: Infer<U[K]>;
36
+ };
37
+
38
+ // =============================================================================
39
+ // Capability Definition Types
40
+ // =============================================================================
41
+
42
+ /**
43
+ * Options for defining a capability
44
+ *
45
+ * @template NBSchema - The schema shape for caveats (nb field).
46
+ * Types are automatically inferred from the schema.
47
+ *
48
+ * @example
49
+ * ```typescript
50
+ * // Type inference happens automatically!
51
+ * const EmployeesRead = defineCapability({
52
+ * can: 'employees/read',
53
+ * protocol: 'myapp:',
54
+ * nb: { limit: Schema.integer().optional() },
55
+ * derives: (claimed, delegated) => {
56
+ * // claimed.nb?.limit is typed as number | undefined
57
+ * const claimedLimit = claimed.nb?.limit ?? Infinity;
58
+ * const delegatedLimit = delegated.nb?.limit ?? Infinity;
59
+ * if (claimedLimit > delegatedLimit) {
60
+ * return { error: new Error('Limit exceeds delegation') };
61
+ * }
62
+ * return { ok: {} };
63
+ * }
64
+ * });
65
+ * ```
66
+ */
67
+ export interface DefineCapabilityOptions<
68
+ // 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
+ > {
72
+ /**
73
+ * The action this capability authorizes
74
+ * Use "/" to namespace actions (e.g., 'employees/read', 'files/write')
75
+ */
76
+ can: string;
77
+
78
+ /**
79
+ * URI protocol for the resource
80
+ * @default 'urn:'
81
+ * @example 'myapp:', 'ixo:', 'https:'
82
+ */
83
+ protocol?: string;
84
+
85
+ /**
86
+ * Whether to support wildcard matching in resource URIs
87
+ * When true, 'myapp:users/*' will match 'myapp:users/123'
88
+ * @default true
89
+ */
90
+ supportWildcards?: boolean;
91
+
92
+ /**
93
+ * Schema for caveats (nb field)
94
+ * Use Schema from @ucanto/validator to define caveat types.
95
+ * Types are automatically inferred - no need to specify generics!
96
+ *
97
+ * @example
98
+ * ```typescript
99
+ * nb: {
100
+ * limit: Schema.integer().optional(),
101
+ * department: Schema.string().optional(),
102
+ * }
103
+ * ```
104
+ */
105
+ nb?: NBSchema;
106
+
107
+ /**
108
+ * Custom derivation function to validate capability attenuation.
109
+ * Called when checking if a claimed capability can be derived from a delegated one.
110
+ *
111
+ * The types for claimed.nb and delegated.nb are automatically inferred
112
+ * from the nb schema definition above.
113
+ *
114
+ * @example
115
+ * ```typescript
116
+ * derives: (claimed, delegated) => {
117
+ * // Types are inferred! claimed.nb?.limit is number | undefined
118
+ * const claimedLimit = claimed.nb?.limit ?? Infinity;
119
+ * const delegatedLimit = delegated.nb?.limit ?? Infinity;
120
+ * if (claimedLimit > delegatedLimit) {
121
+ * return { error: new Error(`Limit exceeds delegation`) };
122
+ * }
123
+ * return { ok: {} };
124
+ * }
125
+ * ```
126
+ */
127
+ derives?: (
128
+ claimed: { with: string; nb?: InferStruct<NBSchema> },
129
+ delegated: { with: string; nb?: InferStruct<NBSchema> },
130
+ ) => { ok: Record<string, never> } | { error: Error };
131
+ }
132
+
133
+ /**
134
+ * Define a capability for your service with optional caveat validation.
135
+ *
136
+ * Types flow automatically from schema definitions - no need to specify
137
+ * generic type parameters manually!
138
+ *
139
+ * @param options - Capability definition options
140
+ * @returns A ucanto capability definition
141
+ *
142
+ * @example
143
+ * ```typescript
144
+ * // Simple capability without caveats
145
+ * const EmployeesRead = defineCapability({
146
+ * can: 'employees/read',
147
+ * protocol: 'myapp:'
148
+ * });
149
+ *
150
+ * // Capability with caveat validation - types are inferred!
151
+ * const EmployeesReadLimited = defineCapability({
152
+ * can: 'employees/read',
153
+ * protocol: 'myapp:',
154
+ * nb: {
155
+ * limit: Schema.integer().optional(),
156
+ * },
157
+ * derives: (claimed, delegated) => {
158
+ * // claimed.nb?.limit is automatically typed as number | undefined
159
+ * const claimedLimit = claimed.nb?.limit ?? Infinity;
160
+ * const delegatedLimit = delegated.nb?.limit ?? Infinity;
161
+ * if (claimedLimit > delegatedLimit) {
162
+ * return { error: new Error(`Cannot request ${claimedLimit}, limit is ${delegatedLimit}`) };
163
+ * }
164
+ * return { ok: {} };
165
+ * }
166
+ * });
167
+ * ```
168
+ */
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
+ ) {
173
+ const protocol = (options.protocol ?? 'urn:') as `${string}:`;
174
+ const supportWildcards = options.supportWildcards ?? true;
175
+
176
+ // Build the nb schema - Schema.struct handles the schema object
177
+ const nbSchema = options.nb
178
+ ? Schema.struct(options.nb as Parameters<typeof Schema.struct>[0])
179
+ : Schema.struct({});
180
+
181
+ return capability({
182
+ can: options.can as `${string}/${string}`,
183
+ with: URI.match({ protocol }),
184
+ nb: nbSchema,
185
+ derives: (claimed, delegated) => {
186
+ const claimedUri = claimed.with;
187
+ const delegatedUri = delegated.with;
188
+
189
+ // First check resource URI matching
190
+ if (claimedUri !== delegatedUri) {
191
+ // Handle wildcard patterns if enabled
192
+ if (supportWildcards) {
193
+ // Single wildcard: myapp:users/* matches myapp:users/123
194
+ if (delegatedUri.endsWith('/*')) {
195
+ const baseUri = delegatedUri.slice(0, -1);
196
+ if (!claimedUri.startsWith(baseUri)) {
197
+ return {
198
+ error: new Error(
199
+ `Resource '${claimedUri}' not covered by '${delegatedUri}'`,
200
+ ),
201
+ };
202
+ }
203
+ }
204
+ // Double wildcard at end: myapp:* matches myapp:anything/here
205
+ else if (delegatedUri.endsWith(':*')) {
206
+ const baseUri = delegatedUri.slice(0, -1);
207
+ if (!claimedUri.startsWith(baseUri)) {
208
+ return {
209
+ error: new Error(
210
+ `Resource '${claimedUri}' not covered by '${delegatedUri}'`,
211
+ ),
212
+ };
213
+ }
214
+ } else {
215
+ return {
216
+ error: new Error(
217
+ `Resource '${claimedUri}' does not match '${delegatedUri}'`,
218
+ ),
219
+ };
220
+ }
221
+ } else {
222
+ return {
223
+ error: new Error(
224
+ `Resource '${claimedUri}' does not match '${delegatedUri}'`,
225
+ ),
226
+ };
227
+ }
228
+ }
229
+
230
+ // Then run custom derives if provided (for caveat validation)
231
+ if (options.derives) {
232
+ return options.derives(
233
+ { with: claimedUri, nb: claimed.nb as InferStruct<NBSchema> },
234
+ { with: delegatedUri, nb: delegated.nb as InferStruct<NBSchema> },
235
+ );
236
+ }
237
+
238
+ return { ok: {} };
239
+ },
240
+ });
241
+ }
242
+
243
+ // Re-export useful types
244
+ export type { UcantoCapability, Infer, InferStruct };