@c9up/ream 0.1.25 → 0.1.26

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 (172) hide show
  1. package/dist/ConfigLoader.d.ts +46 -5
  2. package/dist/ConfigLoader.d.ts.map +1 -1
  3. package/dist/ConfigLoader.js +60 -9
  4. package/dist/ConfigLoader.js.map +1 -1
  5. package/dist/HttpKernel.d.ts.map +1 -1
  6. package/dist/HttpKernel.js +46 -6
  7. package/dist/HttpKernel.js.map +1 -1
  8. package/dist/Ignitor.d.ts.map +1 -1
  9. package/dist/Ignitor.js +21 -3
  10. package/dist/Ignitor.js.map +1 -1
  11. package/dist/container/Container.d.ts +23 -3
  12. package/dist/container/Container.d.ts.map +1 -1
  13. package/dist/container/Container.js +139 -60
  14. package/dist/container/Container.js.map +1 -1
  15. package/dist/env/Env.d.ts +29 -4
  16. package/dist/env/Env.d.ts.map +1 -1
  17. package/dist/env/Env.js +27 -4
  18. package/dist/env/Env.js.map +1 -1
  19. package/dist/env/Secret.d.ts +11 -0
  20. package/dist/env/Secret.d.ts.map +1 -0
  21. package/dist/env/Secret.js +36 -0
  22. package/dist/env/Secret.js.map +1 -0
  23. package/dist/env/interpolate.d.ts +26 -0
  24. package/dist/env/interpolate.d.ts.map +1 -0
  25. package/dist/env/interpolate.js +48 -0
  26. package/dist/env/interpolate.js.map +1 -0
  27. package/dist/env/loadEnvFiles.d.ts.map +1 -1
  28. package/dist/env/loadEnvFiles.js +14 -4
  29. package/dist/env/loadEnvFiles.js.map +1 -1
  30. package/dist/env/schema.d.ts +23 -6
  31. package/dist/env/schema.d.ts.map +1 -1
  32. package/dist/env/schema.js +67 -33
  33. package/dist/env/schema.js.map +1 -1
  34. package/dist/events/Emitter.d.ts +1 -1
  35. package/dist/events/Emitter.d.ts.map +1 -1
  36. package/dist/events/Emitter.js +1 -1
  37. package/dist/events/Emitter.js.map +1 -1
  38. package/dist/events/EventsProvider.d.ts +2 -2
  39. package/dist/events/EventsProvider.d.ts.map +1 -1
  40. package/dist/events/EventsProvider.js +3 -3
  41. package/dist/events/EventsProvider.js.map +1 -1
  42. package/dist/graphql/GraphQLEngine.d.ts +1 -1
  43. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  44. package/dist/graphql/GraphQLEngine.js +2 -2
  45. package/dist/graphql/GraphQLEngine.js.map +1 -1
  46. package/dist/graphql/GraphQLProvider.d.ts.map +1 -1
  47. package/dist/graphql/GraphQLProvider.js +7 -3
  48. package/dist/graphql/GraphQLProvider.js.map +1 -1
  49. package/dist/http/Exception.d.ts +63 -1
  50. package/dist/http/Exception.d.ts.map +1 -1
  51. package/dist/http/Exception.js +185 -48
  52. package/dist/http/Exception.js.map +1 -1
  53. package/dist/http/HttpContext.d.ts +53 -7
  54. package/dist/http/HttpContext.d.ts.map +1 -1
  55. package/dist/http/HttpContext.js +56 -15
  56. package/dist/http/HttpContext.js.map +1 -1
  57. package/dist/http/RedirectBuilder.d.ts.map +1 -1
  58. package/dist/http/RedirectBuilder.js +4 -1
  59. package/dist/http/RedirectBuilder.js.map +1 -1
  60. package/dist/http/Request.d.ts +141 -10
  61. package/dist/http/Request.d.ts.map +1 -1
  62. package/dist/http/Request.js +244 -34
  63. package/dist/http/Request.js.map +1 -1
  64. package/dist/http/Response.d.ts +176 -9
  65. package/dist/http/Response.d.ts.map +1 -1
  66. package/dist/http/Response.js +404 -16
  67. package/dist/http/Response.js.map +1 -1
  68. package/dist/index.d.ts +5 -4
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +4 -3
  71. package/dist/index.js.map +1 -1
  72. package/dist/middleware/Pipeline.js +1 -1
  73. package/dist/middleware/Pipeline.js.map +1 -1
  74. package/dist/openapi/OpenApiProvider.js +4 -4
  75. package/dist/openapi/OpenApiProvider.js.map +1 -1
  76. package/dist/router/Router.d.ts +223 -31
  77. package/dist/router/Router.d.ts.map +1 -1
  78. package/dist/router/Router.js +520 -102
  79. package/dist/router/Router.js.map +1 -1
  80. package/dist/rpc/RpcProvider.d.ts +1 -0
  81. package/dist/rpc/RpcProvider.d.ts.map +1 -1
  82. package/dist/rpc/RpcProvider.js +7 -3
  83. package/dist/rpc/RpcProvider.js.map +1 -1
  84. package/dist/rpc/RpcRouter.d.ts +2 -2
  85. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  86. package/dist/rpc/RpcRouter.js +5 -4
  87. package/dist/rpc/RpcRouter.js.map +1 -1
  88. package/dist/scheduler/ScheduleProvider.d.ts.map +1 -1
  89. package/dist/scheduler/ScheduleProvider.js +7 -15
  90. package/dist/scheduler/ScheduleProvider.js.map +1 -1
  91. package/dist/server/Server.d.ts +17 -4
  92. package/dist/server/Server.d.ts.map +1 -1
  93. package/dist/server/Server.js +41 -7
  94. package/dist/server/Server.js.map +1 -1
  95. package/dist/session/SessionMiddleware.js +3 -3
  96. package/dist/session/SessionMiddleware.js.map +1 -1
  97. package/dist/storage/StaticProvider.js +1 -1
  98. package/dist/storage/StaticProvider.js.map +1 -1
  99. package/dist/testing/RequestBuilder.d.ts +60 -0
  100. package/dist/testing/RequestBuilder.d.ts.map +1 -1
  101. package/dist/testing/RequestBuilder.js +180 -1
  102. package/dist/testing/RequestBuilder.js.map +1 -1
  103. package/dist/testing/TestClient.d.ts +22 -1
  104. package/dist/testing/TestClient.d.ts.map +1 -1
  105. package/dist/testing/TestClient.js +40 -0
  106. package/dist/testing/TestClient.js.map +1 -1
  107. package/dist/utils/Macroable.d.ts +30 -0
  108. package/dist/utils/Macroable.d.ts.map +1 -0
  109. package/dist/utils/Macroable.js +43 -0
  110. package/dist/utils/Macroable.js.map +1 -0
  111. package/dist/utils/inflect.d.ts +17 -0
  112. package/dist/utils/inflect.d.ts.map +1 -0
  113. package/dist/utils/inflect.js +32 -0
  114. package/dist/utils/inflect.js.map +1 -0
  115. package/dist/utils/objectPath.d.ts +57 -0
  116. package/dist/utils/objectPath.d.ts.map +1 -0
  117. package/dist/utils/objectPath.js +141 -0
  118. package/dist/utils/objectPath.js.map +1 -0
  119. package/events.darwin-arm64.node +0 -0
  120. package/events.darwin-x64.node +0 -0
  121. package/events.linux-arm64-gnu.node +0 -0
  122. package/events.linux-x64-gnu.node +0 -0
  123. package/events.win32-x64-msvc.node +0 -0
  124. package/index.darwin-arm64.node +0 -0
  125. package/index.darwin-x64.node +0 -0
  126. package/index.linux-arm64-gnu.node +0 -0
  127. package/index.linux-x64-gnu.node +0 -0
  128. package/index.win32-x64-msvc.node +0 -0
  129. package/package.json +6 -1
  130. package/scheduler.darwin-arm64.node +0 -0
  131. package/scheduler.darwin-x64.node +0 -0
  132. package/scheduler.linux-arm64-gnu.node +0 -0
  133. package/scheduler.linux-x64-gnu.node +0 -0
  134. package/scheduler.win32-x64-msvc.node +0 -0
  135. package/src/ConfigLoader.ts +72 -9
  136. package/src/HttpKernel.ts +53 -15
  137. package/src/Ignitor.ts +24 -3
  138. package/src/container/Container.ts +145 -61
  139. package/src/env/Env.ts +49 -5
  140. package/src/env/Secret.ts +41 -0
  141. package/src/env/interpolate.ts +54 -0
  142. package/src/env/loadEnvFiles.ts +12 -4
  143. package/src/env/schema.ts +109 -35
  144. package/src/events/Emitter.ts +2 -2
  145. package/src/events/EventsProvider.ts +5 -5
  146. package/src/graphql/GraphQLEngine.ts +4 -4
  147. package/src/graphql/GraphQLProvider.ts +7 -3
  148. package/src/http/Exception.ts +214 -37
  149. package/src/http/HttpContext.ts +87 -18
  150. package/src/http/RedirectBuilder.ts +4 -1
  151. package/src/http/Request.ts +264 -34
  152. package/src/http/Response.ts +478 -27
  153. package/src/index.ts +11 -2
  154. package/src/middleware/Pipeline.ts +1 -1
  155. package/src/openapi/OpenApiProvider.ts +4 -4
  156. package/src/router/Router.ts +631 -113
  157. package/src/rpc/RpcProvider.ts +6 -2
  158. package/src/rpc/RpcRouter.ts +7 -6
  159. package/src/scheduler/ScheduleProvider.ts +7 -15
  160. package/src/server/Server.ts +49 -10
  161. package/src/session/SessionMiddleware.ts +4 -4
  162. package/src/storage/StaticProvider.ts +1 -1
  163. package/src/testing/RequestBuilder.ts +218 -1
  164. package/src/testing/TestClient.ts +57 -1
  165. package/src/utils/Macroable.ts +54 -0
  166. package/src/utils/inflect.ts +29 -0
  167. package/src/utils/objectPath.ts +142 -0
  168. package/dist/decorators/Lazy.d.ts +0 -19
  169. package/dist/decorators/Lazy.d.ts.map +0 -1
  170. package/dist/decorators/Lazy.js +0 -53
  171. package/dist/decorators/Lazy.js.map +0 -1
  172. package/src/decorators/Lazy.ts +0 -57
@@ -7,7 +7,11 @@
7
7
  * @implements FR21
8
8
  */
9
9
 
10
+ import type { CookieSigner } from '../security/CookieSigner.js'
11
+ import type { SignedUrl } from '../security/SignedUrl.js'
10
12
  import type { Dict } from '../types/helpers.js'
13
+ import { Macroable } from '../utils/Macroable.js'
14
+ import { getPath, omitPaths, pickPaths } from '../utils/objectPath.js'
11
15
 
12
16
  export interface RawRequest {
13
17
  method: string
@@ -25,6 +29,12 @@ export interface RawRequest {
25
29
  * `ip()` accessor falls back to legacy header parsing.
26
30
  */
27
31
  ip?: string
32
+ /**
33
+ * Connection scheme as seen by the server (`http`/`https`), when the Rust
34
+ * layer ships it. Defaults to `http` in `protocol()` when absent; the
35
+ * `X-Forwarded-Proto` header only overrides it when trust-proxy is enabled.
36
+ */
37
+ scheme?: 'http' | 'https'
28
38
  /**
29
39
  * Pre-parsed `multipart/form-data` payload — the HyperServer parses bodies
30
40
  * server-side via `multer` and ships fields/files in this typed shape.
@@ -48,12 +58,19 @@ export interface RawRequest {
48
58
  cookies?: Dict
49
59
  }
50
60
 
51
- export class Request {
61
+ export class Request extends Macroable {
52
62
  #raw: RawRequest
53
63
  #params: Dict
64
+ #cookieSigner?: CookieSigner
65
+ #signedUrl?: SignedUrl
66
+ #allowMethodSpoofing = false
67
+ #trustProxy = false
68
+ #routeInfo?: { name?: string; pattern: string }
69
+ #response?: { fresh(): boolean }
54
70
  #parsedBody: Dict<unknown> | undefined
55
71
  #parsedQs: Dict<unknown> | undefined
56
72
  #merged: Dict<unknown> | undefined
73
+ #original: Dict<unknown> | undefined
57
74
  #files: Map<string, import('../bodyparser/MultipartFile.js').MultipartFile[]> = new Map()
58
75
  #validated: unknown
59
76
 
@@ -64,18 +81,83 @@ export class Request {
64
81
  */
65
82
  csrfToken?: string
66
83
 
84
+ /**
85
+ * `true` only when CSRF was enabled, the request method guarded, the route not
86
+ * excepted, AND the token validated for this request — set by the
87
+ * `@c9up/blackhole` middleware. Unlike {@link csrfToken} (seeded on every
88
+ * passing request, even when verification was skipped), this is the trustworthy
89
+ * "the request was CSRF-verified" signal a consumer reads to fail-close on
90
+ * state-changing routes. `undefined` when the middleware never ran.
91
+ */
92
+ csrfProtected?: boolean
93
+
67
94
  constructor(raw: RawRequest, params: Dict = {}) {
95
+ super()
68
96
  this.#raw = raw
69
97
  this.#params = params
70
98
  }
71
99
 
72
100
  // ─── HTTP accessors ───────────────────────────────────────
73
101
 
74
- /** HTTP method (GET, POST, etc.). */
102
+ /**
103
+ * The HTTP method, honouring `_method` form spoofing when enabled (AdonisJS
104
+ * `method`). With spoofing enabled and a real POST, the `_method` field
105
+ * (`PUT`/`PATCH`/`DELETE`) overrides — the classic HTML-form workaround.
106
+ * Spoofing is OFF by default (opt in via {@link setMethodSpoofing}); routing
107
+ * always uses {@link intended}.
108
+ */
75
109
  method(): string {
110
+ if (this.#allowMethodSpoofing && this.intended().toUpperCase() === 'POST') {
111
+ const spoofed = this.input('_method', this.intended())
112
+ if (typeof spoofed === 'string' && spoofed) return spoofed.toUpperCase()
113
+ }
114
+ return this.intended()
115
+ }
116
+
117
+ /** The real HTTP method, ignoring any `_method` spoofing (AdonisJS `intended`). */
118
+ intended(): string {
76
119
  return this.#raw.method
77
120
  }
78
121
 
122
+ /** @internal Give the request access to its response — wired by HttpContext (for `fresh()`). */
123
+ setResponse(response: { fresh(): boolean }): void {
124
+ this.#response = response
125
+ }
126
+
127
+ /** @internal Record the matched route — wired by HttpContext (for `matchesRoute()`). */
128
+ setRouteInfo(info: { name?: string; pattern: string }): void {
129
+ this.#routeInfo = info
130
+ }
131
+
132
+ /**
133
+ * Whether the client's cached copy is still fresh (AdonisJS `fresh`) —
134
+ * delegates to the response's `ETag`/`If-None-Match` revalidation check, so a
135
+ * handler can answer `304 Not Modified`. False before the response is wired.
136
+ */
137
+ fresh(): boolean {
138
+ return this.#response?.fresh() ?? false
139
+ }
140
+
141
+ /** The negation of {@link fresh} (AdonisJS `stale`). */
142
+ stale(): boolean {
143
+ return !this.fresh()
144
+ }
145
+
146
+ /** True when the matched route's name or pattern equals `identifier` (AdonisJS `matchesRoute`). */
147
+ matchesRoute(identifier: string): boolean {
148
+ if (!this.#routeInfo) return false
149
+ return this.#routeInfo.name === identifier || this.#routeInfo.pattern === identifier
150
+ }
151
+
152
+ /**
153
+ * Enable/disable `_method` form spoofing (AdonisJS gates this behind the
154
+ * `allowMethodSpoofing` config; ream exposes it as an explicit opt-in, off by
155
+ * default, so an attacker can never silently rewrite a POST into a DELETE).
156
+ */
157
+ setMethodSpoofing(enabled: boolean): void {
158
+ this.#allowMethodSpoofing = enabled
159
+ }
160
+
79
161
  /** Request URL (path + query string). */
80
162
  url(includeQs = true): string {
81
163
  if (includeQs && this.#raw.query) {
@@ -110,6 +192,88 @@ export class Request {
110
192
  return this.#raw.remoteAddr ?? '127.0.0.1'
111
193
  }
112
194
 
195
+ /**
196
+ * Enable/disable trusting proxy headers (`X-Forwarded-Proto`/`-Host`/`-For`)
197
+ * for `protocol()`/`host()`/`ips()`. OFF by default — turning it on when the
198
+ * app is NOT behind a trusted proxy lets clients spoof their scheme/host/IP,
199
+ * so it's an explicit opt-in (same stance as {@link setMethodSpoofing}).
200
+ */
201
+ setTrustProxy(enabled: boolean): void {
202
+ this.#trustProxy = enabled
203
+ }
204
+
205
+ /**
206
+ * The request host including port (AdonisJS `host`). Honours
207
+ * `X-Forwarded-Host` ONLY when trust-proxy is enabled; otherwise the `Host`
208
+ * header. Returns null when neither is present.
209
+ */
210
+ host(): string | null {
211
+ if (this.#trustProxy) {
212
+ const forwarded = this.#raw.headers['x-forwarded-host']
213
+ if (forwarded) return forwarded.split(',')[0].trim()
214
+ }
215
+ return this.#raw.headers.host ?? null
216
+ }
217
+
218
+ /**
219
+ * The request protocol — `http` or `https` (AdonisJS `protocol`). Honours
220
+ * `X-Forwarded-Proto` only under trust-proxy; otherwise the connection scheme
221
+ * the Rust layer reported (defaulting to `http`).
222
+ */
223
+ protocol(): string {
224
+ if (this.#trustProxy) {
225
+ const forwarded = this.#raw.headers['x-forwarded-proto']
226
+ if (forwarded) return forwarded.split(',')[0].trim().toLowerCase()
227
+ }
228
+ return this.#raw.scheme ?? 'http'
229
+ }
230
+
231
+ /** True when the request protocol is `https` (AdonisJS `secure`). */
232
+ secure(): boolean {
233
+ return this.protocol() === 'https'
234
+ }
235
+
236
+ /**
237
+ * The client IP chain (AdonisJS `ips`). Under trust-proxy, the parsed
238
+ * `X-Forwarded-For` list (left-most = original client); otherwise just the
239
+ * single resolved {@link ip}.
240
+ */
241
+ ips(): string[] {
242
+ if (this.#trustProxy) {
243
+ const forwarded = this.#raw.headers['x-forwarded-for']
244
+ if (forwarded) {
245
+ return forwarded
246
+ .split(',')
247
+ .map((value) => value.trim())
248
+ .filter((value) => value.length > 0)
249
+ }
250
+ }
251
+ return [this.ip()]
252
+ }
253
+
254
+ /** The request hostname without port (AdonisJS `hostname`), or null. */
255
+ hostname(): string | null {
256
+ const host = this.host()
257
+ if (host === null) return null
258
+ // Strip the port. IPv6 literals are bracketed (`[::1]:3000`) — keep the brackets' contents.
259
+ if (host.startsWith('[')) return host.slice(0, host.indexOf(']') + 1) || host
260
+ const colon = host.indexOf(':')
261
+ return colon === -1 ? host : host.slice(0, colon)
262
+ }
263
+
264
+ /**
265
+ * Subdomains of the hostname (AdonisJS `subdomains`). `offset` drops the
266
+ * registrable domain (default 2 → `example.com`); a leading `www` is removed.
267
+ * Empty for an IP host or a host with no subdomain.
268
+ */
269
+ subdomains(offset = 2): string[] {
270
+ const hostname = this.hostname()
271
+ if (hostname === null || isIpLiteral(hostname)) return []
272
+ const parts = hostname.split('.').reverse().slice(offset)
273
+ if (parts.length > 0 && parts[parts.length - 1] === 'www') parts.pop()
274
+ return parts
275
+ }
276
+
113
277
  /**
114
278
  * Return all cookies sent on this request as a name → value map. The
115
279
  * HyperServer parses the `Cookie:` header once via the `cookie` crate
@@ -138,10 +302,53 @@ export class Request {
138
302
  return result
139
303
  }
140
304
 
141
- /** Pre-parsed cookie value by name, or `null` when absent. */
142
- cookie(name: string): string | null {
305
+ /** Inject the APP_KEY-backed cookie signer — wired by HttpContext. */
306
+ setCookieSigner(signer: CookieSigner): void {
307
+ this.#cookieSigner = signer
308
+ }
309
+
310
+ /**
311
+ * Signed cookie value, verified with APP_KEY (AdonisJS default). Returns
312
+ * `defaultValue` (or null) when absent OR when the signature is invalid
313
+ * (tampered / not signed).
314
+ */
315
+ cookie(name: string, defaultValue?: string): string | null {
316
+ const raw = this.plainCookie(name)
317
+ const value = raw === null ? null : this.#cookieSigner ? this.#cookieSigner.unsign(raw) : raw
318
+ return value ?? defaultValue ?? null
319
+ }
320
+
321
+ /** Raw (unsigned) cookie value (AdonisJS `plainCookie`), or `defaultValue`/null when absent. */
322
+ plainCookie(name: string, defaultValue?: string): string | null {
143
323
  const cookies = this.#raw.cookies ?? this.cookies()
144
- return cookies[name] ?? null
324
+ return cookies[name] ?? defaultValue ?? null
325
+ }
326
+
327
+ /**
328
+ * Encrypted cookie value, decrypted with APP_KEY (AdonisJS `encryptedCookie`).
329
+ * Returns `defaultValue` (or null) when absent, undecryptable, or no
330
+ * encryption service.
331
+ */
332
+ encryptedCookie(name: string, defaultValue?: string): string | null {
333
+ const raw = this.plainCookie(name)
334
+ const value = raw === null || !this.#cookieSigner ? null : this.#cookieSigner.decrypt(raw)
335
+ return value ?? defaultValue ?? null
336
+ }
337
+
338
+ /** @internal Inject the APP_KEY-backed signed-URL helper — wired by HttpContext. */
339
+ setSignedUrl(signedUrl: SignedUrl): void {
340
+ this.#signedUrl = signedUrl
341
+ }
342
+
343
+ /**
344
+ * Verify the request's signed-URL `signature` (AdonisJS `hasValidSignature`).
345
+ * Delegates to the {@link SignedUrl} helper, which recomputes the HMAC over
346
+ * the current path + query, rejects an expired `expires`, and checks the
347
+ * `purpose`. Returns false without an APP_KEY-backed signer.
348
+ */
349
+ hasValidSignature(purpose?: string): boolean {
350
+ if (!this.#signedUrl) return false
351
+ return this.#signedUrl.verify(this.url(), purpose)
145
352
  }
146
353
 
147
354
  /** Get the raw body as a string (decoded from base64 if binary). */
@@ -162,9 +369,9 @@ export class Request {
162
369
 
163
370
  // ─── Headers ──────────────────────────────────────────────
164
371
 
165
- /** Get a single request header (case-insensitive). */
166
- header(key: string): string | undefined {
167
- return this.#raw.headers[key.toLowerCase()]
372
+ /** Get a single request header (case-insensitive), or `defaultValue` when absent. */
373
+ header(key: string, defaultValue?: string): string | undefined {
374
+ return this.#raw.headers[key.toLowerCase()] ?? defaultValue
168
375
  }
169
376
 
170
377
  /** Get all request headers. */
@@ -204,15 +411,15 @@ export class Request {
204
411
 
205
412
  // ─── Merged input (body + qs) ─────────────────────────────
206
413
 
207
- /** Get a single input value from body or query string. */
414
+ /**
415
+ * Get a single input value from body or query string, by AdonisJS
416
+ * dot-notation (`input('user.address.city')`). Returns `defaultValue` when
417
+ * the path is absent (parity with `lodash.get`, reimplemented dependency-free).
418
+ */
208
419
  input<T = unknown>(key: string, defaultValue?: T): T {
209
- const merged = this.all()
210
- if (key in merged) {
211
- // biome-ignore lint/suspicious/noExplicitAny: generic accessor — caller brands the value type via T
212
- return merged[key] as any as T
213
- }
214
- // biome-ignore lint/suspicious/noExplicitAny: generic default — caller brands the value type via T
215
- return defaultValue as any as T
420
+ // Untyped request data: the caller declares the shape it expects (same
421
+ // contract as AdonisJS `request.input<T>`, whose return is loose `any`).
422
+ return getPath(this.all(), key, defaultValue) as T
216
423
  }
217
424
 
218
425
  /** Get all input (query string merged with body). */
@@ -220,31 +427,48 @@ export class Request {
220
427
  if (!this.#merged) {
221
428
  this.#ensureParsedBody()
222
429
  this.#merged = { ...this.qs(), ...this.#parsedBody }
430
+ // Snapshot the first-seen input as the immutable "original" (flash old-input).
431
+ if (this.#original === undefined) this.#original = { ...this.#merged }
223
432
  }
224
433
  return { ...this.#merged }
225
434
  }
226
435
 
227
- /** Cherry-pick specific keys from input. */
228
- only<K extends string>(keys: K[]): Partial<Record<K, unknown>> {
229
- const merged = this.all()
230
- const result: Partial<Record<K, unknown>> = {}
231
- for (const key of keys) {
232
- if (key in merged) {
233
- result[key] = merged[key]
234
- }
235
- }
236
- return result
436
+ /**
437
+ * The original request input, captured once and never mutated (AdonisJS
438
+ * `request.original`) — the basis for flash "old input" on validation errors.
439
+ */
440
+ original(): Dict<unknown> {
441
+ this.all() // ensure the snapshot is captured
442
+ return { ...(this.#original ?? {}) }
237
443
  }
238
444
 
239
- /** Get all input except specific keys. */
445
+ /**
446
+ * Cherry-pick specific keys from input, honouring dot-notation for nested
447
+ * branches (`only(['user.id'])` → `{ user: { id } }`) — AdonisJS `only`
448
+ * (`lodash.pick`). Absent paths are skipped.
449
+ */
450
+ only(keys: string[]): Dict<unknown> {
451
+ return pickPaths(this.all(), keys)
452
+ }
453
+
454
+ /**
455
+ * Get all input except specific keys, honouring dot-notation for nested
456
+ * branches — AdonisJS `except` (`lodash.omit`). Never mutates the input.
457
+ */
240
458
  except(keys: string[]): Dict<unknown> {
241
- const merged = this.all()
242
- const keySet = new Set(keys)
243
- const result: Dict<unknown> = {}
244
- for (const [k, v] of Object.entries(merged)) {
245
- if (!keySet.has(k)) result[k] = v
246
- }
247
- return result
459
+ return omitPaths(this.all(), keys)
460
+ }
461
+
462
+ /**
463
+ * True when the request carries a body — a `content-length` above zero or a
464
+ * `transfer-encoding` header (AdonisJS `request.hasBody`). Lets a handler
465
+ * branch on "was anything sent" without parsing.
466
+ */
467
+ hasBody(): boolean {
468
+ const transferEncoding = this.#raw.headers['transfer-encoding']
469
+ if (transferEncoding && transferEncoding.length > 0) return true
470
+ const contentLength = Number.parseInt(this.#raw.headers['content-length'] ?? '', 10)
471
+ return Number.isFinite(contentLength) && contentLength > 0
248
472
  }
249
473
 
250
474
  // ─── Content negotiation ──────────────────────────────────
@@ -427,6 +651,12 @@ function isPlainObject(value: unknown): value is Dict<unknown> {
427
651
  return typeof value === 'object' && value !== null && !Array.isArray(value)
428
652
  }
429
653
 
654
+ /** True for an IPv4 dotted-quad or a (bracketed) IPv6 literal — hosts with no subdomains. */
655
+ function isIpLiteral(host: string): boolean {
656
+ if (host.startsWith('[') || host.includes('::')) return true
657
+ return /^(\d{1,3}\.){3}\d{1,3}$/.test(host)
658
+ }
659
+
430
660
  /**
431
661
  * Decode a percent-encoded segment without throwing on malformed input.
432
662
  * Browsers happily send `q=%E0%A4%A` (truncated UTF-8 sequences) — refusing