@c9up/ream 0.1.32 → 0.1.34

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 (193) hide show
  1. package/dist/Application.d.ts +27 -0
  2. package/dist/Application.d.ts.map +1 -1
  3. package/dist/Application.js +29 -0
  4. package/dist/Application.js.map +1 -1
  5. package/dist/ConfigLoader.d.ts +4 -0
  6. package/dist/ConfigLoader.d.ts.map +1 -1
  7. package/dist/ConfigLoader.js +0 -8
  8. package/dist/ConfigLoader.js.map +1 -1
  9. package/dist/ErrorBoundary.d.ts.map +1 -1
  10. package/dist/ErrorBoundary.js +13 -1
  11. package/dist/ErrorBoundary.js.map +1 -1
  12. package/dist/HttpKernel.d.ts +8 -0
  13. package/dist/HttpKernel.d.ts.map +1 -1
  14. package/dist/HttpKernel.js +35 -12
  15. package/dist/HttpKernel.js.map +1 -1
  16. package/dist/Ignitor.d.ts +8 -0
  17. package/dist/Ignitor.d.ts.map +1 -1
  18. package/dist/Ignitor.js +39 -0
  19. package/dist/Ignitor.js.map +1 -1
  20. package/dist/bodyparser/BodyParserMiddleware.d.ts +11 -4
  21. package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
  22. package/dist/bodyparser/BodyParserMiddleware.js +55 -43
  23. package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
  24. package/dist/bodyparser/qsParse.d.ts +52 -0
  25. package/dist/bodyparser/qsParse.d.ts.map +1 -0
  26. package/dist/bodyparser/qsParse.js +161 -0
  27. package/dist/bodyparser/qsParse.js.map +1 -0
  28. package/dist/bootstrap/hyper-server.d.ts.map +1 -1
  29. package/dist/bootstrap/hyper-server.js +2 -8
  30. package/dist/bootstrap/hyper-server.js.map +1 -1
  31. package/dist/container/Container.d.ts +41 -0
  32. package/dist/container/Container.d.ts.map +1 -1
  33. package/dist/container/Container.js +193 -26
  34. package/dist/container/Container.js.map +1 -1
  35. package/dist/container/ContainerResolver.d.ts +46 -0
  36. package/dist/container/ContainerResolver.d.ts.map +1 -0
  37. package/dist/container/ContainerResolver.js +60 -0
  38. package/dist/container/ContainerResolver.js.map +1 -0
  39. package/dist/container/ContextualBindingsBuilder.d.ts +20 -0
  40. package/dist/container/ContextualBindingsBuilder.d.ts.map +1 -0
  41. package/dist/container/ContextualBindingsBuilder.js +31 -0
  42. package/dist/container/ContextualBindingsBuilder.js.map +1 -0
  43. package/dist/container/types.d.ts +9 -6
  44. package/dist/container/types.d.ts.map +1 -1
  45. package/dist/decorators/Service.d.ts +1 -5
  46. package/dist/decorators/Service.d.ts.map +1 -1
  47. package/dist/decorators/Service.js +10 -1
  48. package/dist/decorators/Service.js.map +1 -1
  49. package/dist/events/Emitter.d.ts +66 -6
  50. package/dist/events/Emitter.d.ts.map +1 -1
  51. package/dist/events/Emitter.js +165 -28
  52. package/dist/events/Emitter.js.map +1 -1
  53. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  54. package/dist/graphql/GraphQLEngine.js +5 -5
  55. package/dist/graphql/GraphQLEngine.js.map +1 -1
  56. package/dist/helpers/duration.d.ts +32 -0
  57. package/dist/helpers/duration.d.ts.map +1 -0
  58. package/dist/helpers/duration.js +74 -0
  59. package/dist/helpers/duration.js.map +1 -0
  60. package/dist/helpers/napi-loader.d.ts +10 -1
  61. package/dist/helpers/napi-loader.d.ts.map +1 -1
  62. package/dist/helpers/napi-loader.js +18 -3
  63. package/dist/helpers/napi-loader.js.map +1 -1
  64. package/dist/helpers/types.d.ts +1 -1
  65. package/dist/helpers/types.d.ts.map +1 -1
  66. package/dist/http/Exception.d.ts.map +1 -1
  67. package/dist/http/Exception.js +17 -5
  68. package/dist/http/Exception.js.map +1 -1
  69. package/dist/http/HttpContext.d.ts +24 -5
  70. package/dist/http/HttpContext.d.ts.map +1 -1
  71. package/dist/http/HttpContext.js +12 -2
  72. package/dist/http/HttpContext.js.map +1 -1
  73. package/dist/http/RedirectBuilder.d.ts +48 -2
  74. package/dist/http/RedirectBuilder.d.ts.map +1 -1
  75. package/dist/http/RedirectBuilder.js +95 -14
  76. package/dist/http/RedirectBuilder.js.map +1 -1
  77. package/dist/http/Request.d.ts +143 -8
  78. package/dist/http/Request.d.ts.map +1 -1
  79. package/dist/http/Request.js +241 -21
  80. package/dist/http/Request.js.map +1 -1
  81. package/dist/http/Response.d.ts +74 -3
  82. package/dist/http/Response.d.ts.map +1 -1
  83. package/dist/http/Response.js +177 -4
  84. package/dist/http/Response.js.map +1 -1
  85. package/dist/index.d.ts +9 -3
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +5 -1
  88. package/dist/index.js.map +1 -1
  89. package/dist/router/Router.d.ts +36 -7
  90. package/dist/router/Router.d.ts.map +1 -1
  91. package/dist/router/Router.js +66 -22
  92. package/dist/router/Router.js.map +1 -1
  93. package/dist/security/CookieSigner.d.ts +35 -4
  94. package/dist/security/CookieSigner.d.ts.map +1 -1
  95. package/dist/security/CookieSigner.js +99 -10
  96. package/dist/security/CookieSigner.js.map +1 -1
  97. package/dist/server/Server.d.ts +2 -3
  98. package/dist/server/Server.d.ts.map +1 -1
  99. package/dist/server/Server.js +30 -13
  100. package/dist/server/Server.js.map +1 -1
  101. package/dist/session/ReadOnlyValuesStore.d.ts +24 -0
  102. package/dist/session/ReadOnlyValuesStore.d.ts.map +1 -0
  103. package/dist/session/ReadOnlyValuesStore.js +45 -0
  104. package/dist/session/ReadOnlyValuesStore.js.map +1 -0
  105. package/dist/session/Session.d.ts +181 -9
  106. package/dist/session/Session.d.ts.map +1 -1
  107. package/dist/session/Session.js +284 -33
  108. package/dist/session/Session.js.map +1 -1
  109. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  110. package/dist/session/SessionMiddleware.js +87 -33
  111. package/dist/session/SessionMiddleware.js.map +1 -1
  112. package/dist/session/drivers/DatabaseDriver.d.ts +66 -0
  113. package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -0
  114. package/dist/session/drivers/DatabaseDriver.js +132 -0
  115. package/dist/session/drivers/DatabaseDriver.js.map +1 -0
  116. package/dist/session/drivers/FileDriver.d.ts +22 -0
  117. package/dist/session/drivers/FileDriver.d.ts.map +1 -0
  118. package/dist/session/drivers/FileDriver.js +66 -0
  119. package/dist/session/drivers/FileDriver.js.map +1 -0
  120. package/dist/session/drivers/MemoryDriver.d.ts +10 -2
  121. package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
  122. package/dist/session/drivers/MemoryDriver.js +70 -0
  123. package/dist/session/drivers/MemoryDriver.js.map +1 -1
  124. package/dist/session/drivers/RedisDriver.d.ts +10 -2
  125. package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
  126. package/dist/session/drivers/RedisDriver.js +58 -0
  127. package/dist/session/drivers/RedisDriver.js.map +1 -1
  128. package/dist/session/templateTags.d.ts +42 -0
  129. package/dist/session/templateTags.d.ts.map +1 -0
  130. package/dist/session/templateTags.js +101 -0
  131. package/dist/session/templateTags.js.map +1 -0
  132. package/dist/testing/RequestBuilder.d.ts +38 -1
  133. package/dist/testing/RequestBuilder.d.ts.map +1 -1
  134. package/dist/testing/RequestBuilder.js +40 -1
  135. package/dist/testing/RequestBuilder.js.map +1 -1
  136. package/dist/testing/TestClient.d.ts +3 -1
  137. package/dist/testing/TestClient.d.ts.map +1 -1
  138. package/dist/testing/TestClient.js +3 -1
  139. package/dist/testing/TestClient.js.map +1 -1
  140. package/dist/types/helpers.d.ts +1 -1
  141. package/dist/types/helpers.d.ts.map +1 -1
  142. package/dist/utils/Macroable.d.ts +14 -5
  143. package/dist/utils/Macroable.d.ts.map +1 -1
  144. package/dist/utils/Macroable.js +20 -18
  145. package/dist/utils/Macroable.js.map +1 -1
  146. package/events.win32-x64-msvc.node +0 -0
  147. package/index.win32-x64-msvc.node +0 -0
  148. package/package.json +1 -1
  149. package/scheduler.win32-x64-msvc.node +0 -0
  150. package/src/Application.ts +39 -0
  151. package/src/ConfigLoader.ts +6 -0
  152. package/src/ErrorBoundary.ts +13 -1
  153. package/src/HttpKernel.ts +45 -19
  154. package/src/Ignitor.ts +39 -0
  155. package/src/bodyparser/BodyParserMiddleware.ts +66 -44
  156. package/src/bodyparser/qsParse.ts +179 -0
  157. package/src/bootstrap/hyper-server.ts +2 -9
  158. package/src/container/Container.ts +265 -25
  159. package/src/container/ContainerResolver.ts +84 -0
  160. package/src/container/ContextualBindingsBuilder.ts +41 -0
  161. package/src/container/types.ts +11 -7
  162. package/src/decorators/Service.ts +16 -3
  163. package/src/events/Emitter.ts +200 -25
  164. package/src/graphql/GraphQLEngine.ts +7 -8
  165. package/src/helpers/duration.ts +71 -0
  166. package/src/helpers/napi-loader.ts +19 -3
  167. package/src/helpers/types.ts +4 -1
  168. package/src/http/Exception.ts +17 -5
  169. package/src/http/HttpContext.ts +28 -5
  170. package/src/http/RedirectBuilder.ts +126 -11
  171. package/src/http/Request.ts +303 -13
  172. package/src/http/Response.ts +213 -6
  173. package/src/index.ts +17 -3
  174. package/src/router/Router.ts +98 -33
  175. package/src/security/CookieSigner.ts +102 -10
  176. package/src/server/Server.ts +36 -16
  177. package/src/session/ReadOnlyValuesStore.ts +52 -0
  178. package/src/session/Session.ts +364 -37
  179. package/src/session/SessionMiddleware.ts +93 -30
  180. package/src/session/drivers/DatabaseDriver.ts +184 -0
  181. package/src/session/drivers/FileDriver.ts +82 -0
  182. package/src/session/drivers/MemoryDriver.ts +75 -2
  183. package/src/session/drivers/RedisDriver.ts +77 -2
  184. package/src/session/templateTags.ts +124 -0
  185. package/src/testing/RequestBuilder.ts +76 -0
  186. package/src/testing/TestClient.ts +10 -2
  187. package/src/types/helpers.ts +4 -1
  188. package/src/utils/Macroable.ts +23 -8
  189. package/dist/utils/inflect.d.ts +0 -17
  190. package/dist/utils/inflect.d.ts.map +0 -1
  191. package/dist/utils/inflect.js +0 -32
  192. package/dist/utils/inflect.js.map +0 -1
  193. package/src/utils/inflect.ts +0 -29
@@ -28,14 +28,23 @@ function isSameOriginOrRelative(referer: string, requestUrl?: string): boolean {
28
28
  }
29
29
  }
30
30
 
31
+ /** The slice of the session the intended-URL methods use. */
32
+ export interface IntendedUrlStore {
33
+ setIntendedUrl(url: string): void
34
+ pullIntendedUrl(): string | null
35
+ }
36
+
31
37
  export class RedirectBuilder {
32
38
  #response: Response
33
39
  #status = 302
34
- #qs: Record<string, string> | null = null
40
+ #qs: Record<string, unknown> = {}
35
41
  #forwardQs = false
36
42
  #requestUrl?: string
37
43
  #requestReferer?: string
38
44
  #routeUrlResolver?: RouteUrlResolver
45
+ #session?: IntendedUrlStore
46
+ #isAjax = false
47
+ #hasRoute = true
39
48
 
40
49
  constructor(
41
50
  response: Response,
@@ -43,12 +52,77 @@ export class RedirectBuilder {
43
52
  requestUrl?: string
44
53
  requestReferer?: string
45
54
  routeUrlResolver?: RouteUrlResolver
55
+ /** The request session, for the intended-URL methods. */
56
+ session?: IntendedUrlStore
57
+ /** Whether this is an XHR — `withIntendedUrl` skips those. */
58
+ isAjax?: boolean
59
+ /** Whether a route matched — `withIntendedUrl` skips 404s. */
60
+ hasRoute?: boolean
46
61
  },
47
62
  ) {
48
63
  this.#response = response
49
64
  this.#requestUrl = options?.requestUrl
50
65
  this.#requestReferer = options?.requestReferer
51
66
  this.#routeUrlResolver = options?.routeUrlResolver
67
+ this.#session = options?.session
68
+ this.#isAjax = options?.isAjax ?? false
69
+ this.#hasRoute = options?.hasRoute ?? true
70
+ }
71
+
72
+ /**
73
+ * Remember where the user was heading, then carry on redirecting — AdonisJS'
74
+ * `withIntendedUrl` (a macro `@adonisjs/session` adds to Redirect).
75
+ *
76
+ * Only a GET, non-XHR, route-matched request is stored, exactly as upstream:
77
+ * remembering a POST would replay it as a GET after login, and remembering a
78
+ * 404 would send the user back to a dead end.
79
+ */
80
+ withIntendedUrl(method = 'GET'): this {
81
+ if (
82
+ this.#session &&
83
+ method.toUpperCase() === 'GET' &&
84
+ !this.#isAjax &&
85
+ this.#hasRoute &&
86
+ this.#requestUrl
87
+ ) {
88
+ this.#session.setIntendedUrl(this.#requestUrl)
89
+ }
90
+ return this
91
+ }
92
+
93
+ /**
94
+ * Redirect to the remembered URL, consuming it — AdonisJS' `toIntended`.
95
+ *
96
+ * The stored value is validated before use: it is written from a request URL,
97
+ * but a session store is not a trust boundary, and a redirect to an absolute
98
+ * off-site URL is an open redirect. Anything not same-origin or relative
99
+ * falls back, which is what `back()` already does with the Referer.
100
+ */
101
+ toIntended(fallback = '/'): void {
102
+ const intended = this.#session?.pullIntendedUrl() ?? null
103
+ this.toPath(this.#safeIntended(intended, fallback))
104
+ }
105
+
106
+ /** `toIntended`, falling back to a named route — AdonisJS' `toIntendedRoute`. */
107
+ toIntendedRoute(name: string, params?: Record<string, string>): void {
108
+ const intended = this.#session?.pullIntendedUrl() ?? null
109
+ const safe = intended === null ? null : this.#safeIntended(intended, null)
110
+ if (safe !== null) {
111
+ this.toPath(safe)
112
+ return
113
+ }
114
+ this.toRoute(name, params)
115
+ }
116
+
117
+ #safeIntended<T extends string | null>(intended: string | null, fallback: T): string | T {
118
+ if (!intended) return fallback
119
+ if (this.#requestUrl && !isSameOriginOrRelative(intended, this.#requestUrl)) {
120
+ return fallback
121
+ }
122
+ // A protocol-relative `//evil.com` is absolute to a browser even though it
123
+ // looks relative — reject it whether or not a request URL is known.
124
+ if (intended.startsWith('//')) return fallback
125
+ return intended
52
126
  }
53
127
 
54
128
  /** Set redirect status code. */
@@ -57,13 +131,42 @@ export class RedirectBuilder {
57
131
  return this
58
132
  }
59
133
 
60
- /** Forward current query string to the redirect target. */
61
- withQs(qs?: Record<string, string>): this {
62
- if (qs) {
63
- this.#qs = qs
64
- } else {
134
+ /**
135
+ * Query string for the redirect target, in the four shapes AdonisJS accepts:
136
+ *
137
+ * withQs() forward the request's own query string
138
+ * withQs(false) stop forwarding it
139
+ * withQs({ a: 1, b: 2 }) merge these values in
140
+ * withQs('a', 1) set one value
141
+ *
142
+ * The object and name/value forms MERGE, as upstream does — calling it twice
143
+ * adds to what is there rather than replacing it.
144
+ */
145
+ withQs(): this
146
+ withQs(forward: boolean): this
147
+ withQs(values: Record<string, unknown>): this
148
+ withQs(name: string, value: unknown): this
149
+ withQs(name?: boolean | string | Record<string, unknown>, value?: unknown): this {
150
+ if (name === undefined) {
65
151
  this.#forwardQs = true
152
+ return this
153
+ }
154
+ if (typeof name === 'boolean') {
155
+ this.#forwardQs = name
156
+ return this
66
157
  }
158
+ if (typeof name === 'string') {
159
+ this.#qs[name] = value
160
+ return this
161
+ }
162
+ Object.assign(this.#qs, name)
163
+ return this
164
+ }
165
+
166
+ /** Drop every value added with {@link withQs}, and stop forwarding. */
167
+ clearQs(): this {
168
+ this.#qs = {}
169
+ this.#forwardQs = false
67
170
  return this
68
171
  }
69
172
 
@@ -98,14 +201,26 @@ export class RedirectBuilder {
98
201
  }
99
202
  }
100
203
 
204
+ /**
205
+ * Forwarded query string first, explicit values on top — the order AdonisJS
206
+ * uses, so `withQs()` plus `withQs('page', 2)` overrides an inbound `page`
207
+ * rather than being overridden by it.
208
+ */
101
209
  #appendQs(path: string): string {
102
- let qs = ''
103
- if (this.#qs) {
104
- qs = new URLSearchParams(this.#qs).toString()
105
- } else if (this.#forwardQs && this.#requestUrl) {
210
+ const params = new URLSearchParams()
211
+ if (this.#forwardQs && this.#requestUrl) {
106
212
  const qsIdx = this.#requestUrl.indexOf('?')
107
- if (qsIdx !== -1) qs = this.#requestUrl.slice(qsIdx + 1)
213
+ if (qsIdx !== -1) {
214
+ for (const [key, value] of new URLSearchParams(this.#requestUrl.slice(qsIdx + 1))) {
215
+ params.append(key, value)
216
+ }
217
+ }
218
+ }
219
+ for (const [key, value] of Object.entries(this.#qs)) {
220
+ if (value === undefined || value === null) continue
221
+ params.set(key, String(value))
108
222
  }
223
+ const qs = params.toString()
109
224
  if (!qs) return path
110
225
  return path.includes('?') ? `${path}&${qs}` : `${path}?${qs}`
111
226
  }
@@ -12,6 +12,7 @@ import type { SignedUrl } from '../security/SignedUrl.js'
12
12
  import type { Dict } from '../types/helpers.js'
13
13
  import { Macroable } from '../utils/Macroable.js'
14
14
  import { getPath, omitPaths, pickPaths } from '../utils/objectPath.js'
15
+ import { unpackCookieValue } from './Response.js'
15
16
 
16
17
  export interface RawRequest {
17
18
  method: string
@@ -60,7 +61,7 @@ export interface RawRequest {
60
61
 
61
62
  export class Request extends Macroable {
62
63
  #raw: RawRequest
63
- #params: Dict
64
+ #params: Dict<string | string[]>
64
65
  #cookieSigner?: CookieSigner
65
66
  #signedUrl?: SignedUrl
66
67
  #allowMethodSpoofing = false
@@ -71,6 +72,8 @@ export class Request extends Macroable {
71
72
  #parsedQs: Dict<unknown> | undefined
72
73
  #merged: Dict<unknown> | undefined
73
74
  #original: Dict<unknown> | undefined
75
+ /** Set by {@link updateRawBody}; `raw()` reports it instead of the wire body. */
76
+ #rawBodyOverride: string | undefined
74
77
  #files: Map<string, import('../bodyparser/MultipartFile.js').MultipartFile[]> = new Map()
75
78
  #validated: unknown
76
79
 
@@ -91,7 +94,7 @@ export class Request extends Macroable {
91
94
  */
92
95
  csrfProtected?: boolean
93
96
 
94
- constructor(raw: RawRequest, params: Dict = {}) {
97
+ constructor(raw: RawRequest, params: Dict<string | string[]> = {}) {
95
98
  super()
96
99
  this.#raw = raw
97
100
  this.#params = params
@@ -202,6 +205,22 @@ export class Request extends Macroable {
202
205
  this.#trustProxy = enabled
203
206
  }
204
207
 
208
+ /**
209
+ * The HTTP/2 `:authority` pseudo-header, falling back to `Host` (AdonisJS
210
+ * `authority`).
211
+ *
212
+ * Deliberately does NOT consult `X-Forwarded-Host` and ignores trust-proxy,
213
+ * as Adonis does: no proxy convention forwards the original `:authority`,
214
+ * so honouring a forwarded header here would let a proxy rewrite the value
215
+ * you validate a redirect target against. Use {@link host} when you do want
216
+ * the proxy-aware value.
217
+ */
218
+ authority(): string | null {
219
+ const pseudo = this.#raw.headers[':authority']
220
+ if (pseudo) return pseudo
221
+ return this.#raw.headers.host ?? null
222
+ }
223
+
205
224
  /**
206
225
  * The request host including port (AdonisJS `host`). Honours
207
226
  * `X-Forwarded-Host` ONLY when trust-proxy is enabled; otherwise the `Host`
@@ -312,16 +331,44 @@ export class Request extends Macroable {
312
331
  * `defaultValue` (or null) when absent OR when the signature is invalid
313
332
  * (tampered / not signed).
314
333
  */
334
+ cookie(name: string): string | null
335
+ cookie(name: string, defaultValue: string): string
336
+ cookie(name: string, defaultValue?: string): string | null
315
337
  cookie(name: string, defaultValue?: string): string | null {
316
- const raw = this.plainCookie(name)
338
+ const raw = this.plainCookie<string>(name, undefined, { encoded: false })
317
339
  const value = raw === null ? null : this.#cookieSigner ? this.#cookieSigner.unsign(raw) : raw
318
340
  return value ?? defaultValue ?? null
319
341
  }
320
342
 
321
- /** Raw (unsigned) cookie value (AdonisJS `plainCookie`), or `defaultValue`/null when absent. */
322
- plainCookie(name: string, defaultValue?: string): string | null {
343
+ /**
344
+ * An unsigned cookie (AdonisJS `plainCookie`), unpacked.
345
+ *
346
+ * `response.plainCookie()` writes a base64url JSON envelope, so this reads
347
+ * the value BACK WITH ITS TYPE — an object stays an object, a number a
348
+ * number. A cookie set by something else, or written with `encode: false`,
349
+ * comes back as the raw string.
350
+ *
351
+ * Pass `encoded: false` to skip unpacking entirely.
352
+ */
353
+ plainCookie<T = string>(name: string): T | null
354
+ plainCookie<T = string>(name: string, defaultValue: T, options?: { encoded?: boolean }): T
355
+ plainCookie<T = string>(
356
+ name: string,
357
+ defaultValue: undefined,
358
+ options?: { encoded?: boolean },
359
+ ): T | null
360
+ plainCookie<T = string>(name: string, defaultValue?: T, options?: { encoded?: boolean }): T | null
361
+ plainCookie<T = string>(
362
+ name: string,
363
+ defaultValue?: T,
364
+ options?: { encoded?: boolean },
365
+ ): T | null {
323
366
  const cookies = this.#raw.cookies ?? this.cookies()
324
- return cookies[name] ?? defaultValue ?? null
367
+ const raw = cookies[name]
368
+ if (raw === undefined) return defaultValue ?? null
369
+ // Caller's claim about an untyped store, same contract as `input<T>`.
370
+ if (options?.encoded === false) return raw as T
371
+ return unpackCookieValue(raw) as T
325
372
  }
326
373
 
327
374
  /**
@@ -329,8 +376,11 @@ export class Request extends Macroable {
329
376
  * Returns `defaultValue` (or null) when absent, undecryptable, or no
330
377
  * encryption service.
331
378
  */
379
+ encryptedCookie(name: string): string | null
380
+ encryptedCookie(name: string, defaultValue: string): string
381
+ encryptedCookie(name: string, defaultValue?: string): string | null
332
382
  encryptedCookie(name: string, defaultValue?: string): string | null {
333
- const raw = this.plainCookie(name)
383
+ const raw = this.plainCookie<string>(name, undefined, { encoded: false })
334
384
  const value = raw === null || !this.#cookieSigner ? null : this.#cookieSigner.decrypt(raw)
335
385
  return value ?? defaultValue ?? null
336
386
  }
@@ -353,6 +403,7 @@ export class Request extends Macroable {
353
403
 
354
404
  /** Get the raw body as a string (decoded from base64 if binary). */
355
405
  raw(): string {
406
+ if (this.#rawBodyOverride !== undefined) return this.#rawBodyOverride
356
407
  if (this.#raw.bodyEncoding === 'base64') {
357
408
  return Buffer.from(this.#raw.body, 'base64').toString('utf8')
358
409
  }
@@ -370,6 +421,9 @@ export class Request extends Macroable {
370
421
  // ─── Headers ──────────────────────────────────────────────
371
422
 
372
423
  /** Get a single request header (case-insensitive), or `defaultValue` when absent. */
424
+ header(key: string): string | undefined
425
+ header(key: string, defaultValue: string): string
426
+ header(key: string, defaultValue?: string): string | undefined
373
427
  header(key: string, defaultValue?: string): string | undefined {
374
428
  return this.#raw.headers[key.toLowerCase()] ?? defaultValue
375
429
  }
@@ -381,16 +435,219 @@ export class Request extends Macroable {
381
435
 
382
436
  // ─── Route params ─────────────────────────────────────────
383
437
 
384
- /** Get a single route parameter. */
438
+ /**
439
+ * Get a single route parameter.
440
+ *
441
+ * The catch-all `*` is an ARRAY of segments (AdonisJS hands it that way), so
442
+ * it is returned joined here — `param('*')` reading `undefined` because the
443
+ * value was not a string would be worse than the path it describes.
444
+ * Use {@link params} to get the segments themselves.
445
+ */
446
+ param(key: string): string | undefined
447
+ param(key: string, defaultValue: string): string
448
+ param(key: string, defaultValue?: string): string | undefined
385
449
  param(key: string, defaultValue?: string): string | undefined {
386
- return this.#params[key] ?? defaultValue
450
+ const value = this.#params[key]
451
+ if (value === undefined) return defaultValue
452
+ return Array.isArray(value) ? value.join('/') : value
387
453
  }
388
454
 
389
- /** Get all route parameters. */
390
- params(): Readonly<Dict> {
455
+ /** Get all route parameters; `*` holds its segments as an array. */
456
+ params(): Readonly<Dict<string | string[]>> {
391
457
  return this.#params
392
458
  }
393
459
 
460
+ // ─── Identity and shape ───────────────────────────────────
461
+
462
+ /**
463
+ * The request id from `x-request-id` (AdonisJS `id`).
464
+ *
465
+ * Absent when the proxy or client did not set one — it is not invented here,
466
+ * because a correlation id nobody else knows correlates nothing.
467
+ */
468
+ id(): string | undefined {
469
+ return this.header('x-request-id')
470
+ }
471
+
472
+ /** The URL split into path and raw query string (AdonisJS `parsedUrl`). */
473
+ parsedUrl(): { pathname: string; search: string; query: string } {
474
+ const query = this.#raw.query ?? ''
475
+ return {
476
+ pathname: this.#raw.path,
477
+ search: query ? `?${query}` : '',
478
+ query,
479
+ }
480
+ }
481
+
482
+ /** `X-Requested-With: xmlhttprequest` (AdonisJS `ajax`). */
483
+ ajax(): boolean {
484
+ return (this.header('x-requested-with') ?? '').toLowerCase() === 'xmlhttprequest'
485
+ }
486
+
487
+ /** Whether the client sent `X-Pjax` (AdonisJS `pjax`). */
488
+ pjax(): boolean {
489
+ return this.header('x-pjax') !== undefined
490
+ }
491
+
492
+ /**
493
+ * Whether this is a speculative prefetch or prerender rather than a real
494
+ * navigation (AdonisJS `prefetch`).
495
+ *
496
+ * Worth checking before anything with a side effect: a browser may fetch a
497
+ * link the user never clicks, and counting that as a visit — or worse, acting
498
+ * on it — attributes an intention nobody had.
499
+ */
500
+ prefetch(): boolean {
501
+ const purpose = (
502
+ this.header('sec-purpose') ??
503
+ this.header('purpose') ??
504
+ this.header('x-purpose') ??
505
+ this.header('x-moz') ??
506
+ ''
507
+ ).toLowerCase()
508
+ return purpose.includes('prefetch') || purpose.includes('prerender')
509
+ }
510
+
511
+ /**
512
+ * The full URL — `protocol://host/path` (AdonisJS `completeUrl`).
513
+ *
514
+ * Pass `true` to keep the query string.
515
+ */
516
+ completeUrl(includeQueryString = false): string {
517
+ const path = includeQueryString ? this.url(true) : this.url(false)
518
+ return `${this.protocol()}://${this.host()}${path}`
519
+ }
520
+
521
+ /**
522
+ * Where the user came from, per the `Referer` header — but only when it
523
+ * points somewhere we trust (AdonisJS `getPreviousUrl`).
524
+ *
525
+ * A referrer is attacker-controlled, so redirecting back to it unchecked is
526
+ * an open redirect. The host must be this request's own or one of
527
+ * `allowedHosts`; anything else falls back.
528
+ */
529
+ getPreviousUrl(allowedHosts: string[] = [], fallback = '/'): string {
530
+ const referer = this.header('referer') ?? this.header('referrer')
531
+ if (!referer) return fallback
532
+ let parsed: URL
533
+ try {
534
+ parsed = new URL(referer)
535
+ } catch {
536
+ return fallback
537
+ }
538
+ const trusted = new Set([this.host(), ...allowedHosts])
539
+ if (!trusted.has(parsed.host)) return fallback
540
+ return `${parsed.pathname}${parsed.search}`
541
+ }
542
+
543
+ // ─── Content negotiation ──────────────────────────────────
544
+
545
+ /** Every media type the client accepts, best first (AdonisJS `types`). */
546
+ types(): string[] {
547
+ return this.#negotiated('accept')
548
+ }
549
+
550
+ /** Every language the client accepts, best first (AdonisJS `languages`). */
551
+ languages(): string[] {
552
+ return this.#negotiated('accept-language')
553
+ }
554
+
555
+ /** Every charset the client accepts, best first (AdonisJS `charsets`). */
556
+ charsets(): string[] {
557
+ return this.#negotiated('accept-charset')
558
+ }
559
+
560
+ /**
561
+ * The best of the offered charsets per `Accept-Charset` (AdonisJS
562
+ * `charset`), or null when none matches. The plural {@link charsets} lists
563
+ * everything the client will take.
564
+ */
565
+ charset<T extends string>(charsets: T[]): T | null {
566
+ return this.#bestOf('accept-charset', charsets)
567
+ }
568
+
569
+ /** Every encoding the client accepts, best first (AdonisJS `encodings`). */
570
+ encodings(): string[] {
571
+ return this.#negotiated('accept-encoding')
572
+ }
573
+
574
+ /**
575
+ * The best of the offered encodings per `Accept-Encoding` (AdonisJS
576
+ * `encoding`), or null when none matches.
577
+ */
578
+ encoding<T extends string>(encodings: T[]): T | null {
579
+ return this.#bestOf('accept-encoding', encodings)
580
+ }
581
+
582
+ /**
583
+ * The best of the offered values for one `Accept-*` header (AdonisJS
584
+ * `charset` / `encoding`).
585
+ *
586
+ * Client preference decides: the header's q-order is walked first, and the
587
+ * order the server offered them only breaks a tie within one q-bucket —
588
+ * the same rule {@link accepts} follows. An absent header means the client
589
+ * expressed no preference, so the server's first offer wins.
590
+ */
591
+ #bestOf<T extends string>(header: string, offered: T[]): T | null {
592
+ const raw = this.#raw.headers[header]
593
+ if (!raw?.trim()) return offered[0] ?? null
594
+ const entries = parseAcceptHeader(raw)
595
+ if (entries.length === 0) return null
596
+ for (const bucket of bucketByQ(entries)) {
597
+ for (const value of offered) {
598
+ for (const entry of bucket) {
599
+ const candidate = entry.value.toLowerCase()
600
+ if (candidate === '*' || candidate === value.toLowerCase()) return value
601
+ }
602
+ }
603
+ }
604
+ return null
605
+ }
606
+
607
+ /**
608
+ * Parse one `Accept-*` header into the client's preference order.
609
+ *
610
+ * Sorted by q descending, ties broken by the order the client wrote them —
611
+ * which is what "preference" means when two entries share a q.
612
+ */
613
+ #negotiated(header: string): string[] {
614
+ const raw = this.#raw.headers[header]
615
+ if (!raw) return []
616
+ return parseAcceptHeader(raw)
617
+ .sort((a, b) => b.q - a.q || a.index - b.index)
618
+ .map((entry) => entry.value)
619
+ }
620
+
621
+ // ─── Serialization ────────────────────────────────────────
622
+
623
+ /** Every cookie, parsed (AdonisJS `cookiesList`). */
624
+ cookiesList(): Dict {
625
+ return this.cookies()
626
+ }
627
+
628
+ /**
629
+ * A JSON-safe view of the request (AdonisJS `serialize`), for logs and error
630
+ * reports. Carries no body: it may hold credentials, and a log line is the
631
+ * last place they should land.
632
+ */
633
+ serialize(): Record<string, unknown> {
634
+ return {
635
+ id: this.id(),
636
+ url: this.url(true),
637
+ method: this.method(),
638
+ protocol: this.protocol(),
639
+ host: this.host(),
640
+ headers: this.headers(),
641
+ qs: this.qs(),
642
+ params: this.params(),
643
+ }
644
+ }
645
+
646
+ /** Same as {@link serialize} — what `JSON.stringify(request)` uses. */
647
+ toJSON(): Record<string, unknown> {
648
+ return this.serialize()
649
+ }
650
+
394
651
  // ─── Query string ─────────────────────────────────────────
395
652
 
396
653
  /** Get parsed query string as an object. */
@@ -500,7 +757,7 @@ export class Request extends Macroable {
500
757
  * Aliases (`'json'` ↔ `application/json`, `'html'` ↔ `text/html`,
501
758
  * `'xml'` ↔ any subtype containing `xml`) are honoured.
502
759
  */
503
- accepts(types: string[]): string | null {
760
+ accepts<T extends string>(types: T[]): T | null {
504
761
  const header = this.#raw.headers.accept ?? '*/*'
505
762
  const entries = parseAcceptHeader(header)
506
763
  if (entries.length === 0) return null
@@ -523,7 +780,7 @@ export class Request extends Macroable {
523
780
  * primary subtag) matches the highest-q client entry, falling back to
524
781
  * `langs[0]` when nothing matches and the client header is empty/absent.
525
782
  */
526
- language(langs: string[]): string | null {
783
+ language<T extends string>(langs: T[]): T | null {
527
784
  const header = this.#raw.headers['accept-language'] ?? ''
528
785
  if (!header.trim()) return langs[0] ?? null
529
786
  const entries = parseAcceptHeader(header)
@@ -601,6 +858,39 @@ export class Request extends Macroable {
601
858
  this.#merged = undefined // reset merged cache
602
859
  }
603
860
 
861
+ /**
862
+ * Replace the request body (AdonisJS `updateBody`) — what a middleware that
863
+ * sanitizes or normalises input calls. {@link all} is recomputed from the
864
+ * query string and the new body on the next read.
865
+ *
866
+ * {@link original} keeps the first-seen input: flash old-input must show
867
+ * what the user actually sent, not what a middleware rewrote it into.
868
+ */
869
+ updateBody(body: Dict<unknown>): void {
870
+ this.all() // snapshot the original before it is replaced
871
+ this.setParsedBody(body)
872
+ }
873
+
874
+ /**
875
+ * Replace the query string data (AdonisJS `updateQs`). {@link all} is
876
+ * recomputed on the next read; the raw query string is left alone, so
877
+ * `parsedUrl()` still reports what the client sent.
878
+ */
879
+ updateQs(data: Dict<unknown>): void {
880
+ this.all()
881
+ this.#parsedQs = { ...data }
882
+ this.#merged = undefined
883
+ }
884
+
885
+ /**
886
+ * Replace the raw body (AdonisJS `updateRawBody`) — what the bodyparser
887
+ * sets when it cannot parse the body, or for multipart. Does NOT reparse:
888
+ * the parsed body is whatever {@link updateBody} last set.
889
+ */
890
+ updateRawBody(rawBody: string): void {
891
+ this.#rawBodyOverride = rawBody
892
+ }
893
+
604
894
  /**
605
895
  * @internal Store the validated + transformed payload (called by the
606
896
  * validation middleware after a route validator passes).