@veloxts/router 0.8.2 → 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 +84 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/procedure/builder.js +55 -3
- package/dist/procedure/types.d.ts +38 -1
- package/dist/raw.d.ts +159 -0
- package/dist/raw.js +52 -0
- package/dist/rest/adapter.js +43 -0
- package/dist/types.d.ts +34 -0
- package/package.json +10 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,89 @@
|
|
|
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
|
+
|
|
78
|
+
## 0.8.3
|
|
79
|
+
|
|
80
|
+
### Patch Changes
|
|
81
|
+
|
|
82
|
+
- bump dependencies across packages (April 2026)
|
|
83
|
+
- Updated dependencies
|
|
84
|
+
- @veloxts/core@0.8.3
|
|
85
|
+
- @veloxts/validation@0.8.3
|
|
86
|
+
|
|
3
87
|
## 0.8.2
|
|
4
88
|
|
|
5
89
|
### Patch Changes
|
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
|
|
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:
|
|
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
|
+
}
|
package/dist/rest/adapter.js
CHANGED
|
@@ -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.
|
|
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",
|
|
@@ -37,17 +37,18 @@
|
|
|
37
37
|
}
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@trpc/server": "11.
|
|
41
|
-
"fastify": "5.8.
|
|
42
|
-
"@veloxts/core": "0.
|
|
43
|
-
"@veloxts/validation": "0.
|
|
40
|
+
"@trpc/server": "11.16.0",
|
|
41
|
+
"fastify": "5.8.5",
|
|
42
|
+
"@veloxts/core": "0.9.0",
|
|
43
|
+
"@veloxts/validation": "0.9.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
|
-
"@
|
|
47
|
-
"
|
|
46
|
+
"@fastify/cookie": "11.0.2",
|
|
47
|
+
"@vitest/coverage-v8": "4.1.5",
|
|
48
|
+
"esbuild": "0.28.0",
|
|
48
49
|
"typescript": "5.9.3",
|
|
49
|
-
"vite": "7.3.
|
|
50
|
-
"vitest": "4.1.
|
|
50
|
+
"vite": "7.3.2",
|
|
51
|
+
"vitest": "4.1.5",
|
|
51
52
|
"zod": "4.3.6"
|
|
52
53
|
},
|
|
53
54
|
"peerDependencies": {
|