@c9up/ream 0.1.6 → 0.1.8

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 (163) hide show
  1. package/dist/Application.d.ts +9 -1
  2. package/dist/Application.d.ts.map +1 -1
  3. package/dist/Application.js +35 -5
  4. package/dist/Application.js.map +1 -1
  5. package/dist/GracefulShutdown.d.ts.map +1 -1
  6. package/dist/GracefulShutdown.js +13 -7
  7. package/dist/GracefulShutdown.js.map +1 -1
  8. package/dist/HttpKernel.d.ts.map +1 -1
  9. package/dist/HttpKernel.js +43 -14
  10. package/dist/HttpKernel.js.map +1 -1
  11. package/dist/Ignitor.d.ts +10 -10
  12. package/dist/Ignitor.d.ts.map +1 -1
  13. package/dist/Ignitor.js +104 -32
  14. package/dist/Ignitor.js.map +1 -1
  15. package/dist/bodyparser/BodyParserMiddleware.d.ts +3 -0
  16. package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
  17. package/dist/bodyparser/BodyParserMiddleware.js +32 -16
  18. package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
  19. package/dist/bodyparser/parseSize.d.ts +2 -1
  20. package/dist/bodyparser/parseSize.d.ts.map +1 -1
  21. package/dist/bodyparser/parseSize.js +2 -1
  22. package/dist/bodyparser/parseSize.js.map +1 -1
  23. package/dist/container/Container.d.ts.map +1 -1
  24. package/dist/container/Container.js +6 -7
  25. package/dist/container/Container.js.map +1 -1
  26. package/dist/decorators/Service.d.ts +4 -6
  27. package/dist/decorators/Service.d.ts.map +1 -1
  28. package/dist/decorators/Service.js +0 -1
  29. package/dist/decorators/Service.js.map +1 -1
  30. package/dist/events/Emitter.d.ts +16 -6
  31. package/dist/events/Emitter.d.ts.map +1 -1
  32. package/dist/events/Emitter.js +32 -25
  33. package/dist/events/Emitter.js.map +1 -1
  34. package/dist/events/EventsProvider.d.ts +1 -0
  35. package/dist/events/EventsProvider.d.ts.map +1 -1
  36. package/dist/events/EventsProvider.js +6 -4
  37. package/dist/events/EventsProvider.js.map +1 -1
  38. package/dist/graphql/GraphQLEngine.d.ts +16 -0
  39. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  40. package/dist/graphql/GraphQLEngine.js +122 -4
  41. package/dist/graphql/GraphQLEngine.js.map +1 -1
  42. package/dist/graphql/GraphQLProvider.d.ts.map +1 -1
  43. package/dist/graphql/GraphQLProvider.js +13 -2
  44. package/dist/graphql/GraphQLProvider.js.map +1 -1
  45. package/dist/http/HttpContext.d.ts +27 -1
  46. package/dist/http/HttpContext.d.ts.map +1 -1
  47. package/dist/http/HttpContext.js +20 -1
  48. package/dist/http/HttpContext.js.map +1 -1
  49. package/dist/http/RedirectBuilder.d.ts +5 -1
  50. package/dist/http/RedirectBuilder.d.ts.map +1 -1
  51. package/dist/http/RedirectBuilder.js +26 -3
  52. package/dist/http/RedirectBuilder.js.map +1 -1
  53. package/dist/http/Request.d.ts +13 -0
  54. package/dist/http/Request.d.ts.map +1 -1
  55. package/dist/http/Request.js +18 -0
  56. package/dist/http/Request.js.map +1 -1
  57. package/dist/index.d.ts +2 -2
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +1 -1
  60. package/dist/middleware/Pipeline.d.ts +13 -0
  61. package/dist/middleware/Pipeline.d.ts.map +1 -1
  62. package/dist/middleware/Pipeline.js +43 -7
  63. package/dist/middleware/Pipeline.js.map +1 -1
  64. package/dist/router/Router.d.ts +21 -2
  65. package/dist/router/Router.d.ts.map +1 -1
  66. package/dist/router/Router.js +53 -8
  67. package/dist/router/Router.js.map +1 -1
  68. package/dist/rpc/RpcProvider.d.ts.map +1 -1
  69. package/dist/rpc/RpcProvider.js +11 -2
  70. package/dist/rpc/RpcProvider.js.map +1 -1
  71. package/dist/rpc/RpcRouter.d.ts +11 -3
  72. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  73. package/dist/rpc/RpcRouter.js +92 -8
  74. package/dist/rpc/RpcRouter.js.map +1 -1
  75. package/dist/scheduler/ScheduleProvider.d.ts.map +1 -1
  76. package/dist/scheduler/ScheduleProvider.js +10 -0
  77. package/dist/scheduler/ScheduleProvider.js.map +1 -1
  78. package/dist/scheduler/locks/MemoryLockBackend.d.ts +0 -1
  79. package/dist/scheduler/locks/MemoryLockBackend.d.ts.map +1 -1
  80. package/dist/scheduler/locks/MemoryLockBackend.js +0 -1
  81. package/dist/scheduler/locks/MemoryLockBackend.js.map +1 -1
  82. package/dist/services/app.d.ts +6 -0
  83. package/dist/services/app.d.ts.map +1 -1
  84. package/dist/services/app.js +9 -0
  85. package/dist/services/app.js.map +1 -1
  86. package/dist/services/router.d.ts +5 -0
  87. package/dist/services/router.d.ts.map +1 -1
  88. package/dist/services/router.js +8 -0
  89. package/dist/services/router.js.map +1 -1
  90. package/dist/services/server.d.ts +5 -0
  91. package/dist/services/server.d.ts.map +1 -1
  92. package/dist/services/server.js +8 -0
  93. package/dist/services/server.js.map +1 -1
  94. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  95. package/dist/session/SessionMiddleware.js +4 -0
  96. package/dist/session/SessionMiddleware.js.map +1 -1
  97. package/dist/session/drivers/MemoryDriver.d.ts +1 -1
  98. package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
  99. package/dist/session/drivers/MemoryDriver.js +14 -6
  100. package/dist/session/drivers/MemoryDriver.js.map +1 -1
  101. package/dist/testing/TestClient.d.ts +11 -5
  102. package/dist/testing/TestClient.d.ts.map +1 -1
  103. package/dist/testing/TestClient.js +9 -4
  104. package/dist/testing/TestClient.js.map +1 -1
  105. package/events.darwin-arm64.node +0 -0
  106. package/events.darwin-x64.node +0 -0
  107. package/events.linux-arm64-gnu.node +0 -0
  108. package/events.linux-x64-gnu.node +0 -0
  109. package/events.win32-x64-msvc.node +0 -0
  110. package/index.darwin-arm64.node +0 -0
  111. package/index.darwin-x64.node +0 -0
  112. package/index.linux-arm64-gnu.node +0 -0
  113. package/index.linux-x64-gnu.node +0 -0
  114. package/index.win32-x64-msvc.node +0 -0
  115. package/package.json +29 -1
  116. package/scheduler.darwin-arm64.node +0 -0
  117. package/scheduler.darwin-x64.node +0 -0
  118. package/scheduler.linux-arm64-gnu.node +0 -0
  119. package/scheduler.linux-x64-gnu.node +0 -0
  120. package/scheduler.win32-x64-msvc.node +0 -0
  121. package/src/Application.ts +36 -5
  122. package/src/GracefulShutdown.ts +15 -7
  123. package/src/HttpKernel.ts +52 -17
  124. package/src/Ignitor.ts +106 -34
  125. package/src/bodyparser/BodyParserMiddleware.ts +43 -16
  126. package/src/bodyparser/parseSize.ts +2 -1
  127. package/src/container/Container.ts +6 -9
  128. package/src/decorators/Service.ts +4 -6
  129. package/src/events/Emitter.ts +36 -28
  130. package/src/events/EventsProvider.ts +6 -4
  131. package/src/graphql/GraphQLEngine.ts +139 -3
  132. package/src/graphql/GraphQLProvider.ts +17 -2
  133. package/src/http/HttpContext.ts +38 -1
  134. package/src/http/RedirectBuilder.ts +23 -3
  135. package/src/http/Request.ts +20 -0
  136. package/src/index.ts +2 -2
  137. package/src/middleware/Pipeline.ts +61 -8
  138. package/src/router/Router.ts +53 -8
  139. package/src/rpc/RpcProvider.ts +14 -2
  140. package/src/rpc/RpcRouter.ts +115 -11
  141. package/src/scheduler/ScheduleProvider.ts +10 -0
  142. package/src/scheduler/locks/MemoryLockBackend.ts +0 -2
  143. package/src/services/app.ts +9 -0
  144. package/src/services/router.ts +8 -0
  145. package/src/services/server.ts +8 -0
  146. package/src/session/SessionMiddleware.ts +6 -0
  147. package/src/session/drivers/MemoryDriver.ts +14 -6
  148. package/src/testing/TestClient.ts +18 -7
  149. package/dist/server/HyperServer.d.ts +0 -64
  150. package/dist/server/HyperServer.d.ts.map +0 -1
  151. package/dist/server/HyperServer.js +0 -54
  152. package/dist/server/HyperServer.js.map +0 -1
  153. package/dist/storage/S3Driver.d.ts +0 -27
  154. package/dist/storage/S3Driver.d.ts.map +0 -1
  155. package/dist/storage/S3Driver.js +0 -135
  156. package/dist/storage/S3Driver.js.map +0 -1
  157. package/dist/storage/StorageManager.d.ts +0 -31
  158. package/dist/storage/StorageManager.d.ts.map +0 -1
  159. package/dist/storage/StorageManager.js +0 -120
  160. package/dist/storage/StorageManager.js.map +0 -1
  161. package/src/server/HyperServer.ts +0 -121
  162. package/src/storage/S3Driver.ts +0 -171
  163. package/src/storage/StorageManager.ts +0 -142
@@ -55,6 +55,7 @@ export class Request {
55
55
  #parsedQs: Dict<unknown> | undefined
56
56
  #merged: Dict<unknown> | undefined
57
57
  #files: Map<string, import('../bodyparser/MultipartFile.js').MultipartFile[]> = new Map()
58
+ #validated: unknown
58
59
 
59
60
  /**
60
61
  * CSRF token for this request (AdonisJS idiom: `request.csrfToken`). Seeded
@@ -376,6 +377,25 @@ export class Request {
376
377
  this.#merged = undefined // reset merged cache
377
378
  }
378
379
 
380
+ /**
381
+ * @internal Store the validated + transformed payload (called by the
382
+ * validation middleware after a route validator passes).
383
+ */
384
+ setValidated(data: unknown): void {
385
+ this.#validated = data
386
+ }
387
+
388
+ /**
389
+ * The validated + coerced payload produced by the route's `.validate()`
390
+ * validator, or `undefined` when the route declares no validator. Prefer
391
+ * this over `body()` in a validated handler — it carries the sanitized,
392
+ * type-coerced values, not the raw input. Returns `unknown`; narrow it at
393
+ * the call site (the validator already guaranteed the shape).
394
+ */
395
+ validated(): unknown {
396
+ return this.#validated
397
+ }
398
+
379
399
  #ensureParsedBody(): void {
380
400
  if (this.#parsedBody !== undefined) return
381
401
  const raw = this.#raw.body
package/src/index.ts CHANGED
@@ -25,7 +25,7 @@ export {
25
25
  Exception,
26
26
  ExceptionHandler,
27
27
  } from './http/Exception.js'
28
- export type { AuthState, RouteInfo } from './http/HttpContext.js'
28
+ export type { Authorizer, AuthState, RouteInfo } from './http/HttpContext.js'
29
29
  export { HttpContext } from './http/HttpContext.js'
30
30
  export { RedirectBuilder } from './http/RedirectBuilder.js'
31
31
  export type { RawRequest } from './http/Request.js'
@@ -260,4 +260,4 @@ export {
260
260
  StatsTracker,
261
261
  } from './scheduler/index.js'
262
262
 
263
- export const VERSION = '0.1.6'
263
+ export const VERSION = '0.1.7'
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { ReamError } from '../errors/ReamError.js'
15
- import { E_FORBIDDEN, E_UNAUTHORIZED } from '../http/Exception.js'
15
+ import { E_FORBIDDEN, E_UNAUTHORIZED, E_VALIDATION_ERROR } from '../http/Exception.js'
16
16
  import type { HttpContext } from '../http/HttpContext.js'
17
17
 
18
18
  export type MiddlewareFunction = (
@@ -20,6 +20,15 @@ export type MiddlewareFunction = (
20
20
  next: () => Promise<void>,
21
21
  ) => Promise<void> | void
22
22
 
23
+ /**
24
+ * Structural contract for a route validator. A `@c9up/rune` schema matches this
25
+ * shape, so ream stays decoupled from the validation engine — any object with a
26
+ * `validate(data)` returning `{ valid, errors, data? }` works.
27
+ */
28
+ export interface RuntimeValidator {
29
+ validate(data: unknown): { valid: boolean; errors: unknown[]; data?: unknown }
30
+ }
31
+
23
32
  /** Named middleware registry. */
24
33
  export class MiddlewareRegistry {
25
34
  #global: MiddlewareFunction[] = []
@@ -50,15 +59,29 @@ export class MiddlewareRegistry {
50
59
  namedMiddleware: string[],
51
60
  inlineMiddleware: MiddlewareFunction[],
52
61
  handler: MiddlewareFunction,
53
- options?: { guards?: string[]; roles?: string[]; permissions?: string[] },
62
+ options?: {
63
+ guards?: string[]
64
+ roles?: string[]
65
+ permissions?: string[]
66
+ validators?: RuntimeValidator[]
67
+ },
54
68
  ): MiddlewareFunction {
55
69
  const stack: MiddlewareFunction[] = [
56
70
  // 1. Global middleware
57
71
  ...this.#global,
58
- // 2. Named middleware
59
- ...namedMiddleware
60
- .map((name) => this.#named.get(name))
61
- .filter((mw): mw is MiddlewareFunction => mw !== undefined),
72
+ // 2. Named middleware — an unknown name is a hard error, NOT a silent
73
+ // skip: a typo'd `.middleware('auht')` must never run the route
74
+ // unprotected with zero diagnostics.
75
+ ...namedMiddleware.map((name) => {
76
+ const mw = this.#named.get(name)
77
+ if (mw === undefined) {
78
+ throw new Error(
79
+ `[E_MIDDLEWARE_NOT_FOUND] Named middleware '${name}' is not registered. ` +
80
+ `Registered: ${[...this.#named.keys()].join(', ') || '(none)'}`,
81
+ )
82
+ }
83
+ return mw
84
+ }),
62
85
  // 3. Inline middleware
63
86
  ...inlineMiddleware,
64
87
  // 4. Guard enforcement (throws exceptions instead of setting response)
@@ -67,6 +90,11 @@ export class MiddlewareRegistry {
67
90
  (options?.permissions?.length ?? 0) > 0
68
91
  ? [createGuardMiddleware(options?.guards ?? [], options?.roles, options?.permissions)]
69
92
  : []),
93
+ // 4b. Validation — runs AFTER auth (so unauthenticated requests 401 before
94
+ // the body is inspected) and BEFORE the handler.
95
+ ...((options?.validators?.length ?? 0) > 0
96
+ ? [createValidationMiddleware(options?.validators ?? [])]
97
+ : []),
70
98
  // 5. Handler
71
99
  handler,
72
100
  ]
@@ -114,8 +142,13 @@ function createGuardMiddleware(
114
142
  throw new E_UNAUTHORIZED()
115
143
  }
116
144
 
145
+ // Auth providers may expose roles/permissions at the top level OR nested
146
+ // under `user` (e.g. @c9up/warden sets `ctx.auth.user.roles`). Read both so
147
+ // route-level `.role()`/`.permission()` work regardless of the provider's
148
+ // shape — reading only the top level silently denied every authenticated
149
+ // user when warden was wired.
117
150
  if (roles && roles.length > 0) {
118
- const userRoles = ctx.auth.roles ?? []
151
+ const userRoles = ctx.auth.roles ?? ctx.auth.user?.roles ?? []
119
152
  const hasAnyRole = roles.some((r) => userRoles.includes(r))
120
153
  if (!hasAnyRole) {
121
154
  throw new E_FORBIDDEN('Insufficient role', roles)
@@ -123,7 +156,7 @@ function createGuardMiddleware(
123
156
  }
124
157
 
125
158
  if (permissions && permissions.length > 0) {
126
- const userPerms = ctx.auth.permissions ?? []
159
+ const userPerms = ctx.auth.permissions ?? ctx.auth.user?.permissions ?? []
127
160
  if (!permissions.every((p) => userPerms.includes(p))) {
128
161
  throw new E_FORBIDDEN('Insufficient permissions', permissions)
129
162
  }
@@ -133,4 +166,24 @@ function createGuardMiddleware(
133
166
  }
134
167
  }
135
168
 
169
+ /**
170
+ * Validation enforcement middleware. Runs each resolved route validator against
171
+ * the request body; the first failure throws `E_VALIDATION_ERROR` (422). On
172
+ * success the sanitized/coerced payload is stored on `request.validated()`.
173
+ */
174
+ function createValidationMiddleware(validators: RuntimeValidator[]): MiddlewareFunction {
175
+ return async (ctx, next) => {
176
+ for (const validator of validators) {
177
+ const result = validator.validate(ctx.request.body())
178
+ if (!result.valid) {
179
+ throw new E_VALIDATION_ERROR(result.errors)
180
+ }
181
+ if (result.data !== undefined) {
182
+ ctx.request.setValidated(result.data)
183
+ }
184
+ }
185
+ await next()
186
+ }
187
+ }
188
+
136
189
  export { compose }
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { HttpContext } from '../http/HttpContext.js'
8
+ import { safeDecodeURIComponent } from '../http/urlDecode.js'
8
9
  import type { MiddlewareFunction } from '../middleware/Pipeline.js'
9
10
  import type { MiddlewareEntry } from '../server/Server.js'
10
11
  import { resolveMiddlewareEntry } from '../server/Server.js'
@@ -120,7 +121,21 @@ export class RouteBuilder {
120
121
  return this
121
122
  }
122
123
 
123
- /** Attach a validator. */
124
+ /**
125
+ * Validate the request body against a named validator. At request time the
126
+ * kernel resolves the validator from the IoC container under the token
127
+ * `validator:<name>` and runs it after the auth guards and before the handler;
128
+ * a failure short-circuits with `422 E_VALIDATION_ERROR` and the validated,
129
+ * coerced payload is exposed on `ctx.request.validated()`. The same name also
130
+ * feeds the OpenAPI generator's requestBody schema.
131
+ *
132
+ * Register the validator (any object with `validate(data) => { valid, errors,
133
+ * data? }`, e.g. a `@c9up/rune` schema):
134
+ *
135
+ * container.singleton('validator:createUser', () => schema({ ... }))
136
+ *
137
+ * An unregistered name is a hard error — validation is never silently skipped.
138
+ */
124
139
  validate(validator: string): this {
125
140
  this.#route.validators.push(validator)
126
141
  return this
@@ -572,6 +587,13 @@ export class Router {
572
587
  }
573
588
  }
574
589
 
590
+ // HEAD falls back to the matching GET route (RFC 9110 §9.3.2 semantics —
591
+ // same headers, no body). Without this, `curl -I` and LB health probes
592
+ // 404 on every GET-only route.
593
+ if (method === 'HEAD') {
594
+ return this.match('GET', path, host)
595
+ }
596
+
575
597
  return undefined
576
598
  }
577
599
 
@@ -592,9 +614,12 @@ export class Router {
592
614
  let url = route.path
593
615
  if (params) {
594
616
  for (const [key, value] of Object.entries(params)) {
595
- url = url
596
- .replace(`:${key}?`, encodeURIComponent(value))
597
- .replace(`:${key}`, encodeURIComponent(value))
617
+ // Word-boundary substitution: a bare `.replace(':id')` would corrupt
618
+ // `:idx` (`/x/:idx` → `/x/42x`). The lookahead requires the param name
619
+ // to END at the placeholder boundary. Keys are validated identifiers
620
+ // in route paths, but escape defensively (caller-supplied object).
621
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
622
+ url = url.replace(new RegExp(`:${escaped}\\??(?![\\w])`, 'g'), encodeURIComponent(value))
598
623
  }
599
624
  }
600
625
  // Strip remaining optional placeholders (`:name?` segments not provided).
@@ -617,9 +642,17 @@ export class Router {
617
642
  return [...this.#routes]
618
643
  }
619
644
 
620
- /** Clear all registered routes (used by hot-reload). */
645
+ /**
646
+ * Clear all registered routes AND registries (used by hot-reload). Router
647
+ * middleware, named middleware, and global matchers are reset too — the
648
+ * preloads that registered them re-run on reload, so keeping them would
649
+ * accumulate duplicates (middleware executing N+1 times after N reloads).
650
+ */
621
651
  clear(): void {
622
652
  this.#routes = []
653
+ this.#routerMiddleware = []
654
+ this.#namedMiddleware.clear()
655
+ this.#globalMatchers = {}
623
656
  this.#indexDirty = true
624
657
  }
625
658
 
@@ -660,6 +693,10 @@ export class Router {
660
693
  const matcher = matcherMap[param]
661
694
  if (!matcher) continue
662
695
  const regex = matcher instanceof RegExp ? matcher : matcher.pattern
696
+ // A user-supplied /g or /y regex is stateful (`lastIndex` advances on
697
+ // match) — without a reset, the same URL alternates match/no-match
698
+ // across requests. Reset before every test.
699
+ if (regex.global || regex.sticky) regex.lastIndex = 0
663
700
  if (!regex.test(value)) return false
664
701
  }
665
702
  return true
@@ -684,12 +721,17 @@ function matchPath(pattern: string, actual: string): Record<string, string> | nu
684
721
  for (let i = 0; i < wildcardIdx; i++) {
685
722
  const part = patternParts[i]
686
723
  if (part.startsWith(':')) {
687
- params[part.substring(1)] = actualParts[i]
724
+ params[part.substring(1)] = safeDecodeURIComponent(actualParts[i])
688
725
  } else if (part !== actualParts[i]) {
689
726
  return null
690
727
  }
691
728
  }
692
- params['*'] = actualParts.slice(wildcardIdx).join('/')
729
+ // Decode per segment, then rejoin — an encoded `/` inside a segment stays
730
+ // a value character, the path structure was already split on raw `/`.
731
+ params['*'] = actualParts
732
+ .slice(wildcardIdx)
733
+ .map((s) => safeDecodeURIComponent(s))
734
+ .join('/')
693
735
  return params
694
736
  }
695
737
 
@@ -708,7 +750,10 @@ function matchPath(pattern: string, actual: string): Record<string, string> | nu
708
750
  if (part.startsWith(':')) {
709
751
  const paramName = isOptional ? part.slice(1, -1) : part.substring(1)
710
752
  if (i < actualParts.length) {
711
- params[paramName] = actualParts[i]
753
+ // Percent-decode the captured segment (query strings already are) so
754
+ // `/users/Jos%C3%A9` yields `José` and matchers test the real value.
755
+ // safeDecode falls back to the raw segment on malformed input.
756
+ params[paramName] = safeDecodeURIComponent(actualParts[i])
712
757
  }
713
758
  // Optional param with no actual part — skip (param not set)
714
759
  } else if (i < actualParts.length && part !== actualParts[i]) {
@@ -15,6 +15,7 @@
15
15
  * @implements Story 24.1, 24.2
16
16
  */
17
17
 
18
+ import { ReamError } from '../errors/ReamError.js'
18
19
  import type { HttpContext } from '../http/HttpContext.js'
19
20
  import type { AppContext } from '../Provider.js'
20
21
  import { Provider } from '../Provider.js'
@@ -40,8 +41,19 @@ export class RpcProvider extends Provider {
40
41
  }
41
42
 
42
43
  override register(): void {
43
- this.rpc.useContainer(this.app.container)
44
- this.app.container.singleton('rpc', () => this.rpc)
44
+ const container = this.app.container
45
+ if (container.has('rpc')) {
46
+ if (container.resolve('rpc') === this.rpc) return // re-register is idempotent
47
+ throw new ReamError(
48
+ 'RPC_PROVIDER_ALREADY_REGISTERED',
49
+ "Container token 'rpc' is already bound to a different instance",
50
+ {
51
+ hint: 'Only one RpcProvider can own the rpc binding. Remove the duplicate provider from your reamrc.ts.',
52
+ },
53
+ )
54
+ }
55
+ this.rpc.useContainer(container)
56
+ container.singleton('rpc', () => this.rpc)
45
57
  }
46
58
 
47
59
  override async boot(): Promise<void> {
@@ -12,9 +12,21 @@
12
12
  */
13
13
 
14
14
  import type { HttpContext } from '../http/HttpContext.js'
15
+ import type { MiddlewareRegistry, RuntimeValidator } from '../middleware/Pipeline.js'
16
+ import { compose } from '../middleware/Pipeline.js'
15
17
 
16
18
  export type RpcHandler = (ctx: HttpContext, params: unknown) => Promise<unknown> | unknown
17
19
 
20
+ /**
21
+ * Subset of the IoC container RpcRouter needs: resolve `namespace()` controllers,
22
+ * the shared `'middleware'` registry, and `validator:<name>` schemas.
23
+ */
24
+ interface RpcContainer {
25
+ make<T>(target: new (...args: unknown[]) => T): T
26
+ resolve<T>(token: string): T
27
+ has(token: string): boolean
28
+ }
29
+
18
30
  export interface RpcMethodDefinition {
19
31
  name: string
20
32
  handler: RpcHandler
@@ -60,13 +72,17 @@ export class RpcMethodBuilder {
60
72
 
61
73
  interface RpcErrorResponse {
62
74
  jsonrpc: '2.0'
63
- error: { code: number; message: string }
75
+ error: { code: number; message: string; data?: unknown }
64
76
  id: unknown
65
77
  }
66
78
 
67
79
  /** Build a JSON-RPC 2.0 error envelope. */
68
- function rpcError(code: number, message: string, id: unknown): RpcErrorResponse {
69
- return { jsonrpc: '2.0', error: { code, message }, id }
80
+ function rpcError(code: number, message: string, id: unknown, data?: unknown): RpcErrorResponse {
81
+ return {
82
+ jsonrpc: '2.0',
83
+ error: data === undefined ? { code, message } : { code, message, data },
84
+ id,
85
+ }
70
86
  }
71
87
 
72
88
  type ParsedRpcRequest =
@@ -91,6 +107,24 @@ function parseRpcRequest(request: unknown): ParsedRpcRequest {
91
107
  return { ok: true, method, params, id }
92
108
  }
93
109
 
110
+ /**
111
+ * A JSON-RPC notification is a well-formed request with NO `id` member. The spec
112
+ * (§4.1) says the server MUST NOT reply to one — it still runs for side-effects.
113
+ * A malformed object (no method / wrong version) is NOT a notification: it gets
114
+ * an `id:null` error response since the server can't tell the client's intent.
115
+ */
116
+ function isNotification(request: unknown): boolean {
117
+ return (
118
+ !!request &&
119
+ typeof request === 'object' &&
120
+ 'jsonrpc' in request &&
121
+ request.jsonrpc === '2.0' &&
122
+ 'method' in request &&
123
+ typeof request.method === 'string' &&
124
+ !('id' in request)
125
+ )
126
+ }
127
+
94
128
  /**
95
129
  * Enforce the method's guards/roles/permissions against the request auth state.
96
130
  * Returns the JSON-RPC error code+message to reject with, or undefined when the
@@ -105,13 +139,15 @@ function checkRpcAuthorization(
105
139
  return { code: -32003, message: 'Unauthorized' }
106
140
  }
107
141
  if (def.roles.length > 0) {
108
- const userRoles = ctx.auth?.roles ?? []
142
+ // Warden nests roles/permissions under ctx.auth.user — mirror the HTTP path
143
+ // (Pipeline.ts createGuardMiddleware) so RPC role gates aren't fail-closed-dead.
144
+ const userRoles = ctx.auth?.roles ?? ctx.auth?.user?.roles ?? []
109
145
  if (!def.roles.some((r: string) => userRoles.includes(r))) {
110
146
  return { code: -32003, message: 'Insufficient role' }
111
147
  }
112
148
  }
113
149
  if (def.permissions.length > 0) {
114
- const userPerms = ctx.auth?.permissions ?? []
150
+ const userPerms = ctx.auth?.permissions ?? ctx.auth?.user?.permissions ?? []
115
151
  if (!def.permissions.every((p: string) => userPerms.includes(p))) {
116
152
  return { code: -32003, message: 'Insufficient permissions' }
117
153
  }
@@ -122,7 +158,7 @@ function checkRpcAuthorization(
122
158
  export class RpcRouter {
123
159
  #methods: Map<string, RpcMethodDefinition> = new Map()
124
160
  #groupConfig: { guards: string[]; roles: string[]; middleware: string[] } | null = null
125
- #container?: { make<T>(target: new (...args: unknown[]) => T): T }
161
+ #container?: RpcContainer
126
162
 
127
163
  /**
128
164
  * Set the IoC container used to resolve `namespace()` controllers — mirrors
@@ -130,7 +166,7 @@ export class RpcRouter {
130
166
  * same way (fresh DI per call). Without a container, controllers fall back to
131
167
  * `new Controller()`.
132
168
  */
133
- useContainer(container: { make<T>(target: new (...args: unknown[]) => T): T }): void {
169
+ useContainer(container: RpcContainer): void {
134
170
  this.#container = container
135
171
  }
136
172
 
@@ -178,8 +214,13 @@ export class RpcRouter {
178
214
  roles: [],
179
215
  middleware: config.middleware ?? [],
180
216
  }
181
- callback(this)
182
- this.#groupConfig = prevConfig
217
+ // try/finally so a throwing callback can't leave #groupConfig stuck at the
218
+ // group's value and leak its guards/middleware onto sibling registrations.
219
+ try {
220
+ callback(this)
221
+ } finally {
222
+ this.#groupConfig = prevConfig
223
+ }
183
224
  }
184
225
 
185
226
  /** Handle an incoming JSON-RPC request. */
@@ -197,18 +238,58 @@ export class RpcRouter {
197
238
  return
198
239
  }
199
240
  // Note: batch items share the same ctx (auth state). Each is processed sequentially.
241
+ // Notifications still run (side-effects) but their result is omitted from
242
+ // the reply per JSON-RPC §4.1.
200
243
  const results: unknown[] = []
201
244
  for (const req of body) {
202
- results.push(await this.#processOne(ctx, req))
245
+ const r = await this.#processOne(ctx, req)
246
+ if (!isNotification(req)) results.push(r)
247
+ }
248
+ // An all-notification batch produces no response body (spec: no reply).
249
+ if (results.length === 0) {
250
+ ctx.response.status(204).send('')
251
+ return
203
252
  }
204
253
  ctx.response.status(200).json(results)
205
254
  return
206
255
  }
207
256
 
208
257
  const result = await this.#processOne(ctx, body)
258
+ // A notification runs for side-effects but MUST NOT receive a reply.
259
+ if (isNotification(body)) {
260
+ ctx.response.status(204).send('')
261
+ return
262
+ }
209
263
  ctx.response.status(200).json(result)
210
264
  }
211
265
 
266
+ /**
267
+ * Run the method's declared named middleware (resolved from the shared
268
+ * `'middleware'` registry) onion-style before the handler. Guards/auth
269
+ * middleware deny by THROWING — #processOne maps the throw to a JSON-RPC
270
+ * error. An unknown middleware name is a hard error, never a silent skip.
271
+ * (RPC shares one ctx per batch, so response-writing/short-circuit middleware
272
+ * are unsupported — deny by throwing.)
273
+ */
274
+ async #runMiddleware(ctx: HttpContext, names: string[]): Promise<void> {
275
+ const registry = this.#container?.has('middleware')
276
+ ? this.#container.resolve<MiddlewareRegistry>('middleware')
277
+ : undefined
278
+ if (!registry) {
279
+ throw new Error(
280
+ `[E_MIDDLEWARE_NOT_FOUND] RPC middleware [${names.join(', ')}] is declared but no middleware registry is wired into the RpcRouter.`,
281
+ )
282
+ }
283
+ const fns = names.map((name) => {
284
+ const mw = registry.get(name)
285
+ if (!mw) {
286
+ throw new Error(`[E_MIDDLEWARE_NOT_FOUND] Named middleware '${name}' is not registered.`)
287
+ }
288
+ return mw
289
+ })
290
+ await compose(fns)(ctx, async () => {})
291
+ }
292
+
212
293
  async #processOne(ctx: HttpContext, request: unknown): Promise<unknown> {
213
294
  const parsed = parseRpcRequest(request)
214
295
  if (!parsed.ok) return parsed.response
@@ -221,7 +302,30 @@ export class RpcRouter {
221
302
  const denied = checkRpcAuthorization(ctx, def)
222
303
  if (denied) return rpcError(denied.code, denied.message, id)
223
304
 
224
- const result = await def.handler(ctx, params)
305
+ // Run declared named middleware (e.g. guards) — they throw to deny, mapped
306
+ // to a JSON-RPC error by the catch below.
307
+ if (def.middleware.length > 0) {
308
+ await this.#runMiddleware(ctx, def.middleware)
309
+ }
310
+
311
+ // Run the declared validator against params. A declared-but-unregistered
312
+ // validator is a hard error — never silently skip validation.
313
+ let effectiveParams = params
314
+ if (def.validator) {
315
+ const token = `validator:${def.validator}`
316
+ if (!this.#container?.has(token)) {
317
+ return rpcError(
318
+ -32603,
319
+ `Validator '${def.validator}' is not registered (bind container.singleton('${token}', () => schema)).`,
320
+ id,
321
+ )
322
+ }
323
+ const outcome = this.#container.resolve<RuntimeValidator>(token).validate(params)
324
+ if (!outcome.valid) return rpcError(-32602, 'Invalid params', id, outcome.errors)
325
+ effectiveParams = outcome.data ?? params
326
+ }
327
+
328
+ const result = await def.handler(ctx, effectiveParams)
225
329
  return { jsonrpc: '2.0', result, id }
226
330
  } catch (err) {
227
331
  // Don't leak internal error details to caller
@@ -35,6 +35,9 @@ export interface ScheduleProviderOptions {
35
35
  scheduler?: Scheduler
36
36
  }
37
37
 
38
+ /** Prevents two ScheduleProvider instances from ticking simultaneously (two apps, same process). */
39
+ let activeProvider: ScheduleProvider | undefined
40
+
38
41
  export class ScheduleProvider extends Provider {
39
42
  readonly scheduler: Scheduler
40
43
  #booted = false
@@ -168,10 +171,17 @@ export class ScheduleProvider extends Provider {
168
171
  }
169
172
 
170
173
  override async start(): Promise<void> {
174
+ if (activeProvider !== undefined && activeProvider !== this) {
175
+ // A previous app's scheduler is still running — stop it before this one starts,
176
+ // otherwise every @Schedule task fires twice per tick.
177
+ activeProvider.scheduler.stop()
178
+ }
179
+ activeProvider = this
171
180
  this.scheduler.start()
172
181
  }
173
182
 
174
183
  override async shutdown(): Promise<void> {
184
+ if (activeProvider === this) activeProvider = undefined
175
185
  this.scheduler.stop()
176
186
  }
177
187
  }
@@ -81,5 +81,3 @@ export class MemoryLockBackend implements LockBackend {
81
81
  return this.#locks.size
82
82
  }
83
83
  }
84
-
85
- export default MemoryLockBackend
@@ -16,6 +16,15 @@ export function setApp(app: Application): void {
16
16
  instance = app
17
17
  }
18
18
 
19
+ /**
20
+ * @internal Unset the locator IF it still points at `app` (called by
21
+ * Ignitor.stop()). Ownership-guarded: when a second Ignitor rebound the
22
+ * locator, the older app's stop() must not tear down the newer binding.
23
+ */
24
+ export function clearApp(app: Application): void {
25
+ if (instance === app) instance = undefined
26
+ }
27
+
19
28
  const app: Application = new Proxy({} as Application, {
20
29
  get(_target, prop) {
21
30
  if (!instance) {
@@ -17,6 +17,14 @@ export function setRouter(router: Router): void {
17
17
  instance = router
18
18
  }
19
19
 
20
+ /**
21
+ * @internal Unset the locator IF it still points at `router` (called by
22
+ * Ignitor.stop()). Ownership-guarded — see services/app.ts.
23
+ */
24
+ export function clearRouter(router: Router): void {
25
+ if (instance === router) instance = undefined
26
+ }
27
+
20
28
  /** @internal Get the router instance directly. */
21
29
  export function getRouter(): Router | undefined {
22
30
  return instance
@@ -16,6 +16,14 @@ export function setServer(server: Server): void {
16
16
  instance = server
17
17
  }
18
18
 
19
+ /**
20
+ * @internal Unset the locator IF it still points at `server` (called by
21
+ * Ignitor.stop()). Ownership-guarded — see services/app.ts.
22
+ */
23
+ export function clearServer(server: Server): void {
24
+ if (instance === server) instance = undefined
25
+ }
26
+
19
27
  /** @internal Get the server instance directly. */
20
28
  export function getServer(): Server | undefined {
21
29
  return instance
@@ -78,6 +78,12 @@ export default class SessionMiddleware {
78
78
  const shouldEmit = session.isDirty() || (rolling && hadIncomingCookie)
79
79
  if (shouldEmit) {
80
80
  const encoded = this.#cookieDriver.encode(session.toJSON())
81
+ if (encoded.length > 4096) {
82
+ process.stderr.write(
83
+ `[session] Cookie session exceeds 4096 bytes (${encoded.length}). ` +
84
+ 'Switch to a server-side driver (memory/redis) or reduce session data.\n',
85
+ )
86
+ }
81
87
  ctx.response.cookie(cookieName, encoded, {
82
88
  maxAge: this.#config.clearWithBrowser ? undefined : maxAge,
83
89
  path: '/',
@@ -1,27 +1,35 @@
1
1
  import type { SessionDriver } from '../Session.js'
2
2
 
3
+ const SWEEP_THRESHOLD = 1000
4
+
3
5
  export class MemoryDriver implements SessionDriver {
4
- private store: Map<string, { data: Record<string, unknown>; expiresAt: number }> = new Map()
6
+ #store: Map<string, { data: Record<string, unknown>; expiresAt: number }> = new Map()
5
7
 
6
8
  async read(sessionId: string): Promise<Record<string, unknown>> {
7
- const entry = this.store.get(sessionId)
9
+ const entry = this.#store.get(sessionId)
8
10
  if (!entry || entry.expiresAt < Date.now()) {
9
- this.store.delete(sessionId)
11
+ this.#store.delete(sessionId)
10
12
  return {}
11
13
  }
12
14
  return { ...entry.data }
13
15
  }
14
16
 
15
17
  async write(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<void> {
16
- this.store.set(sessionId, { data, expiresAt: Date.now() + ttl * 1000 })
18
+ if (this.#store.size >= SWEEP_THRESHOLD) {
19
+ const now = Date.now()
20
+ for (const [id, entry] of this.#store) {
21
+ if (entry.expiresAt < now) this.#store.delete(id)
22
+ }
23
+ }
24
+ this.#store.set(sessionId, { data, expiresAt: Date.now() + ttl * 1000 })
17
25
  }
18
26
 
19
27
  async destroy(sessionId: string): Promise<void> {
20
- this.store.delete(sessionId)
28
+ this.#store.delete(sessionId)
21
29
  }
22
30
 
23
31
  async touch(sessionId: string, ttl: number): Promise<void> {
24
- const entry = this.store.get(sessionId)
32
+ const entry = this.#store.get(sessionId)
25
33
  if (entry) {
26
34
  entry.expiresAt = Date.now() + ttl * 1000
27
35
  }