@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 +12 -0
- package/GUIDE.md +30 -48
- package/README.md +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/openapi/generator.js +5 -1
- package/dist/openapi/schema-converter.d.ts +11 -0
- package/dist/openapi/schema-converter.js +47 -0
- package/dist/procedure/builder.js +184 -43
- package/dist/procedure/types.d.ts +33 -88
- package/dist/resource/index.d.ts +4 -4
- package/dist/resource/index.js +3 -3
- package/dist/resource/instance.d.ts +1 -1
- package/dist/resource/instance.js +1 -1
- package/dist/resource/levels.d.ts +93 -11
- package/dist/resource/levels.js +78 -3
- package/dist/resource/schema.d.ts +2 -0
- package/dist/resource/schema.js +4 -4
- package/dist/resource/tags.d.ts +5 -7
- package/dist/resource/tags.js +1 -1
- package/dist/trpc/adapter.js +5 -0
- package/dist/types.d.ts +16 -8
- package/package.json +3 -3
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
|
|
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 (
|
|
424
|
+
### Automatic Projection with `.output()` and Guards
|
|
427
425
|
|
|
428
|
-
The most elegant approach is to chain `.
|
|
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 {
|
|
429
|
+
import { authenticated, hasRole } from '@veloxts/auth';
|
|
432
430
|
|
|
433
431
|
export const userProcedures = procedures('users', {
|
|
434
|
-
// Authenticated endpoint -
|
|
432
|
+
// Authenticated endpoint - projects { id, name, email, createdAt }
|
|
435
433
|
getProfile: procedure()
|
|
436
|
-
.
|
|
437
|
-
.
|
|
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 -
|
|
441
|
+
// Admin endpoint - projects all fields
|
|
445
442
|
getFullProfile: procedure()
|
|
446
|
-
.
|
|
447
|
-
.
|
|
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
|
|
458
|
-
2.
|
|
459
|
-
3.
|
|
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
|
|
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 (
|
|
515
|
+
// Automatic projection with .output()
|
|
520
516
|
const listUsers = procedure()
|
|
521
|
-
.
|
|
522
|
-
.
|
|
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
|
-
.
|
|
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
|
|
561
|
+
// Automatic projection - type inferred from tagged view
|
|
566
562
|
const getProfile = procedure()
|
|
567
|
-
.
|
|
568
|
-
.
|
|
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** (`.
|
|
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
|
|
602
|
+
## Guard Type Narrowing
|
|
607
603
|
|
|
608
|
-
|
|
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 {
|
|
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
|
-
.
|
|
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
|
|
616
|
+
// Chain multiple guards
|
|
621
617
|
const adminAction = procedure()
|
|
622
|
-
.
|
|
623
|
-
.
|
|
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.
|
|
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
|
|
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(
|
|
215
|
-
return
|
|
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(
|
|
221
|
-
return
|
|
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 .
|
|
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
|
/**
|