@c9up/ream 0.2.28 → 0.2.29

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 (168) hide show
  1. package/dist/Codemods.d.ts +16 -0
  2. package/dist/Codemods.d.ts.map +1 -1
  3. package/dist/Codemods.js +182 -0
  4. package/dist/Codemods.js.map +1 -1
  5. package/dist/Ignitor.d.ts +10 -2
  6. package/dist/Ignitor.d.ts.map +1 -1
  7. package/dist/Ignitor.js +21 -22
  8. package/dist/Ignitor.js.map +1 -1
  9. package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
  10. package/dist/bodyparser/BodyParserMiddleware.js +3 -0
  11. package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
  12. package/dist/bodyparser/qsParse.d.ts +10 -1
  13. package/dist/bodyparser/qsParse.d.ts.map +1 -1
  14. package/dist/bodyparser/qsParse.js +53 -15
  15. package/dist/bodyparser/qsParse.js.map +1 -1
  16. package/dist/codemods/source.d.ts +57 -0
  17. package/dist/codemods/source.d.ts.map +1 -0
  18. package/dist/codemods/source.js +282 -0
  19. package/dist/codemods/source.js.map +1 -0
  20. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  21. package/dist/graphql/GraphQLEngine.js +3 -2
  22. package/dist/graphql/GraphQLEngine.js.map +1 -1
  23. package/dist/http/Exception.d.ts +14 -1
  24. package/dist/http/Exception.d.ts.map +1 -1
  25. package/dist/http/Exception.js +10 -2
  26. package/dist/http/Exception.js.map +1 -1
  27. package/dist/http/HttpContext.d.ts +11 -0
  28. package/dist/http/HttpContext.d.ts.map +1 -1
  29. package/dist/http/HttpContext.js +2 -1
  30. package/dist/http/HttpContext.js.map +1 -1
  31. package/dist/http/RedirectBuilder.d.ts +9 -3
  32. package/dist/http/RedirectBuilder.d.ts.map +1 -1
  33. package/dist/http/RedirectBuilder.js +39 -37
  34. package/dist/http/RedirectBuilder.js.map +1 -1
  35. package/dist/http/Request.d.ts +42 -17
  36. package/dist/http/Request.d.ts.map +1 -1
  37. package/dist/http/Request.js +65 -29
  38. package/dist/http/Request.js.map +1 -1
  39. package/dist/http/SseStream.js +6 -7
  40. package/dist/http/SseStream.js.map +1 -1
  41. package/dist/http/permissions.d.ts +19 -0
  42. package/dist/http/permissions.d.ts.map +1 -0
  43. package/dist/http/permissions.js +28 -0
  44. package/dist/http/permissions.js.map +1 -0
  45. package/dist/http-client/HttpClient.d.ts +111 -0
  46. package/dist/http-client/HttpClient.d.ts.map +1 -0
  47. package/dist/http-client/HttpClient.js +386 -0
  48. package/dist/http-client/HttpClient.js.map +1 -0
  49. package/dist/http-client/addresses.d.ts +14 -0
  50. package/dist/http-client/addresses.d.ts.map +1 -0
  51. package/dist/http-client/addresses.js +57 -0
  52. package/dist/http-client/addresses.js.map +1 -0
  53. package/dist/http-client/fake.d.ts +50 -0
  54. package/dist/http-client/fake.d.ts.map +1 -0
  55. package/dist/http-client/fake.js +95 -0
  56. package/dist/http-client/fake.js.map +1 -0
  57. package/dist/http-client/index.d.ts +7 -0
  58. package/dist/http-client/index.d.ts.map +1 -0
  59. package/dist/http-client/index.js +7 -0
  60. package/dist/http-client/index.js.map +1 -0
  61. package/dist/index.d.ts +4 -3
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +1 -1
  64. package/dist/index.js.map +1 -1
  65. package/dist/middleware/Pipeline.d.ts.map +1 -1
  66. package/dist/middleware/Pipeline.js +6 -3
  67. package/dist/middleware/Pipeline.js.map +1 -1
  68. package/dist/native/generated.d.ts +78 -7
  69. package/dist/native/generated.d.ts.map +1 -1
  70. package/dist/rpc/RpcProvider.d.ts +6 -0
  71. package/dist/rpc/RpcProvider.d.ts.map +1 -1
  72. package/dist/rpc/RpcProvider.js +4 -1
  73. package/dist/rpc/RpcProvider.js.map +1 -1
  74. package/dist/rpc/RpcRouter.d.ts +27 -14
  75. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  76. package/dist/rpc/RpcRouter.js +95 -38
  77. package/dist/rpc/RpcRouter.js.map +1 -1
  78. package/dist/scheduler/locks/RedisLockBackend.d.ts.map +1 -1
  79. package/dist/scheduler/locks/RedisLockBackend.js +13 -2
  80. package/dist/scheduler/locks/RedisLockBackend.js.map +1 -1
  81. package/dist/security/CookieSigner.d.ts.map +1 -1
  82. package/dist/security/CookieSigner.js +17 -3
  83. package/dist/security/CookieSigner.js.map +1 -1
  84. package/dist/security/SignedUrl.d.ts.map +1 -1
  85. package/dist/security/SignedUrl.js +6 -1
  86. package/dist/security/SignedUrl.js.map +1 -1
  87. package/dist/server/Server.d.ts +41 -12
  88. package/dist/server/Server.d.ts.map +1 -1
  89. package/dist/server/Server.js +31 -10
  90. package/dist/server/Server.js.map +1 -1
  91. package/dist/server/nativeGrpc.d.ts +27 -0
  92. package/dist/server/nativeGrpc.d.ts.map +1 -0
  93. package/dist/server/nativeGrpc.js +47 -0
  94. package/dist/server/nativeGrpc.js.map +1 -0
  95. package/dist/server/nativeLimits.d.ts +49 -0
  96. package/dist/server/nativeLimits.d.ts.map +1 -0
  97. package/dist/server/nativeLimits.js +67 -0
  98. package/dist/server/nativeLimits.js.map +1 -0
  99. package/dist/session/Session.d.ts +12 -0
  100. package/dist/session/Session.d.ts.map +1 -1
  101. package/dist/session/Session.js.map +1 -1
  102. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  103. package/dist/session/SessionMiddleware.js +28 -9
  104. package/dist/session/SessionMiddleware.js.map +1 -1
  105. package/dist/session/config.d.ts +5 -0
  106. package/dist/session/config.d.ts.map +1 -1
  107. package/dist/session/config.js +8 -0
  108. package/dist/session/config.js.map +1 -1
  109. package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
  110. package/dist/session/drivers/MemoryDriver.js +12 -5
  111. package/dist/session/drivers/MemoryDriver.js.map +1 -1
  112. package/dist/session/errors.d.ts +17 -0
  113. package/dist/session/errors.d.ts.map +1 -1
  114. package/dist/session/errors.js +21 -0
  115. package/dist/session/errors.js.map +1 -1
  116. package/dist/session/templateTags.d.ts +14 -0
  117. package/dist/session/templateTags.d.ts.map +1 -1
  118. package/dist/session/templateTags.js +45 -17
  119. package/dist/session/templateTags.js.map +1 -1
  120. package/dist/storage/StaticMiddleware.d.ts.map +1 -1
  121. package/dist/storage/StaticMiddleware.js +29 -18
  122. package/dist/storage/StaticMiddleware.js.map +1 -1
  123. package/events.darwin-arm64.node +0 -0
  124. package/events.darwin-x64.node +0 -0
  125. package/events.win32-x64-msvc.node +0 -0
  126. package/index.darwin-arm64.node +0 -0
  127. package/index.darwin-x64.node +0 -0
  128. package/index.linux-arm64-gnu.node +0 -0
  129. package/index.linux-x64-gnu.node +0 -0
  130. package/index.win32-x64-msvc.node +0 -0
  131. package/package.json +11 -2
  132. package/scheduler.darwin-arm64.node +0 -0
  133. package/scheduler.darwin-x64.node +0 -0
  134. package/scheduler.win32-x64-msvc.node +0 -0
  135. package/src/Codemods.ts +265 -0
  136. package/src/Ignitor.ts +37 -22
  137. package/src/bodyparser/BodyParserMiddleware.ts +3 -0
  138. package/src/bodyparser/qsParse.ts +69 -14
  139. package/src/codemods/source.ts +303 -0
  140. package/src/graphql/GraphQLEngine.ts +3 -2
  141. package/src/http/Exception.ts +17 -2
  142. package/src/http/HttpContext.ts +14 -1
  143. package/src/http/RedirectBuilder.ts +48 -37
  144. package/src/http/Request.ts +87 -29
  145. package/src/http/SseStream.ts +6 -7
  146. package/src/http/permissions.ts +31 -0
  147. package/src/http-client/HttpClient.ts +505 -0
  148. package/src/http-client/addresses.ts +58 -0
  149. package/src/http-client/fake.ts +141 -0
  150. package/src/http-client/index.ts +21 -0
  151. package/src/index.ts +5 -1
  152. package/src/middleware/Pipeline.ts +7 -3
  153. package/src/native/generated.ts +89 -7
  154. package/src/rpc/RpcProvider.ts +10 -1
  155. package/src/rpc/RpcRouter.ts +126 -42
  156. package/src/scheduler/locks/RedisLockBackend.ts +12 -4
  157. package/src/security/CookieSigner.ts +17 -3
  158. package/src/security/SignedUrl.ts +6 -1
  159. package/src/server/Server.ts +57 -12
  160. package/src/server/nativeGrpc.ts +59 -0
  161. package/src/server/nativeLimits.ts +94 -0
  162. package/src/session/Session.ts +16 -0
  163. package/src/session/SessionMiddleware.ts +30 -10
  164. package/src/session/config.ts +7 -0
  165. package/src/session/drivers/MemoryDriver.ts +11 -5
  166. package/src/session/errors.ts +31 -0
  167. package/src/session/templateTags.ts +57 -0
  168. package/src/storage/StaticMiddleware.ts +28 -18
package/src/Ignitor.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { Application } from './Application.js'
17
+ import type { BodyParserConfig } from './bodyparser/BodyParserMiddleware.js'
17
18
  import type { Console } from './console/Console.js'
18
19
  import type { CommandLoader, Kernel as ConsoleKernelInstance } from './console/Kernel.js'
19
20
  import { type CommandClass, isCommandClass } from './console/types.js'
@@ -51,13 +52,20 @@ import {
51
52
  import { Router } from './router/Router.js'
52
53
  import { CookieSigner } from './security/CookieSigner.js'
53
54
  import { SignedUrl } from './security/SignedUrl.js'
54
- import { type GrpcHandler, Server } from './server/Server.js'
55
+ import { cancellableGrpc, type NativeGrpcHandler } from './server/nativeGrpc.js'
56
+ import {
57
+ type NativeBodyLimits,
58
+ type NativeConnectionConfig,
59
+ nativeBodyLimits,
60
+ nativeConnectionConfig,
61
+ } from './server/nativeLimits.js'
62
+ import { type GrpcLimits, Server } from './server/Server.js'
55
63
  import { clearApp, setApp } from './services/app.js'
56
64
  import { clearEncryption, setEncryption } from './services/encryption.js'
57
65
  import { clearLogger, setLogger } from './services/logger.js'
58
66
  import { clearRouter, setRouter } from './services/router.js'
59
67
  import { clearServer, setServer } from './services/server.js'
60
- import { registerSessionTemplateTags } from './session/templateTags.js'
68
+ import { installSessionTemplateTags } from './session/templateTags.js'
61
69
 
62
70
  /** Application environment. */
63
71
  /**
@@ -269,7 +277,9 @@ export function defineConfig(config: ReamrcConfig): ReamrcConfig {
269
277
  export interface HyperServerLike {
270
278
  onRequest(callback: (request: HttpKernelRequest) => Promise<HttpKernelResponse>): void
271
279
  /** Optional — present on the real `HyperServer`; required once `server.grpc()` is used. */
272
- onGrpc?(callback: GrpcHandler): void
280
+ onGrpc?(callback: NativeGrpcHandler, limits?: GrpcLimits): void
281
+ /** Optional — reports the calls a client gave up on; without it no call's signal aborts. */
282
+ onGrpcCancel?(callback: (id: number) => void): void
273
283
  /**
274
284
  * Optional — present on the real `HyperServer` (Rust-backed), absent on
275
285
  * trivial test mocks. Ignitor only calls it when a shield config is set.
@@ -279,6 +289,10 @@ export interface HyperServerLike {
279
289
  configureTrustedProxies?(cidrs: string[]): void
280
290
  /** Optional — same lifecycle as `configureShield`. */
281
291
  configureRateLimit?(config: { max: number; windowSecs: number } | null): void
292
+ /** Optional — same lifecycle as `configureShield`. */
293
+ configureBodyLimits?(config: NativeBodyLimits): void
294
+ /** Optional — same lifecycle as `configureShield`. */
295
+ configureConnections?(config: NativeConnectionConfig): void
282
296
  listen(): Promise<void>
283
297
  port(): Promise<number>
284
298
  close(): Promise<void>
@@ -684,26 +698,12 @@ export class Ignitor {
684
698
 
685
699
  /**
686
700
  * Give the template engine `@error` / `@errors` / `@inputError` /
687
- * `@flashMessage`, as AdonisJS's session plugin does.
688
- *
689
- * The engine is duck-typed out of the container so ream carries no dependency
690
- * on it, and an app with no template layer simply skips this.
701
+ * `@flashMessage`, as AdonisJS's session plugin does — through the engine
702
+ * package's module singleton, guarded by `usingInker` (see
703
+ * `installSessionTemplateTags`). An app with no template layer skips this.
691
704
  */
692
705
  async #registerSessionTemplateTags(): Promise<void> {
693
- for (const token of ['inker', 'view']) {
694
- if (!this.#app.container.has(token)) continue
695
- const binding: unknown = await this.#app.container.resolve(token)
696
- for (const candidate of [binding, Reflect.get(Object(binding), '_templates')]) {
697
- const register = Reflect.get(Object(candidate), 'registerTag')
698
- if (typeof register !== 'function') continue
699
- registerSessionTemplateTags({
700
- registerTag: (tag) => {
701
- register.call(candidate, tag)
702
- },
703
- })
704
- return
705
- }
706
- }
706
+ await installSessionTemplateTags(this.#app.usingInker === true)
707
707
  }
708
708
 
709
709
  async #phaseRegister(): Promise<void> {
@@ -914,7 +914,9 @@ export class Ignitor {
914
914
  { hint: 'Use the native HyperServer, or a test server that implements onGrpc().' },
915
915
  )
916
916
  }
917
- this.#_httpServer.onGrpc(grpc)
917
+ const calls = cancellableGrpc(grpc)
918
+ this.#_httpServer.onGrpcCancel?.(calls.onCancel)
919
+ this.#_httpServer.onGrpc(calls.onCall, this.#server.getGrpcLimits())
918
920
  }
919
921
  // Pre-resolve client IPs in Rust from the trusted-proxy CIDRs before
920
922
  // listen. Security filtering itself lives in @c9up/blackhole.
@@ -925,6 +927,19 @@ export class Ignitor {
925
927
  ) {
926
928
  this.#_httpServer.configureTrustedProxies([...trustedProxies])
927
929
  }
930
+ // Body limits from `config/bodyparser.ts`, enforced while the native
931
+ // server reads a body: nothing past them is ever buffered.
932
+ const bodyparser = this.#app.config.get<BodyParserConfig>('bodyparser')
933
+ if (bodyparser && typeof this.#_httpServer.configureBodyLimits === 'function') {
934
+ this.#_httpServer.configureBodyLimits(nativeBodyLimits(bodyparser))
935
+ }
936
+ // AdonisJS `config/app.ts` → `http: { headersTimeout, requestTimeout,
937
+ // keepAliveTimeout }`, plus `maxConnections`.
938
+ if (typeof this.#_httpServer.configureConnections === 'function') {
939
+ this.#_httpServer.configureConnections(
940
+ nativeConnectionConfig(this.#app.config.get<unknown>('app.http')),
941
+ )
942
+ }
928
943
  await this.#_httpServer.listen()
929
944
 
930
945
  // Whoever opened the socket closes it. `app.terminate()` runs the
@@ -136,6 +136,9 @@ export default class BodyParserMiddleware {
136
136
  }
137
137
 
138
138
  const contentType = ctx.request.header('content-type') ?? ''
139
+ // A body over its native limit (`bodyTooLarge`) was never kept: `raw()`
140
+ // throws the same 413 below instead of handing back an empty string. The
141
+ // route is already matched here, so an unknown URL answered 404 first.
139
142
  const rawBody = ctx.request.raw()
140
143
 
141
144
  // Size check. Thrown rather than written: the app's exception handler
@@ -11,7 +11,7 @@
11
11
  * dependency it can write in a screenful, and the limits below are easier to
12
12
  * defend when they are visible.
13
13
  *
14
- * Three guards, all of which `qs` also applies:
14
+ * Four guards, all of which `qs` also applies:
15
15
  *
16
16
  * - **Prototype keys are dropped.** `__proto__[x]=1` in a form body is the
17
17
  * classic prototype-pollution vector; the key never reaches an object.
@@ -19,11 +19,14 @@
19
19
  * as a literal key rather than allocating unbounded nesting.
20
20
  * - **Parameter count is capped** (`qs` defaults to 1000), so a body of a
21
21
  * million `a[0]`, `a[1]`… cannot be turned into a million-entry object.
22
+ * - **Explicit array indices are capped** (`qs` `arrayLimit`, default 20).
23
+ * A larger index is an object key, so `ids[4294967294]=1` stays one entry.
22
24
  */
23
25
 
24
26
  /** `qs` defaults, reproduced so the behaviour is the same out of the box. */
25
27
  export const DEFAULT_DEPTH = 5
26
28
  export const DEFAULT_PARAMETER_LIMIT = 1000
29
+ export const DEFAULT_ARRAY_LIMIT = 20
27
30
 
28
31
  export interface QsParseOptions {
29
32
  /**
@@ -34,6 +37,12 @@ export interface QsParseOptions {
34
37
  allowDots?: boolean
35
38
  depth?: number
36
39
  parameterLimit?: number
40
+ /**
41
+ * Highest explicit index + 1 that still builds an array (`qs` defaults to
42
+ * 20). `ids[25]=x` becomes `{ ids: { '25': 'x' } }`, so a 16-byte request
43
+ * cannot allocate a 100-million-slot array.
44
+ */
45
+ arrayLimit?: number
37
46
  /** Turn `''` into `null`, as AdonisJS' `convertEmptyStringsToNull` does. */
38
47
  convertEmptyStringsToNull?: boolean
39
48
  /** Trim surrounding whitespace, as AdonisJS' `trimWhitespaces` does. */
@@ -77,15 +86,53 @@ function isIndex(segment: string): boolean {
77
86
  return /^\d+$/.test(segment)
78
87
  }
79
88
 
80
- /** Assign `value` at `segments` inside `target`, creating containers as needed. */
81
- function assign(target: Record<string, unknown>, segments: string[], value: unknown): void {
82
- let cursor: Record<string, unknown> | unknown[] = target
89
+ type Container = Record<string, unknown> | unknown[]
90
+
91
+ function isContainer(value: unknown): value is Container {
92
+ return typeof value === 'object' && value !== null
93
+ }
94
+
95
+ /** `{ '0': a, '1': b }` from `[a, b]`, holes left out — what `qs` produces on overflow. */
96
+ function arrayToObject(array: unknown[]): Record<string, unknown> {
97
+ const object: Record<string, unknown> = {}
98
+ array.forEach((item, index) => {
99
+ object[String(index)] = item
100
+ })
101
+ return object
102
+ }
103
+
104
+ /**
105
+ * Assign `value` at `segments` inside `target`, creating containers as needed.
106
+ *
107
+ * An explicit index at or above `arrayLimit` never addresses an array: the
108
+ * container becomes (or is converted into) an object keyed by that index,
109
+ * as `qs` does. Without it, `ids[100000000]=1` allocates a sparse array whose
110
+ * iteration or serialisation blocks the event loop for seconds. Appends
111
+ * (`tags[]`, a repeated key) stay arrays — each one costs a parameter, so
112
+ * `parameterLimit` already bounds them, and a long checkbox group keeps the
113
+ * shape its handler expects (`qs` turns those into objects past the limit).
114
+ */
115
+ function assign(
116
+ target: Record<string, unknown>,
117
+ segments: string[],
118
+ value: unknown,
119
+ arrayLimit: number,
120
+ ): void {
121
+ let cursor: Container = target
122
+ // Swaps `cursor` inside its parent when an array has to become an object.
123
+ let replace: (next: Container) => void = () => {}
83
124
  for (let i = 0; i < segments.length; i++) {
84
125
  const segment = segments[i] ?? ''
85
126
  const last = i === segments.length - 1
86
127
 
87
128
  if (FORBIDDEN_KEYS.has(segment)) return
88
129
 
130
+ if (Array.isArray(cursor) && isIndex(segment) && Number(segment) >= arrayLimit) {
131
+ const converted = arrayToObject(cursor)
132
+ replace(converted)
133
+ cursor = converted
134
+ }
135
+
89
136
  if (last) {
90
137
  if (segment === '') {
91
138
  // `tags[]` — append.
@@ -109,24 +156,31 @@ function assign(target: Record<string, unknown>, segments: string[], value: unkn
109
156
  }
110
157
 
111
158
  const nextSegment = segments[i + 1] ?? ''
112
- const wantsArray = nextSegment === '' || isIndex(nextSegment)
159
+ const wantsArray =
160
+ nextSegment === '' || (isIndex(nextSegment) && Number(nextSegment) < arrayLimit)
113
161
 
114
162
  if (Array.isArray(cursor)) {
115
163
  if (!isIndex(segment)) return
164
+ const array: unknown[] = cursor
116
165
  const index = Number(segment)
117
- const existing = cursor[index]
118
- if (existing === undefined || typeof existing !== 'object' || existing === null) {
119
- cursor[index] = wantsArray ? [] : {}
166
+ const existing: unknown = array[index]
167
+ const child: Container = isContainer(existing) ? existing : wantsArray ? [] : {}
168
+ array[index] = child
169
+ replace = (next) => {
170
+ array[index] = next
120
171
  }
121
- cursor = cursor[index] as Record<string, unknown> | unknown[]
172
+ cursor = child
122
173
  continue
123
174
  }
124
175
 
125
- const existing = cursor[segment]
126
- if (existing === undefined || typeof existing !== 'object' || existing === null) {
127
- cursor[segment] = wantsArray ? [] : {}
176
+ const object: Record<string, unknown> = cursor
177
+ const existing: unknown = object[segment]
178
+ const child: Container = isContainer(existing) ? existing : wantsArray ? [] : {}
179
+ object[segment] = child
180
+ replace = (next) => {
181
+ object[segment] = next
128
182
  }
129
- cursor = cursor[segment] as Record<string, unknown> | unknown[]
183
+ cursor = child
130
184
  }
131
185
  }
132
186
 
@@ -155,6 +209,7 @@ export function parseQueryString(
155
209
  ): Record<string, unknown> {
156
210
  const depth = options.depth ?? DEFAULT_DEPTH
157
211
  const parameterLimit = options.parameterLimit ?? DEFAULT_PARAMETER_LIMIT
212
+ const arrayLimit = options.arrayLimit ?? DEFAULT_ARRAY_LIMIT
158
213
  const result: Record<string, unknown> = {}
159
214
  if (!body) return result
160
215
 
@@ -173,7 +228,7 @@ export function parseQueryString(
173
228
  if (options.trimWhitespaces && typeof value === 'string') value = value.trim()
174
229
  if (options.convertEmptyStringsToNull && value === '') value = null
175
230
 
176
- assign(result, splitKey(key, depth, options.allowDots ?? true), value)
231
+ assign(result, splitKey(key, depth, options.allowDots ?? true), value, arrayLimit)
177
232
  }
178
233
  return result
179
234
  }
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Just enough of a TypeScript scanner for the configure codemods: find a
3
+ * call or an object/array literal in CODE (never inside a string, a template
4
+ * or a comment), split a literal into its top-level entries, and append to
5
+ * one. Upstream does this with a full AST; ream ships no compiler, and the
6
+ * files these codemods edit (`start/env.ts`, `reamrc.ts`) are object and
7
+ * array literals.
8
+ *
9
+ * Not handled: regular-expression literals (a quote inside one reads as the
10
+ * start of a string). Neither file has a reason to contain one.
11
+ */
12
+
13
+ const CLOSERS: Record<string, string> = { '{': '}', '[': ']', '(': ')' }
14
+
15
+ function isIdentifierChar(char: string | undefined): boolean {
16
+ return char !== undefined && /[A-Za-z0-9_$]/.test(char)
17
+ }
18
+
19
+ /**
20
+ * The index just past the string, template or comment starting at `i`, or
21
+ * `i` itself when none starts there. An unterminated one runs to the end.
22
+ */
23
+ function skipNonCode(src: string, i: number): number {
24
+ const char = src[i]
25
+ const next = src[i + 1]
26
+ if (char === '/' && next === '/') {
27
+ const end = src.indexOf('\n', i)
28
+ return end === -1 ? src.length : end
29
+ }
30
+ if (char === '/' && next === '*') {
31
+ const end = src.indexOf('*/', i + 2)
32
+ return end === -1 ? src.length : end + 2
33
+ }
34
+ if (char === "'" || char === '"') {
35
+ let j = i + 1
36
+ while (j < src.length && src[j] !== char && src[j] !== '\n') {
37
+ j += src[j] === '\\' ? 2 : 1
38
+ }
39
+ return j + 1
40
+ }
41
+ if (char === '`') {
42
+ let j = i + 1
43
+ while (j < src.length && src[j] !== '`') {
44
+ if (src[j] === '\\') {
45
+ j += 2
46
+ } else if (src[j] === '$' && src[j + 1] === '{') {
47
+ const close = matchClose(src, j + 1)
48
+ j = close === -1 ? src.length : close + 1
49
+ } else {
50
+ j += 1
51
+ }
52
+ }
53
+ return j + 1
54
+ }
55
+ return i
56
+ }
57
+
58
+ /** Whether the string, template or block comment starting at `i` is closed. */
59
+ function isTerminated(src: string, i: number): boolean {
60
+ const char = src[i]
61
+ if (char === '/') return src.indexOf('*/', i + 2) !== -1
62
+ const end = skipNonCode(src, i)
63
+ return end <= src.length && end - 1 > i && src[end - 1] === char
64
+ }
65
+
66
+ /** Index of the bracket closing the one at `open`, or -1. */
67
+ export function matchClose(src: string, open: number): number {
68
+ const first = CLOSERS[src[open] ?? '']
69
+ if (first === undefined) return -1
70
+ const stack = [first]
71
+ let i = open + 1
72
+ while (i < src.length) {
73
+ const skipped = skipNonCode(src, i)
74
+ if (skipped !== i) {
75
+ i = skipped
76
+ continue
77
+ }
78
+ const char = src[i] ?? ''
79
+ const closer = CLOSERS[char]
80
+ if (closer !== undefined) {
81
+ stack.push(closer)
82
+ } else if (char === stack[stack.length - 1]) {
83
+ stack.pop()
84
+ if (stack.length === 0) return i
85
+ } else if (char === '}' || char === ']' || char === ')') {
86
+ return -1
87
+ }
88
+ i += 1
89
+ }
90
+ return -1
91
+ }
92
+
93
+ /** First code character at or after `i` (before `end`), skipping whitespace and comments. */
94
+ function firstCode(src: string, i: number, end: number): number {
95
+ let j = i
96
+ while (j < end) {
97
+ const char = src[j] ?? ''
98
+ if (/\s/.test(char)) {
99
+ j += 1
100
+ continue
101
+ }
102
+ if (char === '/' && (src[j + 1] === '/' || src[j + 1] === '*')) {
103
+ j = skipNonCode(src, j)
104
+ continue
105
+ }
106
+ return j
107
+ }
108
+ return end
109
+ }
110
+
111
+ /** One top-level entry of a literal: the text between two commas. */
112
+ export interface Segment {
113
+ start: number
114
+ end: number
115
+ /** First code character, or `end` when the entry is only trivia. */
116
+ codeStart: number
117
+ /** Just past the last code character (comments and whitespace excluded). */
118
+ codeEnd: number
119
+ }
120
+
121
+ /** Split `src[start, end)` on its top-level commas. */
122
+ export function segments(src: string, start: number, end: number): Segment[] {
123
+ const out: Segment[] = []
124
+ let segmentStart = start
125
+ let codeEnd = start
126
+ let depth = 0
127
+ let i = start
128
+ const close = (at: number) => {
129
+ const codeStart = firstCode(src, segmentStart, at)
130
+ out.push({ start: segmentStart, end: at, codeStart, codeEnd: Math.max(codeEnd, codeStart) })
131
+ }
132
+ while (i < end) {
133
+ const char = src[i] ?? ''
134
+ if (char === '/' && (src[i + 1] === '/' || src[i + 1] === '*')) {
135
+ i = skipNonCode(src, i)
136
+ continue
137
+ }
138
+ const skipped = skipNonCode(src, i)
139
+ if (skipped !== i) {
140
+ i = skipped
141
+ codeEnd = i
142
+ continue
143
+ }
144
+ if (char === ',' && depth === 0) {
145
+ close(i)
146
+ segmentStart = i + 1
147
+ codeEnd = i + 1
148
+ i += 1
149
+ continue
150
+ }
151
+ if (CLOSERS[char] !== undefined) depth += 1
152
+ else if (char === '}' || char === ']' || char === ')') depth -= 1
153
+ if (!/\s/.test(char)) codeEnd = i + 1
154
+ i += 1
155
+ }
156
+ close(end)
157
+ return out
158
+ }
159
+
160
+ /**
161
+ * Whether `code` is one complete expression that cannot reach outside the
162
+ * slot it is written into: brackets balanced and never closing past where
163
+ * they opened, strings and comments terminated, no top-level comma.
164
+ */
165
+ export function isSingleExpression(code: string): boolean {
166
+ if (code.trim() === '') return false
167
+ let depth = 0
168
+ let i = 0
169
+ while (i < code.length) {
170
+ const skipped = skipNonCode(code, i)
171
+ if (skipped !== i) {
172
+ // A line comment would swallow the comma written after the value.
173
+ const lineComment = code[i] === '/' && code[i + 1] === '/'
174
+ if (lineComment || !isTerminated(code, i)) return false
175
+ i = skipped
176
+ continue
177
+ }
178
+ const char = code[i] ?? ''
179
+ if (CLOSERS[char] !== undefined) depth += 1
180
+ else if (char === '}' || char === ']' || char === ')') {
181
+ depth -= 1
182
+ if (depth < 0) return false
183
+ } else if (char === ',' && depth === 0) return false
184
+ i += 1
185
+ }
186
+ return depth === 0
187
+ }
188
+
189
+ /**
190
+ * Index of the `(` of the first call to `callee` in code, or -1. A mention
191
+ * inside a string or a comment, or as the tail of a longer name
192
+ * (`MyEnv.create`), does not count.
193
+ */
194
+ export function findCall(src: string, callee: string): number {
195
+ let i = 0
196
+ while (i < src.length) {
197
+ const skipped = skipNonCode(src, i)
198
+ if (skipped !== i) {
199
+ i = skipped
200
+ continue
201
+ }
202
+ const before = src[i - 1]
203
+ if (src.startsWith(callee, i) && !isIdentifierChar(before) && before !== '.') {
204
+ const paren = firstCode(src, i + callee.length, src.length)
205
+ if (src[paren] === '(') return paren
206
+ }
207
+ i += 1
208
+ }
209
+ return -1
210
+ }
211
+
212
+ /** Index of the `{` after the first `export default` in code, or -1. */
213
+ export function findExportDefaultObject(src: string): number {
214
+ let i = 0
215
+ while (i < src.length) {
216
+ const skipped = skipNonCode(src, i)
217
+ if (skipped !== i) {
218
+ i = skipped
219
+ continue
220
+ }
221
+ if (src.startsWith('export', i) && !isIdentifierChar(src[i - 1])) {
222
+ const word = firstCode(src, i + 'export'.length, src.length)
223
+ if (src.startsWith('default', word) && !isIdentifierChar(src[word + 'default'.length])) {
224
+ const brace = firstCode(src, word + 'default'.length, src.length)
225
+ return src[brace] === '{' ? brace : -1
226
+ }
227
+ }
228
+ i += 1
229
+ }
230
+ return -1
231
+ }
232
+
233
+ /** A `key: value` entry of an object literal. */
234
+ export interface Property {
235
+ key: string
236
+ valueStart: number
237
+ valueEnd: number
238
+ }
239
+
240
+ /** The `key: value` entries of the object literal opening at `open`. */
241
+ export function properties(src: string, open: number, close: number): Property[] {
242
+ const out: Property[] = []
243
+ for (const segment of segments(src, open + 1, close)) {
244
+ if (segment.codeStart >= segment.end) continue
245
+ const text = src.slice(segment.codeStart, segment.end)
246
+ const match = /^(?:([A-Za-z_$][\w$]*)|'([^'\\\n]*)'|"([^"\\\n]*)")\s*:/.exec(text)
247
+ if (match === null) continue
248
+ const key = match[1] ?? match[2] ?? match[3] ?? ''
249
+ const valueStart = firstCode(src, segment.codeStart + match[0].length, segment.end)
250
+ out.push({ key, valueStart, valueEnd: segment.codeEnd })
251
+ }
252
+ return out
253
+ }
254
+
255
+ /** Leading whitespace of the line holding `index`. */
256
+ function lineIndent(src: string, index: number): string {
257
+ const lineStart = src.lastIndexOf('\n', index - 1) + 1
258
+ return /^[ \t]*/.exec(src.slice(lineStart))?.[0] ?? ''
259
+ }
260
+
261
+ /**
262
+ * Append entries to the literal `src[open..close]` and return the new text.
263
+ *
264
+ * `build` receives the indentation of the existing entries (or one level
265
+ * deeper than the closing bracket when there are none) and returns the lines
266
+ * to add, each already ending with its comma. `blankLine` separates the new
267
+ * entries from existing ones. A missing comma after the last existing entry
268
+ * is added.
269
+ */
270
+ export function appendToLiteral(
271
+ src: string,
272
+ open: number,
273
+ close: number,
274
+ eol: string,
275
+ build: (indent: string) => string[],
276
+ blankLine = false,
277
+ ): string {
278
+ const all = segments(src, open + 1, close)
279
+ const entries = all.filter((s) => s.codeStart < s.end)
280
+ const first = entries[0]
281
+ const closeIndent = lineIndent(src, close)
282
+ const firstOnOwnLine =
283
+ first !== undefined &&
284
+ /^[ \t]*$/.test(src.slice(src.lastIndexOf('\n', first.codeStart - 1) + 1, first.codeStart))
285
+ const indent =
286
+ first !== undefined && firstOnOwnLine ? lineIndent(src, first.codeStart) : `${closeIndent} `
287
+ const lines = build(indent)
288
+ const separator = blankLine && entries.length > 0 ? eol : ''
289
+
290
+ const closeLineStart = src.lastIndexOf('\n', close - 1) + 1
291
+ const closeOnOwnLine = /^[ \t]*$/.test(src.slice(closeLineStart, close))
292
+ let out = closeOnOwnLine
293
+ ? `${src.slice(0, closeLineStart)}${separator}${lines.join(eol)}${eol}${src.slice(closeLineStart)}`
294
+ : `${src.slice(0, close)}${eol}${separator}${lines.join(eol)}${eol}${closeIndent}${src.slice(close)}`
295
+
296
+ // The comma goes in last, as it sits before every insertion point above.
297
+ // No comma after the last entry when it is also the last segment.
298
+ const last = entries[entries.length - 1]
299
+ if (last !== undefined && all[all.length - 1] === last) {
300
+ out = `${out.slice(0, last.codeEnd)},${out.slice(last.codeEnd)}`
301
+ }
302
+ return out
303
+ }
@@ -25,6 +25,7 @@ import * as fs from 'node:fs'
25
25
  import { currentNodeEnv } from '../env/nodeEnv.js'
26
26
  import { loadNapi } from '../helpers/napi-loader.js'
27
27
  import type { HttpContext } from '../http/HttpContext.js'
28
+ import { holdsPermissions } from '../http/permissions.js'
28
29
 
29
30
  /** Rust parser output (the `ream-graphql` crate via the `index` NAPI binary). */
30
31
  interface RustParsedField {
@@ -455,9 +456,9 @@ export class GraphQLEngine {
455
456
  }
456
457
  }
457
458
 
459
+ // A token-authenticated request also needs the token to allow each one.
458
460
  if (permissions.length > 0) {
459
- const userPerms = ctx.auth?.permissions ?? ctx.auth?.user?.permissions ?? []
460
- if (!permissions.every((p) => userPerms.includes(p))) {
461
+ if (!holdsPermissions(ctx.auth, permissions)) {
461
462
  return 'Insufficient permissions'
462
463
  }
463
464
  }
@@ -259,6 +259,12 @@ export type HttpError = {
259
259
  report?: (error: unknown, ctx: HttpContext) => Promise<void> | void
260
260
  /** A hint appended to the message, when the exception carries one. */
261
261
  help?: string
262
+ /**
263
+ * Set by a thrown value that is not an {@link Exception} but whose message is
264
+ * written for clients (the `http-errors` convention). Shown outside debug
265
+ * mode even on a 5xx.
266
+ */
267
+ expose?: boolean
262
268
  }
263
269
 
264
270
  /** Renders an HTML error page for a status (AdonisJS `StatusPageRenderer`). */
@@ -352,13 +358,22 @@ export class ExceptionHandler extends Macroable {
352
358
  * The message a client is allowed to see.
353
359
  *
354
360
  * DEVIATION (named): outside debug mode ream only echoes the message of a
355
- * deliberate `Exception`; anything else collapses to a generic line. AdonisJS
361
+ * deliberate `Exception`, of a client error (status below 500, which a
362
+ * package raises on purpose: 401, 403, 413…), or of an error flagged
363
+ * `expose: true`; anything else collapses to a generic line. AdonisJS
356
364
  * echoes `error.message` whatever was thrown, which leaks the text of driver
357
365
  * and library errors to the client.
366
+ *
367
+ * A `code` is NOT a reason to show the message: Node system errors
368
+ * (`ECONNREFUSED`, `ENOENT`) and database drivers (`23505`) carry one, and
369
+ * their messages hold internal hosts, file paths, constraint names and
370
+ * stored values. The error itself is left untouched for reporting.
358
371
  */
359
372
  protected publicMessage(error: HttpError, ctx: HttpContext): string {
360
373
  if (this.isDebuggingEnabled(ctx)) return error.message
361
- if (error.code !== 'E_UNKNOWN') return error.message
374
+ if (error instanceof Exception || error.status < 500 || error.expose === true) {
375
+ return error.message
376
+ }
362
377
  return 'An internal error occurred'
363
378
  }
364
379
 
@@ -33,7 +33,14 @@ export interface AuthState {
33
33
  id: string
34
34
  email?: string
35
35
  roles?: string[]
36
+ /** The user's own permissions — never widened by a token's abilities. */
36
37
  permissions?: string[]
38
+ /**
39
+ * Set by a token guard (AdonisJS `user.currentAccessToken`): the token this
40
+ * request authenticated with. Its abilities restrict the user's
41
+ * permissions on every `.permission()` gate; they never add to them.
42
+ */
43
+ currentAccessToken?: AccessTokenAbilities
37
44
  [key: string]: unknown
38
45
  }
39
46
  roles?: string[]
@@ -65,6 +72,11 @@ export interface AuthState {
65
72
  readonly authenticatedViaGuard?: string
66
73
  }
67
74
 
75
+ /** What a permission gate needs from an access token: whether it allows an ability. */
76
+ export interface AccessTokenAbilities {
77
+ allows(ability: string): boolean
78
+ }
79
+
68
80
  /**
69
81
  * Per-request authorization entry point — populated by an authorization
70
82
  * middleware (e.g. `@c9up/warden`'s Bouncer initializer). A structural
@@ -440,8 +452,9 @@ export class HttpContext extends Macroable {
440
452
  this.response.setRedirectFactory(
441
453
  () =>
442
454
  new RedirectBuilder(this.response, {
443
- requestUrl: this.request.url(),
455
+ requestUrl: this.request.url(true),
444
456
  requestReferer: this.request.header('referer'),
457
+ requestAuthority: this.request.authority() ?? undefined,
445
458
  routeUrlResolver: this.#routeUrlResolver,
446
459
  }),
447
460
  )