@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.
- package/.eslintrc.js +9 -0
- package/.prettierignore +3 -0
- package/.prettierrc.js +4 -0
- package/.turbo/turbo-build.log +4 -0
- package/CHANGELOG.md +0 -0
- package/README.md +189 -0
- package/dist/capabilities/capability.d.ts +33 -0
- package/dist/capabilities/capability.d.ts.map +1 -0
- package/dist/capabilities/capability.js +53 -0
- package/dist/capabilities/capability.js.map +1 -0
- package/dist/client/create-client.d.ts +33 -0
- package/dist/client/create-client.d.ts.map +1 -0
- package/dist/client/create-client.js +104 -0
- package/dist/client/create-client.js.map +1 -0
- package/dist/did/ixo-resolver.d.ts +8 -0
- package/dist/did/ixo-resolver.d.ts.map +1 -0
- package/dist/did/ixo-resolver.js +162 -0
- package/dist/did/ixo-resolver.js.map +1 -0
- package/dist/did/utils.d.ts +4 -0
- package/dist/did/utils.d.ts.map +1 -0
- package/dist/did/utils.js +85 -0
- package/dist/did/utils.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/store/memory.d.ts +25 -0
- package/dist/store/memory.d.ts.map +1 -0
- package/dist/store/memory.js +71 -0
- package/dist/store/memory.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/types.d.ts +29 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/validator/validator.d.ts +29 -0
- package/dist/validator/validator.d.ts.map +1 -0
- package/dist/validator/validator.js +179 -0
- package/dist/validator/validator.js.map +1 -0
- package/jest.config.js +3 -0
- package/package.json +78 -0
- package/scripts/test-ucan.ts +457 -0
- package/src/capabilities/capability.ts +244 -0
- package/src/client/create-client.ts +329 -0
- package/src/did/ixo-resolver.ts +325 -0
- package/src/did/utils.ts +141 -0
- package/src/index.ts +135 -0
- package/src/store/memory.ts +194 -0
- package/src/types.ts +108 -0
- package/src/validator/validator.ts +399 -0
- 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 };
|