@veloxts/router 0.8.3 → 0.9.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,80 @@
1
1
  # @veloxts/router
2
2
 
3
+ ## 0.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e1c32a2: Add `.check()` post-middleware authorization primitive on the procedure builder.
8
+
9
+ `.check()` runs **after** input validation, `.through()` pipeline transforms, and all `.use()` middleware have populated the context — immediately before the handler. Use it for authorization that depends on input fields and/or context values populated by middleware:
10
+
11
+ ```typescript
12
+ procedure()
13
+ .input(z.object({ sessionId: z.string() }))
14
+ .guard(authenticated) // pre-input, ctx-only (unchanged)
15
+ .use(loadParticipant) // middleware extends ctx
16
+ .check(
17
+ (
18
+ { input, ctx }, // sees input + middleware-extended ctx
19
+ ) => ctx.participant.sessionId === input.sessionId,
20
+ )
21
+ .query(handler);
22
+ ```
23
+
24
+ **When to use which:**
25
+ - `.guard(authenticated)` — pre-input, ctx-only authentication (fast-fail).
26
+ - `.policy(PostPolicy.update)` — declarative resource-policy authorization.
27
+ - `.check(({ input, ctx }) => ...)` — ad-hoc authorization that needs both `input` and middleware-extended `ctx`.
28
+
29
+ Returning `false` throws `ForbiddenError` (403). Throwing inside the function propagates as-is, so callers can throw custom domain errors with finer-grained status codes. Multiple `.check()` calls AND-compose with short-circuit on first failure.
30
+
31
+ Closes the gap exposed by the `ara/apps/api` audit where `requireParticipant((i) => i.id)` had to be implemented as `.use()` middleware because `.guard()` couldn't see middleware-extended ctx.
32
+
33
+ - feat(router): add raw() response primitive for redirects, cookies, custom headers and .check() post-middleware authorization primitive
34
+ - 83c5da1: Add `raw()` response primitive for procedures.
35
+
36
+ Procedure handlers can now return non-JSON HTTP responses — redirects, cookies, custom headers, raw bodies, streams — without bypassing the procedure system. This closes the gap exposed by the `ara/apps/api` audit, where the OAuth callback at `src/auth/atlassian.routes.ts` had to be a raw `fastify.get(...)` route because procedures couldn't model `reply.redirect()` or cookies.
37
+
38
+ ```typescript
39
+ import { procedure, raw } from "@veloxts/router";
40
+
41
+ export const oauthProcedures = procedures("oauth", {
42
+ authorize: procedure()
43
+ .rest({ method: "GET", path: "/auth/atlassian" })
44
+ .query(async ({ ctx }) => {
45
+ const { url, state, codeVerifier } = provider.buildAuthorizeUrl();
46
+ return raw({
47
+ cookies: [
48
+ {
49
+ name: "oauth_state",
50
+ value: pack(state, codeVerifier, secret),
51
+ options: { httpOnly: true, sameSite: "lax" },
52
+ },
53
+ ],
54
+ redirect: { url, status: 302 },
55
+ });
56
+ }),
57
+ });
58
+ ```
59
+
60
+ `raw()` accepts:
61
+ - `status` — HTTP status (default 200, ignored when `redirect` is set)
62
+ - `headers` — response headers
63
+ - `cookies` — array of `{ name, value, options }` (requires `@fastify/cookie` to be registered)
64
+ - `redirect` — `{ url, status? }` for 301/302/303/307/308 redirects
65
+ - `body` — string, Buffer, or Node `Readable` stream
66
+
67
+ The REST adapter detects `RawResponse` via a brand and short-circuits before applying the auto 201/204 status logic. OpenAPI generation already handles missing response schemas gracefully — `.raw()` procedures without `.output()` document as a response without a body schema (valid OpenAPI).
68
+
69
+ Also exports `RawResponse`, `RawResponseOptions`, `RawResponseCookie`, `RawResponseCookieOptions`, `RawResponseRedirect`, and `isRawResponse` from `@veloxts/router`.
70
+
71
+ ### Patch Changes
72
+
73
+ - Updated dependencies
74
+ - Updated dependencies [ca6ede3]
75
+ - @veloxts/core@0.9.0
76
+ - @veloxts/validation@0.9.0
77
+
3
78
  ## 0.8.3
4
79
 
5
80
  ### Patch Changes
@@ -471,7 +546,6 @@
471
546
  - ### feat(auth): Unified Adapter-Only Architecture
472
547
 
473
548
  **New Features:**
474
-
475
549
  - Add `JwtAdapter` implementing the `AuthAdapter` interface for unified JWT authentication
476
550
  - Add `jwtAuth()` convenience function for direct adapter usage with optional built-in routes (`/api/auth/refresh`, `/api/auth/logout`)
477
551
  - Add `AuthContext` discriminated union (`NativeAuthContext | AdapterAuthContext`) for type-safe auth mode handling
@@ -479,24 +553,20 @@
479
553
  - Add shared decoration utilities (`decorateAuth`, `setRequestAuth`, `checkDoubleRegistration`)
480
554
 
481
555
  **Architecture Changes:**
482
-
483
556
  - `authPlugin` now uses `JwtAdapter` internally - all authentication flows through the adapter pattern
484
557
  - Single code path for authentication (no more dual native/adapter modes)
485
558
  - `authContext.authMode` is now always `'adapter'` with `providerId='jwt'` when using `authPlugin`
486
559
 
487
560
  **Breaking Changes:**
488
-
489
561
  - Remove deprecated `LegacySessionConfig` interface (use `sessionMiddleware` instead)
490
562
  - Remove deprecated `session` field from `AuthConfig`
491
563
  - `User` interface no longer has index signature (extend via declaration merging)
492
564
 
493
565
  **Type Safety Improvements:**
494
-
495
566
  - `AuthContext` discriminated union enables exhaustive type narrowing based on `authMode`
496
567
  - Export `NativeAuthContext` and `AdapterAuthContext` types for explicit typing
497
568
 
498
569
  **Migration:**
499
-
500
570
  - Existing `authPlugin` usage remains backward-compatible
501
571
  - If checking `authContext.token`, use `authContext.session` instead (token stored in session for adapter mode)
502
572
 
@@ -515,12 +585,10 @@
515
585
  Addresses 9 user feedback items to improve DX, reduce boilerplate, and eliminate template duplications.
516
586
 
517
587
  ### Phase 1: Validation Helpers (`@veloxts/validation`)
518
-
519
588
  - Add `prismaDecimal()`, `prismaDecimalNullable()`, `prismaDecimalOptional()` for Prisma Decimal → number conversion
520
589
  - Add `dateToIso`, `dateToIsoNullable`, `dateToIsoOptional` aliases for consistency
521
590
 
522
591
  ### Phase 2: Template Deduplication (`@veloxts/auth`)
523
-
524
592
  - Export `createEnhancedTokenStore()` with token revocation and refresh token reuse detection
525
593
  - Export `parseUserRoles()` and `DEFAULT_ALLOWED_ROLES`
526
594
  - Fix memory leak: track pending timeouts for proper cleanup on `destroy()`
@@ -528,20 +596,17 @@
528
596
  - Fix jwtManager singleton pattern in templates
529
597
 
530
598
  ### Phase 3: Router Helpers (`@veloxts/router`)
531
-
532
599
  - Add `createRouter()` returning `{ collections, router }` for DRY setup
533
600
  - Add `toRouter()` for router-only use cases
534
601
  - Update all router templates to use `createRouter()`
535
602
 
536
603
  ### Phase 4: Guard Type Narrowing - Experimental (`@veloxts/auth`, `@veloxts/router`)
537
-
538
604
  - Add `NarrowingGuard` interface with phantom `_narrows` type
539
605
  - Add `authenticatedNarrow` and `hasRoleNarrow()` guards
540
606
  - Add `guardNarrow()` method to `ProcedureBuilder` for context narrowing
541
607
  - Enables `ctx.user` to be non-null after guard passes
542
608
 
543
609
  ### Phase 5: Documentation (`@veloxts/router`)
544
-
545
610
  - Document `.rest()` override patterns
546
611
  - Document `createRouter()` helper usage
547
612
  - Document `guardNarrow()` experimental API
@@ -1466,7 +1531,6 @@
1466
1531
  ### Patch Changes
1467
1532
 
1468
1533
  - Fix Prisma client generation in scaffolder
1469
-
1470
1534
  - Added automatic Prisma client generation after dependency installation in create-velox-app
1471
1535
  - Fixed database template to validate DATABASE_URL environment variable
1472
1536
  - Added alpha release warning to all package READMEs
package/dist/index.d.ts CHANGED
@@ -39,7 +39,7 @@
39
39
  */
40
40
  /** Router package version */
41
41
  export declare const ROUTER_VERSION: string;
42
- export type { AfterHandler, CompiledProcedure, ContextExtensions, ContextFactory, ExtendedContext, GuardLike, HttpMethod, InferProcedureContext, InferProcedureErrors, InferProcedureInput, InferProcedureOutput, Middleware, MiddlewareArgs, MiddlewareFunction, MiddlewareNext, MiddlewareResult, ParentResourceChain, ParentResourceConfig, PolicyActionLike, ProcedureCollection, ProcedureHandler, ProcedureHandlerArgs, ProcedureRecord, ProcedureType, RestRouteOverride, TransactionalOptions, } from './types.js';
42
+ export type { AfterHandler, CheckFn, CompiledProcedure, ContextExtensions, ContextFactory, ExtendedContext, GuardLike, HttpMethod, InferProcedureContext, InferProcedureErrors, InferProcedureInput, InferProcedureOutput, Middleware, MiddlewareArgs, MiddlewareFunction, MiddlewareNext, MiddlewareResult, ParentResourceChain, ParentResourceConfig, PolicyActionLike, ProcedureCollection, ProcedureHandler, ProcedureHandlerArgs, ProcedureRecord, ProcedureType, RestRouteOverride, TransactionalOptions, } from './types.js';
43
43
  export { PROCEDURE_METHOD_MAP, } from './types.js';
44
44
  export type { GuardErrorResponse, RouterErrorCode } from './errors.js';
45
45
  export { GuardError, isGuardError } from './errors.js';
@@ -47,6 +47,8 @@ export type { DefineProceduresOptions, } from './procedure/builder.js';
47
47
  export { defineProcedures, executeProcedure, isCompiledProcedure, isProcedureCollection, procedure, procedures, } from './procedure/builder.js';
48
48
  export type { PipelineStep, RevertAction, StepOptions } from './procedure/pipeline.js';
49
49
  export { defineRevert, defineStep } from './procedure/pipeline.js';
50
+ export type { RawResponse, RawResponseCookie, RawResponseCookieOptions, RawResponseOptions, RawResponseRedirect, } from './raw.js';
51
+ export { isRawResponse, raw } from './raw.js';
50
52
  export { createProcedure, typedProcedure } from './procedure/factory.js';
51
53
  export type { BuilderRuntimeState, InferOutputSchema, InferProcedures, InferSchemaOutput, PostHandlerBuilder, ProcedureBuilder, ProcedureBuilderState, ProcedureDefinitions, ValidOutputSchema, ValidSchema, } from './procedure/types.js';
52
54
  export type { RouterResult } from './router-utils.js';
package/dist/index.js CHANGED
@@ -52,6 +52,7 @@ export {
52
52
  defineProcedures, executeProcedure, isCompiledProcedure, isProcedureCollection, procedure, procedures, // Short alias for defineProcedures
53
53
  } from './procedure/builder.js';
54
54
  export { defineRevert, defineStep } from './procedure/pipeline.js';
55
+ export { isRawResponse, raw } from './raw.js';
55
56
  // ============================================================================
56
57
  // Router Utilities
57
58
  // ============================================================================
@@ -169,6 +169,19 @@ function createBuilder(state) {
169
169
  policyAction: action,
170
170
  });
171
171
  },
172
+ /**
173
+ * Adds a post-middleware authorization check.
174
+ *
175
+ * Runs after input validation, pipeline transforms, and middleware —
176
+ * immediately before the handler. Returning `false` throws ForbiddenError;
177
+ * throwing propagates as-is.
178
+ */
179
+ check(check) {
180
+ return createBuilder({
181
+ ...state,
182
+ checks: [...(state.checks ?? []), check],
183
+ });
184
+ },
172
185
  /**
173
186
  * Declares domain error classes this procedure may throw
174
187
  */
@@ -326,17 +339,26 @@ function compileProcedureOrBranching(type, handlerOrMap, state) {
326
339
  */
327
340
  function compileProcedure(type, handler, state) {
328
341
  const typedMiddlewares = state.middlewares;
342
+ // Wrap the handler with .check() invocations so checks run AFTER middleware.
343
+ // Wrapping at the handler level (rather than inserting a phase in
344
+ // executeProcedure) keeps the precompiled middleware executor intact and
345
+ // ensures checks see middleware-extended ctx + post-pipeline input.
346
+ const checks = state.checks;
347
+ const handlerWithChecks = checks && checks.length > 0 ? wrapHandlerWithChecks(handler, checks) : handler;
329
348
  // Pre-compile the middleware chain executor if middlewares exist
330
349
  // This avoids rebuilding the chain on every request
331
- const precompiledExecutor = typedMiddlewares.length > 0 ? createMiddlewareExecutor(typedMiddlewares, handler) : undefined;
350
+ const precompiledExecutor = typedMiddlewares.length > 0
351
+ ? createMiddlewareExecutor(typedMiddlewares, handlerWithChecks)
352
+ : undefined;
332
353
  // Create the final procedure object with .useAfter() support
333
354
  return createPostHandlerBuilder({
334
355
  type,
335
- handler,
356
+ handler: handlerWithChecks,
336
357
  inputSchema: state.inputSchema,
337
358
  outputSchema: state.outputSchema,
338
359
  middlewares: typedMiddlewares,
339
360
  guards: state.guards,
361
+ checks,
340
362
  restOverride: state.restOverride,
341
363
  deprecated: state.deprecated,
342
364
  deprecationMessage: state.deprecationMessage,
@@ -362,6 +384,25 @@ function compileProcedure(type, handler, state) {
362
384
  policyAction: state.policyAction,
363
385
  });
364
386
  }
387
+ /**
388
+ * Wraps a handler with `.check()` predicates that run before the handler.
389
+ *
390
+ * Checks AND-compose with short-circuit. Returning `false` throws
391
+ * ForbiddenError; throwing inside a check propagates as-is.
392
+ *
393
+ * @internal
394
+ */
395
+ function wrapHandlerWithChecks(handler, checks) {
396
+ return async ({ input, ctx }) => {
397
+ for (const check of checks) {
398
+ const passed = await check({ input, ctx });
399
+ if (!passed) {
400
+ throw new ForbiddenError('Authorization check failed');
401
+ }
402
+ }
403
+ return handler({ input, ctx });
404
+ };
405
+ }
365
406
  /**
366
407
  * Compiles a Level 3 branched procedure with a handler map
367
408
  *
@@ -372,6 +413,16 @@ function compileProcedure(type, handler, state) {
372
413
  */
373
414
  function compileProcedureWithHandlerMap(type, dispatchHandler, handlerMap, state) {
374
415
  const typedMiddlewares = state.middlewares;
416
+ // Wrap each branched handler with .check() invocations (parallel to
417
+ // compileProcedure). The dispatch handler stays unwrapped because checks
418
+ // run inside the per-branch handlers.
419
+ const checks = state.checks;
420
+ const handlerMapWithChecks = checks && checks.length > 0
421
+ ? Object.fromEntries(Object.entries(handlerMap).map(([key, branchHandler]) => [
422
+ key,
423
+ wrapHandlerWithChecks(branchHandler, checks),
424
+ ]))
425
+ : handlerMap;
375
426
  return createPostHandlerBuilder({
376
427
  type,
377
428
  handler: dispatchHandler,
@@ -379,6 +430,7 @@ function compileProcedureWithHandlerMap(type, dispatchHandler, handlerMap, state
379
430
  outputSchema: undefined, // Level 3 uses resource schema projection, not Zod output validation
380
431
  middlewares: typedMiddlewares,
381
432
  guards: [], // Guards come from access level config
433
+ checks,
382
434
  restOverride: state.restOverride,
383
435
  deprecated: state.deprecated,
384
436
  deprecationMessage: state.deprecationMessage,
@@ -388,7 +440,7 @@ function compileProcedureWithHandlerMap(type, dispatchHandler, handlerMap, state
388
440
  _precompiledExecutor: undefined, // Branched procedures don't use precompiled chains
389
441
  _resourceSchema: state.resourceSchema,
390
442
  _resourceLevel: undefined, // No fixed level — determined at runtime by branch selection
391
- _handlerMap: handlerMap,
443
+ _handlerMap: handlerMapWithChecks,
392
444
  // Store error classes declared via .throws()
393
445
  errorClasses: state.errorClasses,
394
446
  // Store transactional configuration
@@ -12,7 +12,7 @@
12
12
  import type { BaseContext, DomainError } from '@veloxts/core';
13
13
  import type { ZodType } from 'zod';
14
14
  import type { OutputForLevel, ResourceSchema, TaggedResourceSchema } from '../resource/index.js';
15
- import type { AfterHandler, CompiledProcedure, GuardLike, MiddlewareFunction, ParentResourceConfig, PolicyActionLike, ProcedureHandler, RestRouteOverride, TransactionalOptions } from '../types.js';
15
+ import type { AfterHandler, CheckFn, CompiledProcedure, GuardLike, MiddlewareFunction, ParentResourceConfig, PolicyActionLike, ProcedureHandler, RestRouteOverride, TransactionalOptions } from '../types.js';
16
16
  import type { PipelineStep } from './pipeline.js';
17
17
  /**
18
18
  * Internal state type that accumulates type information through the builder chain
@@ -257,6 +257,41 @@ export interface ProcedureBuilder<TInput = unknown, TOutput = unknown, TContext
257
257
  policy<TResourceName extends string>(action: string extends TResourceName ? PolicyActionLike<unknown, unknown, TResourceName> : Uncapitalize<TResourceName> extends keyof TContext ? PolicyActionLike<unknown, unknown, TResourceName> : PolicyActionLike<unknown, unknown, TResourceName> & {
258
258
  _contextMissing: `Add .use() middleware to provide '${Uncapitalize<TResourceName>}' in context before .policy()`;
259
259
  }): ProcedureBuilder<TInput, TOutput, TContext, TErrors>;
260
+ /**
261
+ * Adds a post-middleware authorization check.
262
+ *
263
+ * `.check()` runs AFTER input validation, AFTER `.through()` pipeline
264
+ * transforms, and AFTER all `.use()` middleware have populated the context
265
+ * — immediately before the handler. Use it for authorization that depends
266
+ * on input fields and/or context values populated by middleware (e.g.
267
+ * resource ownership where the resource was loaded by a preceding `.use()`).
268
+ *
269
+ * Returning `false` throws a `ForbiddenError` (403). Throwing inside the
270
+ * function propagates the original error so callers can throw custom
271
+ * errors with finer-grained status codes.
272
+ *
273
+ * Multiple `.check()` calls AND-compose with short-circuit on first failure.
274
+ *
275
+ * **When to use which:**
276
+ * - `.guard(authenticated)` — pre-input, ctx-only authentication (fast-fail).
277
+ * - `.policy(PostPolicy.update)` — declarative resource-policy authorization.
278
+ * - `.check(({ input, ctx }) => ...)` — ad-hoc authorization that needs
279
+ * both `input` and middleware-extended `ctx`.
280
+ *
281
+ * @param check - Post-middleware authorization predicate
282
+ * @returns Same builder (no type changes)
283
+ *
284
+ * @example
285
+ * ```typescript
286
+ * procedure()
287
+ * .input(z.object({ sessionId: z.string() }))
288
+ * .guard(authenticated)
289
+ * .use(loadParticipant) // adds ctx.participant
290
+ * .check(({ input, ctx }) => ctx.participant.sessionId === input.sessionId)
291
+ * .query(handler);
292
+ * ```
293
+ */
294
+ check(check: CheckFn<TInput, TContext>): ProcedureBuilder<TInput, TOutput, TContext, TErrors>;
260
295
  /**
261
296
  * Declares domain error classes that this procedure may throw
262
297
  *
@@ -627,6 +662,8 @@ export interface BuilderRuntimeState {
627
662
  pipelineSteps?: PipelineStep[];
628
663
  /** Policy action reference for declarative authorization */
629
664
  policyAction?: PolicyActionLike;
665
+ /** Post-middleware checks declared via .check() */
666
+ checks?: CheckFn[];
630
667
  }
631
668
  /**
632
669
  * Type for the procedures object passed to defineProcedures
package/dist/raw.d.ts ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Raw response primitive for procedures
3
+ *
4
+ * Lets procedure handlers return non-JSON HTTP responses (redirects, cookies,
5
+ * custom headers, raw bodies, streams) without bypassing the procedure system.
6
+ *
7
+ * Returning a `RawResponse` from a procedure handler tells the REST adapter
8
+ * to apply the response settings to Fastify's `reply` and skip the default
9
+ * JSON serialization path.
10
+ *
11
+ * Procedures using `raw()` typically omit `.output()` since the response
12
+ * body isn't a JSON-serialized DTO. Their OpenAPI documentation is generated
13
+ * without a response schema (valid OpenAPI for raw responses).
14
+ *
15
+ * @example OAuth callback (replaces a raw `fastify.get(...)` route)
16
+ * ```typescript
17
+ * import { procedure, raw } from '@veloxts/router';
18
+ *
19
+ * export const oauthProcedures = procedures('oauth', {
20
+ * authorize: procedure()
21
+ * .rest({ method: 'GET', path: '/auth/atlassian' })
22
+ * .query(async ({ ctx }) => {
23
+ * const { url, state, codeVerifier } = provider.buildAuthorizeUrl();
24
+ * return raw({
25
+ * cookies: [
26
+ * { name: 'oauth_state', value: pack(state, codeVerifier), options: { httpOnly: true } },
27
+ * ],
28
+ * redirect: { url, status: 302 },
29
+ * });
30
+ * }),
31
+ * });
32
+ * ```
33
+ *
34
+ * @example File download
35
+ * ```typescript
36
+ * import { createReadStream } from 'node:fs';
37
+ *
38
+ * download: procedure()
39
+ * .input(z.object({ id: z.string() }))
40
+ * .query(async ({ input }) => raw({
41
+ * status: 200,
42
+ * headers: {
43
+ * 'Content-Type': 'application/octet-stream',
44
+ * 'Content-Disposition': `attachment; filename="${input.id}.bin"`,
45
+ * },
46
+ * body: createReadStream(`/storage/${input.id}.bin`),
47
+ * }));
48
+ * ```
49
+ *
50
+ * @module raw
51
+ */
52
+ import type { Readable } from 'node:stream';
53
+ /** Brand identifying a `RawResponse` returned from `raw()`. */
54
+ declare const RAW_RESPONSE_BRAND = "__velox_raw_response__";
55
+ /**
56
+ * Cookie options accepted by `RawResponse.cookies`.
57
+ *
58
+ * Mirrors `@fastify/cookie`'s `CookieSerializeOptions` shape but kept as a
59
+ * plain interface so this module has no hard dependency on `@fastify/cookie`.
60
+ * Procedures using `cookies` require that plugin to be registered (see
61
+ * `@veloxts/auth` template for the standard wiring).
62
+ */
63
+ export interface RawResponseCookieOptions {
64
+ domain?: string;
65
+ expires?: Date;
66
+ httpOnly?: boolean;
67
+ maxAge?: number;
68
+ path?: string;
69
+ priority?: 'low' | 'medium' | 'high';
70
+ sameSite?: 'lax' | 'none' | 'strict' | boolean;
71
+ secure?: boolean | 'auto';
72
+ signed?: boolean;
73
+ }
74
+ /** Cookie to set on the response. */
75
+ export interface RawResponseCookie {
76
+ /** Cookie name. */
77
+ readonly name: string;
78
+ /** Cookie value (string only — caller is responsible for encoding). */
79
+ readonly value: string;
80
+ /** Optional cookie attributes (max-age, httpOnly, secure, etc.). */
81
+ readonly options?: RawResponseCookieOptions;
82
+ }
83
+ /** Redirect descriptor. */
84
+ export interface RawResponseRedirect {
85
+ /** Absolute or relative URL to redirect to. */
86
+ readonly url: string;
87
+ /**
88
+ * HTTP redirect status. Defaults to `302` (Found).
89
+ * Use `303` for POST → GET redirects, `307`/`308` to preserve method+body.
90
+ */
91
+ readonly status?: 301 | 302 | 303 | 307 | 308;
92
+ }
93
+ /**
94
+ * Options accepted by `raw()`. All fields optional — a bare `raw({})`
95
+ * sends an empty 200 response.
96
+ */
97
+ export interface RawResponseOptions {
98
+ /**
99
+ * HTTP status code. Defaults to `200`. Ignored when `redirect` is set
100
+ * (the redirect's `status` wins).
101
+ */
102
+ status?: number;
103
+ /** Headers to set on the response. */
104
+ headers?: Record<string, string>;
105
+ /**
106
+ * Cookies to set via `reply.setCookie`. Requires `@fastify/cookie` to be
107
+ * registered on the Fastify instance.
108
+ */
109
+ cookies?: ReadonlyArray<RawResponseCookie>;
110
+ /** Redirect to a URL. When set, `body` is ignored. */
111
+ redirect?: RawResponseRedirect;
112
+ /**
113
+ * Response body. Strings, Buffers, and Node streams are sent as-is via
114
+ * `reply.send()` so the caller controls Content-Type via `headers`.
115
+ * Plain objects are NOT serialized — use a regular `.output()` schema for
116
+ * JSON responses.
117
+ */
118
+ body?: string | Buffer | Readable;
119
+ }
120
+ /**
121
+ * Branded raw-response object returned by `raw()`. The REST adapter detects
122
+ * this brand and applies the contained settings to Fastify's `reply`.
123
+ */
124
+ export interface RawResponse extends RawResponseOptions {
125
+ readonly [RAW_RESPONSE_BRAND]: true;
126
+ }
127
+ /**
128
+ * Create a raw HTTP response from a procedure handler.
129
+ *
130
+ * The returned object is a branded marker — its presence tells the REST
131
+ * adapter to bypass JSON serialization and apply headers/cookies/redirect/body
132
+ * directly to the Fastify reply.
133
+ *
134
+ * @param options - Response settings. All fields optional.
135
+ * @returns A `RawResponse` to return from a `.query()` or `.mutation()` handler.
136
+ *
137
+ * @example
138
+ * ```typescript
139
+ * .query(async ({ ctx }) => raw({
140
+ * cookies: [{ name: 'session', value: token, options: { httpOnly: true } }],
141
+ * redirect: { url: '/dashboard', status: 302 },
142
+ * }))
143
+ * ```
144
+ */
145
+ export declare function raw(options?: RawResponseOptions): RawResponse;
146
+ /**
147
+ * Type guard: returns true when `value` is a `RawResponse` produced by `raw()`.
148
+ *
149
+ * Verifies via a private WeakSet — checking the brand key alone would let
150
+ * user-controlled JSON forge raw responses if echoed by a handler. Values
151
+ * constructed in another realm (Worker, vm) won't be in this realm's
152
+ * registry; that's intentional — if you need cross-realm raw responses,
153
+ * call `raw()` on the realm that owns the REST adapter.
154
+ *
155
+ * Used by the REST adapter to short-circuit JSON serialization. Exported for
156
+ * advanced cases (custom adapters, testing) — most users don't need it.
157
+ */
158
+ export declare function isRawResponse(value: unknown): value is RawResponse;
159
+ export {};
package/dist/raw.js ADDED
@@ -0,0 +1,52 @@
1
+ /** Brand identifying a `RawResponse` returned from `raw()`. */
2
+ const RAW_RESPONSE_BRAND = '__velox_raw_response__';
3
+ /**
4
+ * WeakSet of objects produced by `raw()`. Used by `isRawResponse` to verify
5
+ * that a value originated from this module rather than just carrying the
6
+ * brand key. Defeats forgery attempts where a procedure handler echoes
7
+ * user-controlled JSON containing `__velox_raw_response__: true` (e.g. a
8
+ * generic echo handler returning `request.body` verbatim) — without this
9
+ * registry, such a payload could trigger an unintended redirect or cookie
10
+ * set via the REST adapter's short-circuit.
11
+ *
12
+ * @internal
13
+ */
14
+ const rawInstances = new WeakSet();
15
+ /**
16
+ * Create a raw HTTP response from a procedure handler.
17
+ *
18
+ * The returned object is a branded marker — its presence tells the REST
19
+ * adapter to bypass JSON serialization and apply headers/cookies/redirect/body
20
+ * directly to the Fastify reply.
21
+ *
22
+ * @param options - Response settings. All fields optional.
23
+ * @returns A `RawResponse` to return from a `.query()` or `.mutation()` handler.
24
+ *
25
+ * @example
26
+ * ```typescript
27
+ * .query(async ({ ctx }) => raw({
28
+ * cookies: [{ name: 'session', value: token, options: { httpOnly: true } }],
29
+ * redirect: { url: '/dashboard', status: 302 },
30
+ * }))
31
+ * ```
32
+ */
33
+ export function raw(options = {}) {
34
+ const response = { ...options, [RAW_RESPONSE_BRAND]: true };
35
+ rawInstances.add(response);
36
+ return response;
37
+ }
38
+ /**
39
+ * Type guard: returns true when `value` is a `RawResponse` produced by `raw()`.
40
+ *
41
+ * Verifies via a private WeakSet — checking the brand key alone would let
42
+ * user-controlled JSON forge raw responses if echoed by a handler. Values
43
+ * constructed in another realm (Worker, vm) won't be in this realm's
44
+ * registry; that's intentional — if you need cross-realm raw responses,
45
+ * call `raw()` on the realm that owns the REST adapter.
46
+ *
47
+ * Used by the REST adapter to short-circuit JSON serialization. Exported for
48
+ * advanced cases (custom adapters, testing) — most users don't need it.
49
+ */
50
+ export function isRawResponse(value) {
51
+ return typeof value === 'object' && value !== null && rawInstances.has(value);
52
+ }
@@ -10,6 +10,7 @@
10
10
  import { ConfigurationError, createLogger } from '@veloxts/core';
11
11
  const log = createLogger('router');
12
12
  import { executeProcedure } from '../procedure/builder.js';
13
+ import { isRawResponse } from '../raw.js';
13
14
  import { buildMultiLevelNestedPath, buildNestedRestPath, buildRestPath, calculateNestingDepth, parseNamingConvention, } from './naming.js';
14
15
  import { registerCollections } from './registry.js';
15
16
  /** Default nesting depth threshold for warnings */
@@ -176,6 +177,10 @@ function createRouteHandler(route) {
176
177
  const ctx = getContextFromRequest(request);
177
178
  // Execute the procedure
178
179
  const result = await executeProcedure(route.procedure, input, ctx);
180
+ // Short-circuit on raw responses (redirects, cookies, custom headers/body)
181
+ if (isRawResponse(result)) {
182
+ return applyRawResponse(reply, result);
183
+ }
179
184
  // Set appropriate HTTP status codes based on method and result
180
185
  switch (route.method) {
181
186
  case 'POST':
@@ -196,6 +201,44 @@ function createRouteHandler(route) {
196
201
  return result;
197
202
  };
198
203
  }
204
+ /**
205
+ * Apply a `RawResponse` to a Fastify reply.
206
+ *
207
+ * Sets headers, cookies, status, and either redirects or sends the body.
208
+ * Returns the reply so Fastify treats the request as handled (no implicit
209
+ * JSON serialization of the return value).
210
+ *
211
+ * Cookies require `@fastify/cookie` to be registered on the Fastify instance —
212
+ * we cast through `unknown` to avoid a hard dependency on that plugin's types.
213
+ *
214
+ * @internal
215
+ */
216
+ function applyRawResponse(reply, response) {
217
+ if (response.headers) {
218
+ for (const [name, value] of Object.entries(response.headers)) {
219
+ reply.header(name, value);
220
+ }
221
+ }
222
+ if (response.cookies && response.cookies.length > 0) {
223
+ const replyWithCookie = reply;
224
+ if (typeof replyWithCookie.setCookie !== 'function') {
225
+ throw new ConfigurationError('raw().cookies requires @fastify/cookie to be registered on the Fastify instance.');
226
+ }
227
+ for (const cookie of response.cookies) {
228
+ replyWithCookie.setCookie(cookie.name, cookie.value, cookie.options);
229
+ }
230
+ }
231
+ if (response.redirect) {
232
+ return reply.redirect(response.redirect.url, response.redirect.status ?? 302);
233
+ }
234
+ if (response.status !== undefined) {
235
+ reply.status(response.status);
236
+ }
237
+ if (response.body !== undefined) {
238
+ return reply.send(response.body);
239
+ }
240
+ return reply.send();
241
+ }
199
242
  /**
200
243
  * Type guard to check if a value is a plain object
201
244
  */
package/dist/types.d.ts CHANGED
@@ -221,6 +221,32 @@ export type MiddlewareFunction<TInput, TContext extends BaseContext = BaseContex
221
221
  * ```
222
222
  */
223
223
  export type Middleware<TContext extends BaseContext = BaseContext> = MiddlewareFunction<unknown, TContext, TContext>;
224
+ /**
225
+ * Post-middleware authorization check.
226
+ *
227
+ * Runs AFTER input validation, AFTER pipeline transforms, and AFTER all
228
+ * `.use()` middleware has populated the context — but BEFORE the handler.
229
+ * Use `.check()` for authorization that depends on input fields and/or
230
+ * context values populated by middleware (e.g. resource ownership checks
231
+ * where ctx.participant was loaded by `.use(loadParticipant)`).
232
+ *
233
+ * Returning `false` throws a `ForbiddenError` (403). Throwing inside the
234
+ * function propagates the original error so callers can throw custom
235
+ * domain errors with finer-grained status codes.
236
+ *
237
+ * Multiple `.check()` calls AND-compose with short-circuit on first failure.
238
+ *
239
+ * Distinguished from `.guard()`: guards are pre-input, ctx-only, fast-fail
240
+ * authentication checks. Checks are post-middleware authorization that can
241
+ * read input.
242
+ *
243
+ * @template TInput - The validated input type
244
+ * @template TContext - The context type (post-middleware extensions)
245
+ */
246
+ export type CheckFn<TInput = unknown, TContext extends BaseContext = BaseContext> = (args: {
247
+ input: TInput;
248
+ ctx: TContext;
249
+ }) => boolean | Promise<boolean>;
224
250
  /**
225
251
  * REST route override configuration
226
252
  *
@@ -325,6 +351,14 @@ export interface CompiledProcedure<TInput = unknown, TOutput = unknown, TContext
325
351
  readonly middlewares: ReadonlyArray<MiddlewareFunction<TInput, TContext, TContext, TOutput>>;
326
352
  /** Guards to execute before handler (checked before middleware) */
327
353
  readonly guards: ReadonlyArray<GuardLike<TContext>>;
354
+ /**
355
+ * Post-middleware authorization checks (registered via `.check()`).
356
+ *
357
+ * Run after input validation, pipeline transforms, and all middleware
358
+ * have populated the context — immediately before the handler. Returning
359
+ * `false` throws ForbiddenError (403); throwing propagates as-is.
360
+ */
361
+ readonly checks?: ReadonlyArray<CheckFn<TInput, TContext>>;
328
362
  /** REST route override (if specified) */
329
363
  readonly restOverride?: RestRouteOverride;
330
364
  /** Whether this procedure is deprecated */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veloxts/router",
3
- "version": "0.8.3",
3
+ "version": "0.9.0",
4
4
  "description": "Procedure definitions with tRPC and REST routing for VeloxTS framework",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -39,10 +39,11 @@
39
39
  "dependencies": {
40
40
  "@trpc/server": "11.16.0",
41
41
  "fastify": "5.8.5",
42
- "@veloxts/core": "0.8.3",
43
- "@veloxts/validation": "0.8.3"
42
+ "@veloxts/core": "0.9.0",
43
+ "@veloxts/validation": "0.9.0"
44
44
  },
45
45
  "devDependencies": {
46
+ "@fastify/cookie": "11.0.2",
46
47
  "@vitest/coverage-v8": "4.1.5",
47
48
  "esbuild": "0.28.0",
48
49
  "typescript": "5.9.3",