@ontrails/testing 1.0.0-beta.11 → 1.0.0-beta.13

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 (45) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +72 -22
  3. package/README.md +9 -9
  4. package/dist/context.d.ts +41 -11
  5. package/dist/context.d.ts.map +1 -1
  6. package/dist/context.js +36 -21
  7. package/dist/context.js.map +1 -1
  8. package/dist/contracts.js +8 -8
  9. package/dist/contracts.js.map +1 -1
  10. package/dist/crosses.d.ts +38 -0
  11. package/dist/crosses.d.ts.map +1 -0
  12. package/dist/crosses.js +213 -0
  13. package/dist/crosses.js.map +1 -0
  14. package/dist/examples.d.ts +4 -4
  15. package/dist/examples.d.ts.map +1 -1
  16. package/dist/examples.js +52 -32
  17. package/dist/examples.js.map +1 -1
  18. package/dist/harness-mcp.d.ts +1 -1
  19. package/dist/harness-mcp.js +2 -2
  20. package/dist/harness-mcp.js.map +1 -1
  21. package/dist/index.d.ts +6 -6
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +3 -3
  24. package/dist/index.js.map +1 -1
  25. package/dist/trail.js +1 -1
  26. package/dist/trail.js.map +1 -1
  27. package/dist/types.d.ts +8 -8
  28. package/dist/types.d.ts.map +1 -1
  29. package/package.json +5 -5
  30. package/src/__tests__/all.test.ts +20 -18
  31. package/src/__tests__/context.test.ts +27 -27
  32. package/src/__tests__/contracts.test.ts +29 -29
  33. package/src/__tests__/{follows.test.ts → crosses.test.ts} +193 -195
  34. package/src/__tests__/detours.test.ts +3 -3
  35. package/src/__tests__/examples.test.ts +221 -126
  36. package/src/__tests__/trail.test.ts +4 -4
  37. package/src/context.ts +80 -32
  38. package/src/contracts.ts +12 -12
  39. package/src/{follows.ts → crosses.ts} +97 -92
  40. package/src/examples.ts +76 -46
  41. package/src/harness-mcp.ts +2 -2
  42. package/src/index.ts +15 -7
  43. package/src/trail.ts +1 -1
  44. package/src/types.ts +9 -9
  45. package/tsconfig.tsbuildinfo +1 -1
@@ -10,21 +10,21 @@ import { testDetours } from '../detours.js';
10
10
  // ---------------------------------------------------------------------------
11
11
 
12
12
  const showTrail = trail('entity.show', {
13
+ blaze: (input: { id: string }) => Result.ok({ id: input.id }),
13
14
  detours: {
14
15
  related: ['entity.list'],
15
16
  },
16
17
  input: z.object({ id: z.string() }),
17
- run: (input: { id: string }) => Result.ok({ id: input.id }),
18
18
  });
19
19
 
20
20
  const listTrail = trail('entity.list', {
21
+ blaze: () => Result.ok([]),
21
22
  input: z.object({}),
22
- run: () => Result.ok([]),
23
23
  });
24
24
 
25
25
  const noDetoursTrail = trail('entity.plain', {
26
+ blaze: () => Result.ok('ok'),
26
27
  input: z.object({}),
27
- run: () => Result.ok('ok'),
28
28
  });
29
29
 
30
30
  // ---------------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  import { describe, test } from 'bun:test';
2
2
 
3
- import { NotFoundError, Result, service, trail, topo } from '@ontrails/core';
3
+ import { NotFoundError, Result, provision, trail, topo } from '@ontrails/core';
4
4
  import { z } from 'zod';
5
5
 
6
6
  import { testExamples } from '../examples.js';
@@ -10,6 +10,8 @@ import { testExamples } from '../examples.js';
10
10
  // ---------------------------------------------------------------------------
11
11
 
12
12
  const greetTrail = trail('greet', {
13
+ blaze: (input: { name: string }) =>
14
+ Result.ok({ greeting: `Hello, ${input.name}` }),
13
15
  description: 'Greet someone',
14
16
  examples: [
15
17
  {
@@ -20,11 +22,11 @@ const greetTrail = trail('greet', {
20
22
  ],
21
23
  input: z.object({ name: z.string() }),
22
24
  output: z.object({ greeting: z.string() }),
23
- run: (input: { name: string }) =>
24
- Result.ok({ greeting: `Hello, ${input.name}` }),
25
25
  });
26
26
 
27
27
  const searchTrail = trail('search', {
28
+ blaze: (input: { query: string }) =>
29
+ Result.ok({ results: [`result for ${input.query}`] }),
28
30
  description: 'Search for things',
29
31
  examples: [
30
32
  {
@@ -34,11 +36,15 @@ const searchTrail = trail('search', {
34
36
  ],
35
37
  input: z.object({ query: z.string() }),
36
38
  output: z.object({ results: z.array(z.string()) }),
37
- run: (input: { query: string }) =>
38
- Result.ok({ results: [`result for ${input.query}`] }),
39
39
  });
40
40
 
41
41
  const entityTrail = trail('entity.show', {
42
+ blaze: (input: { name: string }) => {
43
+ if (input.name === 'missing') {
44
+ return Result.err(new NotFoundError('Entity not found'));
45
+ }
46
+ return Result.ok({ id: 1, name: input.name });
47
+ },
42
48
  description: 'Show entity',
43
49
  examples: [
44
50
  {
@@ -54,55 +60,52 @@ const entityTrail = trail('entity.show', {
54
60
  ],
55
61
  input: z.object({ name: z.string() }),
56
62
  output: z.object({ id: z.number(), name: z.string() }),
57
- run: (input: { name: string }) => {
58
- if (input.name === 'missing') {
59
- return Result.err(new NotFoundError('Entity not found'));
60
- }
61
- return Result.ok({ id: 1, name: input.name });
62
- },
63
63
  });
64
64
 
65
65
  const noExamplesTrail = trail('noexamples', {
66
+ blaze: (input: { x: number }) => Result.ok(input.x * 2),
66
67
  input: z.object({ x: z.number() }),
67
- run: (input: { x: number }) => Result.ok(input.x * 2),
68
68
  });
69
69
 
70
- const mockDbService = service('db.mock.examples', {
70
+ const mockDbProvision = provision('db.mock.examples', {
71
71
  create: () => Result.ok({ source: 'factory' }),
72
72
  mock: () => ({ source: 'mock' }),
73
73
  });
74
74
 
75
- const mockServiceTrail = trail('service.mocked', {
76
- description: 'Trail that reads from a mocked service',
75
+ const mockProvisionTrail = trail('provision.mocked', {
76
+ blaze: (_input, ctx) =>
77
+ Result.ok({ source: mockDbProvision.from(ctx).source }),
78
+ description: 'Trail that reads from a mocked provision',
77
79
  examples: [
78
80
  {
79
81
  expected: { source: 'mock' },
80
82
  input: {},
81
- name: 'Uses auto-resolved service mock',
83
+ name: 'Uses auto-resolved provision mock',
82
84
  },
83
85
  ],
84
86
  input: z.object({}),
85
87
  output: z.object({ source: z.string() }),
86
- run: (_input, ctx) => Result.ok({ source: mockDbService.from(ctx).source }),
87
- services: [mockDbService],
88
+ provisions: [mockDbProvision],
88
89
  });
89
90
 
90
- const explicitOverrideTrail = trail('service.override', {
91
- description: 'Trail whose service mock can be overridden explicitly',
91
+ const explicitOverrideTrail = trail('provision.override', {
92
+ blaze: (_input, ctx) =>
93
+ Result.ok({ source: mockDbProvision.from(ctx).source }),
94
+ description: 'Trail whose provision mock can be overridden explicitly',
92
95
  examples: [
93
96
  {
94
97
  expected: { source: 'override' },
95
98
  input: {},
96
- name: 'Explicit service override wins over mock factory',
99
+ name: 'Explicit provision override wins over mock factory',
97
100
  },
98
101
  ],
99
102
  input: z.object({}),
100
103
  output: z.object({ source: z.string() }),
101
- run: (_input, ctx) => Result.ok({ source: mockDbService.from(ctx).source }),
102
- services: [mockDbService],
104
+ provisions: [mockDbProvision],
103
105
  });
104
106
 
105
107
  const transformedInputTrail = trail('example.transformed', {
108
+ blaze: (input: { value: number }) => Result.ok({ value: input.value }),
106
109
  description: 'Trail whose input schema transforms once',
107
110
  examples: [
108
111
  {
@@ -115,75 +118,64 @@ const transformedInputTrail = trail('example.transformed', {
115
118
  .object({ value: z.string() })
116
119
  .transform(({ value }) => ({ value: Number(value) + 1 })),
117
120
  output: z.object({ value: z.number() }),
118
- run: (input: { value: number }) => Result.ok({ value: input.value }),
119
121
  });
120
122
 
121
- const ctxOverrideTrail = trail('service.ctx-override', {
122
- description: 'Trail whose service mock can be overridden by ctx.extensions',
123
+ const ctxOverrideTrail = trail('provision.ctx-override', {
124
+ blaze: (_input, ctx) =>
125
+ Result.ok({ source: mockDbProvision.from(ctx).source }),
126
+ description: 'Trail whose provision mock can be overridden by ctx.extensions',
123
127
  examples: [
124
128
  {
125
129
  expected: { source: 'ctx' },
126
130
  input: {},
127
- name: 'Context extensions beat auto-resolved mock services',
131
+ name: 'Context extensions beat auto-resolved mock provisions',
128
132
  },
129
133
  ],
130
134
  input: z.object({}),
131
135
  output: z.object({ source: z.string() }),
132
- run: (_input, ctx) => Result.ok({ source: mockDbService.from(ctx).source }),
133
- services: [mockDbService],
136
+ provisions: [mockDbProvision],
134
137
  });
135
138
 
136
- const undeclaredDbService = service('db.undeclared.examples', {
139
+ const undeclaredDbProvision = provision('db.undeclared.examples', {
137
140
  create: () => Result.ok({ source: 'factory' }),
138
141
  mock: () => ({ source: 'mock' }),
139
142
  });
140
143
 
141
- const undeclaredServiceTrail = trail('service.undeclared.examples', {
142
- description: 'Trail that uses a service without declaring it',
144
+ const undeclaredProvisionTrail = trail('provision.undeclared.examples', {
145
+ blaze: (_input, ctx) =>
146
+ Result.ok({ source: undeclaredDbProvision.from(ctx).source }),
147
+ description: 'Trail that uses a provision without declaring it',
143
148
  examples: [
144
149
  {
145
150
  error: 'InternalError',
146
151
  input: {},
147
- name: 'Undeclared services stay unavailable during example execution',
152
+ name: 'Undeclared provisions stay unavailable during example execution',
148
153
  },
149
154
  ],
150
155
  input: z.object({}),
151
156
  output: z.object({ source: z.string() }),
152
- run: (_input, ctx) =>
153
- Result.ok({ source: undeclaredDbService.from(ctx).source }),
154
157
  });
155
- const followedDbService = service('db.mock.examples.follow', {
158
+ const crossDbProvision = provision('db.mock.examples.crosses', {
156
159
  create: () => Result.ok({ source: 'factory' }),
157
160
  mock: () => ({ source: 'mock' }),
158
161
  });
159
162
 
160
- const followedLeafTrail = trail('service.follow.leaf', {
161
- description: 'Leaf trail that resolves a service inside a follow chain',
163
+ const crossLeafTrail = trail('provision.crosses.leaf', {
164
+ blaze: (_input, ctx) =>
165
+ Result.ok({ childSource: crossDbProvision.from(ctx).source }),
166
+ description: 'Leaf trail that resolves a provision inside a cross chain',
162
167
  input: z.object({}),
163
168
  output: z.object({ childSource: z.string() }),
164
- run: (_input, ctx) =>
165
- Result.ok({ childSource: followedDbService.from(ctx).source }),
166
- services: [followedDbService],
169
+ provisions: [crossDbProvision],
167
170
  });
168
171
 
169
- const followedRootTrail = trail('service.follow.root', {
170
- description: 'Root trail that follows a child trail using services',
171
- examples: [
172
- {
173
- expected: { childSource: 'mock', rootSource: 'mock' },
174
- input: {},
175
- name: 'Propagates service mocks through follow execution',
176
- },
177
- ],
178
- follow: ['service.follow.leaf'],
179
- input: z.object({}),
180
- output: z.object({ childSource: z.string(), rootSource: z.string() }),
181
- run: async (_input, ctx) => {
182
- if (!ctx.follow) {
183
- return Result.err(new Error('follow not available'));
172
+ const crossRootTrail = trail('provision.crosses.root', {
173
+ blaze: async (_input, ctx) => {
174
+ if (!ctx.cross) {
175
+ return Result.err(new Error('cross not available'));
184
176
  }
185
- const childResult = await ctx.follow<{ childSource: string }>(
186
- 'service.follow.leaf',
177
+ const childResult = await ctx.cross<{ childSource: string }>(
178
+ 'provision.crosses.leaf',
187
179
  {}
188
180
  );
189
181
  if (childResult.isErr()) {
@@ -191,52 +183,52 @@ const followedRootTrail = trail('service.follow.root', {
191
183
  }
192
184
  return Result.ok({
193
185
  childSource: childResult.value.childSource,
194
- rootSource: followedDbService.from(ctx).source,
186
+ rootSource: crossDbProvision.from(ctx).source,
195
187
  });
196
188
  },
197
- services: [followedDbService],
189
+ crosses: ['provision.crosses.leaf'],
190
+ description: 'Root trail that crosses a child trail using provisions',
191
+ examples: [
192
+ {
193
+ expected: { childSource: 'mock', rootSource: 'mock' },
194
+ input: {},
195
+ name: 'Propagates provision mocks through cross execution',
196
+ },
197
+ ],
198
+ input: z.object({}),
199
+ output: z.object({ childSource: z.string(), rootSource: z.string() }),
200
+ provisions: [crossDbProvision],
198
201
  });
199
202
 
200
203
  // ---------------------------------------------------------------------------
201
- // Composition trails (for follow coverage)
204
+ // Composition trails (for cross coverage)
202
205
  // ---------------------------------------------------------------------------
203
206
 
204
207
  const addTrail = trail('entity.add', {
208
+ blaze: (input: { name: string }) => Result.ok({ id: '1', name: input.name }),
205
209
  description: 'Add an entity',
206
210
  input: z.object({ name: z.string() }),
207
211
  output: z.object({ id: z.string(), name: z.string() }),
208
- run: (input: { name: string }) => Result.ok({ id: '1', name: input.name }),
209
212
  });
210
213
 
211
214
  const relateTrail = trail('entity.relate', {
215
+ blaze: (input: { from: string; to: string }) =>
216
+ Result.ok({ from: input.from, to: input.to }),
212
217
  description: 'Relate entities',
213
218
  input: z.object({ from: z.string(), to: z.string() }),
214
219
  output: z.object({ from: z.string(), to: z.string() }),
215
- run: (input: { from: string; to: string }) =>
216
- Result.ok({ from: input.from, to: input.to }),
217
220
  });
218
221
 
219
222
  const onboardTrail = trail('entity.onboard', {
220
- description: 'Onboard a new entity',
221
- examples: [
222
- {
223
- expected: { id: '1', name: 'Alpha' },
224
- input: { name: 'Alpha' },
225
- name: 'Onboard Alpha',
226
- },
227
- ],
228
- follow: ['entity.add', 'entity.relate'],
229
- input: z.object({ name: z.string() }),
230
- output: z.object({ id: z.string(), name: z.string() }),
231
- run: async (input: { name: string }, ctx) => {
232
- if (!ctx.follow) {
233
- return Result.err(new Error('follow not available'));
223
+ blaze: async (input: { name: string }, ctx) => {
224
+ if (!ctx.cross) {
225
+ return Result.err(new Error('cross not available'));
234
226
  }
235
- const addResult = await ctx.follow('entity.add', input);
227
+ const addResult = await ctx.cross('entity.add', input);
236
228
  if (addResult.isErr()) {
237
229
  return addResult;
238
230
  }
239
- const relateResult = await ctx.follow('entity.relate', {
231
+ const relateResult = await ctx.cross('entity.relate', {
240
232
  from: 'root',
241
233
  to: (addResult.value as { id: string }).id,
242
234
  });
@@ -245,6 +237,17 @@ const onboardTrail = trail('entity.onboard', {
245
237
  }
246
238
  return Result.ok({ id: '1', name: input.name });
247
239
  },
240
+ crosses: ['entity.add', 'entity.relate'],
241
+ description: 'Onboard a new entity',
242
+ examples: [
243
+ {
244
+ expected: { id: '1', name: 'Alpha' },
245
+ input: { name: 'Alpha' },
246
+ name: 'Onboard Alpha',
247
+ },
248
+ ],
249
+ input: z.object({ name: z.string() }),
250
+ output: z.object({ id: z.string(), name: z.string() }),
248
251
  });
249
252
 
250
253
  // ---------------------------------------------------------------------------
@@ -293,25 +296,25 @@ describe('testExamples skips trails with no examples', () => {
293
296
  });
294
297
  });
295
298
 
296
- describe('testExamples service mocks', () => {
299
+ describe('testExamples provision mocks', () => {
297
300
  // eslint-disable-next-line jest/require-hook
298
301
  testExamples(
299
- topo('service-mock-app', {
300
- mockDbService,
301
- mockServiceTrail,
302
+ topo('provision-mock-app', {
303
+ mockDbProvision,
304
+ mockProvisionTrail,
302
305
  } as Record<string, unknown>)
303
306
  );
304
307
  });
305
308
 
306
- describe('testExamples explicit service overrides', () => {
309
+ describe('testExamples explicit provision overrides', () => {
307
310
  // eslint-disable-next-line jest/require-hook
308
311
  testExamples(
309
- topo('service-override-app', {
312
+ topo('provision-override-app', {
310
313
  explicitOverrideTrail,
311
- mockDbService,
314
+ mockDbProvision,
312
315
  } as Record<string, unknown>),
313
316
  {
314
- services: { 'db.mock.examples': { source: 'override' } },
317
+ provisions: { 'db.mock.examples': { source: 'override' } },
315
318
  }
316
319
  );
317
320
  });
@@ -330,7 +333,7 @@ describe('testExamples context extension overrides', () => {
330
333
  testExamples(
331
334
  topo('ctx-override-app', {
332
335
  ctxOverrideTrail,
333
- mockDbService,
336
+ mockDbProvision,
334
337
  } as Record<string, unknown>),
335
338
  {
336
339
  ctx: {
@@ -340,17 +343,17 @@ describe('testExamples context extension overrides', () => {
340
343
  );
341
344
  });
342
345
 
343
- describe('testExamples service declarations', () => {
346
+ describe('testExamples provision declarations', () => {
344
347
  // eslint-disable-next-line jest/require-hook
345
348
  testExamples(
346
- topo('undeclared-service-app', {
347
- undeclaredDbService,
348
- undeclaredServiceTrail,
349
+ topo('undeclared-provision-app', {
350
+ undeclaredDbProvision,
351
+ undeclaredProvisionTrail,
349
352
  } as Record<string, unknown>)
350
353
  );
351
354
  });
352
355
 
353
- describe('testExamples follow coverage for composition trails', () => {
356
+ describe('testExamples crossing coverage for trails with crossings', () => {
354
357
  // eslint-disable-next-line jest/require-hook
355
358
  testExamples(
356
359
  topo('composition-app', {
@@ -361,73 +364,70 @@ describe('testExamples follow coverage for composition trails', () => {
361
364
  );
362
365
  });
363
366
 
364
- describe('testExamples service mocks through follow', () => {
367
+ describe('testExamples provision mocks through cross', () => {
365
368
  // eslint-disable-next-line jest/require-hook
366
369
  testExamples(
367
- topo('service-follow-app', {
368
- followedDbService,
369
- followedLeafTrail,
370
- followedRootTrail,
370
+ topo('provision-cross-app', {
371
+ crossDbProvision,
372
+ crossLeafTrail,
373
+ crossRootTrail,
371
374
  } as Record<string, unknown>)
372
375
  );
373
376
  });
374
377
 
375
378
  // ---------------------------------------------------------------------------
376
- // Nested follow chain: A → B → C
379
+ // Nested cross chain: A → B → C
377
380
  // ---------------------------------------------------------------------------
378
381
 
379
382
  const leafTrail = trail('step.leaf', {
383
+ blaze: (input: { value: string }) => Result.ok({ leaf: input.value }),
380
384
  description: 'Leaf trail in a nested chain',
381
385
  input: z.object({ value: z.string() }),
382
386
  output: z.object({ leaf: z.string() }),
383
- run: (input: { value: string }) => Result.ok({ leaf: input.value }),
384
387
  });
385
388
 
386
389
  const middleTrail = trail('step.middle', {
387
- description: 'Middle trail that follows the leaf',
388
- follow: ['step.leaf'],
389
- input: z.object({ value: z.string() }),
390
- output: z.object({ middle: z.string() }),
391
- run: async (input: { value: string }, ctx) => {
392
- if (!ctx.follow) {
393
- return Result.err(new Error('follow not available'));
390
+ blaze: async (input: { value: string }, ctx) => {
391
+ if (!ctx.cross) {
392
+ return Result.err(new Error('cross not available'));
394
393
  }
395
- const leafResult = await ctx.follow<{ leaf: string }>('step.leaf', input);
394
+ const leafResult = await ctx.cross<{ leaf: string }>('step.leaf', input);
396
395
  if (leafResult.isErr()) {
397
396
  return leafResult;
398
397
  }
399
398
  return Result.ok({ middle: leafResult.value.leaf });
400
399
  },
400
+ crosses: ['step.leaf'],
401
+ description: 'Middle trail that crosses the leaf',
402
+ input: z.object({ value: z.string() }),
403
+ output: z.object({ middle: z.string() }),
401
404
  });
402
405
 
403
406
  const rootTrail = trail('step.root', {
404
- description: 'Root trail that follows the middle trail',
407
+ blaze: async (input: { value: string }, ctx) => {
408
+ if (!ctx.cross) {
409
+ return Result.err(new Error('cross not available'));
410
+ }
411
+ const midResult = await ctx.cross<{ middle: string }>('step.middle', input);
412
+ if (midResult.isErr()) {
413
+ return midResult;
414
+ }
415
+ return Result.ok({ root: midResult.value.middle });
416
+ },
417
+ crosses: ['step.middle'],
418
+ description: 'Root trail that crosses the middle trail',
405
419
  examples: [
406
420
  {
407
421
  expected: { root: 'hello' },
408
422
  input: { value: 'hello' },
409
- name: 'Nested follow chain A→B→C',
423
+ name: 'Nested cross chain A→B→C',
410
424
  },
411
425
  ],
412
- follow: ['step.middle'],
413
426
  input: z.object({ value: z.string() }),
414
427
  output: z.object({ root: z.string() }),
415
- run: async (input: { value: string }, ctx) => {
416
- if (!ctx.follow) {
417
- return Result.err(new Error('follow not available'));
418
- }
419
- const midResult = await ctx.follow<{ middle: string }>(
420
- 'step.middle',
421
- input
422
- );
423
- if (midResult.isErr()) {
424
- return midResult;
425
- }
426
- return Result.ok({ root: midResult.value.middle });
427
- },
428
428
  });
429
429
 
430
- describe('testExamples nested follow chain (A → B → C)', () => {
430
+ describe('testExamples nested cross chain (A → B → C)', () => {
431
431
  // eslint-disable-next-line jest/require-hook
432
432
  testExamples(
433
433
  topo('nested-chain-app', {
@@ -437,3 +437,98 @@ describe('testExamples nested follow chain (A → B → C)', () => {
437
437
  } as Record<string, unknown>)
438
438
  );
439
439
  });
440
+
441
+ // ---------------------------------------------------------------------------
442
+ // Auto-minting permit tests (B3)
443
+ // ---------------------------------------------------------------------------
444
+
445
+ const scopedTrail = trail('scoped.trail', {
446
+ blaze: (_input, ctx) => {
447
+ // Verify the permit was auto-minted with declared scopes
448
+ const permit = ctx.permit as
449
+ | { id: string; scopes: readonly string[] }
450
+ | undefined;
451
+ if (!permit || !permit.scopes.includes('admin')) {
452
+ return Result.err(new Error('Missing permit or scopes'));
453
+ }
454
+ return Result.ok({ ok: true });
455
+ },
456
+ description: 'Trail requiring admin scope',
457
+ examples: [
458
+ {
459
+ expected: { ok: true },
460
+ input: {},
461
+ name: 'Runs with auto-minted permit',
462
+ },
463
+ ],
464
+ input: z.object({}),
465
+ output: z.object({ ok: z.boolean() }),
466
+ permit: { scopes: ['admin'] },
467
+ });
468
+
469
+ const publicTrail = trail('public.trail', {
470
+ blaze: (_input, ctx) => {
471
+ // Public trail should NOT get a permit
472
+ if (ctx.permit !== undefined) {
473
+ return Result.err(new Error('Unexpected permit on public trail'));
474
+ }
475
+ return Result.ok({ ok: true });
476
+ },
477
+ description: 'Public trail — no permit needed',
478
+ examples: [
479
+ {
480
+ expected: { ok: true },
481
+ input: {},
482
+ name: 'Runs without a permit',
483
+ },
484
+ ],
485
+ input: z.object({}),
486
+ output: z.object({ ok: z.boolean() }),
487
+ permit: 'public',
488
+ });
489
+
490
+ describe('testExamples auto-minting permits', () => {
491
+ describe('scoped trail gets auto-minted permit', () => {
492
+ // eslint-disable-next-line jest/require-hook
493
+ testExamples(
494
+ topo('mint-scoped-app', {
495
+ scopedTrail,
496
+ } as Record<string, unknown>)
497
+ );
498
+ });
499
+
500
+ describe('public trail does NOT get a permit', () => {
501
+ // eslint-disable-next-line jest/require-hook
502
+ testExamples(
503
+ topo('mint-public-app', {
504
+ publicTrail,
505
+ } as Record<string, unknown>)
506
+ );
507
+ });
508
+
509
+ describe('strictPermits skips auto-minting', () => {
510
+ const strictScopedTrail = trail('strict.scoped', {
511
+ blaze: (_input, ctx) =>
512
+ Result.ok({ hasPermit: ctx.permit !== undefined }),
513
+ description: 'Trail that expects no permit under strictPermits',
514
+ examples: [
515
+ {
516
+ expected: { hasPermit: false },
517
+ input: {},
518
+ name: 'No auto-minted permit when strictPermits is true',
519
+ },
520
+ ],
521
+ input: z.object({}),
522
+ output: z.object({ hasPermit: z.boolean() }),
523
+ permit: { scopes: ['admin'] },
524
+ });
525
+
526
+ // eslint-disable-next-line jest/require-hook
527
+ testExamples(
528
+ topo('strict-app', {
529
+ strictScopedTrail,
530
+ } as Record<string, unknown>),
531
+ { strictPermits: true }
532
+ );
533
+ });
534
+ });
@@ -10,20 +10,20 @@ import { testTrail } from '../trail.js';
10
10
  // ---------------------------------------------------------------------------
11
11
 
12
12
  const greetTrail = trail('greet', {
13
+ blaze: (input: { name: string }) =>
14
+ Result.ok({ greeting: `Hello, ${input.name}` }),
13
15
  input: z.object({ name: z.string() }),
14
16
  output: z.object({ greeting: z.string() }),
15
- run: (input: { name: string }) =>
16
- Result.ok({ greeting: `Hello, ${input.name}` }),
17
17
  });
18
18
 
19
19
  const failTrail = trail('fail', {
20
- input: z.object({ id: z.string() }),
21
- run: (input: { id: string }) => {
20
+ blaze: (input: { id: string }) => {
22
21
  if (input.id === 'missing') {
23
22
  return Result.err(new NotFoundError('Not found: missing'));
24
23
  }
25
24
  return Result.ok({ id: input.id });
26
25
  },
26
+ input: z.object({ id: z.string() }),
27
27
  });
28
28
 
29
29
  // ---------------------------------------------------------------------------