@c9up/ream 0.1.7 → 0.1.9

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 (162) 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 +39 -9
  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 +1 -1
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/middleware/Pipeline.d.ts +13 -0
  60. package/dist/middleware/Pipeline.d.ts.map +1 -1
  61. package/dist/middleware/Pipeline.js +43 -7
  62. package/dist/middleware/Pipeline.js.map +1 -1
  63. package/dist/router/Router.d.ts +21 -2
  64. package/dist/router/Router.d.ts.map +1 -1
  65. package/dist/router/Router.js +53 -8
  66. package/dist/router/Router.js.map +1 -1
  67. package/dist/rpc/RpcProvider.d.ts.map +1 -1
  68. package/dist/rpc/RpcProvider.js +11 -2
  69. package/dist/rpc/RpcProvider.js.map +1 -1
  70. package/dist/rpc/RpcRouter.d.ts +11 -3
  71. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  72. package/dist/rpc/RpcRouter.js +92 -8
  73. package/dist/rpc/RpcRouter.js.map +1 -1
  74. package/dist/scheduler/ScheduleProvider.d.ts.map +1 -1
  75. package/dist/scheduler/ScheduleProvider.js +10 -0
  76. package/dist/scheduler/ScheduleProvider.js.map +1 -1
  77. package/dist/scheduler/locks/MemoryLockBackend.d.ts +0 -1
  78. package/dist/scheduler/locks/MemoryLockBackend.d.ts.map +1 -1
  79. package/dist/scheduler/locks/MemoryLockBackend.js +0 -1
  80. package/dist/scheduler/locks/MemoryLockBackend.js.map +1 -1
  81. package/dist/services/app.d.ts +6 -0
  82. package/dist/services/app.d.ts.map +1 -1
  83. package/dist/services/app.js +9 -0
  84. package/dist/services/app.js.map +1 -1
  85. package/dist/services/router.d.ts +5 -0
  86. package/dist/services/router.d.ts.map +1 -1
  87. package/dist/services/router.js +8 -0
  88. package/dist/services/router.js.map +1 -1
  89. package/dist/services/server.d.ts +5 -0
  90. package/dist/services/server.d.ts.map +1 -1
  91. package/dist/services/server.js +8 -0
  92. package/dist/services/server.js.map +1 -1
  93. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  94. package/dist/session/SessionMiddleware.js +4 -0
  95. package/dist/session/SessionMiddleware.js.map +1 -1
  96. package/dist/session/drivers/MemoryDriver.d.ts +1 -1
  97. package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
  98. package/dist/session/drivers/MemoryDriver.js +14 -6
  99. package/dist/session/drivers/MemoryDriver.js.map +1 -1
  100. package/dist/testing/TestClient.d.ts +11 -5
  101. package/dist/testing/TestClient.d.ts.map +1 -1
  102. package/dist/testing/TestClient.js +9 -4
  103. package/dist/testing/TestClient.js.map +1 -1
  104. package/events.darwin-arm64.node +0 -0
  105. package/events.darwin-x64.node +0 -0
  106. package/events.linux-arm64-gnu.node +0 -0
  107. package/events.linux-x64-gnu.node +0 -0
  108. package/events.win32-x64-msvc.node +0 -0
  109. package/index.darwin-arm64.node +0 -0
  110. package/index.darwin-x64.node +0 -0
  111. package/index.linux-arm64-gnu.node +0 -0
  112. package/index.linux-x64-gnu.node +0 -0
  113. package/index.win32-x64-msvc.node +0 -0
  114. package/package.json +1 -1
  115. package/scheduler.darwin-arm64.node +0 -0
  116. package/scheduler.darwin-x64.node +0 -0
  117. package/scheduler.linux-arm64-gnu.node +0 -0
  118. package/scheduler.linux-x64-gnu.node +0 -0
  119. package/scheduler.win32-x64-msvc.node +0 -0
  120. package/src/Application.ts +36 -5
  121. package/src/GracefulShutdown.ts +15 -7
  122. package/src/HttpKernel.ts +52 -17
  123. package/src/Ignitor.ts +106 -34
  124. package/src/bodyparser/BodyParserMiddleware.ts +43 -16
  125. package/src/bodyparser/parseSize.ts +2 -1
  126. package/src/container/Container.ts +42 -12
  127. package/src/decorators/Service.ts +4 -6
  128. package/src/events/Emitter.ts +36 -28
  129. package/src/events/EventsProvider.ts +6 -4
  130. package/src/graphql/GraphQLEngine.ts +139 -3
  131. package/src/graphql/GraphQLProvider.ts +17 -2
  132. package/src/http/HttpContext.ts +38 -1
  133. package/src/http/RedirectBuilder.ts +23 -3
  134. package/src/http/Request.ts +20 -0
  135. package/src/index.ts +1 -1
  136. package/src/middleware/Pipeline.ts +61 -8
  137. package/src/router/Router.ts +53 -8
  138. package/src/rpc/RpcProvider.ts +14 -2
  139. package/src/rpc/RpcRouter.ts +115 -11
  140. package/src/scheduler/ScheduleProvider.ts +10 -0
  141. package/src/scheduler/locks/MemoryLockBackend.ts +0 -2
  142. package/src/services/app.ts +9 -0
  143. package/src/services/router.ts +8 -0
  144. package/src/services/server.ts +8 -0
  145. package/src/session/SessionMiddleware.ts +6 -0
  146. package/src/session/drivers/MemoryDriver.ts +14 -6
  147. package/src/testing/TestClient.ts +18 -7
  148. package/dist/server/HyperServer.d.ts +0 -64
  149. package/dist/server/HyperServer.d.ts.map +0 -1
  150. package/dist/server/HyperServer.js +0 -54
  151. package/dist/server/HyperServer.js.map +0 -1
  152. package/dist/storage/S3Driver.d.ts +0 -27
  153. package/dist/storage/S3Driver.d.ts.map +0 -1
  154. package/dist/storage/S3Driver.js +0 -135
  155. package/dist/storage/S3Driver.js.map +0 -1
  156. package/dist/storage/StorageManager.d.ts +0 -31
  157. package/dist/storage/StorageManager.d.ts.map +0 -1
  158. package/dist/storage/StorageManager.js +0 -120
  159. package/dist/storage/StorageManager.js.map +0 -1
  160. package/src/server/HyperServer.ts +0 -121
  161. package/src/storage/S3Driver.ts +0 -171
  162. package/src/storage/StorageManager.ts +0 -142
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type { HttpContext } from '../http/HttpContext.js'
10
10
  import { hydrateMultipartPayload } from './MultipartFile.js'
11
+ import { parseSize } from './parseSize.js'
11
12
 
12
13
  export interface BodyParserConfig {
13
14
  json?: {
@@ -27,7 +28,10 @@ export interface BodyParserConfig {
27
28
  }
28
29
  multipart?: {
29
30
  enabled?: boolean
31
+ /** Total multipart body size cap (sum of all file sizes). Default: '20mb'. */
30
32
  limit?: string
33
+ /** Maximum number of files per request. Default: 20. */
34
+ maxFiles?: number
31
35
  maxFields?: number
32
36
  tmpDir?: string
33
37
  types?: string[]
@@ -60,6 +64,7 @@ const DEFAULT_CONFIG: ResolvedBodyParserConfig = {
60
64
  multipart: {
61
65
  enabled: true,
62
66
  limit: '20mb',
67
+ maxFiles: 20,
63
68
  maxFields: 500,
64
69
  tmpDir: '/tmp',
65
70
  types: ['multipart/form-data'],
@@ -113,6 +118,7 @@ export default class BodyParserMiddleware {
113
118
  if (this.#config.multipart.enabled && matchesType(contentType, this.#config.multipart.types)) {
114
119
  const payload = ctx.request.multipart()
115
120
  if (payload) {
121
+ if (this.#rejectMultipart(ctx, payload.files, payload.fields)) return
116
122
  const { fields, files } = hydrateMultipartPayload(payload)
117
123
  ctx.request.setParsedBody(fields)
118
124
  ctx.request.setFiles(files)
@@ -128,28 +134,49 @@ export default class BodyParserMiddleware {
128
134
  if (matchesType(contentType, this.#config.multipart.types)) return this.#config.multipart.limit
129
135
  return this.#config.raw.limit
130
136
  }
137
+
138
+ /** Returns true (and writes a 413/400) when the multipart payload exceeds configured limits. */
139
+ #rejectMultipart(
140
+ ctx: HttpContext,
141
+ files: Array<{ size: number }>,
142
+ fields: Array<unknown>,
143
+ ): boolean {
144
+ const cfg = this.#config.multipart
145
+ const maxBytes = parseSize(cfg.limit)
146
+
147
+ if (fields.length > cfg.maxFields) {
148
+ ctx.response.status(400).json({
149
+ error: {
150
+ code: 'E_TOO_MANY_FIELDS',
151
+ message: `Upload exceeds maxFields (${cfg.maxFields})`,
152
+ },
153
+ })
154
+ return true
155
+ }
156
+
157
+ if (files.length > cfg.maxFiles) {
158
+ ctx.response.status(400).json({
159
+ error: { code: 'E_TOO_MANY_FILES', message: `Upload exceeds maxFiles (${cfg.maxFiles})` },
160
+ })
161
+ return true
162
+ }
163
+
164
+ const totalBytes = files.reduce((sum, f) => sum + f.size, 0)
165
+ if (totalBytes > maxBytes) {
166
+ ctx.response.status(413).json({
167
+ error: { code: 'E_REQUEST_ENTITY_TOO_LARGE', message: 'Upload exceeds size limit' },
168
+ })
169
+ return true
170
+ }
171
+
172
+ return false
173
+ }
131
174
  }
132
175
 
133
176
  function matchesType(contentType: string, types: string[]): boolean {
134
177
  return types.some((t) => contentType.includes(t))
135
178
  }
136
179
 
137
- function parseSize(size: string): number {
138
- const match = size.match(/^(\d+)(kb|mb|gb)?$/i)
139
- if (!match) return 1024 * 1024 // default 1mb
140
- const num = parseInt(match[1], 10)
141
- switch (match[2]?.toLowerCase()) {
142
- case 'kb':
143
- return num * 1024
144
- case 'mb':
145
- return num * 1024 * 1024
146
- case 'gb':
147
- return num * 1024 * 1024 * 1024
148
- default:
149
- return num
150
- }
151
- }
152
-
153
180
  function parseFormUrlEncoded(body: string): Record<string, unknown> {
154
181
  const result: Record<string, unknown> = {}
155
182
  if (!body) return result
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Parse a human-readable size string (e.g. '1mb', '512kb', '2gb') to bytes.
3
- * Single source of truth — used by both BodyParserMiddleware and MultipartFile.
3
+ * Single source of truth, imported by BodyParserMiddleware (which previously
4
+ * carried an identical inline copy — consolidated 2026-06-15).
4
5
  */
5
6
  export function parseSize(size: string): number {
6
7
  const match = size.match(/^(\d+)(kb|mb|gb)?$/i)
@@ -158,15 +158,12 @@ export class Container {
158
158
  const paramTypes: unknown[] =
159
159
  Reflect.getMetadata('design:paramtypes', target.prototype, method) ?? []
160
160
 
161
- const args = paramTypes.map((type, index) => {
162
- // Runtime values take precedence
163
- if (runtimeValues && index < runtimeValues.length) {
164
- return runtimeValues[index]
165
- }
166
- // Auto-resolve class dependencies — type is a constructor function here
167
- if (isClassConstructor(type)) {
168
- return this.resolve(type)
169
- }
161
+ const len = Math.max(paramTypes.length, runtimeValues?.length ?? 0)
162
+ const args = Array.from({ length: len }, (_, index) => {
163
+ // Runtime values take precedence (and fill slots beyond paramTypes)
164
+ if (runtimeValues && index < runtimeValues.length) return runtimeValues[index]
165
+ const type = paramTypes[index]
166
+ if (isClassConstructor(type)) return this.resolve(type)
170
167
  return undefined
171
168
  })
172
169
 
@@ -281,15 +278,48 @@ export class Container {
281
278
  const paramTypes: unknown[] =
282
279
  explicitDeps ?? Reflect.getMetadata('design:paramtypes', target) ?? []
283
280
 
281
+ const injectTokens = getInjectTokens(target)
282
+ const lazyIndices = getLazyParams(target)
283
+
284
+ // No `design:paramtypes` (a dev transpiler may not emit decorator metadata —
285
+ // esbuild / tsx don't). Recover what we can, and NEVER construct with
286
+ // silently-undefined deps (that masked a whole DI outage in dev):
284
287
  if (paramTypes.length === 0) {
288
+ // (a) `@Inject(token)` records its tokens INDEPENDENTLY of decorator
289
+ // metadata, so even without `design:paramtypes` we can resolve the
290
+ // constructor from that map alone.
291
+ if (injectTokens.size > 0) {
292
+ const maxIndex = Math.max(...injectTokens.keys())
293
+ const deps = Array.from({ length: maxIndex + 1 }, (_value, index) => {
294
+ const depToken = injectTokens.get(index)
295
+ if (!depToken) return undefined
296
+ if (lazyIndices.includes(index)) {
297
+ return createLazyProxy(() => this.resolve<object>(depToken))
298
+ }
299
+ return this.resolve(depToken)
300
+ })
301
+ const instance = new target(...deps)
302
+ if (scope === 'singleton') this.#singletons.set(key, instance)
303
+ return instance
304
+ }
305
+ // (b) The constructor declares parameters but we have no way to resolve
306
+ // them (no metadata, no @Inject, no containerInjections). Fail LOUDLY
307
+ // instead of `new target()` with undefined deps.
308
+ if (target.length > 0) {
309
+ throw new ReamError(
310
+ 'CONTAINER_MISSING_METADATA',
311
+ `Cannot auto-construct ${target.name}: it declares ${target.length} constructor parameter(s) but no dependency metadata was found.`,
312
+ {
313
+ hint: 'Enable decorator metadata (swc/tsc emitDecoratorMetadata), annotate constructor params with @Inject(token), or declare static containerInjections.',
314
+ },
315
+ )
316
+ }
317
+ // (c) Genuine zero-argument class.
285
318
  const instance = new target()
286
319
  if (scope === 'singleton') this.#singletons.set(key, instance)
287
320
  return instance
288
321
  }
289
322
 
290
- const injectTokens = getInjectTokens(target)
291
- const lazyIndices = getLazyParams(target)
292
-
293
323
  const deps = paramTypes.map((type, index) => {
294
324
  const namedToken = injectTokens.get(index)
295
325
  // After the typeof check, type is a constructor function and valid as ServiceToken
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import 'reflect-metadata'
8
- import type { ServiceMetadata, ServiceScope } from '../container/types.js'
8
+ import type { ServiceMetadata, ServiceScope, ServiceToken } from '../container/types.js'
9
9
 
10
10
  const SERVICE_METADATA_KEY = Symbol('ream:service')
11
11
  const INJECT_METADATA_KEY = Symbol('ream:inject')
@@ -50,9 +50,9 @@ export function Service(options: { scope?: ServiceScope; as?: string } = {}): Cl
50
50
  *
51
51
  * @implements FR16
52
52
  */
53
- export function Inject(token: string): ParameterDecorator {
53
+ export function Inject(token: ServiceToken): ParameterDecorator {
54
54
  return (target, _propertyKey, parameterIndex) => {
55
- const existingTokens: Map<number, string> =
55
+ const existingTokens: Map<number, ServiceToken> =
56
56
  Reflect.getOwnMetadata(INJECT_METADATA_KEY, target) ?? new Map()
57
57
  existingTokens.set(parameterIndex, token)
58
58
  Reflect.defineMetadata(INJECT_METADATA_KEY, existingTokens, target)
@@ -62,7 +62,7 @@ export function Inject(token: string): ParameterDecorator {
62
62
  /**
63
63
  * Get named injection tokens for a class constructor.
64
64
  */
65
- export function getInjectTokens(target: AnyConstructor): Map<number, string> {
65
+ export function getInjectTokens(target: AnyConstructor): Map<number, ServiceToken> {
66
66
  return Reflect.getOwnMetadata(INJECT_METADATA_KEY, target) ?? new Map()
67
67
  }
68
68
 
@@ -81,5 +81,3 @@ export function getInjectTokens(target: AnyConstructor): Map<number, string> {
81
81
  export function inject(): ClassDecorator {
82
82
  return Service({ scope: 'transient' })
83
83
  }
84
-
85
- export { INJECT_METADATA_KEY, SERVICE_METADATA_KEY }
@@ -12,8 +12,12 @@
12
12
  * Listeners support @inject() for DI when a container resolver is provided.
13
13
  */
14
14
 
15
+ import { AsyncLocalStorage } from 'node:async_hooks'
15
16
  import type { EventBus } from './native.js'
16
17
 
18
+ /** Per-request correlation ID stored in async context so concurrent requests don't corrupt each other. */
19
+ const correlationStorage = new AsyncLocalStorage<string | undefined>()
20
+
17
21
  /**
18
22
  * Listener class interface — must have a handle() method.
19
23
  */
@@ -56,8 +60,9 @@ export class Emitter {
56
60
  /**
57
61
  * Listen for a string-based event.
58
62
  * emitter.on('user:registered', (user) => { ... })
63
+ * emitter.on<User>('user:registered', (user) => { ... }) // typed payload
59
64
  */
60
- on(event: string, listener: ListenerFn): void
65
+ on<T>(event: string, listener: ListenerFn<T>): void
61
66
  on(event: EventConstructor | string, listener: Listener): void {
62
67
  if (typeof event === 'string') {
63
68
  const list = this.stringListeners.get(event) ?? []
@@ -229,38 +234,36 @@ export class Emitter {
229
234
 
230
235
  // ─── Correlation context ──────────────────────────────────
231
236
 
232
- /** Current correlation ID for event chain tracing. */
233
- private _correlationId?: string
234
-
235
- /** Set the correlation ID for subsequent events (typically from HTTP request context). */
237
+ /**
238
+ * Set the correlation ID for subsequent events in the current async context.
239
+ * Uses AsyncLocalStorage so concurrent requests each have their own trace ID —
240
+ * a shared mutable field would let concurrent requests corrupt each other.
241
+ */
236
242
  setCorrelationId(id: string): void {
237
- this._correlationId = id
243
+ correlationStorage.enterWith(id)
238
244
  }
239
245
 
240
- /** Get the current correlation ID. */
246
+ /**
247
+ * Clear the correlation ID for the current async context. `enterWith` does
248
+ * not auto-unset, so a long-lived/reused context (and, notably, sequential
249
+ * tests sharing a worker) would leak the last set id — call this to reset.
250
+ */
251
+ clearCorrelationId(): void {
252
+ correlationStorage.enterWith(undefined)
253
+ }
254
+
255
+ /** Get the correlation ID for the current async context. */
241
256
  getCorrelationId(): string | undefined {
242
- return this._correlationId
257
+ return correlationStorage.getStore()
243
258
  }
244
259
 
245
- /**
246
- * Inject the active correlation ID at the top level of the payload so
247
- * the bus's Event envelope (`{ id, name, data, correlationId, ... }`)
248
- * surfaces it on the subscriber side — both the Rust `ream-events`
249
- * envelope serde and `FakeBus.parseEmitData()` already read
250
- * `correlationId` from the parsed `data` string. Without this injection
251
- * `setCorrelationId()` was a no-op functional API. Returns `data`
252
- * unchanged when no correlation ID is set OR when `data` is not a plain
253
- * object (primitives / arrays / null can't carry a sibling field).
254
- */
260
+ /** Inject the active correlation ID at the top level of the payload. */
255
261
  #wrapForBus(data: unknown): unknown {
256
- if (this._correlationId === undefined) return data
257
- if (data === null || typeof data !== 'object' || Array.isArray(data)) {
258
- return data
259
- }
260
- const obj = data as Record<string, unknown>
261
- // User-supplied correlationId wins — don't clobber an explicit value.
262
- if (typeof obj.correlationId === 'string') return data
263
- return { ...obj, correlationId: this._correlationId }
262
+ const correlationId = correlationStorage.getStore()
263
+ if (correlationId === undefined) return data
264
+ if (!isPlainObject(data)) return data
265
+ if (typeof data.correlationId === 'string') return data
266
+ return { ...data, correlationId }
264
267
  }
265
268
 
266
269
  // ─── Introspection ────────────────────────────────────────
@@ -304,8 +307,9 @@ export class BaseEvent {
304
307
  BaseEvent._emitter = emitter
305
308
  }
306
309
 
307
- static resetEmitter(): void {
308
- BaseEvent._emitter = undefined
310
+ /** @internal Ownership-guarded reset — only clears if `emitter` is still the active one. */
311
+ static resetEmitter(emitter: Emitter): void {
312
+ if (BaseEvent._emitter === emitter) BaseEvent._emitter = undefined
309
313
  }
310
314
 
311
315
  /**
@@ -331,3 +335,7 @@ function classToEventName(cls: EventConstructor): string {
331
335
  function isListenerClass(listener: Listener): boolean {
332
336
  return typeof listener === 'function' && typeof listener.prototype?.handle === 'function'
333
337
  }
338
+
339
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
340
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
341
+ }
@@ -23,6 +23,8 @@ export interface EventsAppContext {
23
23
  }
24
24
 
25
25
  export default class EventsProvider {
26
+ #emitter: Emitter | undefined
27
+
26
28
  constructor(protected app: EventsAppContext) {}
27
29
 
28
30
  register() {
@@ -47,12 +49,12 @@ export default class EventsProvider {
47
49
  }
48
50
 
49
51
  async boot() {
50
- const emitter = this.app.container.resolve<Emitter>(Emitter)
51
- BaseEvent.useEmitter(emitter)
52
- setEmitter(emitter)
52
+ this.#emitter = this.app.container.resolve<Emitter>(Emitter)
53
+ BaseEvent.useEmitter(this.#emitter)
54
+ setEmitter(this.#emitter)
53
55
  }
54
56
 
55
57
  async shutdown() {
56
- BaseEvent.resetEmitter()
58
+ if (this.#emitter) BaseEvent.resetEmitter(this.#emitter)
57
59
  }
58
60
  }
@@ -40,6 +40,43 @@ interface RustParseResult {
40
40
  }
41
41
  interface GraphqlNative {
42
42
  graphqlParse(query: string): string
43
+ /** `{ "Type.field": { "argName": "ScalarType" } }` extracted from the SDL. */
44
+ graphqlSchemaArgTypes(sdl: string): string
45
+ }
46
+
47
+ /**
48
+ * Coerce an argument value to its schema-declared scalar type. GraphQL input
49
+ * coercion: a string literal `"5"` for an `Int` arg becomes the number `5`, a
50
+ * number for an `ID` becomes its string form, etc. Arrays coerce element-wise.
51
+ * Unknown / custom / enum types pass through untouched.
52
+ */
53
+ function coerceScalar(value: unknown, type: string): unknown {
54
+ if (value === null || value === undefined) return value
55
+ if (Array.isArray(value)) return value.map((v) => coerceScalar(v, type))
56
+ switch (type) {
57
+ case 'Int':
58
+ if (typeof value === 'number') return value
59
+ if (typeof value === 'string' && value.trim() !== '' && Number.isInteger(Number(value))) {
60
+ return Number(value)
61
+ }
62
+ return value
63
+ case 'Float':
64
+ if (typeof value === 'number') return value
65
+ if (typeof value === 'string' && value.trim() !== '' && !Number.isNaN(Number(value))) {
66
+ return Number(value)
67
+ }
68
+ return value
69
+ case 'Boolean':
70
+ if (typeof value === 'boolean') return value
71
+ if (value === 'true') return true
72
+ if (value === 'false') return false
73
+ return value
74
+ case 'ID':
75
+ return typeof value === 'number' ? String(value) : value
76
+ default:
77
+ // String, custom scalars, enums, input objects — left as the client sent.
78
+ return value
79
+ }
43
80
  }
44
81
 
45
82
  let graphqlNativeCache: GraphqlNative | undefined
@@ -65,6 +102,39 @@ const OPERATION_TYPE: Record<string, string> = {
65
102
  subscription: 'Subscription',
66
103
  }
67
104
 
105
+ /**
106
+ * Bound a parsed selection tree's depth and total field count. Iterative (explicit
107
+ * stack) so a pathological deeply-nested query can't overflow the JS call stack
108
+ * during the check itself, and short-circuits as soon as a limit is crossed.
109
+ * Returns an error message, or `null` when the query is within limits.
110
+ */
111
+ function checkSelectionLimits(
112
+ fields: SelectionField[],
113
+ maxDepth: number,
114
+ maxComplexity: number,
115
+ ): string | null {
116
+ let count = 0
117
+ const stack: Array<{ field: SelectionField; depth: number }> = fields.map((field) => ({
118
+ field,
119
+ depth: 1,
120
+ }))
121
+ while (stack.length > 0) {
122
+ const top = stack.pop()
123
+ if (!top) break
124
+ if (top.depth > maxDepth) {
125
+ return `Query is too deep (max depth ${maxDepth})`
126
+ }
127
+ count += 1
128
+ if (count > maxComplexity) {
129
+ return `Query is too complex (max ${maxComplexity} fields)`
130
+ }
131
+ for (const child of top.field.selection) {
132
+ stack.push({ field: child, depth: top.depth + 1 })
133
+ }
134
+ }
135
+ return null
136
+ }
137
+
68
138
  /** Map a Rust `ParsedField` (sub_fields) onto the engine's `SelectionField` (selection). */
69
139
  function mapRustField(f: RustParsedField): SelectionField {
70
140
  return {
@@ -82,6 +152,22 @@ export interface GraphQLConfig {
82
152
  path?: string
83
153
  /** Enable playground/introspection in dev (default: true when NODE_ENV !== 'production'). */
84
154
  playground?: boolean
155
+ /**
156
+ * Reject a query string longer than this many characters BEFORE parsing —
157
+ * caps the work the parser does on a hostile payload (default: 100_000).
158
+ */
159
+ maxQueryBytes?: number
160
+ /**
161
+ * Reject a query whose selection set nests deeper than this — bounds
162
+ * resolver recursion / amplification (default: 12).
163
+ */
164
+ maxDepth?: number
165
+ /**
166
+ * Reject a query selecting more than this many fields in total (aliases
167
+ * count) — bounds fan-out where a client repeats an expensive resolver
168
+ * (default: 1000).
169
+ */
170
+ maxComplexity?: number
85
171
  }
86
172
 
87
173
  export interface ResolverOptions {
@@ -120,6 +206,24 @@ export class GraphQLEngine {
120
206
  #container?: { make<T>(target: new (...args: unknown[]) => T): T }
121
207
  readonly path: string
122
208
  #playground: boolean
209
+ #maxQueryBytes: number
210
+ #maxDepth: number
211
+ #maxComplexity: number
212
+ /** Lazily-parsed `Type.field → argName → scalarType` map for input coercion. */
213
+ #argTypes?: Record<string, Record<string, string>>
214
+
215
+ /** Parse (once) the schema's argument types from the SDL for coercion. */
216
+ #schemaArgTypes(): Record<string, Record<string, string>> {
217
+ if (this.#argTypes === undefined) {
218
+ try {
219
+ this.#argTypes = JSON.parse(graphqlNative().graphqlSchemaArgTypes(this.#schemaSource))
220
+ } catch {
221
+ // Native unavailable or schema unparseable → coercion becomes a no-op.
222
+ this.#argTypes = {}
223
+ }
224
+ }
225
+ return this.#argTypes ?? {}
226
+ }
123
227
 
124
228
  constructor(config: GraphQLConfig) {
125
229
  if (!fs.existsSync(config.schemaPath)) {
@@ -128,6 +232,9 @@ export class GraphQLEngine {
128
232
  this.#schemaSource = fs.readFileSync(config.schemaPath, 'utf8')
129
233
  this.path = config.path ?? '/graphql'
130
234
  this.#playground = config.playground ?? process.env.NODE_ENV !== 'production'
235
+ this.#maxQueryBytes = config.maxQueryBytes ?? 100_000
236
+ this.#maxDepth = config.maxDepth ?? 12
237
+ this.#maxComplexity = config.maxComplexity ?? 1000
131
238
  }
132
239
 
133
240
  /** Set IoC container for resolver instantiation. */
@@ -196,12 +303,28 @@ export class GraphQLEngine {
196
303
  data?: Record<string, unknown>
197
304
  errors?: Array<{ message: string; path?: string[] }>
198
305
  }> {
306
+ // DoS guard 1 — cap the raw query size BEFORE handing it to the parser, so a
307
+ // hostile multi-MB payload can't tie up the native parser.
308
+ if (request.query.length > this.#maxQueryBytes) {
309
+ return {
310
+ errors: [{ message: `Query exceeds the maximum size of ${this.#maxQueryBytes} bytes` }],
311
+ }
312
+ }
313
+
199
314
  // Parse the query to extract operation type and fields
200
315
  const parsed = this.#parseQuery(request.query)
201
316
  if (parsed.errors) {
202
317
  return { errors: parsed.errors }
203
318
  }
204
319
 
320
+ // DoS guard 2 — bound the selection tree's depth and total field count
321
+ // (aliases included) before resolving anything. Iterative walk so the check
322
+ // itself can't blow the stack on a deeply nested query.
323
+ const limitError = checkSelectionLimits(parsed.fields, this.#maxDepth, this.#maxComplexity)
324
+ if (limitError) {
325
+ return { errors: [{ message: limitError }] }
326
+ }
327
+
205
328
  const data: Record<string, unknown> = {}
206
329
  const errors: Array<{ message: string; path?: string[] }> = []
207
330
 
@@ -260,7 +383,7 @@ export class GraphQLEngine {
260
383
  }
261
384
  }
262
385
 
263
- const args = this.#buildArgs(field, request.variables)
386
+ const args = this.#buildArgs(field, request.variables, typeName)
264
387
  const result = await handler.call(instance, null, args, ctx)
265
388
  // Prune to the client's selection set: a resolver returning a rich
266
389
  // object (ORM entity, etc.) must not leak fields the client did not
@@ -279,6 +402,7 @@ export class GraphQLEngine {
279
402
  #buildArgs(
280
403
  field: SelectionField,
281
404
  variables: Record<string, unknown> | undefined,
405
+ typeName: string,
282
406
  ): Record<string, unknown> {
283
407
  const args: Record<string, unknown> = {}
284
408
  for (const [k, v] of Object.entries(field.args)) {
@@ -293,6 +417,15 @@ export class GraphQLEngine {
293
417
  if (!(k in args)) args[k] = v
294
418
  }
295
419
  }
420
+ // Coerce each argument to its schema-declared scalar type (Int/Float/Boolean/
421
+ // ID). A literal `task(id: "5")` or a string variable thus reaches the
422
+ // resolver as the right runtime type instead of being silently mistyped.
423
+ const argTypes = this.#schemaArgTypes()[`${typeName}.${field.name}`]
424
+ if (argTypes) {
425
+ for (const [k, declaredType] of Object.entries(argTypes)) {
426
+ if (k in args) args[k] = coerceScalar(args[k], declaredType)
427
+ }
428
+ }
296
429
  return args
297
430
  }
298
431
 
@@ -307,15 +440,18 @@ export class GraphQLEngine {
307
440
  return 'Unauthorized'
308
441
  }
309
442
 
443
+ // Read roles/permissions from the top level OR nested under `user` — the
444
+ // auth provider (e.g. warden) sets `ctx.auth.user.roles`, not the top level.
445
+ // Mirrors the HTTP guard middleware fix.
310
446
  if (roles.length > 0) {
311
- const userRoles = ctx.auth?.roles ?? []
447
+ const userRoles = ctx.auth?.roles ?? ctx.auth?.user?.roles ?? []
312
448
  if (!roles.some((r) => userRoles.includes(r))) {
313
449
  return 'Insufficient role'
314
450
  }
315
451
  }
316
452
 
317
453
  if (permissions.length > 0) {
318
- const userPerms = ctx.auth?.permissions ?? []
454
+ const userPerms = ctx.auth?.permissions ?? ctx.auth?.user?.permissions ?? []
319
455
  if (!permissions.every((p) => userPerms.includes(p))) {
320
456
  return 'Insufficient permissions'
321
457
  }
@@ -15,6 +15,7 @@
15
15
  * @implements Story 24.5
16
16
  */
17
17
 
18
+ import { ReamError } from '../errors/ReamError.js'
18
19
  import type { AppContext } from '../Provider.js'
19
20
  import { Provider } from '../Provider.js'
20
21
  import type { Router } from '../router/Router.js'
@@ -42,9 +43,23 @@ export class GraphQLProvider extends Provider {
42
43
  const config = this.app.config.get<GraphQLConfig>('graphql')
43
44
  const engine = this.#engine ?? (config?.schemaPath ? new GraphQLEngine(config) : undefined)
44
45
  if (!engine) return // GraphQL not configured — opt-out.
45
- engine.useContainer(this.app.container)
46
+ const container = this.app.container
47
+ if (container.has('graphql')) {
48
+ if (container.resolve('graphql') === engine) {
49
+ this.#engine = engine
50
+ return // re-register is idempotent
51
+ }
52
+ throw new ReamError(
53
+ 'GRAPHQL_PROVIDER_ALREADY_REGISTERED',
54
+ "Container token 'graphql' is already bound to a different instance",
55
+ {
56
+ hint: 'Only one GraphQLProvider can own the graphql binding. Remove the duplicate provider from your reamrc.ts.',
57
+ },
58
+ )
59
+ }
60
+ engine.useContainer(container)
46
61
  this.#engine = engine
47
- this.app.container.singleton('graphql', () => engine)
62
+ container.singleton('graphql', () => engine)
48
63
  }
49
64
 
50
65
  override async boot(): Promise<void> {
@@ -27,6 +27,23 @@ export interface AuthState {
27
27
  permissions?: string[]
28
28
  }
29
29
 
30
+ /**
31
+ * Per-request authorization entry point — populated by an authorization
32
+ * middleware (e.g. `@c9up/warden`'s Bouncer initializer). A structural
33
+ * contract only: Ream stays agnostic of any concrete authorization package,
34
+ * exactly as `auth` is a slot that Warden fills. A handler authorizes via
35
+ * `await ctx.bouncer?.authorize('post.edit', post)` (throws on denial, mapped
36
+ * to 403 by the ExceptionHandler) or branches on `ctx.bouncer?.allows(...)`.
37
+ */
38
+ export interface Authorizer {
39
+ /** True iff the action is authorized. Never throws on denial. */
40
+ allows(ability: string, ...args: unknown[]): Promise<boolean>
41
+ /** Boolean negation of {@link allows}. Never throws on denial. */
42
+ denies(ability: string, ...args: unknown[]): Promise<boolean>
43
+ /** Resolves on allow; throws an authorization failure (status 403) on deny. */
44
+ authorize(ability: string, ...args: unknown[]): Promise<void>
45
+ }
46
+
30
47
  export interface RouteInfo {
31
48
  pattern: string
32
49
  name?: string
@@ -62,7 +79,11 @@ export class HttpContext {
62
79
  /** Authentication state — populated by auth middleware. */
63
80
  auth: AuthState = { authenticated: false }
64
81
 
65
- /** Detected locale. */
82
+ /**
83
+ * Request locale — the primary subtag of the first `Accept-Language` entry
84
+ * (e.g. `fr-CH,fr;q=0.9` → `fr`), defaulting to `en`. Middleware (or i18n)
85
+ * may override it; it's a plain mutable field.
86
+ */
66
87
  locale = 'en'
67
88
 
68
89
  /** Per-request key-value store (for middleware to pass data downstream). */
@@ -75,6 +96,13 @@ export class HttpContext {
75
96
  */
76
97
  events?: Emitter
77
98
 
99
+ /**
100
+ * Per-request authorization entry point, when an authorization middleware
101
+ * is wired (e.g. `@c9up/warden`'s Bouncer initializer). Undefined when no
102
+ * such middleware ran. See {@link Authorizer}.
103
+ */
104
+ bouncer?: Authorizer
105
+
78
106
  /** Route URL resolver for redirect().toRoute(). */
79
107
  #routeUrlResolver?: RouteUrlResolver
80
108
 
@@ -89,6 +117,7 @@ export class HttpContext {
89
117
  this.route = route
90
118
  this.request = new Request(rawRequest, params)
91
119
  this.response = new Response()
120
+ this.locale = parseAcceptLanguage(this.request.header('accept-language')) ?? 'en'
92
121
 
93
122
  // Wire redirect builder with request context
94
123
  this.response.setRedirectFactory(
@@ -106,3 +135,11 @@ export class HttpContext {
106
135
  this.#routeUrlResolver = resolver
107
136
  }
108
137
  }
138
+
139
+ /** Primary subtag of the first Accept-Language entry: `fr-CH,fr;q=0.9` → `fr`. */
140
+ function parseAcceptLanguage(header: string | undefined): string | undefined {
141
+ if (!header) return undefined
142
+ const first = header.split(',')[0]?.trim().split(';')[0]?.trim()
143
+ const primary = first?.split('-')[0]?.toLowerCase()
144
+ return primary || undefined
145
+ }