@veloxts/router 0.7.9 → 0.8.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @veloxts/router
2
2
 
3
+ ## 0.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - feat(router): new simplified procedure builder
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies
12
+ - @veloxts/core@0.8.0
13
+ - @veloxts/validation@0.8.0
14
+
3
15
  ## 0.7.9
4
16
 
5
17
  ### Patch Changes
package/GUIDE.md CHANGED
@@ -28,11 +28,9 @@ export const userProcedures = procedures('users', {
28
28
  ## Procedure API
29
29
 
30
30
  - `.input(schema)` - Validate input with Zod
31
- - `.output(schema)` - Validate output with Zod
32
- - `.expose(schema)` - Context-dependent output with resource schema
31
+ - `.output(schema)` - Validate output with Zod schema or tagged resource view
33
32
  - `.use(middleware)` - Add middleware
34
33
  - `.guard(guard)` - Add authorization guard
35
- - `.guardNarrow(guard)` - Add guard with TypeScript type narrowing
36
34
  - `.rest({ method, path })` - Override REST path
37
35
  - `.query(handler)` - Finalize as read operation
38
36
  - `.mutation(handler)` - Finalize as write operation
@@ -405,7 +403,7 @@ console.table(routes);
405
403
 
406
404
  ## Resource API (Context-Dependent Outputs)
407
405
 
408
- The Resource API provides context-dependent output types using phantom types. Pass a resource schema to `.output()` instead of a Zod schema to define field visibility per access level.
406
+ The Resource API provides context-dependent output types using phantom types. Pass a tagged resource view to `.output()` instead of a plain Zod schema to define field visibility per access level.
409
407
 
410
408
  ### Defining a Resource Schema
411
409
 
@@ -423,28 +421,27 @@ const UserSchema = resourceSchema()
423
421
  .build();
424
422
  ```
425
423
 
426
- ### Automatic Projection (Simple Cases)
424
+ ### Automatic Projection with `.output()` and Guards
427
425
 
428
- The most elegant approach is to chain `.expose()` with a narrowing guard. The procedure executor automatically projects fields based on the guard's access level:
426
+ The most elegant approach is to chain `.guard()` with `.output()` using a tagged resource view. The procedure builder uses the guard's access level to determine which fields to include:
429
427
 
430
428
  ```typescript
431
- import { authenticatedNarrow, adminNarrow } from '@veloxts/auth';
429
+ import { authenticated, hasRole } from '@veloxts/auth';
432
430
 
433
431
  export const userProcedures = procedures('users', {
434
- // Authenticated endpoint - auto-projects { id, name, email, createdAt }
432
+ // Authenticated endpoint - projects { id, name, email, createdAt }
435
433
  getProfile: procedure()
436
- .guardNarrow(authenticatedNarrow)
437
- .expose(UserSchema)
434
+ .guard(authenticated)
435
+ .output(UserSchema.authenticated)
438
436
  .input(z.object({ id: z.string().uuid() }))
439
437
  .query(async ({ input, ctx }) => {
440
- // Just return the full data - projection is automatic!
441
438
  return ctx.db.user.findUniqueOrThrow({ where: { id: input.id } });
442
439
  }),
443
440
 
444
- // Admin endpoint - auto-projects all fields
441
+ // Admin endpoint - projects all fields
445
442
  getFullProfile: procedure()
446
- .guardNarrow(adminNarrow)
447
- .expose(UserSchema)
443
+ .guard(hasRole('admin'))
444
+ .output(UserSchema.admin)
448
445
  .input(z.object({ id: z.string().uuid() }))
449
446
  .query(async ({ input, ctx }) => {
450
447
  return ctx.db.user.findUniqueOrThrow({ where: { id: input.id } });
@@ -454,14 +451,13 @@ export const userProcedures = procedures('users', {
454
451
 
455
452
  **How it works:**
456
453
 
457
- 1. The `guardNarrow()` method accepts guards with an `accessLevel` property (`'public'`, `'authenticated'`, or `'admin'`)
458
- 2. After the guard passes, the procedure executor tracks the highest access level
459
- 3. When the handler returns, the executor automatically projects the result using the resource schema
460
- 4. No manual `.forX()` calls needed - the output type is inferred at compile time
454
+ 1. The `.output()` method accepts both plain Zod schemas and tagged resource views (e.g., `UserSchema.authenticated`)
455
+ 2. When a tagged view is passed, the procedure executor automatically projects the handler's return value
456
+ 3. The output type is inferred at compile time from the tagged view
461
457
 
462
458
  **Benefits:**
463
459
  - Clean, declarative code - no projection logic in handlers
464
- - Type-safe - return types are inferred from guard + schema
460
+ - Type-safe - return types are inferred from the tagged view
465
461
  - Less boilerplate - handlers just return data
466
462
  - Consistent - impossible to forget projection
467
463
 
@@ -516,10 +512,10 @@ export const userProcedures = procedures('users', {
516
512
  For arrays of items, use `resourceCollection()`:
517
513
 
518
514
  ```typescript
519
- // Automatic projection (simple cases)
515
+ // Automatic projection with .output()
520
516
  const listUsers = procedure()
521
- .guardNarrow(authenticatedNarrow)
522
- .expose(UserSchema)
517
+ .guard(authenticated)
518
+ .output(UserSchema.authenticated)
523
519
  .query(async ({ ctx }) => {
524
520
  return ctx.db.user.findMany({ take: 50 });
525
521
  });
@@ -539,7 +535,7 @@ For manual projection with dynamic access level, use `.for(ctx)`:
539
535
 
540
536
  ```typescript
541
537
  const getUser = procedure()
542
- .guardNarrow(authenticatedNarrow)
538
+ .guard(authenticated)
543
539
  .input(z.object({ id: z.string().uuid() }))
544
540
  .query(async ({ input, ctx }) => {
545
541
  const user = await ctx.db.user.findUniqueOrThrow({ where: { id: input.id } });
@@ -562,10 +558,10 @@ const getUser = procedure()
562
558
  The Resource API provides compile-time type safety:
563
559
 
564
560
  ```typescript
565
- // Automatic projection - type inferred from guard
561
+ // Automatic projection - type inferred from tagged view
566
562
  const getProfile = procedure()
567
- .guardNarrow(authenticatedNarrow)
568
- .expose(UserSchema)
563
+ .guard(authenticated)
564
+ .output(UserSchema.authenticated)
569
565
  .query(async ({ ctx }) => {
570
566
  return ctx.db.user.findFirst();
571
567
  });
@@ -586,7 +582,7 @@ const adminResult = resource(user, UserSchema.admin);
586
582
 
587
583
  | Scenario | Approach |
588
584
  |----------|----------|
589
- | Guard determines access level | **Automatic** (`.guardNarrow().output()`) |
585
+ | Guard determines access level | **Automatic** (`.guard().output(Schema.level)`) |
590
586
  | Public endpoints (no guard) | Tagged view (`UserSchema.public`) |
591
587
  | Conditional/dynamic projection | Tagged view or `.for(ctx)` in handler |
592
588
  | Simple, declarative code | **Automatic** |
@@ -603,43 +599,29 @@ const getUser = procedure()
603
599
  .query(handler);
604
600
  ```
605
601
 
606
- ## Guard Type Narrowing (Experimental)
602
+ ## Guard Type Narrowing
607
603
 
608
- When using guards like `authenticated`, TypeScript doesn't know that `ctx.user` is guaranteed non-null after the guard passes. Use `guardNarrow()` to narrow the context type:
604
+ Guards like `authenticated` and `hasRole()` both enforce authorization and narrow the context type. After a guard passes, TypeScript knows `ctx.user` is non-null:
609
605
 
610
606
  ```typescript
611
- import { authenticatedNarrow, hasRoleNarrow } from '@veloxts/auth';
607
+ import { authenticated, hasRole } from '@veloxts/auth';
612
608
 
613
609
  // ctx.user is guaranteed non-null after guard passes
614
610
  const getProfile = procedure()
615
- .guardNarrow(authenticatedNarrow)
611
+ .guard(authenticated)
616
612
  .query(({ ctx }) => {
617
613
  return { email: ctx.user.email }; // No null check needed!
618
614
  });
619
615
 
620
- // Chain multiple narrowing guards
616
+ // Chain multiple guards
621
617
  const adminAction = procedure()
622
- .guardNarrow(authenticatedNarrow)
623
- .guardNarrow(hasRoleNarrow('admin'))
618
+ .guard(authenticated)
619
+ .guard(hasRole('admin'))
624
620
  .mutation(({ ctx }) => {
625
621
  // ctx.user is non-null with roles
626
622
  });
627
623
  ```
628
624
 
629
- **Note**: This API is experimental. The current stable alternative is to use middleware for context extension:
630
-
631
- ```typescript
632
- const getProfile = procedure()
633
- .guard(authenticated)
634
- .use(async ({ ctx, next }) => {
635
- if (!ctx.user) throw new Error('Unreachable');
636
- return next({ ctx: { user: ctx.user } });
637
- })
638
- .query(({ ctx }) => {
639
- // ctx.user is non-null via middleware
640
- });
641
- ```
642
-
643
625
  ## Schema Browser-Safety
644
626
 
645
627
  When building full-stack apps, schemas may be imported on both server and client. Avoid importing server-only dependencies in schema files:
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @veloxts/router
2
2
 
3
- > **Early Access (v0.7.x)**
3
+ > **Early Access (v0.8.x)**
4
4
 
5
5
  Procedure-based routing for VeloxTS Framework - provides hybrid tRPC and REST adapters with naming convention-based HTTP method inference. Learn more at [@veloxts/velox](https://www.npmjs.com/package/@veloxts/velox).
6
6
 
package/dist/index.d.ts CHANGED
@@ -46,7 +46,7 @@ export { GuardError, isGuardError } from './errors.js';
46
46
  export type { DefineProceduresOptions, } from './procedure/builder.js';
47
47
  export { defineProcedures, executeProcedure, isCompiledProcedure, isProcedureCollection, procedure, procedures, } from './procedure/builder.js';
48
48
  export { createProcedure, typedProcedure } from './procedure/factory.js';
49
- export type { BuilderRuntimeState, InferProcedures, InferSchemaOutput, ProcedureBuilder, ProcedureBuilderState, ProcedureDefinitions, ValidSchema, } from './procedure/types.js';
49
+ export type { BuilderRuntimeState, InferOutputSchema, InferProcedures, InferSchemaOutput, ProcedureBuilder, ProcedureBuilderState, ProcedureDefinitions, ValidOutputSchema, ValidSchema, } from './procedure/types.js';
50
50
  export type { RouterResult } from './router-utils.js';
51
51
  export { createRouter, toRouter } from './router-utils.js';
52
52
  export type { NamingWarning, NamingWarningType, WarningConfig, WarningOption } from './warnings.js';
@@ -114,4 +114,4 @@ export type { AccessLevel, AccessLevelConfig, ADMIN, AdminOutput, AdminTaggedCon
114
114
  * });
115
115
  * ```
116
116
  */
117
- export { defineAccessLevels, getAccessibleLevels, getVisibilityForTag, isFieldVisibleToLevel, isResourceSchema, isVisibleAtLevel, Resource, ResourceCollection, ResourceSchemaBuilder, resource, resourceCollection, resourceSchema, } from './resource/index.js';
117
+ export { defaultAccess, defineAccessLevels, getAccessibleLevels, getVisibilityForTag, isFieldVisibleToLevel, isResourceSchema, isVisibleAtLevel, Resource, ResourceCollection, ResourceSchemaBuilder, resource, resourceCollection, resourceSchema, } from './resource/index.js';
package/dist/index.js CHANGED
@@ -110,7 +110,7 @@ swaggerPlugin, validateOpenApiSpec, zodSchemaToJsonSchema, } from './openapi/ind
110
110
  */
111
111
  export {
112
112
  // Access level configuration
113
- defineAccessLevels, getAccessibleLevels, getVisibilityForTag, isFieldVisibleToLevel, isResourceSchema,
113
+ defaultAccess, defineAccessLevels, getAccessibleLevels, getVisibilityForTag, isFieldVisibleToLevel, isResourceSchema,
114
114
  // Visibility
115
115
  isVisibleAtLevel, Resource, ResourceCollection, ResourceSchemaBuilder,
116
116
  // Resource instances
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { generateRestRoutes } from '../rest/adapter.js';
9
9
  import { buildParameters, convertToOpenAPIPath, joinPaths } from './path-extractor.js';
10
- import { removeSchemaProperties, resourceSchemaToJsonSchema, zodSchemaToJsonSchema, } from './schema-converter.js';
10
+ import { removeSchemaProperties, resourceSchemaToJsonSchema, resourceSchemaToJsonSchemaForBranching, zodSchemaToJsonSchema, } from './schema-converter.js';
11
11
  import { extractUsedSecuritySchemes, filterUsedSecuritySchemes, guardsToSecurity, mergeSecuritySchemes, } from './security-mapper.js';
12
12
  // ============================================================================
13
13
  // Main Generator
@@ -150,6 +150,10 @@ function generateOperation(route, namespace, options) {
150
150
  if (procedure.outputSchema) {
151
151
  outputSchema = zodSchemaToJsonSchema(procedure.outputSchema);
152
152
  }
153
+ else if (procedure._handlerMap && procedure._resourceSchema) {
154
+ // Level 3: branched procedure — include all fields, non-public fields optional
155
+ outputSchema = resourceSchemaToJsonSchemaForBranching(procedure._resourceSchema);
156
+ }
153
157
  else if (procedure._resourceSchema) {
154
158
  const level = procedure._resourceLevel ?? 'public';
155
159
  outputSchema = resourceSchemaToJsonSchema(procedure._resourceSchema, level);
@@ -113,3 +113,14 @@ export declare function schemaHasProperties(schema: JSONSchema | undefined): boo
113
113
  * @returns JSON Schema with only the fields visible at the given level
114
114
  */
115
115
  export declare function resourceSchemaToJsonSchema(schema: ResourceSchema, level?: string): JSONSchema;
116
+ /**
117
+ * Converts a resource schema to JSON Schema for Level 3 branched procedures
118
+ *
119
+ * Includes ALL fields from the resource schema. Fields visible at the
120
+ * lowest level (first in the levels array) are marked as required;
121
+ * higher-level-only fields are optional.
122
+ *
123
+ * @param schema - The resource schema (must have _levelConfig for custom levels)
124
+ * @returns JSON Schema with all fields, non-public fields optional
125
+ */
126
+ export declare function resourceSchemaToJsonSchemaForBranching(schema: ResourceSchema): JSONSchema;
@@ -308,3 +308,50 @@ export function resourceSchemaToJsonSchema(schema, level = 'public') {
308
308
  ...(required.length > 0 ? { required } : {}),
309
309
  };
310
310
  }
311
+ /**
312
+ * Converts a resource schema to JSON Schema for Level 3 branched procedures
313
+ *
314
+ * Includes ALL fields from the resource schema. Fields visible at the
315
+ * lowest level (first in the levels array) are marked as required;
316
+ * higher-level-only fields are optional.
317
+ *
318
+ * @param schema - The resource schema (must have _levelConfig for custom levels)
319
+ * @returns JSON Schema with all fields, non-public fields optional
320
+ */
321
+ export function resourceSchemaToJsonSchemaForBranching(schema) {
322
+ const properties = {};
323
+ const required = [];
324
+ // Determine the lowest (most accessible) level
325
+ const schemaWithConfig = schema;
326
+ const lowestLevel = schemaWithConfig._levelConfig?.levels[0] ?? 'public';
327
+ for (const field of schema.fields) {
328
+ const runtimeField = field;
329
+ if (field.nestedSchema) {
330
+ const nestedJsonSchema = resourceSchemaToJsonSchemaForBranching(field.nestedSchema);
331
+ if (field.cardinality === 'many') {
332
+ properties[field.name] = { type: 'array', items: nestedJsonSchema };
333
+ }
334
+ else {
335
+ properties[field.name] = { ...nestedJsonSchema, nullable: true };
336
+ }
337
+ }
338
+ else if (field.schema) {
339
+ const fieldJsonSchema = zodSchemaToJsonSchema(field.schema);
340
+ if (fieldJsonSchema) {
341
+ properties[field.name] = fieldJsonSchema;
342
+ }
343
+ }
344
+ else {
345
+ properties[field.name] = {};
346
+ }
347
+ // Only public/lowest-level fields are required
348
+ if (isFieldVisibleToLevel(runtimeField, lowestLevel)) {
349
+ required.push(field.name);
350
+ }
351
+ }
352
+ return {
353
+ type: 'object',
354
+ properties,
355
+ ...(required.length > 0 ? { required } : {}),
356
+ };
357
+ }
@@ -10,7 +10,7 @@
10
10
  import { ConfigurationError, logWarning } from '@veloxts/core';
11
11
  import { GuardError } from '../errors.js';
12
12
  import { createMiddlewareExecutor, executeMiddlewareChain } from '../middleware/chain.js';
13
- import { isTaggedResourceSchema, Resource, } from '../resource/index.js';
13
+ import { isResourceSchema, isTaggedResourceSchema, Resource, } from '../resource/index.js';
14
14
  import { deriveParentParamName } from '../utils/pluralization.js';
15
15
  import { analyzeNamingConvention, isDevelopment, normalizeWarningOption, } from '../warnings.js';
16
16
  // ============================================================================
@@ -85,24 +85,41 @@ function createBuilder(state) {
85
85
  });
86
86
  },
87
87
  /**
88
- * Sets the output validation schema (Zod-only)
88
+ * Sets the output schema.
89
+ *
90
+ * Accepts either:
91
+ * - A Zod schema (Level 1) — validates output after handler
92
+ * - A tagged resource view (Level 2) — auto-projects handler result
93
+ * through the tagged level's field visibility
89
94
  */
90
95
  output(schema) {
96
+ // Level 2: tagged resource view — set up auto-projection
97
+ if (isTaggedResourceSchema(schema)) {
98
+ return createBuilder({
99
+ ...state,
100
+ resourceSchema: schema,
101
+ resourceLevel: schema._level,
102
+ outputSchema: undefined,
103
+ branchingMode: undefined,
104
+ });
105
+ }
106
+ // Level 3: untagged resource schema — enables branching mode
107
+ if (isResourceSchema(schema)) {
108
+ return createBuilder({
109
+ ...state,
110
+ resourceSchema: schema,
111
+ resourceLevel: undefined,
112
+ outputSchema: undefined,
113
+ branchingMode: true,
114
+ });
115
+ }
116
+ // Level 1: plain Zod schema — validate output
91
117
  return createBuilder({
92
118
  ...state,
93
119
  outputSchema: schema,
94
- });
95
- },
96
- /**
97
- * Sets field-level visibility via a resource schema
98
- */
99
- expose(schema) {
100
- const level = isTaggedResourceSchema(schema) ? schema._level : undefined;
101
- return createBuilder({
102
- ...state,
103
- resourceSchema: schema,
104
- resourceLevel: level,
105
- outputSchema: undefined,
120
+ resourceSchema: undefined,
121
+ resourceLevel: undefined,
122
+ branchingMode: undefined,
106
123
  });
107
124
  },
108
125
  /**
@@ -129,18 +146,6 @@ function createBuilder(state) {
129
146
  guards: [...state.guards, guardDef],
130
147
  });
131
148
  },
132
- /**
133
- * Adds an authorization guard with type narrowing (EXPERIMENTAL)
134
- *
135
- * Unlike `guard()`, this method narrows the context type based on
136
- * what the guard guarantees after it passes.
137
- */
138
- guardNarrow(guardDef) {
139
- return createBuilder({
140
- ...state,
141
- guards: [...state.guards, guardDef],
142
- });
143
- },
144
149
  /**
145
150
  * Adds multiple authorization guards at once
146
151
  *
@@ -210,30 +215,55 @@ function createBuilder(state) {
210
215
  },
211
216
  /**
212
217
  * Finalizes as a query procedure
218
+ *
219
+ * In branching mode (Level 3), accepts a handler map keyed by schema level keys.
213
220
  */
214
- query(handler) {
215
- return compileProcedure('query', handler, state);
221
+ query(handlerOrMap) {
222
+ return compileProcedureOrBranching('query', handlerOrMap, state);
216
223
  },
217
224
  /**
218
225
  * Finalizes as a mutation procedure
226
+ *
227
+ * In branching mode (Level 3), accepts a handler map keyed by schema level keys.
219
228
  */
220
- mutation(handler) {
221
- return compileProcedure('mutation', handler, state);
222
- },
223
- /**
224
- * @deprecated Use `.expose()` instead. `.resource()` will be removed in v1.0.
225
- */
226
- resource(schema) {
227
- const level = isTaggedResourceSchema(schema) ? schema._level : undefined;
228
- return createBuilder({
229
- ...state,
230
- resourceSchema: schema,
231
- resourceLevel: level,
232
- outputSchema: undefined,
233
- });
229
+ mutation(handlerOrMap) {
230
+ return compileProcedureOrBranching('mutation', handlerOrMap, state);
234
231
  },
235
232
  };
236
233
  }
234
+ /**
235
+ * Routes to the correct compile strategy based on branching mode
236
+ *
237
+ * In branching mode (Level 3), validates the handler map and synthesizes
238
+ * a dispatch handler. In normal mode, delegates to compileProcedure.
239
+ *
240
+ * @internal
241
+ */
242
+ function compileProcedureOrBranching(type, handlerOrMap, state) {
243
+ if (state.branchingMode) {
244
+ // Level 3: handler map required
245
+ if (typeof handlerOrMap === 'function') {
246
+ throw new ConfigurationError('Handler map required when .output() receives a resource schema. ' +
247
+ 'Use { [Schema.level.key]: handler } syntax.');
248
+ }
249
+ if (state.guards.length > 0) {
250
+ throw new ConfigurationError('Cannot use handler map with .guard(). ' +
251
+ 'Guards come from defineAccessLevels() in Level 3 branching mode.');
252
+ }
253
+ // Synthesize a dispatch handler so CompiledProcedure.handler is always defined.
254
+ // The real branch selection happens in executeProcedure.
255
+ const dispatchHandler = async () => {
256
+ throw new ConfigurationError('Level 3 branched procedures must be executed via executeProcedure(). ' +
257
+ 'Direct handler invocation is not supported.');
258
+ };
259
+ return compileProcedureWithHandlerMap(type, dispatchHandler, handlerOrMap, state);
260
+ }
261
+ // Not in branching mode: handler map is not allowed
262
+ if (typeof handlerOrMap !== 'function') {
263
+ throw new ConfigurationError('Handler map can only be used when .output() receives an untagged resource schema.');
264
+ }
265
+ return compileProcedure(type, handlerOrMap, state);
266
+ }
237
267
  /**
238
268
  * Compiles a procedure from the builder state
239
269
  *
@@ -270,6 +300,35 @@ function compileProcedure(type, handler, state) {
270
300
  _resourceLevel: state.resourceLevel,
271
301
  };
272
302
  }
303
+ /**
304
+ * Compiles a Level 3 branched procedure with a handler map
305
+ *
306
+ * Creates a CompiledProcedure with both a synthesized dispatch handler
307
+ * (so .handler is always defined) and the _handlerMap for branch selection.
308
+ *
309
+ * @internal
310
+ */
311
+ function compileProcedureWithHandlerMap(type, dispatchHandler, handlerMap, state) {
312
+ const typedMiddlewares = state.middlewares;
313
+ return {
314
+ type,
315
+ handler: dispatchHandler,
316
+ inputSchema: state.inputSchema,
317
+ outputSchema: undefined, // Level 3 uses resource schema projection, not Zod output validation
318
+ middlewares: typedMiddlewares,
319
+ guards: [], // Guards come from access level config
320
+ restOverride: state.restOverride,
321
+ deprecated: state.deprecated,
322
+ deprecationMessage: state.deprecationMessage,
323
+ isWebhook: state.isWebhook,
324
+ parentResource: state.parentResource,
325
+ parentResources: state.parentResources,
326
+ _precompiledExecutor: undefined, // Branched procedures don't use precompiled chains
327
+ _resourceSchema: state.resourceSchema,
328
+ _resourceLevel: undefined, // No fixed level — determined at runtime by branch selection
329
+ _handlerMap: handlerMap,
330
+ };
331
+ }
273
332
  /**
274
333
  * Defines a collection of procedures under a namespace
275
334
  *
@@ -437,7 +496,7 @@ export async function executeProcedure(procedure, rawInput, ctx) {
437
496
  // IMPORTANT: last guard's accessLevel wins. With custom levels that
438
497
  // have no inherent hierarchy, ordering of guards matters.
439
498
  // Guards without accessLevel (e.g. plain `authenticated`) do NOT
440
- // update the level — it stays at 'public' for .expose() projection.
499
+ // update the level — it stays at 'public' for .output() projection.
441
500
  const guardWithLevel = guard;
442
501
  if (guardWithLevel.accessLevel) {
443
502
  accessLevel = guardWithLevel.accessLevel;
@@ -451,6 +510,10 @@ export async function executeProcedure(procedure, rawInput, ctx) {
451
510
  const input = procedure.inputSchema
452
511
  ? procedure.inputSchema.parse(rawInput)
453
512
  : rawInput;
513
+ // Step 2.5: Level 3 branch selection — if handler map is present
514
+ if (procedure._handlerMap) {
515
+ return executeBranchedProcedure(procedure, input, ctxWithLevel);
516
+ }
454
517
  // Step 3: Execute handler (with or without middleware)
455
518
  let result;
456
519
  if (procedure._precompiledExecutor) {
@@ -487,6 +550,84 @@ export async function executeProcedure(procedure, rawInput, ctx) {
487
550
  return result;
488
551
  }
489
552
  // ============================================================================
553
+ // Level 3 Branch Selection
554
+ // ============================================================================
555
+ /**
556
+ * Executes a Level 3 branched procedure
557
+ *
558
+ * Evaluates guards from the resource schema's access level config
559
+ * most-privileged-first to select the matching branch handler, then
560
+ * auto-projects the result through that level's field visibility.
561
+ *
562
+ * @internal
563
+ */
564
+ async function executeBranchedProcedure(procedure, input, ctx) {
565
+ const handlerMap = procedure._handlerMap;
566
+ const schema = procedure._resourceSchema;
567
+ const levelConfig = schema?._levelConfig;
568
+ if (!levelConfig) {
569
+ throw new ConfigurationError('Resource schema must be built with defineAccessLevels() containing guards for Level 3 branching.');
570
+ }
571
+ // Evaluate guards most-privileged-first (reverse level order)
572
+ const levels = [...levelConfig.levels];
573
+ const reversedLevels = [...levels].reverse();
574
+ let selectedLevel;
575
+ let selectedHandler;
576
+ for (const level of reversedLevels) {
577
+ const guard = levelConfig.guards[level];
578
+ if (!guard) {
579
+ // No guard = public/fallback level — only select if handler exists
580
+ const key = `__velox_level_${level}`;
581
+ if (handlerMap[key]) {
582
+ // Don't select yet — keep looking for a higher-privilege match.
583
+ // This fallback is used if no guarded level matches.
584
+ if (!selectedHandler) {
585
+ selectedLevel = level;
586
+ selectedHandler = handlerMap[key];
587
+ }
588
+ }
589
+ continue;
590
+ }
591
+ const passed = await guard(ctx);
592
+ if (passed) {
593
+ // Guard passed — find the best handler at or below this level
594
+ const levelIndex = levels.indexOf(level);
595
+ for (let i = levelIndex; i >= 0; i--) {
596
+ const candidateLevel = levels[i];
597
+ const key = `__velox_level_${candidateLevel}`;
598
+ if (handlerMap[key]) {
599
+ selectedHandler = handlerMap[key];
600
+ selectedLevel = candidateLevel;
601
+ break;
602
+ }
603
+ }
604
+ break;
605
+ }
606
+ }
607
+ if (!selectedHandler || !selectedLevel) {
608
+ throw new GuardError('access', 'No matching branch for this access level', 403);
609
+ }
610
+ // Execute middleware chain if any, then the selected handler
611
+ let result;
612
+ if (procedure.middlewares.length > 0) {
613
+ result = await executeMiddlewareChain(procedure.middlewares, input, ctx, async () => selectedHandler({ input, ctx }));
614
+ }
615
+ else {
616
+ result = await selectedHandler({ input, ctx });
617
+ }
618
+ // Auto-project through the selected level's visibility
619
+ const projectOne = (item) => {
620
+ return new Resource(item, schema).forLevel(selectedLevel);
621
+ };
622
+ if (Array.isArray(result)) {
623
+ result = result.map((item) => projectOne(item));
624
+ }
625
+ else {
626
+ result = projectOne(result);
627
+ }
628
+ return result;
629
+ }
630
+ // ============================================================================
490
631
  // Type Utilities
491
632
  // ============================================================================
492
633
  /**