@c9up/ream 0.1.34 → 0.2.1

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 (210) hide show
  1. package/dist/Application.d.ts +68 -0
  2. package/dist/Application.d.ts.map +1 -1
  3. package/dist/Application.js +142 -6
  4. package/dist/Application.js.map +1 -1
  5. package/dist/HealthCheck.d.ts +14 -7
  6. package/dist/HealthCheck.d.ts.map +1 -1
  7. package/dist/HealthCheck.js +21 -12
  8. package/dist/HealthCheck.js.map +1 -1
  9. package/dist/HttpKernel.d.ts +12 -0
  10. package/dist/HttpKernel.d.ts.map +1 -1
  11. package/dist/HttpKernel.js +15 -4
  12. package/dist/HttpKernel.js.map +1 -1
  13. package/dist/Ignitor.d.ts +8 -8
  14. package/dist/Ignitor.d.ts.map +1 -1
  15. package/dist/Ignitor.js +11 -0
  16. package/dist/Ignitor.js.map +1 -1
  17. package/dist/bodyparser/BodyParserMiddleware.d.ts +13 -2
  18. package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
  19. package/dist/bodyparser/BodyParserMiddleware.js +50 -30
  20. package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
  21. package/dist/bodyparser/MultipartFile.d.ts +75 -6
  22. package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
  23. package/dist/bodyparser/MultipartFile.js +128 -8
  24. package/dist/bodyparser/MultipartFile.js.map +1 -1
  25. package/dist/console/BaseCommand.d.ts +8 -1
  26. package/dist/console/BaseCommand.d.ts.map +1 -1
  27. package/dist/console/BaseCommand.js +1 -1
  28. package/dist/console/BaseCommand.js.map +1 -1
  29. package/dist/console/Kernel.d.ts +15 -0
  30. package/dist/console/Kernel.d.ts.map +1 -1
  31. package/dist/console/Kernel.js +17 -0
  32. package/dist/console/Kernel.js.map +1 -1
  33. package/dist/directories.d.ts +52 -0
  34. package/dist/directories.d.ts.map +1 -0
  35. package/dist/directories.js +38 -0
  36. package/dist/directories.js.map +1 -0
  37. package/dist/events/Emitter.d.ts +49 -6
  38. package/dist/events/Emitter.d.ts.map +1 -1
  39. package/dist/events/Emitter.js +117 -7
  40. package/dist/events/Emitter.js.map +1 -1
  41. package/dist/events/EventsBuffer.d.ts +47 -0
  42. package/dist/events/EventsBuffer.d.ts.map +1 -0
  43. package/dist/events/EventsBuffer.js +107 -0
  44. package/dist/events/EventsBuffer.js.map +1 -0
  45. package/dist/events/index.d.ts +2 -0
  46. package/dist/events/index.d.ts.map +1 -1
  47. package/dist/events/index.js +1 -0
  48. package/dist/events/index.js.map +1 -1
  49. package/dist/health/BaseCheck.d.ts +19 -0
  50. package/dist/health/BaseCheck.d.ts.map +1 -0
  51. package/dist/health/BaseCheck.js +24 -0
  52. package/dist/health/BaseCheck.js.map +1 -0
  53. package/dist/health/HealthChecks.d.ts +15 -0
  54. package/dist/health/HealthChecks.d.ts.map +1 -0
  55. package/dist/health/HealthChecks.js +69 -0
  56. package/dist/health/HealthChecks.js.map +1 -0
  57. package/dist/health/Result.d.ts +27 -0
  58. package/dist/health/Result.d.ts.map +1 -0
  59. package/dist/health/Result.js +55 -0
  60. package/dist/health/Result.js.map +1 -0
  61. package/dist/health/checks/DiskSpaceCheck.d.ts +29 -0
  62. package/dist/health/checks/DiskSpaceCheck.d.ts.map +1 -0
  63. package/dist/health/checks/DiskSpaceCheck.js +58 -0
  64. package/dist/health/checks/DiskSpaceCheck.js.map +1 -0
  65. package/dist/health/checks/MemoryHeapCheck.d.ts +21 -0
  66. package/dist/health/checks/MemoryHeapCheck.d.ts.map +1 -0
  67. package/dist/health/checks/MemoryHeapCheck.js +55 -0
  68. package/dist/health/checks/MemoryHeapCheck.js.map +1 -0
  69. package/dist/health/checks/MemoryRSSCheck.d.ts +21 -0
  70. package/dist/health/checks/MemoryRSSCheck.d.ts.map +1 -0
  71. package/dist/health/checks/MemoryRSSCheck.js +55 -0
  72. package/dist/health/checks/MemoryRSSCheck.js.map +1 -0
  73. package/dist/health/checks/memory.d.ts +24 -0
  74. package/dist/health/checks/memory.d.ts.map +1 -0
  75. package/dist/health/checks/memory.js +88 -0
  76. package/dist/health/checks/memory.js.map +1 -0
  77. package/dist/health/index.d.ts +9 -0
  78. package/dist/health/index.d.ts.map +1 -0
  79. package/dist/health/index.js +8 -0
  80. package/dist/health/index.js.map +1 -0
  81. package/dist/health/tracing.d.ts +12 -0
  82. package/dist/health/tracing.d.ts.map +1 -0
  83. package/dist/health/tracing.js +11 -0
  84. package/dist/health/tracing.js.map +1 -0
  85. package/dist/health/types.d.ts +51 -0
  86. package/dist/health/types.d.ts.map +1 -0
  87. package/dist/health/types.js +5 -0
  88. package/dist/health/types.js.map +1 -0
  89. package/dist/helpers/bytes.d.ts +28 -0
  90. package/dist/helpers/bytes.d.ts.map +1 -0
  91. package/dist/helpers/bytes.js +63 -0
  92. package/dist/helpers/bytes.js.map +1 -0
  93. package/dist/helpers/string.d.ts +4 -0
  94. package/dist/helpers/string.d.ts.map +1 -1
  95. package/dist/helpers/string.js +2 -0
  96. package/dist/helpers/string.js.map +1 -1
  97. package/dist/http/HttpContext.d.ts +30 -2
  98. package/dist/http/HttpContext.d.ts.map +1 -1
  99. package/dist/http/HttpContext.js +73 -3
  100. package/dist/http/HttpContext.js.map +1 -1
  101. package/dist/http/Request.d.ts +42 -3
  102. package/dist/http/Request.d.ts.map +1 -1
  103. package/dist/http/Request.js +51 -7
  104. package/dist/http/Request.js.map +1 -1
  105. package/dist/http/Response.d.ts +82 -2
  106. package/dist/http/Response.d.ts.map +1 -1
  107. package/dist/http/Response.js +240 -13
  108. package/dist/http/Response.js.map +1 -1
  109. package/dist/http/SseStream.d.ts +10 -0
  110. package/dist/http/SseStream.d.ts.map +1 -1
  111. package/dist/http/SseStream.js.map +1 -1
  112. package/dist/index.d.ts +7 -2
  113. package/dist/index.d.ts.map +1 -1
  114. package/dist/index.js +4 -1
  115. package/dist/index.js.map +1 -1
  116. package/dist/migrations/MigrationRegistry.d.ts +28 -0
  117. package/dist/migrations/MigrationRegistry.d.ts.map +1 -0
  118. package/dist/migrations/MigrationRegistry.js +41 -0
  119. package/dist/migrations/MigrationRegistry.js.map +1 -0
  120. package/dist/migrations/types.d.ts +64 -0
  121. package/dist/migrations/types.d.ts.map +1 -0
  122. package/dist/migrations/types.js +15 -0
  123. package/dist/migrations/types.js.map +1 -0
  124. package/dist/router/Router.d.ts +112 -9
  125. package/dist/router/Router.d.ts.map +1 -1
  126. package/dist/router/Router.js +206 -28
  127. package/dist/router/Router.js.map +1 -1
  128. package/dist/security/CookieSigner.d.ts +43 -4
  129. package/dist/security/CookieSigner.d.ts.map +1 -1
  130. package/dist/security/CookieSigner.js +68 -10
  131. package/dist/security/CookieSigner.js.map +1 -1
  132. package/dist/session/Session.d.ts +46 -1
  133. package/dist/session/Session.d.ts.map +1 -1
  134. package/dist/session/Session.js +57 -4
  135. package/dist/session/Session.js.map +1 -1
  136. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  137. package/dist/session/SessionMiddleware.js +19 -9
  138. package/dist/session/SessionMiddleware.js.map +1 -1
  139. package/dist/session/drivers/CookieDriver.d.ts +1 -1
  140. package/dist/session/drivers/CookieDriver.d.ts.map +1 -1
  141. package/dist/session/drivers/CookieDriver.js +3 -3
  142. package/dist/session/drivers/CookieDriver.js.map +1 -1
  143. package/dist/session/drivers/DatabaseDriver.d.ts +1 -1
  144. package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -1
  145. package/dist/session/drivers/DatabaseDriver.js +2 -2
  146. package/dist/session/drivers/DatabaseDriver.js.map +1 -1
  147. package/dist/session/drivers/FileDriver.d.ts +1 -1
  148. package/dist/session/drivers/FileDriver.d.ts.map +1 -1
  149. package/dist/session/drivers/FileDriver.js +7 -5
  150. package/dist/session/drivers/FileDriver.js.map +1 -1
  151. package/dist/session/drivers/MemoryDriver.d.ts +1 -1
  152. package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
  153. package/dist/session/drivers/MemoryDriver.js +1 -1
  154. package/dist/session/drivers/MemoryDriver.js.map +1 -1
  155. package/dist/session/drivers/RedisDriver.d.ts +1 -1
  156. package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
  157. package/dist/session/drivers/RedisDriver.js +11 -4
  158. package/dist/session/drivers/RedisDriver.js.map +1 -1
  159. package/events.win32-x64-msvc.node +0 -0
  160. package/index.darwin-arm64.node +0 -0
  161. package/index.darwin-x64.node +0 -0
  162. package/index.linux-arm64-gnu.node +0 -0
  163. package/index.linux-x64-gnu.node +0 -0
  164. package/index.win32-x64-msvc.node +0 -0
  165. package/package.json +48 -31
  166. package/scheduler.win32-x64-msvc.node +0 -0
  167. package/src/Application.ts +169 -6
  168. package/src/HealthCheck.ts +27 -14
  169. package/src/HttpKernel.ts +27 -4
  170. package/src/Ignitor.ts +17 -8
  171. package/src/bodyparser/BodyParserMiddleware.ts +70 -36
  172. package/src/bodyparser/MultipartFile.ts +153 -8
  173. package/src/console/BaseCommand.ts +8 -1
  174. package/src/console/Kernel.ts +18 -0
  175. package/src/directories.ts +81 -0
  176. package/src/events/Emitter.ts +129 -13
  177. package/src/events/EventsBuffer.ts +131 -0
  178. package/src/events/index.ts +7 -0
  179. package/src/health/BaseCheck.ts +30 -0
  180. package/src/health/HealthChecks.ts +83 -0
  181. package/src/health/Result.ts +69 -0
  182. package/src/health/checks/DiskSpaceCheck.ts +69 -0
  183. package/src/health/checks/MemoryHeapCheck.ts +68 -0
  184. package/src/health/checks/MemoryRSSCheck.ts +68 -0
  185. package/src/health/checks/memory.ts +115 -0
  186. package/src/health/index.ts +13 -0
  187. package/src/health/tracing.ts +15 -0
  188. package/src/health/types.ts +51 -0
  189. package/src/helpers/bytes.ts +71 -0
  190. package/src/helpers/string.ts +2 -0
  191. package/src/http/HttpContext.ts +78 -4
  192. package/src/http/Request.ts +62 -10
  193. package/src/http/Response.ts +283 -14
  194. package/src/http/SseStream.ts +10 -0
  195. package/src/index.ts +27 -1
  196. package/src/migrations/MigrationRegistry.ts +49 -0
  197. package/src/migrations/types.ts +68 -0
  198. package/src/router/Router.ts +241 -32
  199. package/src/security/CookieSigner.ts +92 -18
  200. package/src/session/Session.ts +71 -5
  201. package/src/session/SessionMiddleware.ts +19 -9
  202. package/src/session/drivers/CookieDriver.ts +4 -4
  203. package/src/session/drivers/DatabaseDriver.ts +3 -3
  204. package/src/session/drivers/FileDriver.ts +8 -6
  205. package/src/session/drivers/MemoryDriver.ts +2 -2
  206. package/src/session/drivers/RedisDriver.ts +12 -5
  207. package/tsconfig.app.json +13 -13
  208. package/tsconfig.base.json +19 -0
  209. package/tsconfig.client.json +16 -0
  210. package/tsconfig.package.json +15 -0
@@ -6,12 +6,19 @@
6
6
  * Configured via config/bodyparser.ts.
7
7
  */
8
8
 
9
+ import { Exception } from '../http/Exception.js'
9
10
  import type { HttpContext } from '../http/HttpContext.js'
10
11
  import { hydrateMultipartPayload } from './MultipartFile.js'
11
12
  import { parseSize } from './parseSize.js'
12
13
  import { parseQueryString, type QsParseOptions } from './qsParse.js'
13
14
 
14
15
  export interface BodyParserConfig {
16
+ /**
17
+ * HTTP methods whose body is parsed at all. AdonisJS defaults to
18
+ * `['POST', 'PUT', 'PATCH', 'DELETE']` and returns early for anything else,
19
+ * so a GET that happens to carry a JSON content-type is left untouched.
20
+ */
21
+ allowedMethods?: string[]
15
22
  json?: {
16
23
  limit?: string // e.g. '1mb'
17
24
  types?: string[]
@@ -37,15 +44,21 @@ export interface BodyParserConfig {
37
44
  multipart?: {
38
45
  /** Total multipart body size cap (sum of all file sizes). Default: '20mb'. */
39
46
  limit?: string
40
- /** Maximum number of files per request. Default: 20. */
47
+ /**
48
+ * Maximum number of files per request. AdonisJS has no such option — it
49
+ * bounds a multipart body by total size and field count only, so ten
50
+ * thousand one-byte files pass. Kept as a DoS bound, at the same scale as
51
+ * `maxFields` so it never rejects an upload AdonisJS would have accepted.
52
+ */
41
53
  maxFiles?: number
54
+ /** Maximum number of form fields. AdonisJS default: 1000. */
42
55
  maxFields?: number
43
- tmpDir?: string
44
56
  types?: string[]
45
57
  }
46
58
  }
47
59
 
48
60
  interface ResolvedBodyParserConfig {
61
+ allowedMethods: string[]
49
62
  json: Required<NonNullable<BodyParserConfig['json']>>
50
63
  form: Required<NonNullable<BodyParserConfig['form']>>
51
64
  raw: Required<NonNullable<BodyParserConfig['raw']>>
@@ -53,6 +66,9 @@ interface ResolvedBodyParserConfig {
53
66
  }
54
67
 
55
68
  const DEFAULT_CONFIG: ResolvedBodyParserConfig = {
69
+ // AdonisJS define_config: allowedMethods. A body is parsed only for methods
70
+ // that are supposed to carry one.
71
+ allowedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
56
72
  json: {
57
73
  limit: '1mb',
58
74
  types: ['application/json', 'application/vnd.api+json'],
@@ -73,9 +89,8 @@ const DEFAULT_CONFIG: ResolvedBodyParserConfig = {
73
89
  },
74
90
  multipart: {
75
91
  limit: '20mb',
76
- maxFiles: 20,
77
- maxFields: 500,
78
- tmpDir: '/tmp',
92
+ maxFiles: 1000,
93
+ maxFields: 1000,
79
94
  types: ['multipart/form-data'],
80
95
  },
81
96
  }
@@ -86,6 +101,7 @@ export default class BodyParserMiddleware {
86
101
  constructor(config?: BodyParserConfig) {
87
102
  assertNoEnabledFlag(config)
88
103
  this.#config = {
104
+ allowedMethods: config?.allowedMethods ?? DEFAULT_CONFIG.allowedMethods,
89
105
  json: { ...DEFAULT_CONFIG.json, ...config?.json },
90
106
  form: { ...DEFAULT_CONFIG.form, ...config?.form },
91
107
  raw: { ...DEFAULT_CONFIG.raw, ...config?.raw },
@@ -94,15 +110,28 @@ export default class BodyParserMiddleware {
94
110
  }
95
111
 
96
112
  async handle(ctx: HttpContext, next: () => Promise<void>) {
113
+ // Only methods that are supposed to carry a body get one parsed
114
+ // (AdonisJS `allowedMethods`). Comparison is case-sensitive on the
115
+ // canonical upper-case verb, as AdonisJS' `includes()` is.
116
+ if (!this.#config.allowedMethods.includes(ctx.request.method())) {
117
+ return next()
118
+ }
119
+
120
+ // Many clients send `content-length: 0` on a bodyless request, which the
121
+ // parsers below do not recognise as empty (AdonisJS returns early here for
122
+ // the same reason).
123
+ if (!ctx.request.hasBody()) {
124
+ return next()
125
+ }
126
+
97
127
  const contentType = ctx.request.header('content-type') ?? ''
98
128
  const rawBody = ctx.request.raw()
99
129
 
100
- // Size check
130
+ // Size check. Thrown rather than written: the app's exception handler
131
+ // negotiates the response, so an HTML app gets an error page instead of a
132
+ // JSON envelope it never asked for — and can override the rendering.
101
133
  if (Buffer.byteLength(rawBody, 'utf8') > parseSize(this.#getLimit(contentType))) {
102
- ctx.response.status(413).json({
103
- error: { code: 'E_REQUEST_ENTITY_TOO_LARGE', message: 'Request body exceeds size limit' },
104
- })
105
- return
134
+ throw entityTooLarge('request entity too large')
106
135
  }
107
136
 
108
137
  // JSON
@@ -134,7 +163,7 @@ export default class BodyParserMiddleware {
134
163
  if (matchesType(contentType, this.#config.multipart.types)) {
135
164
  const payload = ctx.request.multipart()
136
165
  if (payload) {
137
- if (this.#rejectMultipart(ctx, payload.files, payload.fields)) return
166
+ this.#assertMultipartWithinLimits(payload.files, payload.fields)
138
167
  const { fields, files } = hydrateMultipartPayload(payload)
139
168
  // Fingerprint each file's real type from its magic bytes BEFORE handlers
140
169
  // run, so `request.file(field, { extnames })` validates against the
@@ -155,44 +184,36 @@ export default class BodyParserMiddleware {
155
184
  return this.#config.raw.limit
156
185
  }
157
186
 
158
- /** Returns true (and writes a 413/400) when the multipart payload exceeds configured limits. */
159
- #rejectMultipart(
160
- ctx: HttpContext,
161
- files: Array<{ size: number }>,
162
- fields: Array<unknown>,
163
- ): boolean {
187
+ /**
188
+ * Throws when the multipart payload exceeds a configured limit.
189
+ *
190
+ * All three raise the same 413 `E_REQUEST_ENTITY_TOO_LARGE` AdonisJS raises
191
+ * for `maxFields` and for an oversized body — one code for "you sent too
192
+ * much", whichever dimension it was.
193
+ */
194
+ #assertMultipartWithinLimits(files: Array<{ size: number }>, fields: Array<unknown>): void {
164
195
  const cfg = this.#config.multipart
165
- const maxBytes = parseSize(cfg.limit)
166
196
 
167
197
  if (fields.length > cfg.maxFields) {
168
- ctx.response.status(400).json({
169
- error: {
170
- code: 'E_TOO_MANY_FIELDS',
171
- message: `Upload exceeds maxFields (${cfg.maxFields})`,
172
- },
173
- })
174
- return true
198
+ throw entityTooLarge('Fields length limit exceeded')
175
199
  }
176
200
 
177
201
  if (files.length > cfg.maxFiles) {
178
- ctx.response.status(400).json({
179
- error: { code: 'E_TOO_MANY_FILES', message: `Upload exceeds maxFiles (${cfg.maxFiles})` },
180
- })
181
- return true
202
+ throw entityTooLarge('Files length limit exceeded')
182
203
  }
183
204
 
184
205
  const totalBytes = files.reduce((sum, f) => sum + f.size, 0)
185
- if (totalBytes > maxBytes) {
186
- ctx.response.status(413).json({
187
- error: { code: 'E_REQUEST_ENTITY_TOO_LARGE', message: 'Upload exceeds size limit' },
188
- })
189
- return true
206
+ if (totalBytes > parseSize(cfg.limit)) {
207
+ throw entityTooLarge('request entity too large')
190
208
  }
191
-
192
- return false
193
209
  }
194
210
  }
195
211
 
212
+ /** The one exception every body-size limit raises (AdonisJS parity). */
213
+ function entityTooLarge(message: string): Exception {
214
+ return new Exception(message, { status: 413, code: 'E_REQUEST_ENTITY_TOO_LARGE' })
215
+ }
216
+
196
217
  /**
197
218
  * Whether a request's content-type is claimed by one of `types`.
198
219
  *
@@ -233,4 +254,17 @@ function assertNoEnabledFlag(config?: BodyParserConfig): void {
233
254
  )
234
255
  }
235
256
  }
257
+
258
+ // `multipart.tmpDir` was accepted and never read by anything: the Rust
259
+ // multipart parser hands the body over in memory, so no file is ever written
260
+ // to a temporary directory — an app pointing it at a volume was silently
261
+ // getting nothing. Saying so beats letting it keep believing.
262
+ const multipart: unknown = config.multipart
263
+ if (multipart !== null && typeof multipart === 'object' && 'tmpDir' in multipart) {
264
+ throw new Error(
265
+ '[E_BODYPARSER_CONFIG] `multipart.tmpDir` is not supported — uploaded files are held in ' +
266
+ 'memory and never written to a temporary directory. Choose the destination when you ' +
267
+ 'store the file: `await file.moveToDisk(directory)`.',
268
+ )
269
+ }
236
270
  }
@@ -19,11 +19,70 @@ export class MultipartFile {
19
19
  readonly clientName: string
20
20
 
21
21
  /**
22
- * MIME type from the Content-Type header. ATTACKER-CONTROLLED — never trust it
23
- * for security decisions or as a response Content-Type. Prefer
24
- * {@link detectedType}, which is fingerprinted from the file's magic bytes.
22
+ * The PRIMARY mime type — `image` for `image/png`. Pair it with
23
+ * {@link subtype}, as AdonisJS does.
24
+ *
25
+ * DERIVED FROM THE MAGIC BYTES when they can be read, and only from the
26
+ * Content-Type header when they cannot — upstream's precedence, and the one
27
+ * that matters: the header is written by the client, so a `.exe` announced as
28
+ * `image/png` would otherwise sail through a mime allowlist. A renamed file
29
+ * reports what it actually is.
30
+ *
31
+ * BREAKING as of 0.2.0: it used to hold the whole `image/png`. TypeScript
32
+ * cannot catch a comparison against a full mime string — `file.type ===
33
+ * 'image/png'` still compiles and simply never matches. Use {@link mime}.
25
34
  */
26
- readonly type: string
35
+ get type(): string | undefined {
36
+ return this.#mimeParts()[0]
37
+ }
38
+
39
+ /** The mime SUBTYPE — `png` for `image/png`. Same source as {@link type}. */
40
+ get subtype(): string | undefined {
41
+ return this.#mimeParts()[1]
42
+ }
43
+
44
+ /**
45
+ * The full mime to validate against — `image/png`.
46
+ *
47
+ * What an allowlist wants, and the reason this exists: splitting the mime for
48
+ * upstream parity left every caller re-joining it by hand, and a caller that
49
+ * forgot got a filter that silently matched nothing. Same trustworthy source
50
+ * as {@link type}.
51
+ */
52
+ get mime(): string | undefined {
53
+ const [type, subtype] = this.#mimeParts()
54
+ if (!type) return undefined
55
+ return subtype ? `${type}/${subtype}` : type
56
+ }
57
+
58
+ /** Split the trustworthy mime once, detected bytes first, header second. */
59
+ #mimeParts(): [string | undefined, string | undefined] {
60
+ const source = this.#detected?.mime ?? this.#headerMime
61
+ if (!source) return [undefined, undefined]
62
+ const [type, subtype] = source.split(';')[0].trim().split('/')
63
+ return [type || undefined, subtype || undefined]
64
+ }
65
+
66
+ /**
67
+ * The part's headers.
68
+ *
69
+ * NAMED CONSTRAINT — Rust's multipart parser forwards only the part's
70
+ * content-type across the NAPI boundary today, so this carries that one
71
+ * header rather than every header the client sent.
72
+ */
73
+ readonly headers: Record<string, string>
74
+
75
+ /** Always true. Lets a value be told apart from a plain form field. */
76
+ readonly isMultipartFile = true as const
77
+
78
+ /** Where the file is in its life: idle, consumed, or moved to disk. */
79
+ state: 'idle' | 'streaming' | 'consumed' | 'moved' = 'consumed'
80
+
81
+ /** Absolute path the file was moved to, once it has been. */
82
+ filePath?: string
83
+
84
+ /** The name it was written under, once it has been moved. */
85
+ fileName?: string
27
86
 
28
87
  /** File size in bytes. */
29
88
  readonly size: number
@@ -43,6 +102,12 @@ export class MultipartFile {
43
102
  /** Extension derived from the (attacker-controlled) client filename. */
44
103
  #clientExtname: string
45
104
 
105
+ /** Rules set through `sizeLimit` / `allowedExtensions`, used by a bare `validate()`. */
106
+ #validationOptions: FileValidationOptions = {}
107
+
108
+ /** The Content-Type the client sent — attacker-controlled, the fallback only. */
109
+ readonly #headerMime: string
110
+
46
111
  /** Magic-byte fingerprint — set by {@link detectType}; absent for content `file-type` can't detect (text: txt/csv/svg/json). */
47
112
  #detected?: { ext: string; mime: string }
48
113
 
@@ -54,7 +119,8 @@ export class MultipartFile {
54
119
  }) {
55
120
  this.fieldName = options.fieldName
56
121
  this.clientName = options.clientName
57
- this.type = options.type
122
+ this.headers = { 'content-type': options.type }
123
+ this.#headerMime = options.type
58
124
  this.content = options.content
59
125
  this.size = options.content.length
60
126
  const segments = options.clientName.split('.')
@@ -91,8 +157,37 @@ export class MultipartFile {
91
157
  return this.#detected?.ext ?? this.#clientExtname
92
158
  }
93
159
 
94
- /** Validate file against size and extension rules. */
95
- validate(options: FileValidationOptions): boolean {
160
+ /** The size ceiling set through {@link sizeLimit}, if any. */
161
+ get sizeLimit(): number | string | undefined {
162
+ return this.#validationOptions.size
163
+ }
164
+
165
+ set sizeLimit(limit: number | string | undefined) {
166
+ this.#validationOptions.size = typeof limit === 'number' ? String(limit) : limit
167
+ }
168
+
169
+ /** The extension allowlist set through {@link allowedExtensions}, if any. */
170
+ get allowedExtensions(): string[] | undefined {
171
+ return this.#validationOptions.extnames
172
+ }
173
+
174
+ set allowedExtensions(extensions: string[] | undefined) {
175
+ this.#validationOptions.extnames = extensions
176
+ }
177
+
178
+ /** Whether validation turned anything up. The inverse of {@link isValid}. */
179
+ get hasErrors(): boolean {
180
+ return this.errors.length > 0
181
+ }
182
+
183
+ /**
184
+ * Validate the file against size and extension rules.
185
+ *
186
+ * The argument is optional: called bare it uses whatever was set through
187
+ * {@link sizeLimit} and {@link allowedExtensions}, which is how AdonisJS
188
+ * spells it.
189
+ */
190
+ validate(options: FileValidationOptions = this.#validationOptions): boolean {
96
191
  if (options.size) {
97
192
  const maxBytes = parseFileSize(options.size)
98
193
  if (this.size > maxBytes) {
@@ -145,10 +240,60 @@ export class MultipartFile {
145
240
  }
146
241
  const filePath = join(directory, fileName)
147
242
  writeFileSync(filePath, this.content)
148
- this.#moved = true
243
+ this.markAsMoved(fileName, filePath)
149
244
  return filePath
150
245
  }
151
246
 
247
+ /**
248
+ * Write the file into `location` (AdonisJS `move`).
249
+ *
250
+ * NAMED DEVIATION — upstream renames a temporary file it streamed to disk;
251
+ * Ream holds the bytes in memory (`multipart.tmpDir` is refused at
252
+ * construction), so this writes the buffer. There is therefore no
253
+ * `E_MISSING_FILE_TMP_PATH`: there is no temporary file to be missing.
254
+ *
255
+ * `overwrite` defaults to true, as upstream. The generated name carries the
256
+ * detected extension, and both it and a caller-supplied `name` go through the
257
+ * same plain-filename guard {@link moveToDisk} uses — the common footgun is
258
+ * passing `file.clientName` straight through.
259
+ */
260
+ async move(location: string, options?: { name?: string; overwrite?: boolean }): Promise<void> {
261
+ const { mkdir, writeFile, access } = await import('node:fs/promises')
262
+ const { join } = await import('node:path')
263
+
264
+ const name = options?.name ?? `${randomBytes(16).toString('hex')}.${this.extname || 'unknown'}`
265
+ if (!isSafeFilename(name)) {
266
+ throw new Error(
267
+ `MultipartFile.move: 'name' must be a plain filename (no path separators, '.', or '..'). Got: ${JSON.stringify(name)}`,
268
+ )
269
+ }
270
+
271
+ const filePath = join(location, name)
272
+ if (options?.overwrite === false) {
273
+ const exists = await access(filePath).then(
274
+ () => true,
275
+ () => false,
276
+ )
277
+ if (exists) {
278
+ throw new Error(
279
+ `"${name}" already exists at "${location}". Set "overwrite = true" to overwrite it`,
280
+ )
281
+ }
282
+ }
283
+
284
+ await mkdir(location, { recursive: true })
285
+ await writeFile(filePath, this.content)
286
+ this.markAsMoved(name, filePath)
287
+ }
288
+
289
+ /** Record that the file now lives on disk. */
290
+ markAsMoved(fileName: string, filePath: string): void {
291
+ this.fileName = fileName
292
+ this.filePath = filePath
293
+ this.state = 'moved'
294
+ this.#moved = true
295
+ }
296
+
152
297
  get isMoved(): boolean {
153
298
  return this.#moved
154
299
  }
@@ -112,7 +112,7 @@ export abstract class BaseCommand {
112
112
 
113
113
  /**
114
114
  * The error thrown by `prepare` / `interact` / `run`, assigned before
115
- * `completed()` runs so the hook can inspect it (Console parity).
115
+ * `completed()` runs so the hook can inspect it.
116
116
  */
117
117
  error?: unknown
118
118
 
@@ -124,6 +124,13 @@ export abstract class BaseCommand {
124
124
  * `run`, then `completed` — which runs even when one of the earlier stages
125
125
  * threw. Returning `true` from `completed()` marks the error handled, so the
126
126
  * kernel stops propagating it.
127
+ *
128
+ * A SUPERSET, not parity: AdonisJS carried these three through ace 13 and
129
+ * dropped them in ace 14, whose command lifecycle is `hydrate()` then
130
+ * `run()` — both of which {@link hydrate} and {@link run} match exactly.
131
+ * They are kept because a `completed()` that runs even after a failure is
132
+ * the only place a command can clean up after itself, and a command written
133
+ * against either lifecycle works here.
127
134
  */
128
135
  // Parameters are allowed: the container injects them (`@inject()` on a
129
136
  // lifecycle method), and a no-arg signature rejects those commands.
@@ -268,6 +268,24 @@ export class Kernel {
268
268
  return this.#binaryName
269
269
  }
270
270
 
271
+ /**
272
+ * A named `true`, for a flag listener that wants to stop the run (Console
273
+ * `kernel.shortcircuit`).
274
+ *
275
+ * A listener returning a bare `true` reads like a bug at the call site;
276
+ * `return kernel.shortcircuit()` says what the boolean means:
277
+ *
278
+ * ```ts
279
+ * kernel.on('version', (_c, kernel) => {
280
+ * console.log(version)
281
+ * return kernel.shortcircuit()
282
+ * })
283
+ * ```
284
+ */
285
+ shortcircuit(): boolean {
286
+ return true
287
+ }
288
+
271
289
  /**
272
290
  * Declare a flag every command accepts (Console `kernel.defineFlag`).
273
291
  *
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The conventional directory layout, as `@adonisjs/application` exports it.
3
+ *
4
+ * Every path helper on {@link Application} reads this map, so an app that moves
5
+ * a directory declares it once in `reamrc.ts` and every helper follows:
6
+ *
7
+ * ```ts
8
+ * export default defineConfig({
9
+ * directories: { httpControllers: 'app/http/controllers' },
10
+ * })
11
+ * ```
12
+ */
13
+ export interface DirectoriesNode {
14
+ [key: string]: string
15
+ config: string
16
+ commands: string
17
+ contracts: string
18
+ public: string
19
+ providers: string
20
+ languageFiles: string
21
+ migrations: string
22
+ seeders: string
23
+ factories: string
24
+ views: string
25
+ start: string
26
+ tmp: string
27
+ tests: string
28
+ httpControllers: string
29
+ models: string
30
+ services: string
31
+ exceptions: string
32
+ mailers: string
33
+ mails: string
34
+ middleware: string
35
+ policies: string
36
+ validators: string
37
+ events: string
38
+ listeners: string
39
+ transformers: string
40
+ stubs: string
41
+ generatedClient: string
42
+ generatedServer: string
43
+ }
44
+
45
+ /**
46
+ * Defaults, matching AdonisJS directory for directory.
47
+ *
48
+ * NAMED DEVIATION — the two generated directories are `.ream/*` where upstream
49
+ * writes `.adonisjs/*`. A dot-directory in the user's project is product
50
+ * identity, not layout.
51
+ */
52
+ export const directories: DirectoriesNode = {
53
+ config: 'config',
54
+ commands: 'commands',
55
+ contracts: 'contracts',
56
+ public: 'public',
57
+ providers: 'providers',
58
+ languageFiles: 'resources/lang',
59
+ migrations: 'database/migrations',
60
+ seeders: 'database/seeders',
61
+ factories: 'database/factories',
62
+ views: 'resources/views',
63
+ start: 'start',
64
+ tmp: 'tmp',
65
+ tests: 'tests',
66
+ httpControllers: 'app/controllers',
67
+ models: 'app/models',
68
+ services: 'app/services',
69
+ exceptions: 'app/exceptions',
70
+ mailers: 'app/mailers',
71
+ mails: 'app/mails',
72
+ middleware: 'app/middleware',
73
+ policies: 'app/policies',
74
+ validators: 'app/validators',
75
+ events: 'app/events',
76
+ listeners: 'app/listeners',
77
+ transformers: 'app/transformers',
78
+ stubs: 'stubs',
79
+ generatedClient: '.ream/client',
80
+ generatedServer: '.ream/server',
81
+ }