@c9up/ream 0.2.29 → 0.2.30

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 (220) hide show
  1. package/README.md +5 -6
  2. package/dist/Codemods.d.ts +8 -0
  3. package/dist/Codemods.d.ts.map +1 -1
  4. package/dist/Codemods.js +29 -1
  5. package/dist/Codemods.js.map +1 -1
  6. package/dist/HttpKernel.d.ts.map +1 -1
  7. package/dist/HttpKernel.js +17 -5
  8. package/dist/HttpKernel.js.map +1 -1
  9. package/dist/Ignitor.d.ts +5 -0
  10. package/dist/Ignitor.d.ts.map +1 -1
  11. package/dist/Ignitor.js +12 -1
  12. package/dist/Ignitor.js.map +1 -1
  13. package/dist/bodyparser/BodyParserMiddleware.d.ts +4 -4
  14. package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
  15. package/dist/bodyparser/BodyParserMiddleware.js +38 -11
  16. package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
  17. package/dist/bodyparser/MultipartFile.d.ts +21 -15
  18. package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
  19. package/dist/bodyparser/MultipartFile.js +40 -58
  20. package/dist/bodyparser/MultipartFile.js.map +1 -1
  21. package/dist/bodyparser/parseSize.d.ts +7 -4
  22. package/dist/bodyparser/parseSize.d.ts.map +1 -1
  23. package/dist/bodyparser/parseSize.js +12 -17
  24. package/dist/bodyparser/parseSize.js.map +1 -1
  25. package/dist/bodyparser/qsParse.d.ts +10 -0
  26. package/dist/bodyparser/qsParse.d.ts.map +1 -1
  27. package/dist/bodyparser/qsParse.js +27 -12
  28. package/dist/bodyparser/qsParse.js.map +1 -1
  29. package/dist/console/prompts.d.ts.map +1 -1
  30. package/dist/console/prompts.js +3 -1
  31. package/dist/console/prompts.js.map +1 -1
  32. package/dist/container/Container.d.ts.map +1 -1
  33. package/dist/container/Container.js +61 -71
  34. package/dist/container/Container.js.map +1 -1
  35. package/dist/events/Emitter.d.ts +12 -0
  36. package/dist/events/Emitter.d.ts.map +1 -1
  37. package/dist/events/Emitter.js +36 -9
  38. package/dist/events/Emitter.js.map +1 -1
  39. package/dist/events/helix/helpers.d.ts.map +1 -1
  40. package/dist/events/helix/helpers.js +19 -9
  41. package/dist/events/helix/helpers.js.map +1 -1
  42. package/dist/graphql/GraphQLEngine.d.ts +8 -0
  43. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  44. package/dist/graphql/GraphQLEngine.js +77 -11
  45. package/dist/graphql/GraphQLEngine.js.map +1 -1
  46. package/dist/helpers/MessageBuilder.d.ts +4 -2
  47. package/dist/helpers/MessageBuilder.d.ts.map +1 -1
  48. package/dist/helpers/MessageBuilder.js +4 -2
  49. package/dist/helpers/MessageBuilder.js.map +1 -1
  50. package/dist/helpers/errors.d.ts +11 -0
  51. package/dist/helpers/errors.d.ts.map +1 -0
  52. package/dist/helpers/errors.js +17 -0
  53. package/dist/helpers/errors.js.map +1 -0
  54. package/dist/helpers/napi-loader.d.ts.map +1 -1
  55. package/dist/helpers/napi-loader.js +3 -2
  56. package/dist/helpers/napi-loader.js.map +1 -1
  57. package/dist/http/HttpContext.d.ts +20 -2
  58. package/dist/http/HttpContext.d.ts.map +1 -1
  59. package/dist/http/HttpContext.js +10 -4
  60. package/dist/http/HttpContext.js.map +1 -1
  61. package/dist/http/RedirectBuilder.d.ts.map +1 -1
  62. package/dist/http/RedirectBuilder.js +4 -0
  63. package/dist/http/RedirectBuilder.js.map +1 -1
  64. package/dist/http/Request.d.ts.map +1 -1
  65. package/dist/http/Request.js +15 -29
  66. package/dist/http/Request.js.map +1 -1
  67. package/dist/http/Response.d.ts +7 -4
  68. package/dist/http/Response.d.ts.map +1 -1
  69. package/dist/http/Response.js +14 -6
  70. package/dist/http/Response.js.map +1 -1
  71. package/dist/http/permissions.d.ts +14 -8
  72. package/dist/http/permissions.d.ts.map +1 -1
  73. package/dist/http/permissions.js +26 -16
  74. package/dist/http/permissions.js.map +1 -1
  75. package/dist/index.d.ts +2 -2
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +1 -1
  78. package/dist/index.js.map +1 -1
  79. package/dist/middleware/Pipeline.js +1 -1
  80. package/dist/middleware/Pipeline.js.map +1 -1
  81. package/dist/native/generated.d.ts +16 -30
  82. package/dist/native/generated.d.ts.map +1 -1
  83. package/dist/profiler/Profiler.d.ts.map +1 -1
  84. package/dist/profiler/Profiler.js +4 -1
  85. package/dist/profiler/Profiler.js.map +1 -1
  86. package/dist/profiler/client.generated.d.ts +5 -0
  87. package/dist/profiler/client.generated.d.ts.map +1 -0
  88. package/dist/profiler/client.generated.js +6 -0
  89. package/dist/profiler/client.generated.js.map +1 -0
  90. package/dist/profiler/collectors.d.ts +92 -13
  91. package/dist/profiler/collectors.d.ts.map +1 -1
  92. package/dist/profiler/collectors.js +106 -19
  93. package/dist/profiler/collectors.js.map +1 -1
  94. package/dist/profiler/endpoint.d.ts +2 -1
  95. package/dist/profiler/endpoint.d.ts.map +1 -1
  96. package/dist/profiler/endpoint.js +25 -1
  97. package/dist/profiler/endpoint.js.map +1 -1
  98. package/dist/profiler/index.d.ts +8 -4
  99. package/dist/profiler/index.d.ts.map +1 -1
  100. package/dist/profiler/index.js +11 -4
  101. package/dist/profiler/index.js.map +1 -1
  102. package/dist/profiler/middleware.d.ts.map +1 -1
  103. package/dist/profiler/middleware.js +76 -10
  104. package/dist/profiler/middleware.js.map +1 -1
  105. package/dist/profiler/preview.d.ts +17 -0
  106. package/dist/profiler/preview.d.ts.map +1 -0
  107. package/dist/profiler/preview.js +100 -0
  108. package/dist/profiler/preview.js.map +1 -0
  109. package/dist/profiler/toolbar.d.ts +15 -23
  110. package/dist/profiler/toolbar.d.ts.map +1 -1
  111. package/dist/profiler/toolbar.js +25 -91
  112. package/dist/profiler/toolbar.js.map +1 -1
  113. package/dist/profiler/types.d.ts +6 -1
  114. package/dist/profiler/types.d.ts.map +1 -1
  115. package/dist/router/Router.d.ts +6 -0
  116. package/dist/router/Router.d.ts.map +1 -1
  117. package/dist/router/Router.js +115 -12
  118. package/dist/router/Router.js.map +1 -1
  119. package/dist/rpc/RpcRouter.js +3 -3
  120. package/dist/rpc/RpcRouter.js.map +1 -1
  121. package/dist/scheduler/Scheduler.js +1 -1
  122. package/dist/scheduler/Scheduler.js.map +1 -1
  123. package/dist/security/CookieSigner.d.ts.map +1 -1
  124. package/dist/security/CookieSigner.js +15 -5
  125. package/dist/security/CookieSigner.js.map +1 -1
  126. package/dist/security/SignedUrl.d.ts.map +1 -1
  127. package/dist/security/SignedUrl.js +10 -18
  128. package/dist/security/SignedUrl.js.map +1 -1
  129. package/dist/server/Server.d.ts +4 -1
  130. package/dist/server/Server.d.ts.map +1 -1
  131. package/dist/server/Server.js.map +1 -1
  132. package/dist/server/nativeLimits.d.ts +9 -1
  133. package/dist/server/nativeLimits.d.ts.map +1 -1
  134. package/dist/server/nativeLimits.js +8 -0
  135. package/dist/server/nativeLimits.js.map +1 -1
  136. package/dist/session/Session.d.ts.map +1 -1
  137. package/dist/session/Session.js +13 -4
  138. package/dist/session/Session.js.map +1 -1
  139. package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
  140. package/dist/session/drivers/MemoryDriver.js +14 -2
  141. package/dist/session/drivers/MemoryDriver.js.map +1 -1
  142. package/dist/storage/StaticMiddleware.d.ts.map +1 -1
  143. package/dist/storage/StaticMiddleware.js +20 -9
  144. package/dist/storage/StaticMiddleware.js.map +1 -1
  145. package/dist/testing/ApiResponse.d.ts.map +1 -1
  146. package/dist/testing/ApiResponse.js +7 -1
  147. package/dist/testing/ApiResponse.js.map +1 -1
  148. package/dist/testing/TestClient.d.ts.map +1 -1
  149. package/dist/testing/TestClient.js +16 -4
  150. package/dist/testing/TestClient.js.map +1 -1
  151. package/dist/utils/Macroable.d.ts +1 -1
  152. package/dist/utils/Macroable.js +2 -2
  153. package/dist/utils/Macroable.js.map +1 -1
  154. package/events.darwin-arm64.node +0 -0
  155. package/events.darwin-x64.node +0 -0
  156. package/events.linux-arm64-gnu.node +0 -0
  157. package/events.linux-x64-gnu.node +0 -0
  158. package/events.win32-x64-msvc.node +0 -0
  159. package/index.darwin-arm64.node +0 -0
  160. package/index.darwin-x64.node +0 -0
  161. package/index.linux-arm64-gnu.node +0 -0
  162. package/index.linux-x64-gnu.node +0 -0
  163. package/index.win32-x64-msvc.node +0 -0
  164. package/package.json +1 -2
  165. package/scheduler.darwin-arm64.node +0 -0
  166. package/scheduler.darwin-x64.node +0 -0
  167. package/scheduler.linux-arm64-gnu.node +0 -0
  168. package/scheduler.linux-x64-gnu.node +0 -0
  169. package/scheduler.win32-x64-msvc.node +0 -0
  170. package/src/Codemods.ts +49 -3
  171. package/src/HttpKernel.ts +18 -7
  172. package/src/Ignitor.ts +21 -4
  173. package/src/bodyparser/BodyParserMiddleware.ts +42 -16
  174. package/src/bodyparser/MultipartFile.ts +46 -67
  175. package/src/bodyparser/parseSize.ts +17 -17
  176. package/src/bodyparser/qsParse.ts +28 -13
  177. package/src/console/prompts.ts +3 -1
  178. package/src/container/Container.ts +97 -81
  179. package/src/events/Emitter.ts +37 -10
  180. package/src/events/helix/helpers.ts +24 -19
  181. package/src/graphql/GraphQLEngine.ts +89 -13
  182. package/src/helpers/MessageBuilder.ts +4 -2
  183. package/src/helpers/errors.ts +16 -0
  184. package/src/helpers/napi-loader.ts +3 -2
  185. package/src/http/HttpContext.ts +20 -4
  186. package/src/http/RedirectBuilder.ts +3 -0
  187. package/src/http/Request.ts +15 -29
  188. package/src/http/Response.ts +15 -7
  189. package/src/http/permissions.ts +31 -15
  190. package/src/index.ts +2 -1
  191. package/src/middleware/Pipeline.ts +1 -1
  192. package/src/native/generated.ts +18 -42
  193. package/src/profiler/Profiler.ts +4 -1
  194. package/src/profiler/client/toolbar.css +595 -0
  195. package/src/profiler/client/toolbar.js +1299 -0
  196. package/src/profiler/client.generated.ts +7 -0
  197. package/src/profiler/collectors.ts +156 -19
  198. package/src/profiler/endpoint.ts +27 -1
  199. package/src/profiler/index.ts +17 -3
  200. package/src/profiler/middleware.ts +74 -9
  201. package/src/profiler/preview.ts +104 -0
  202. package/src/profiler/toolbar.ts +28 -91
  203. package/src/profiler/types.ts +6 -1
  204. package/src/router/Router.ts +136 -13
  205. package/src/rpc/RpcRouter.ts +4 -4
  206. package/src/scheduler/Scheduler.ts +1 -1
  207. package/src/security/CookieSigner.ts +13 -5
  208. package/src/security/SignedUrl.ts +12 -16
  209. package/src/server/Server.ts +4 -1
  210. package/src/server/nativeLimits.ts +18 -1
  211. package/src/session/Session.ts +13 -4
  212. package/src/session/drivers/MemoryDriver.ts +15 -2
  213. package/src/storage/StaticMiddleware.ts +18 -9
  214. package/src/testing/ApiResponse.ts +7 -1
  215. package/src/testing/TestClient.ts +14 -5
  216. package/src/utils/Macroable.ts +2 -2
  217. package/scripts/build-napi-types.mjs +0 -69
  218. package/scripts/copy-napi.mjs +0 -121
  219. package/scripts/generate-mime.mjs +0 -119
  220. package/scripts/generate-napi-types.mjs +0 -187
@@ -96,6 +96,8 @@ export class Emitter {
96
96
  #fakedEvents?: Set<BufferedEventName>
97
97
  /** Wildcard subscriptions, so `offAny(listener)` can find its Rust handle. */
98
98
  readonly #anySubscriptions = new Map<(eventName: string, data: unknown) => void, Set<number>>()
99
+ /** Observers told of every emission as it happens — see {@link tap}. */
100
+ readonly #taps = new Set<(event: string, data: unknown) => void>()
99
101
 
100
102
  constructor(bus: EventBus, resolver?: EmitterResolver) {
101
103
  this.#bus = bus
@@ -227,6 +229,34 @@ export class Emitter {
227
229
 
228
230
  // ─── Emit ─────────────────────────────────────────────────
229
231
 
232
+ /**
233
+ * Watch every emission — string and class-based — as it happens.
234
+ *
235
+ * Development tooling: the profiler's events panel. `onAny` is not enough for
236
+ * it, because a wildcard subscription goes through the native bus and is
237
+ * called later, outside the request that emitted. A tap runs synchronously,
238
+ * in the emitting code's own async context, before any listener.
239
+ *
240
+ * It observes and nothing more: it sees a faked event too, it cannot change
241
+ * or cancel the emission, and a tap that throws is ignored.
242
+ */
243
+ tap(observer: (event: string, data: unknown) => void): UnsubscribeFunction {
244
+ this.#taps.add(observer)
245
+ return () => {
246
+ this.#taps.delete(observer)
247
+ }
248
+ }
249
+
250
+ #notifyTaps(event: string, data: unknown): void {
251
+ for (const observer of this.#taps) {
252
+ try {
253
+ observer(event, data)
254
+ } catch {
255
+ // Observation must never break the emission it watches.
256
+ }
257
+ }
258
+ }
259
+
230
260
  /**
231
261
  * Run an event's listeners, in parallel or one after another.
232
262
  *
@@ -235,10 +265,9 @@ export class Emitter {
235
265
  * event it does not own.
236
266
  */
237
267
  async #dispatchListeners(event: string, data: unknown, serial: boolean): Promise<void> {
238
- const listeners = [
239
- ...(this.#stringListeners.get(event) ?? []),
240
- ...this.#wildcardListenersFor(event),
241
- ]
268
+ // `onAny` listeners are not here: they subscribe through the native bus,
269
+ // which delivers every matching event to them on its own.
270
+ const listeners = this.#stringListeners.get(event) ?? []
242
271
  if (listeners.length === 0) return
243
272
  const run = async (fn: ListenerFn): Promise<void> => {
244
273
  try {
@@ -254,17 +283,13 @@ export class Emitter {
254
283
  await Promise.all(listeners.map(run))
255
284
  }
256
285
 
257
- /** Listeners registered through `onAny` whose pattern matches `event`. */
258
- #wildcardListenersFor(_event: string): ListenerFn[] {
259
- return []
260
- }
261
-
262
286
  /**
263
287
  * Emit and run the listeners ONE AT A TIME (AdonisJS `emitSerial`).
264
288
  *
265
289
  * Use it when order matters — a listener that seeds what the next one reads.
266
290
  */
267
291
  async emitSerial(event: string, data: unknown): Promise<void> {
292
+ this.#notifyTaps(event, data)
268
293
  if (this.#capture(event, data)) return
269
294
  await this.#dispatchListeners(event, data, true)
270
295
  this.#publishToBus(event, data)
@@ -282,6 +307,7 @@ export class Emitter {
282
307
  * rejects is reported through `emitError`, never left unhandled.
283
308
  */
284
309
  async emit(event: string, data: unknown): Promise<void> {
310
+ this.#notifyTaps(event, data)
285
311
  if (this.#capture(event, data)) return
286
312
  await this.#dispatchListeners(event, data, false)
287
313
  this.#publishToBus(event, data)
@@ -331,11 +357,12 @@ export class Emitter {
331
357
  */
332
358
  async dispatchEvent<T extends object>(event: T): Promise<void> {
333
359
  const EventClass = event.constructor as EventConstructor<T>
360
+ const name = (EventClass as { eventName?: string }).eventName ?? classToEventName(EventClass)
361
+ this.#notifyTaps(name, event)
334
362
  // Faked by the class, as AdonisJS does — `fake([TaskDeclared])` catches
335
363
  // `new TaskDeclared(...).emit()`.
336
364
  if (this.#capture(EventClass, event)) return
337
365
  const listeners = this.#classListeners.get(EventClass) ?? []
338
- const name = (EventClass as { eventName?: string }).eventName ?? classToEventName(EventClass)
339
366
 
340
367
  // Per-listener error isolation — mirrors the string `emit()` contract.
341
368
  // A side-effect listener that throws (SMTP down, etc.) MUST NOT abort
@@ -15,6 +15,16 @@ export interface CollectedEvent {
15
15
  [key: string]: unknown
16
16
  }
17
17
 
18
+ function isCollectedEvent(value: unknown): value is CollectedEvent {
19
+ return (
20
+ typeof value === 'object' &&
21
+ value !== null &&
22
+ 'name' in value &&
23
+ typeof value.name === 'string' &&
24
+ 'data' in value
25
+ )
26
+ }
27
+
18
28
  export interface CollectResult {
19
29
  events: CollectedEvent[]
20
30
  subId: number
@@ -34,15 +44,17 @@ interface BusLike {
34
44
  export function collect(bus: BusLike, pattern: string): CollectResult {
35
45
  const events: CollectedEvent[] = []
36
46
  const subId = bus.subscribe(pattern, (eventJson: string) => {
47
+ const unreadable: CollectedEvent = {
48
+ _raw: eventJson,
49
+ _parseError: true,
50
+ name: '',
51
+ data: undefined,
52
+ }
37
53
  try {
38
- events.push(JSON.parse(eventJson) as CollectedEvent)
54
+ const parsed: unknown = JSON.parse(eventJson)
55
+ events.push(isCollectedEvent(parsed) ? parsed : unreadable)
39
56
  } catch {
40
- events.push({
41
- _raw: eventJson,
42
- _parseError: true,
43
- name: '',
44
- data: undefined,
45
- } as CollectedEvent)
57
+ events.push(unreadable)
46
58
  }
47
59
  })
48
60
  return { events, subId }
@@ -233,20 +245,13 @@ function partialMatch(actual: unknown, expected: unknown): boolean {
233
245
 
234
246
  if (Array.isArray(expected)) {
235
247
  if (!Array.isArray(actual)) return false
236
- return expected.every(
237
- (item, i) => i < (actual as unknown[]).length && partialMatch((actual as unknown[])[i], item),
238
- )
248
+ const items: unknown[] = actual
249
+ return expected.every((item, i) => i < items.length && partialMatch(items[i], item))
239
250
  }
240
251
 
241
- for (const key of Object.keys(expected as object)) {
242
- if (!(key in (actual as object))) return false
243
- if (
244
- !partialMatch(
245
- (actual as Record<string, unknown>)[key],
246
- (expected as Record<string, unknown>)[key],
247
- )
248
- )
249
- return false
252
+ for (const key of Object.keys(expected)) {
253
+ if (!(key in actual)) return false
254
+ if (!partialMatch(Reflect.get(actual, key), Reflect.get(expected, key))) return false
250
255
  }
251
256
  return true
252
257
  }
@@ -24,6 +24,7 @@
24
24
  import * as fs from 'node:fs'
25
25
  import { currentNodeEnv } from '../env/nodeEnv.js'
26
26
  import { loadNapi } from '../helpers/napi-loader.js'
27
+ import { Exception } from '../http/Exception.js'
27
28
  import type { HttpContext } from '../http/HttpContext.js'
28
29
  import { holdsPermissions } from '../http/permissions.js'
29
30
 
@@ -170,6 +171,14 @@ export interface GraphQLConfig {
170
171
  * (default: 1000).
171
172
  */
172
173
  maxComplexity?: number
174
+ /**
175
+ * Echo the message of any error a resolver throws to the client. Default:
176
+ * only when NODE_ENV is development. Otherwise only a deliberate
177
+ * `Exception`, a client error (status below 500) or an error flagged
178
+ * `expose: true` keeps its message; anything else reads "Internal server
179
+ * error", and the real error goes to `ctx.logger`.
180
+ */
181
+ debug?: boolean
173
182
  }
174
183
 
175
184
  export interface ResolverOptions {
@@ -208,6 +217,7 @@ export class GraphQLEngine {
208
217
  #container?: { make<T>(target: abstract new (...args: never[]) => T): Promise<T> }
209
218
  readonly path: string
210
219
  #playground: boolean
220
+ readonly #debug: boolean
211
221
  #maxQueryBytes: number
212
222
  #maxDepth: number
213
223
  #maxComplexity: number
@@ -237,6 +247,7 @@ export class GraphQLEngine {
237
247
  this.#maxQueryBytes = config.maxQueryBytes ?? 100_000
238
248
  this.#maxDepth = config.maxDepth ?? 12
239
249
  this.#maxComplexity = config.maxComplexity ?? 1000
250
+ this.#debug = config.debug ?? currentNodeEnv() === 'development'
240
251
  }
241
252
 
242
253
  /** Set IoC container for resolver instantiation. */
@@ -280,8 +291,19 @@ export class GraphQLEngine {
280
291
  return
281
292
  }
282
293
 
283
- const rawBody = ctx.request.body()
284
- const body = parseGraphQLBody(rawBody)
294
+ // Only a JSON body is a GraphQL request. A form post, a text/plain body or
295
+ // one with no Content-Type is a CORS simple request: a foreign page sends
296
+ // it with the user's cookies and no preflight, so a mutation would run
297
+ // cross-site. Refused before the body is read.
298
+ const mediaType = (ctx.request.header('content-type')?.split(';')[0] ?? '').trim().toLowerCase()
299
+ if (mediaType !== 'application/json') {
300
+ ctx.response.status(415).json({ errors: [{ message: 'Unsupported Media Type' }] })
301
+ return
302
+ }
303
+
304
+ // Read from the raw body rather than `request.body()`, so the endpoint
305
+ // works whether or not the body parser runs in front of it.
306
+ const body = parseGraphQLBody(parseJson(ctx.request.raw()))
285
307
  if (!body) {
286
308
  ctx.response.status(400).json({ errors: [{ message: 'Missing or invalid query' }] })
287
309
  return
@@ -291,11 +313,7 @@ export class GraphQLEngine {
291
313
  const result = await this.#execute(body, ctx)
292
314
  ctx.response.status(200).json(result)
293
315
  } catch (err) {
294
- const message =
295
- currentNodeEnv() !== 'production' && err instanceof Error
296
- ? err.message
297
- : 'Internal server error'
298
- ctx.response.status(500).json({ errors: [{ message }] })
316
+ ctx.response.status(500).json({ errors: [{ message: this.#publicMessage(err, ctx) }] })
299
317
  }
300
318
  }
301
319
 
@@ -365,7 +383,7 @@ export class GraphQLEngine {
365
383
  }
366
384
  }
367
385
 
368
- const guardError = this.#enforceGuards(entry.options, ctx)
386
+ const guardError = await this.#enforceGuards(entry.options, ctx)
369
387
  if (guardError) {
370
388
  return { ok: false, error: { message: guardError, path: [field.name] } }
371
389
  }
@@ -396,11 +414,26 @@ export class GraphQLEngine {
396
414
  // ask for. Empty selection (scalar field) returns the value as-is.
397
415
  return { ok: true, value: pruneToSelection(result, field.selection) }
398
416
  } catch (err) {
399
- const msg = err instanceof Error ? err.message : 'Resolver error'
400
- return { ok: false, error: { message: msg, path: [field.name] } }
417
+ return { ok: false, error: { message: this.#publicMessage(err, ctx), path: [field.name] } }
401
418
  }
402
419
  }
403
420
 
421
+ /**
422
+ * The message a client may see for a thrown error — the rule the HTTP
423
+ * exception handler applies. A driver or library error carries internal
424
+ * hosts, credentials and stored values in its text, so outside debug it is
425
+ * collapsed, and the original is logged for the operator instead.
426
+ */
427
+ #publicMessage(err: unknown, ctx: HttpContext): string {
428
+ const message = err instanceof Error ? err.message : String(err)
429
+ if (this.#debug || isPublicError(err)) return message
430
+ ctx.logger.error('GraphQL resolver failed', {
431
+ message,
432
+ stack: err instanceof Error ? err.stack : undefined,
433
+ })
434
+ return 'Internal server error'
435
+ }
436
+
404
437
  /**
405
438
  * Build the resolver argument bag: `$variable` references resolve against
406
439
  * `request.variables`, then any unreferenced variables are merged in.
@@ -436,7 +469,7 @@ export class GraphQLEngine {
436
469
  }
437
470
 
438
471
  /** Enforce guard/role/permission on a resolver. */
439
- #enforceGuards(options: ResolverOptions, ctx: HttpContext): string | null {
472
+ async #enforceGuards(options: ResolverOptions, ctx: HttpContext): Promise<string | null> {
440
473
  const guards = [...(options.guards ?? []), ...(options.guard ? [options.guard] : [])]
441
474
  const roles = [...(options.roles ?? []), ...(options.role ? [options.role] : [])]
442
475
  const permissions = options.permissions ?? []
@@ -458,7 +491,7 @@ export class GraphQLEngine {
458
491
 
459
492
  // A token-authenticated request also needs the token to allow each one.
460
493
  if (permissions.length > 0) {
461
- if (!holdsPermissions(ctx.auth, permissions)) {
494
+ if (!(await holdsPermissions(ctx.auth, permissions))) {
462
495
  return 'Insufficient permissions'
463
496
  }
464
497
  }
@@ -478,7 +511,10 @@ export class GraphQLEngine {
478
511
  // Full-Rust: the spec-compliant `ream-graphql` parser (graphql-parser
479
512
  // crate) rejects invalid queries before any resolver runs. No TS fallback —
480
513
  // ream ships the native binary, so when Rust exists the engine is Rust.
481
- const raw = JSON.parse(graphqlNative().graphqlParse(query)) as RustParseResult
514
+ const raw: unknown = JSON.parse(graphqlNative().graphqlParse(query))
515
+ if (!isRustParseResult(raw)) {
516
+ return { typeName: 'Query', fields: [], errors: [{ message: 'Unreadable parse result' }] }
517
+ }
482
518
  const typeName = OPERATION_TYPE[raw.operation_type] ?? 'Query'
483
519
  if (raw.errors.length > 0) {
484
520
  return { typeName, fields: [], errors: raw.errors.map((message) => ({ message })) }
@@ -520,6 +556,22 @@ function isVariablesRecord(value: unknown): value is Record<string, unknown> {
520
556
  return typeof value === 'object' && value !== null && !Array.isArray(value)
521
557
  }
522
558
 
559
+ /** A deliberate `Exception`, a client error, or an error flagged `expose: true`. */
560
+ function isPublicError(err: unknown): boolean {
561
+ if (err instanceof Exception) return true
562
+ if (err === null || typeof err !== 'object') return false
563
+ if ('expose' in err && err.expose === true) return true
564
+ return 'status' in err && typeof err.status === 'number' && err.status < 500
565
+ }
566
+
567
+ function parseJson(raw: string): unknown {
568
+ try {
569
+ return JSON.parse(raw)
570
+ } catch {
571
+ return undefined
572
+ }
573
+ }
574
+
523
575
  function parseGraphQLBody(body: unknown): GraphQLRequest | null {
524
576
  if (!body || typeof body !== 'object' || Array.isArray(body)) return null
525
577
  // After typeof check, body is a non-null non-array object; index via 'in' narrowing
@@ -537,6 +589,30 @@ function parseGraphQLBody(body: unknown): GraphQLRequest | null {
537
589
  return { query: q, variables, operationName }
538
590
  }
539
591
 
592
+ /** The shape the native parser answers with, checked rather than assumed. */
593
+ function isRustParseResult(value: unknown): value is RustParseResult {
594
+ if (!isRecord(value)) return false
595
+ return (
596
+ typeof value.operation_type === 'string' &&
597
+ (value.operation_name === null || typeof value.operation_name === 'string') &&
598
+ Array.isArray(value.errors) &&
599
+ value.errors.every((error) => typeof error === 'string') &&
600
+ Array.isArray(value.fields) &&
601
+ value.fields.every(isRustParsedField)
602
+ )
603
+ }
604
+
605
+ function isRustParsedField(value: unknown): value is RustParsedField {
606
+ if (!isRecord(value)) return false
607
+ return (
608
+ typeof value.name === 'string' &&
609
+ (value.alias === null || typeof value.alias === 'string') &&
610
+ isRecord(value.args) &&
611
+ Array.isArray(value.sub_fields) &&
612
+ value.sub_fields.every(isRustParsedField)
613
+ )
614
+ }
615
+
540
616
  function isRecord(v: unknown): v is Record<string, unknown> {
541
617
  return typeof v === 'object' && v !== null && !Array.isArray(v)
542
618
  }
@@ -7,8 +7,10 @@
7
7
  * signed email-verification token. Those two claims travel inside the payload,
8
8
  * which is why they are added before signing rather than checked after.
9
9
  *
10
- * Ported from `@poppinss/utils`; `CookieSigner` and `SignedUrl` sign what this
11
- * produces.
10
+ * Ported from `@poppinss/utils`, as a helper for application code. Ream's own
11
+ * signers do not use it: `CookieSigner` seals its values in a compact envelope
12
+ * of its own (`{ m, e, p }`, an epoch-millisecond expiry), and `SignedUrl`
13
+ * carries the expiry and the purpose as query parameters.
12
14
  */
13
15
 
14
16
  import { milliseconds } from './duration.js'
@@ -0,0 +1,16 @@
1
+ /**
2
+ * A caught value as the `NodeJS.ErrnoException` an error callback takes.
3
+ *
4
+ * `catch` binds `unknown`: an `Error` already is one (every field it adds is
5
+ * optional), and anything else thrown is wrapped, so a callback never reads
6
+ * `.code` or `.message` off a string.
7
+ */
8
+ export function toErrnoException(error: unknown): NodeJS.ErrnoException {
9
+ return error instanceof Error ? error : new Error(String(error))
10
+ }
11
+
12
+ /** The `code` of a caught system error (`ENOENT`, `MODULE_NOT_FOUND`…), if any. */
13
+ export function errorCode(error: unknown): string | undefined {
14
+ if (typeof error !== 'object' || error === null || !('code' in error)) return undefined
15
+ return typeof error.code === 'string' ? error.code : undefined
16
+ }
@@ -12,6 +12,7 @@ import { dirname, join } from 'node:path'
12
12
  import { arch, platform } from 'node:process'
13
13
  import { fileURLToPath } from 'node:url'
14
14
  import { ReamError } from '../errors/ReamError.js'
15
+ import { errorCode } from './errors.js'
15
16
 
16
17
  /**
17
18
  * Platform triple suffix (matches napi-rs naming).
@@ -88,14 +89,14 @@ export function loadNapi<T>(options: LoadNapiOptions): T {
88
89
  try {
89
90
  return req(path) as T
90
91
  } catch (e) {
91
- const code = (e as NodeJS.ErrnoException | undefined)?.code
92
+ const code = errorCode(e)
92
93
  if (code === 'MODULE_NOT_FOUND') continue
93
94
  // Any other error (SyntaxError, ABI mismatch, permissions,
94
95
  // corrupt binary) means the file exists but cannot load. Surface
95
96
  // the real cause rather than a generic "not found".
96
97
  throw new ReamError(
97
98
  `${errorCodePrefix}_NAPI_LOAD_FAILED`,
98
- `${binaryName} NAPI binary exists at '${path}' but failed to load: ${(e as Error).message}`,
99
+ `${binaryName} NAPI binary exists at '${path}' but failed to load: ${e instanceof Error ? e.message : String(e)}`,
99
100
  {
100
101
  hint: 'Check that the binary was built for the current platform and Node ABI. Rebuild with `pnpm --filter @c9up/ream build:rust`.',
101
102
  },
@@ -55,6 +55,16 @@ export interface AuthState {
55
55
  check?(): Promise<boolean>
56
56
  authenticateUsing?(guards?: string[], options?: { loginRoute?: string }): Promise<void>
57
57
  getUserOrFail?(): AuthState['user']
58
+ /**
59
+ * Whether the authenticated user holds every permission in `permissions`,
60
+ * as the auth layer resolves them (warden's authenticator asks its rights
61
+ * resolver and narrows by the current access token). When present, the
62
+ * `.permission()` gates trust this answer instead of the payload's
63
+ * `permissions`, which can be stale or incomplete. `scope` is the auth
64
+ * layer's own notion (warden: `'global'` or a tenant); the gates pass none,
65
+ * so the layer's default applies.
66
+ */
67
+ hasAllPermissions?(permissions: readonly string[], scope?: unknown): Promise<boolean>
58
68
  /**
59
69
  * The guard behind `name` (AdonisJS `auth.use('web')`).
60
70
  *
@@ -405,10 +415,16 @@ export class HttpContext extends Macroable {
405
415
  this.request.setSignedUrl(signedUrl)
406
416
  }
407
417
 
408
- /** Subdomains of the request host (AdonisJS `ctx.subdomains`). */
409
- get subdomains(): string[] {
410
- return this.request.subdomains()
411
- }
418
+ /**
419
+ * The values the matched route's domain captured from the host:
420
+ * `{ tenant: 'acme' }` for a route on `:tenant.app.test` reached through
421
+ * `acme.app.test`. Empty when the route has no parameterised domain. Set by
422
+ * the HTTP kernel from the route match, as upstream's router does.
423
+ *
424
+ * BREAKING — this used to be the host's labels (`['api']`); that is
425
+ * `ctx.request.subdomains()`, and stays there.
426
+ */
427
+ subdomains: Record<string, string> = {}
412
428
 
413
429
  /** Stable identifier for the matched route (`METHOD-pattern`) — AdonisJS `ctx.routeKey`. */
414
430
  get routeKey(): string {
@@ -32,6 +32,9 @@ function isTrustedRedirect(
32
32
  } catch {
33
33
  return false
34
34
  }
35
+ // Only a web URL is a redirect target: `javascript://app.test/%0aalert(1)`
36
+ // has the right host and is a script, not a page.
37
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
35
38
  if (allowedHosts.includes(parsed.host)) return true
36
39
  // Upstream accepts any absolute URL when it knows no host to compare with;
37
40
  // refusing it is the safer reading of an attacker-controlled header.
@@ -478,7 +478,9 @@ export class Request extends Macroable {
478
478
  encryptedCookie(name: string, defaultValue?: string): string | null
479
479
  encryptedCookie(name: string, defaultValue?: string): string | null {
480
480
  const raw = this.plainCookie<string>(name, undefined, { encoded: false })
481
- const value = raw === null || !this.#cookieSigner ? null : this.#cookieSigner.decrypt(raw)
481
+ // Decrypted for the purpose it was written under — its own name — so a
482
+ // value issued as another encrypted cookie is refused here.
483
+ const value = raw === null || !this.#cookieSigner ? null : this.#cookieSigner.decrypt(raw, name)
482
484
  return value ?? defaultValue ?? null
483
485
  }
484
486
 
@@ -757,7 +759,9 @@ export class Request extends Macroable {
757
759
  cookies: this.cookies(),
758
760
  hostname: this.hostname(),
759
761
  ip: this.ip(),
760
- subdomains: this.subdomains(),
762
+ // What the route's domain captured (`ctx.subdomains`), as upstream
763
+ // serialises it — not the host's labels, which `subdomains()` returns.
764
+ subdomains: (this.ctx === undefined ? undefined : Reflect.get(this.ctx, 'subdomains')) ?? {},
761
765
  }
762
766
  }
763
767
 
@@ -1119,39 +1123,21 @@ export class Request extends Macroable {
1119
1123
  }
1120
1124
  }
1121
1125
 
1126
+ /**
1127
+ * The body is whatever the body parser decided it is, and `{}` when no
1128
+ * parser claimed it. Nothing is parsed here: a JSON body is only JSON when
1129
+ * its Content-Type says so. Reading any body as JSON let a foreign page
1130
+ * drive a JSON route with a CORS simple request (no Content-Type, or
1131
+ * `text/plain`), which a browser sends cross-site with the user's cookies
1132
+ * and without a preflight.
1133
+ */
1122
1134
  #ensureParsedBody(): void {
1123
1135
  if (this.#parsedBody !== undefined) return
1124
1136
  this.#assertBodyKept()
1125
- const raw = this.#raw.body
1126
- if (!raw || raw.length === 0) {
1127
- this.#parsedBody = {}
1128
- return
1129
- }
1130
- try {
1131
- const parsed: unknown = JSON.parse(raw)
1132
- this.#parsedBody = isPlainObject(parsed) ? parsed : { _body: parsed }
1133
- } catch {
1134
- this.#parsedBody = {}
1135
- }
1137
+ this.#parsedBody = {}
1136
1138
  }
1137
1139
  }
1138
1140
 
1139
- /** Parse a query string (key=value&key2=value2) into an object. */
1140
- /**
1141
- * Test whether an IPv4 address is within a CIDR range. Bare IP entries
1142
- * (`10.0.0.42`) are accepted as `/32`. IPv6 falls back to string equality
1143
- * — a full v6 implementation isn't required by the current security tests.
1144
- */
1145
- /**
1146
- * Type guard for plain JSON-decoded objects. Excludes `null` (which is
1147
- * `typeof === 'object'`) and arrays so consumers can index by string key
1148
- * without a separate runtime check.
1149
- */
1150
- function isPlainObject(value: unknown): value is Dict<unknown> {
1151
- return typeof value === 'object' && value !== null && !Array.isArray(value)
1152
- }
1153
-
1154
- /** True for an IPv4 dotted-quad or a (bracketed) IPv6 literal — hosts with no subdomains. */
1155
1141
  /**
1156
1142
  * Headers whose VALUE is a credential. Listing them is useful; printing them
1157
1143
  * hands whoever reads the log a working key.
@@ -13,6 +13,7 @@ import { readFile, stat } from 'node:fs/promises'
13
13
  import { basename } from 'node:path'
14
14
  import { ReamError } from '../errors/ReamError.js'
15
15
  import { durationToSeconds } from '../helpers/duration.js'
16
+ import { toErrnoException } from '../helpers/errors.js'
16
17
  import { etag, matchesIfNoneMatch } from './etag.js'
17
18
  import { contentType } from './mime.js'
18
19
 
@@ -848,7 +849,7 @@ export class Response extends Macroable {
848
849
  size = (await stat(filePath)).size
849
850
  } catch (error) {
850
851
  if (errorCallback) {
851
- const [message, status] = errorCallback(error as NodeJS.ErrnoException)
852
+ const [message, status] = errorCallback(toErrnoException(error))
852
853
  this.status(status ?? 404).send(message)
853
854
  return
854
855
  }
@@ -1002,7 +1003,7 @@ export class Response extends Macroable {
1002
1003
  // The headers are already out, so there is no status left to change: end
1003
1004
  // the body and let the client see a short read. `errorCallback` is still
1004
1005
  // consulted for whatever logging it does.
1005
- errorCallback?.(err as NodeJS.ErrnoException)
1006
+ errorCallback?.(toErrnoException(err))
1006
1007
  } finally {
1007
1008
  // Never rethrows: this runs detached, and an unhandled rejection here
1008
1009
  // would take the process down over one client's download.
@@ -1022,7 +1023,7 @@ export class Response extends Macroable {
1022
1023
  }
1023
1024
  } catch (err) {
1024
1025
  const [message, status] = errorCallback
1025
- ? errorCallback(err as NodeJS.ErrnoException)
1026
+ ? errorCallback(toErrnoException(err))
1026
1027
  : ['Cannot process the request', 500]
1027
1028
  this.status(status ?? 500)
1028
1029
  this.send(message)
@@ -1133,7 +1134,9 @@ export class Response extends Macroable {
1133
1134
  'encryptedCookie() requires APP_KEY — set it so the encryption service is registered.',
1134
1135
  )
1135
1136
  }
1136
- return this.#writeCookie(name, this.#cookieSigner.encrypt(value), options)
1137
+ // The cookie name is the purpose, as for a signed cookie: a value issued
1138
+ // as one encrypted cookie cannot be replayed under another one's name.
1139
+ return this.#writeCookie(name, this.#cookieSigner.encrypt(value, undefined, name), options)
1137
1140
  }
1138
1141
 
1139
1142
  #writeCookie(name: string, value: string, options?: CookieOptions): this {
@@ -1195,10 +1198,15 @@ export class Response extends Macroable {
1195
1198
 
1196
1199
  /**
1197
1200
  * Expire a cookie immediately (AdonisJS `clearCookie`) — re-sends it with
1198
- * `Max-Age=0`, the RFC 6265 delete-now signal that `cookie()` honours.
1201
+ * `Max-Age=0` and an `Expires` in 1970, the RFC 6265 delete-now signals.
1202
+ *
1203
+ * A browser matches the cookie to replace on its name, domain and path, so
1204
+ * a cookie written with a `domain` (or a `path`) is only cleared when the
1205
+ * same ones are passed here; `secure` and `sameSite` are accepted so a
1206
+ * `SameSite=None` cookie can be cleared at all.
1199
1207
  */
1200
- clearCookie(name: string, options?: { path?: string }): this {
1201
- return this.plainCookie(name, '', { ...options, maxAge: 0 })
1208
+ clearCookie(name: string, options?: Omit<CookieOptions, 'maxAge' | 'expires'>): this {
1209
+ return this.plainCookie(name, '', { ...options, maxAge: 0, expires: new Date(0) })
1202
1210
  }
1203
1211
 
1204
1212
  // ─── Internals (used by HttpKernel for NAPI serialization) ─
@@ -9,23 +9,39 @@ import type { AuthState } from './HttpContext.js'
9
9
  * Whether the request's identity holds every permission in `required` — what
10
10
  * the route, RPC and GraphQL `.permission()` gates ask.
11
11
  *
12
- * The permissions are the user's own (top-level `auth.permissions`, or
13
- * `auth.user.permissions` where warden puts them). When the request was
14
- * authenticated with a token, each permission must ALSO be allowed by
15
- * `auth.user.currentAccessToken`: an intersection, never a union. A token
16
- * scope named like a permission grants nothing the user lacks, and a token
17
- * limited to `profile` does not carry every permission of its user. A token
18
- * whose `allows` cannot be called denies.
12
+ * When the auth state exposes `hasAllPermissions` (the auth layer resolves
13
+ * permissions itself, as warden does through its rights resolver), its
14
+ * answer decides: a permission granted only by the resolver passes, one left
15
+ * only in the payload does not. Anything but `true` denies, and an error from
16
+ * the resolver propagates rather than grants. Only an auth state without that
17
+ * method falls back to the payload: top-level `auth.permissions`, or
18
+ * `auth.user.permissions`.
19
+ *
20
+ * When the request was authenticated with a token, each permission must ALSO
21
+ * be allowed by `auth.user.currentAccessToken`: an intersection, never a
22
+ * union. A token scope named like a permission grants nothing the user
23
+ * lacks, and a token limited to `profile` does not carry every permission of
24
+ * its user. A token whose `allows` cannot be called denies.
19
25
  */
20
- export function holdsPermissions(
26
+ export async function holdsPermissions(
21
27
  auth: AuthState | undefined,
22
28
  required: readonly string[],
23
- ): boolean {
24
- const granted = auth?.permissions ?? auth?.user?.permissions ?? []
29
+ ): Promise<boolean> {
30
+ if (!(await userHolds(auth, required))) return false
25
31
  const token = auth?.user?.currentAccessToken
26
- return required.every((permission) => {
27
- if (!granted.includes(permission)) return false
28
- if (token === undefined || token === null) return true
29
- return typeof token.allows === 'function' && token.allows(permission) === true
30
- })
32
+ if (token === undefined || token === null) return true
33
+ return required.every(
34
+ (permission) => typeof token.allows === 'function' && token.allows(permission) === true,
35
+ )
36
+ }
37
+
38
+ async function userHolds(
39
+ auth: AuthState | undefined,
40
+ required: readonly string[],
41
+ ): Promise<boolean> {
42
+ if (typeof auth?.hasAllPermissions === 'function') {
43
+ return (await auth.hasAllPermissions(required)) === true
44
+ }
45
+ const granted = auth?.permissions ?? auth?.user?.permissions ?? []
46
+ return required.every((permission) => granted.includes(permission))
31
47
  }