@quilla-be-kit/http 0.7.0 → 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.
Files changed (46) hide show
  1. package/README.md +167 -18
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/decorator/authorize-scope.decorator.js +2 -2
  4. package/dist/decorator/authorize-scope.decorator.js.map +1 -1
  5. package/dist/decorator/controller.decorator.d.ts +1 -0
  6. package/dist/decorator/controller.decorator.d.ts.map +1 -1
  7. package/dist/decorator/controller.decorator.js +2 -1
  8. package/dist/decorator/controller.decorator.js.map +1 -1
  9. package/dist/decorator/method.decorators.d.ts +1 -0
  10. package/dist/decorator/method.decorators.d.ts.map +1 -1
  11. package/dist/decorator/method.decorators.js +1 -0
  12. package/dist/decorator/method.decorators.js.map +1 -1
  13. package/dist/decorator/route.metadata.d.ts +10 -1
  14. package/dist/decorator/route.metadata.d.ts.map +1 -1
  15. package/dist/decorator/route.metadata.js +68 -31
  16. package/dist/decorator/route.metadata.js.map +1 -1
  17. package/dist/decorator/validate-request.decorator.js +2 -2
  18. package/dist/decorator/validate-request.decorator.js.map +1 -1
  19. package/dist/error/default.resolver.d.ts.map +1 -1
  20. package/dist/error/default.resolver.js +4 -0
  21. package/dist/error/default.resolver.js.map +1 -1
  22. package/dist/error/http-status-aware.interface.d.ts +6 -0
  23. package/dist/error/http-status-aware.interface.d.ts.map +1 -0
  24. package/dist/error/http-status-aware.interface.js +10 -0
  25. package/dist/error/http-status-aware.interface.js.map +1 -0
  26. package/dist/error/index.d.ts +1 -0
  27. package/dist/error/index.d.ts.map +1 -1
  28. package/dist/error/index.js +1 -0
  29. package/dist/error/index.js.map +1 -1
  30. package/dist/request/http-attributes.d.ts +6 -0
  31. package/dist/request/http-attributes.d.ts.map +1 -1
  32. package/dist/request/http-attributes.js +6 -0
  33. package/dist/request/http-attributes.js.map +1 -1
  34. package/dist/router/auth-middleware-stack.type.d.ts +21 -5
  35. package/dist/router/auth-middleware-stack.type.d.ts.map +1 -1
  36. package/dist/router/http-module-meta.type.d.ts +5 -0
  37. package/dist/router/http-module-meta.type.d.ts.map +1 -1
  38. package/dist/router/normalized-route.type.d.ts +6 -1
  39. package/dist/router/normalized-route.type.d.ts.map +1 -1
  40. package/dist/router/router-options.type.d.ts +28 -8
  41. package/dist/router/router-options.type.d.ts.map +1 -1
  42. package/dist/router/router.d.ts +2 -2
  43. package/dist/router/router.d.ts.map +1 -1
  44. package/dist/router/router.js +92 -16
  45. package/dist/router/router.js.map +1 -1
  46. package/package.json +4 -4
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
- - **Typed auth middleware stack** — `AuthMiddlewareStack` enforces phase ordering (`tokenVerification` → `sessionLoad?`) so consumers can't misorder security middlewares. Compose it directly from `@quilla-be-kit/security`'s middleware factories.
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
- authMiddlewares: {
113
- tokenVerification: bearerTokenMiddleware({ tokenService }),
114
- sessionLoad: authenticatedSessionMiddleware({
115
- sessionStore,
116
- executionContextProvider: provider,
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 a controller-level **version** default (see
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 middlewares are skipped** for these routes. The non-public variants run every registered `authMiddleware` before the handler.
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 a per-route **version**
177
- override (see [Versioning](#versioning)):
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).
@@ -248,7 +335,7 @@ async create(req: HttpRequest): Promise<HttpResponse> {
248
335
  }
249
336
  ```
250
337
 
251
- On validation failure, throws `ValidationError` with `context.issues` containing the validator's raw error array (e.g. Zod issues, Joi details). The default error resolver (`DefaultErrorResolver`) surfaces this as a 400 response with `body.error.details.issues`. See [Response and error conventions](#response-and-error-conventions) to override the wire shape.
338
+ On validation failure, throws `ValidationError` with `context.issues` containing the validator's raw error array (e.g. Zod issues, Joi details). The default error resolver (`DefaultErrorResolver`) surfaces this as a 400 response with `body.error.details.issues`. See [Error status mapping](#error-status-mapping) for how that 400 is derived, and [Response and error conventions](#response-and-error-conventions) to override the wire shape.
252
339
 
253
340
  ## Multipart / form-data
254
341
 
@@ -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 `authMiddlewares` is set** — Router throws at construction
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
- authMiddlewares: { tokenVerification, sessionLoad? }, // typed stack — non-public routes only
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[] → tokenVerification → sessionLoad? → route middlewares → handler
507
+ system executionContext bootstrap → globalMiddlewares[] → <stack> credentialVerification → <stack> sessionLoad? → route middlewares → handler
413
508
  ```
414
509
 
415
- On a **`*Public` route**, the entire `authMiddlewares` stack is skipped:
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 `authMiddlewares` is set without `executionContext` — the known-static dependency is caught at startup, not at the first authenticated request. The typed `AuthMiddlewareStack` prevents phase misordering at the type level; the array in `globalMiddlewares` stays open-ended because custom middleware ordering is consumer-owned.
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
  },
@@ -568,6 +666,57 @@ status mapping — `ValidationError` → 400, `NotFoundError` → 404, …), `De
568
666
  for an empty body so it becomes a bodyless response), and `DefaultRequestDeserializer` (an
569
667
  identity pass unless configured with `paginationKeys`).
570
668
 
669
+ #### Error status mapping
670
+
671
+ `DefaultErrorResolver` resolves a status in three steps, first match wins.
672
+
673
+ **1. The category table.** These are the defaults for the error categories `@quilla-be-kit/http`
674
+ ships against — and throws itself, so changing them changes documented behavior:
675
+
676
+ | Error | Status | Thrown by the toolkit at |
677
+ |---|---|---|
678
+ | `ValidationError` | 400 | `@ValidateRequest` |
679
+ | `UnauthorizedError` | 401 | `@quilla-be-kit/security` bearer-token and session middleware |
680
+ | `ForbiddenError` | 403 | `@AuthorizeScope` |
681
+ | `NotFoundError` | 404 | — |
682
+ | `ConflictError` | 409 | — |
683
+ | `InternalError` (and `UnknownError`) | 500 | — |
684
+ | `ExternalError` | 502 | — |
685
+
686
+ The table lives here, in the HTTP layer, rather than on the error classes: `@quilla-be-kit/errors`
687
+ is transport-agnostic and is consumed by `messaging` and `persistence`, where a status code means
688
+ nothing.
689
+
690
+ **2. Subclass a category — the zero-config path.** Inheritance is what most custom errors want, and
691
+ costs nothing. `@quilla-be-kit/persistence` already relies on it:
692
+
693
+ ```ts
694
+ export class OptimisticLockError extends ConflictError {} // → 409
695
+ export class CrossScopeAccessError extends NotFoundError {} // → 404
696
+ ```
697
+
698
+ **3. Brand the error — the escape hatch.** When you need a status no category covers, implement
699
+ `HttpStatusAware`. This outranks the category table:
700
+
701
+ ```ts
702
+ import { QuillaError } from '@quilla-be-kit/errors';
703
+ import { HTTP_STATUS, type HttpStatusAware } from '@quilla-be-kit/http';
704
+
705
+ export class GoneError extends QuillaError implements HttpStatusAware {
706
+ readonly code: string = 'GONE';
707
+ readonly [HTTP_STATUS] = 410;
708
+ }
709
+ ```
710
+
711
+ The brand is a `Symbol.for('quilla-be-kit.http.status')` key rather than a plain `httpCode` field
712
+ so that it can only ever be set deliberately — an error that happens to carry an unrelated
713
+ `httpCode` (say, an `ExternalError` subclass storing the *upstream's* status) keeps its category
714
+ status instead of leaking that number to your own clients.
715
+
716
+ A branded value outside 100–599, or one that isn't an integer, is ignored and the error falls
717
+ through to the category table. Anything that isn't a `QuillaError` at all resolves to a generic
718
+ 500 with a redacted body, brand or no brand.
719
+
571
720
  **Custom error format** — e.g. RFC 7807 Problem Details, reusing the default status mapping:
572
721
 
573
722
  ```ts