@quilla-be-kit/http 0.6.0 → 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/README.md +157 -25
- package/dist/.tsbuildinfo +1 -1
- package/dist/adapter/hono/hono-request.adapter.d.ts +3 -1
- package/dist/adapter/hono/hono-request.adapter.d.ts.map +1 -1
- package/dist/adapter/hono/hono-request.adapter.js +4 -2
- package/dist/adapter/hono/hono-request.adapter.js.map +1 -1
- package/dist/adapter/hono/hono.server.d.ts.map +1 -1
- package/dist/adapter/hono/hono.server.js +3 -1
- package/dist/adapter/hono/hono.server.js.map +1 -1
- package/dist/decorator/authorize-scope.decorator.js +2 -2
- package/dist/decorator/authorize-scope.decorator.js.map +1 -1
- package/dist/decorator/controller.decorator.d.ts +1 -0
- package/dist/decorator/controller.decorator.d.ts.map +1 -1
- package/dist/decorator/controller.decorator.js +2 -1
- package/dist/decorator/controller.decorator.js.map +1 -1
- package/dist/decorator/method.decorators.d.ts +1 -0
- package/dist/decorator/method.decorators.d.ts.map +1 -1
- package/dist/decorator/method.decorators.js +1 -0
- package/dist/decorator/method.decorators.js.map +1 -1
- package/dist/decorator/route.metadata.d.ts +10 -1
- package/dist/decorator/route.metadata.d.ts.map +1 -1
- package/dist/decorator/route.metadata.js +68 -31
- package/dist/decorator/route.metadata.js.map +1 -1
- package/dist/decorator/validate-request.decorator.js +2 -2
- package/dist/decorator/validate-request.decorator.js.map +1 -1
- package/dist/request/default.deserializer.d.ts +20 -0
- package/dist/request/default.deserializer.d.ts.map +1 -0
- package/dist/request/default.deserializer.js +22 -0
- package/dist/request/default.deserializer.js.map +1 -0
- package/dist/request/http-attributes.d.ts +6 -0
- package/dist/request/http-attributes.d.ts.map +1 -1
- package/dist/request/http-attributes.js +6 -0
- package/dist/request/http-attributes.js.map +1 -1
- package/dist/request/index.d.ts +2 -0
- package/dist/request/index.d.ts.map +1 -1
- package/dist/request/index.js +1 -0
- package/dist/request/index.js.map +1 -1
- package/dist/request/request-deserializer.interface.d.ts +4 -0
- package/dist/request/request-deserializer.interface.d.ts.map +1 -0
- package/dist/request/request-deserializer.interface.js +2 -0
- package/dist/request/request-deserializer.interface.js.map +1 -0
- package/dist/router/auth-middleware-stack.type.d.ts +21 -5
- package/dist/router/auth-middleware-stack.type.d.ts.map +1 -1
- package/dist/router/http-module-meta.type.d.ts +5 -0
- package/dist/router/http-module-meta.type.d.ts.map +1 -1
- package/dist/router/normalized-route.type.d.ts +6 -1
- package/dist/router/normalized-route.type.d.ts.map +1 -1
- package/dist/router/router-options.type.d.ts +28 -8
- package/dist/router/router-options.type.d.ts.map +1 -1
- package/dist/router/router.d.ts +2 -2
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +92 -16
- package/dist/router/router.js.map +1 -1
- package/dist/server/http-conventions.type.d.ts +2 -0
- package/dist/server/http-conventions.type.d.ts.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Framework-agnostic HTTP layer for a quilla-be-kit service:
|
|
|
4
4
|
|
|
5
5
|
- **Controller decorators** — `@Controller`, `@Get` / `@Post` / `@Put` / `@Patch` / `@Delete` + `*Public` variants, `@AuthorizeScope`, `@ValidateRequest`.
|
|
6
6
|
- **Router** — walks decorated controller instances, composes prefixes, sorts routes by specificity, bridges to `ComponentRegistry<HttpModuleMeta>` from `@quilla-be-kit/runtime`, and (when `executionContext` is configured) installs a **system-owned execution-context bootstrap** so every handler can rely on `provider.getContext()`.
|
|
7
|
-
- **
|
|
7
|
+
- **Named auth stacks** — declare one stack per audience (`bearer` for humans, `apiKey` for machine-to-machine) and select one per route, controller, or module. `AuthMiddlewareStack` enforces phase ordering (`credentialVerification` → `sessionLoad?`) *within* a stack so consumers can't misorder security middlewares. Compose each from `@quilla-be-kit/security`'s middleware factories.
|
|
8
8
|
- **Request / response contracts** — `HttpRequest`, `HttpResponse`, `HttpMiddleware`, `AuthenticatedToken`, `HttpAttributes`.
|
|
9
9
|
- **Validator contract** — `RequestValidator` interface; wire Zod / Joi / Valibot / ArkType with a ~5-line adapter.
|
|
10
10
|
- **Hono adapter** — `@quilla-be-kit/http/adapter/hono` sub-path ships a `HonoServer` that implements `WebServer`. `hono` is an optional peer dep.
|
|
@@ -109,13 +109,16 @@ const router = new Router({
|
|
|
109
109
|
modules: components.getAll(),
|
|
110
110
|
executionContext: { provider },
|
|
111
111
|
globalMiddlewares: [/* your custom globals (cors, rate-limit, request-logger, ...) */],
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
112
|
+
authStacks: {
|
|
113
|
+
bearer: {
|
|
114
|
+
credentialVerification: bearerTokenMiddleware({ tokenService }),
|
|
115
|
+
sessionLoad: authenticatedSessionMiddleware({
|
|
116
|
+
sessionStore,
|
|
117
|
+
executionContextProvider: provider,
|
|
118
|
+
}),
|
|
119
|
+
},
|
|
118
120
|
},
|
|
121
|
+
defaultAuthStack: 'bearer',
|
|
119
122
|
});
|
|
120
123
|
|
|
121
124
|
const server = new HonoServer({
|
|
@@ -150,8 +153,9 @@ await runtime.run(async () => {
|
|
|
150
153
|
### `@Controller(prefix, options?)`
|
|
151
154
|
|
|
152
155
|
Class decorator. Every route on the class gets `prefix` prepended. The optional
|
|
153
|
-
second argument carries
|
|
154
|
-
[Versioning](#versioning))
|
|
156
|
+
second argument carries controller-level **version** (see
|
|
157
|
+
[Versioning](#versioning)) and **auth stack** (see [Auth stacks](#auth-stacks))
|
|
158
|
+
defaults.
|
|
155
159
|
|
|
156
160
|
```ts
|
|
157
161
|
@Controller('/users')
|
|
@@ -159,6 +163,9 @@ class UsersController { ... }
|
|
|
159
163
|
|
|
160
164
|
@Controller('/users', { version: '/api/v1' }) // controller-wide version default
|
|
161
165
|
class UsersController { ... }
|
|
166
|
+
|
|
167
|
+
@Controller('/mcp', { authStack: 'apiKey' }) // controller-wide auth stack
|
|
168
|
+
class McpController { ... }
|
|
162
169
|
```
|
|
163
170
|
|
|
164
171
|
### HTTP method decorators
|
|
@@ -171,15 +178,21 @@ class UsersController { ... }
|
|
|
171
178
|
@Delete(path, options?) @DeletePublic(path, options?)
|
|
172
179
|
```
|
|
173
180
|
|
|
174
|
-
The `*Public` variants mark the route as public — **auth
|
|
181
|
+
The `*Public` variants mark the route as public — **the auth stack is skipped entirely** for these routes. The non-public variants run their resolved auth stack before the handler.
|
|
175
182
|
|
|
176
|
-
The optional `options` argument (`RouteOptions`) carries
|
|
177
|
-
|
|
183
|
+
The optional `options` argument (`RouteOptions`) carries per-route **version**
|
|
184
|
+
(see [Versioning](#versioning)) and **auth stack** (see
|
|
185
|
+
[Auth stacks](#auth-stacks)) overrides:
|
|
178
186
|
|
|
179
187
|
```ts
|
|
180
188
|
@Get('/:id', { version: '/api/v2' })
|
|
189
|
+
@Get('/tools', { authStack: 'apiKey' })
|
|
181
190
|
```
|
|
182
191
|
|
|
192
|
+
Declaring `authStack` on a `*Public` route throws at Router construction — the
|
|
193
|
+
stack could never run, so silently ignoring it would be a bypass-shaped
|
|
194
|
+
surprise.
|
|
195
|
+
|
|
183
196
|
### Versioning
|
|
184
197
|
|
|
185
198
|
A version segment can be declared at three levels and is inserted
|
|
@@ -219,6 +232,80 @@ paths. Version is orthogonal to `*Public` / auth — it affects the path only.
|
|
|
219
232
|
When no version is set anywhere, composed paths are byte-identical to a service
|
|
220
233
|
that never adopted versioning.
|
|
221
234
|
|
|
235
|
+
### Auth stacks
|
|
236
|
+
|
|
237
|
+
Declare one `AuthMiddlewareStack` per authentication audience and select one per
|
|
238
|
+
route. Stack names are yours; Router owns only selection, ordering, and failure
|
|
239
|
+
behavior.
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
const router = new Router({
|
|
243
|
+
modules: components.getAll(),
|
|
244
|
+
executionContext: { provider },
|
|
245
|
+
authStacks: {
|
|
246
|
+
bearer: {
|
|
247
|
+
credentialVerification: bearerTokenMiddleware({ tokenService }),
|
|
248
|
+
sessionLoad: authenticatedSessionMiddleware({ sessionStore, executionContextProvider: provider }),
|
|
249
|
+
},
|
|
250
|
+
apiKey: {
|
|
251
|
+
credentialVerification: apiKeyMiddleware({ apiKeyService }),
|
|
252
|
+
sessionLoad: machineSessionLoad,
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
defaultAuthStack: 'bearer', // type-checked against the declared keys
|
|
256
|
+
});
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
A route resolves to exactly one stack, most specific level winning:
|
|
260
|
+
|
|
261
|
+
```
|
|
262
|
+
@Get(path, { authStack }) ?? @Controller(prefix, { authStack }) ?? HttpModuleMeta.authStack ?? defaultAuthStack
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
`*Public` routes skip the auth phase entirely, so they never resolve a stack —
|
|
266
|
+
a controller- or module-level `authStack` with a `*Public` sibling is fine and
|
|
267
|
+
common:
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
@Controller('/mcp', { authStack: 'apiKey' })
|
|
271
|
+
class McpController {
|
|
272
|
+
@Get('/tools') async tools(req) { ... } // apiKey
|
|
273
|
+
@GetPublic('/healthz') async health(req) { ... } // no auth
|
|
274
|
+
}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Router stamps the resolved name on the request as
|
|
278
|
+
`HttpAttributes.AUTH_STACK`, so a guard can assert *which* stack authenticated
|
|
279
|
+
the caller — `scopes` share one flat namespace across stacks and cannot carry
|
|
280
|
+
that distinction.
|
|
281
|
+
|
|
282
|
+
**Everything that can fail, fails at construction**, never at request time:
|
|
283
|
+
|
|
284
|
+
| Condition | Why it throws |
|
|
285
|
+
| --- | --- |
|
|
286
|
+
| `authStacks` present but empty | Every non-public route would run unauthenticated while looking configured. Omit the option for a service with no auth. |
|
|
287
|
+
| `authStacks` set without `executionContext` | Auth middlewares need an active `ExecutionContext` scope. |
|
|
288
|
+
| `authStacks` set without `defaultAuthStack` | Routes that declare nothing would have no stack. Also a compile-time error. |
|
|
289
|
+
| `defaultAuthStack` names an undeclared stack | Typo. Also a compile-time error. |
|
|
290
|
+
| A route / `@Controller` / module names an undeclared stack | Typo, reported with the controller and handler name. |
|
|
291
|
+
| `authStack` on a `*Public` route | Contradictory — the stack could never run. |
|
|
292
|
+
| The same controller registered twice | Its copies would resolve to different stacks at different paths. |
|
|
293
|
+
|
|
294
|
+
`defaultAuthStack` is constrained to the keys of `authStacks`, so a typo is a
|
|
295
|
+
type error before it is a runtime one. Route-, controller-, and module-level
|
|
296
|
+
`authStack` are plain strings — decorators and module metadata are evaluated
|
|
297
|
+
independently of Router construction, which is exactly why the runtime guards
|
|
298
|
+
above exist.
|
|
299
|
+
|
|
300
|
+
> **One controller, one audience.** Mixing two audiences within a controller is
|
|
301
|
+
> legal but usually a smell: a reviewer scanning the class can no longer tell its
|
|
302
|
+
> auth surface at a glance. Prefer a separate controller.
|
|
303
|
+
|
|
304
|
+
> **Stacks must not share a credential verifier or signing key.** Per-route
|
|
305
|
+
> selection is the only thing keeping audiences apart — a credential minted for
|
|
306
|
+
> one stack is otherwise verifiable by any stack holding the same key. Scope
|
|
307
|
+
> strings must likewise be globally unique across stacks.
|
|
308
|
+
|
|
222
309
|
### `@AuthorizeScope(scope, mode?)`
|
|
223
310
|
|
|
224
311
|
Scope-based authorization. Reads an `AuthenticatedToken` from `request.getAttribute(HttpAttributes.VERIFIED_TOKEN)` and checks the token's `scopes` against the required scope(s).
|
|
@@ -383,7 +470,7 @@ const router = new Router({
|
|
|
383
470
|
// non-public) gets a baseline anonymous context with a correlation id
|
|
384
471
|
// read from `correlationIdHeader` (default `'x-correlation-id'`) or a
|
|
385
472
|
// generated UUID if absent.
|
|
386
|
-
// **Required iff `
|
|
473
|
+
// **Required iff `authStacks` is set** — Router throws at construction
|
|
387
474
|
// otherwise. Skip it for pure-public services that never call
|
|
388
475
|
// `request.getExecutionContext()`. The provider carries its own factory
|
|
389
476
|
// (default `executionContextFactory`); pass a custom factory via
|
|
@@ -395,7 +482,14 @@ const router = new Router({
|
|
|
395
482
|
},
|
|
396
483
|
|
|
397
484
|
globalMiddlewares: [...], // custom — run on every route after system bootstrap
|
|
398
|
-
|
|
485
|
+
|
|
486
|
+
// Named auth stacks — non-public routes only. See "Auth stacks" above for
|
|
487
|
+
// the resolution ladder and the full list of construction-time throws.
|
|
488
|
+
authStacks: {
|
|
489
|
+
bearer: { credentialVerification, sessionLoad? },
|
|
490
|
+
apiKey: { credentialVerification, sessionLoad? },
|
|
491
|
+
},
|
|
492
|
+
defaultAuthStack: 'bearer', // required with `authStacks`; typed to its keys
|
|
399
493
|
});
|
|
400
494
|
```
|
|
401
495
|
|
|
@@ -403,22 +497,25 @@ const router = new Router({
|
|
|
403
497
|
- Routes are sorted by **specificity** (static segments > parametric > wildcard) so `/users/healthz` matches before `/users/:id`.
|
|
404
498
|
- Path composition: `[module prefix] + [effective version] + [registration prefix] + [@Controller prefix] + [@Route path]`, normalized to a single leading slash and no trailing slash. The **effective version** is resource-first and resolves `route option ?? @Controller version ?? HttpModuleMeta.version ?? ''` — see [Versioning](#versioning).
|
|
405
499
|
- Duplicate routes (same method + path) throw at construction time — you catch double-registrations at startup, not under load.
|
|
500
|
+
- Routes **accumulate** down a class hierarchy; they never replace. A subclass that re-decorates an inherited handler with a different path leaves the parent's route live at both paths. A subclass that overrides a decorated handler *without* re-decorating inherits the parent's route metadata while shadowing the wrapper the parent's `@AuthorizeScope` / `@ValidateRequest` installed — so the metadata claims a guard that no longer runs. Re-declare the decorators on the override.
|
|
406
501
|
|
|
407
502
|
### Middleware chain order
|
|
408
503
|
|
|
409
504
|
On a **non-public** route:
|
|
410
505
|
|
|
411
506
|
```
|
|
412
|
-
system executionContext bootstrap → globalMiddlewares[] →
|
|
507
|
+
system executionContext bootstrap → globalMiddlewares[] → <stack> credentialVerification → <stack> sessionLoad? → route middlewares → handler
|
|
413
508
|
```
|
|
414
509
|
|
|
415
|
-
On a **`*Public` route**, the
|
|
510
|
+
On a **`*Public` route**, the auth stack is skipped entirely:
|
|
416
511
|
|
|
417
512
|
```
|
|
418
513
|
system executionContext bootstrap → globalMiddlewares[] → route middlewares → handler
|
|
419
514
|
```
|
|
420
515
|
|
|
421
|
-
The system bootstrap is Router-owned and not configurable from outside — this eliminates "I forgot to add `executionContextMiddleware`" as a failure mode for services that use auth or read `ExecutionContext`. When `executionContext` is omitted, the bootstrap step is skipped entirely; services that never read context pay no boilerplate. Router throws at construction if `
|
|
516
|
+
The system bootstrap is Router-owned and not configurable from outside — this eliminates "I forgot to add `executionContextMiddleware`" as a failure mode for services that use auth or read `ExecutionContext`. When `executionContext` is omitted, the bootstrap step is skipped entirely; services that never read context pay no boilerplate. Router throws at construction if `authStacks` is set without `executionContext` — the known-static dependency is caught at startup, not at the first authenticated request. The typed `AuthMiddlewareStack` prevents phase misordering within a stack at the type level; the array in `globalMiddlewares` stays open-ended because custom middleware ordering is consumer-owned.
|
|
517
|
+
|
|
518
|
+
Router composes the complete chain per route, including which auth stack applies. Adapters iterate `NormalizedRoute.middlewareChain` and wrap each entry — they never re-compose it, so phases cannot drift between adapters.
|
|
422
519
|
|
|
423
520
|
## Bridge to `ComponentRegistry<HttpModuleMeta>`
|
|
424
521
|
|
|
@@ -438,6 +535,7 @@ registry
|
|
|
438
535
|
meta: {
|
|
439
536
|
prefix: '/iam',
|
|
440
537
|
version: '/api/v1', // module-wide default; routes/controllers can override
|
|
538
|
+
authStack: 'bearer', // module-wide default; routes/controllers can override
|
|
441
539
|
controllers: [usersController, authController],
|
|
442
540
|
middlewares: [iamModuleMw],
|
|
443
541
|
},
|
|
@@ -539,15 +637,16 @@ If you need non-default values, omit `cors` and wire `hono/cors` yourself inside
|
|
|
539
637
|
|
|
540
638
|
### Response and error conventions
|
|
541
639
|
|
|
542
|
-
The success/envelope shape
|
|
543
|
-
optional `conventions` facade on `HonoServer`. It groups
|
|
544
|
-
class that reproduces the built-in
|
|
545
|
-
changes.
|
|
640
|
+
The outbound success/envelope shape, the error shape, and the inbound query keys are all
|
|
641
|
+
consumer-overridable through the optional `conventions` facade on `HonoServer`. It groups three
|
|
642
|
+
strategies, each defaulting to a class that reproduces the built-in behavior byte-for-byte — omit
|
|
643
|
+
`conventions` and nothing changes.
|
|
546
644
|
|
|
547
645
|
```ts
|
|
548
646
|
type HttpConventions = {
|
|
549
|
-
readonly errorResolver?: ErrorResolver;
|
|
550
|
-
readonly responseSerializer?: ResponseSerializer;
|
|
647
|
+
readonly errorResolver?: ErrorResolver; // controls the error status + body
|
|
648
|
+
readonly responseSerializer?: ResponseSerializer; // controls the JSON success/envelope body
|
|
649
|
+
readonly requestDeserializer?: RequestDeserializer; // controls the inbound query keys
|
|
551
650
|
};
|
|
552
651
|
|
|
553
652
|
interface ErrorResolver {
|
|
@@ -556,12 +655,16 @@ interface ErrorResolver {
|
|
|
556
655
|
interface ResponseSerializer {
|
|
557
656
|
serialize(response: HttpJsonResponse): unknown; // return the wire body, or undefined for no body
|
|
558
657
|
}
|
|
658
|
+
interface RequestDeserializer {
|
|
659
|
+
deserializeQuery(query: Record<string, string | readonly string[]>): Record<string, string | readonly string[]>;
|
|
660
|
+
}
|
|
559
661
|
```
|
|
560
662
|
|
|
561
663
|
Defaults are exported so a custom strategy can delegate to them: `DefaultErrorResolver` (the
|
|
562
|
-
status mapping — `ValidationError` → 400, `NotFoundError` → 404, …)
|
|
664
|
+
status mapping — `ValidationError` → 400, `NotFoundError` → 404, …), `DefaultResponseSerializer`
|
|
563
665
|
(strips `httpCode`/`headers`, keeps `payload` / `error` / `metadata`, and returns `undefined`
|
|
564
|
-
for an empty body so it becomes a bodyless response)
|
|
666
|
+
for an empty body so it becomes a bodyless response), and `DefaultRequestDeserializer` (an
|
|
667
|
+
identity pass unless configured with `paginationKeys`).
|
|
565
668
|
|
|
566
669
|
**Custom error format** — e.g. RFC 7807 Problem Details, reusing the default status mapping:
|
|
567
670
|
|
|
@@ -630,6 +733,35 @@ The binary/stream response paths never touch the serializer — they still write
|
|
|
630
733
|
On the frontend, `@quilla-fe-kit/api-client-react-query` reconciles a custom envelope with a
|
|
631
734
|
`queryTransformer`, and `@quilla-fe-kit/api-client` a custom error shape with an `errorParser`.
|
|
632
735
|
|
|
736
|
+
**Custom request query dialect** — the request-side mirror of `responseSerializer`. A pagination
|
|
737
|
+
dialect is API-wide, so rather than repeat it at every list schema, rename the query keys once at
|
|
738
|
+
the boundary. `DefaultRequestDeserializer` rewrites a consumer's keys onto the canonical `page` /
|
|
739
|
+
`pageSize` every handler (and `@ValidateRequest`) already reads, so no DTO changes:
|
|
740
|
+
|
|
741
|
+
```ts
|
|
742
|
+
import { DefaultRequestDeserializer } from '@quilla-be-kit/http';
|
|
743
|
+
|
|
744
|
+
const server = new HonoServer({
|
|
745
|
+
port: 3000,
|
|
746
|
+
router,
|
|
747
|
+
serve: honoServe,
|
|
748
|
+
conventions: {
|
|
749
|
+
requestDeserializer: new DefaultRequestDeserializer({
|
|
750
|
+
paginationKeys: { page: 'p', pageSize: 'per_page' },
|
|
751
|
+
}),
|
|
752
|
+
},
|
|
753
|
+
});
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
Now `GET /roles?p=2&per_page=50` reaches handlers as `page` / `pageSize`. This lines up with the
|
|
757
|
+
frontend: `@quilla-fe-kit`'s `RepeatParamsSerializer` renames the same slots on the emitting end
|
|
758
|
+
(also configured once, in its constructor), so both ends speak one dialect for full round-trip
|
|
759
|
+
symmetry. `sort` is intentionally not remappable — the `sort` key already agrees across ends.
|
|
760
|
+
|
|
761
|
+
Only pagination keys are renamed; filter keys and all other query params pass through untouched.
|
|
762
|
+
For a bespoke rule, implement `RequestDeserializer` directly. Non-query sources (params, body)
|
|
763
|
+
are never touched.
|
|
764
|
+
|
|
633
765
|
## Other frameworks
|
|
634
766
|
|
|
635
767
|
If you need Express or Fastify: open an issue. Adapter sub-paths ship as library additions when they exist, not as consumer extension points.
|