@c9up/ream 0.2.9 → 0.2.11

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 (143) hide show
  1. package/README.md +20 -18
  2. package/dist/Application.d.ts +26 -6
  3. package/dist/Application.d.ts.map +1 -1
  4. package/dist/Application.js +40 -12
  5. package/dist/Application.js.map +1 -1
  6. package/dist/ErrorBoundary.d.ts.map +1 -1
  7. package/dist/ErrorBoundary.js +2 -1
  8. package/dist/ErrorBoundary.js.map +1 -1
  9. package/dist/Ignitor.d.ts +13 -0
  10. package/dist/Ignitor.d.ts.map +1 -1
  11. package/dist/Ignitor.js +98 -23
  12. package/dist/Ignitor.js.map +1 -1
  13. package/dist/bodyparser/MultipartFile.d.ts +8 -0
  14. package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
  15. package/dist/bodyparser/MultipartFile.js +46 -1
  16. package/dist/bodyparser/MultipartFile.js.map +1 -1
  17. package/dist/env/Env.d.ts.map +1 -1
  18. package/dist/env/Env.js +2 -1
  19. package/dist/env/Env.js.map +1 -1
  20. package/dist/env/loadEnvFiles.d.ts.map +1 -1
  21. package/dist/env/loadEnvFiles.js +5 -1
  22. package/dist/env/loadEnvFiles.js.map +1 -1
  23. package/dist/env/nodeEnv.d.ts +24 -0
  24. package/dist/env/nodeEnv.d.ts.map +1 -0
  25. package/dist/env/nodeEnv.js +40 -0
  26. package/dist/env/nodeEnv.js.map +1 -0
  27. package/dist/errors/ReamError.d.ts +6 -1
  28. package/dist/errors/ReamError.d.ts.map +1 -1
  29. package/dist/errors/ReamError.js +8 -2
  30. package/dist/errors/ReamError.js.map +1 -1
  31. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  32. package/dist/graphql/GraphQLEngine.js +3 -2
  33. package/dist/graphql/GraphQLEngine.js.map +1 -1
  34. package/dist/http/Exception.d.ts.map +1 -1
  35. package/dist/http/Exception.js +2 -1
  36. package/dist/http/Exception.js.map +1 -1
  37. package/dist/http/Request.d.ts +18 -3
  38. package/dist/http/Request.d.ts.map +1 -1
  39. package/dist/http/Request.js +71 -8
  40. package/dist/http/Request.js.map +1 -1
  41. package/dist/http/Response.d.ts +11 -4
  42. package/dist/http/Response.d.ts.map +1 -1
  43. package/dist/http/Response.js +46 -17
  44. package/dist/http/Response.js.map +1 -1
  45. package/dist/index.d.ts +3 -3
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +2 -2
  48. package/dist/index.js.map +1 -1
  49. package/dist/router/Router.d.ts.map +1 -1
  50. package/dist/router/Router.js +14 -5
  51. package/dist/router/Router.js.map +1 -1
  52. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  53. package/dist/rpc/RpcRouter.js +2 -1
  54. package/dist/rpc/RpcRouter.js.map +1 -1
  55. package/dist/scheduler/ScheduleProvider.d.ts +14 -0
  56. package/dist/scheduler/ScheduleProvider.d.ts.map +1 -1
  57. package/dist/scheduler/ScheduleProvider.js +62 -14
  58. package/dist/scheduler/ScheduleProvider.js.map +1 -1
  59. package/dist/scheduler/config.d.ts +29 -0
  60. package/dist/scheduler/config.d.ts.map +1 -0
  61. package/dist/scheduler/config.js +14 -0
  62. package/dist/scheduler/config.js.map +1 -0
  63. package/dist/scheduler/index.d.ts +4 -0
  64. package/dist/scheduler/index.d.ts.map +1 -1
  65. package/dist/scheduler/index.js +3 -0
  66. package/dist/scheduler/index.js.map +1 -1
  67. package/dist/scheduler/locks/LockBackend.d.ts +13 -3
  68. package/dist/scheduler/locks/LockBackend.d.ts.map +1 -1
  69. package/dist/scheduler/locks/LockBackend.js +20 -4
  70. package/dist/scheduler/locks/LockBackend.js.map +1 -1
  71. package/dist/scheduler/locks/MemoryLockBackend.d.ts +5 -6
  72. package/dist/scheduler/locks/MemoryLockBackend.d.ts.map +1 -1
  73. package/dist/scheduler/locks/MemoryLockBackend.js +5 -13
  74. package/dist/scheduler/locks/MemoryLockBackend.js.map +1 -1
  75. package/dist/scheduler/locks/RedisLockBackend.d.ts +53 -0
  76. package/dist/scheduler/locks/RedisLockBackend.d.ts.map +1 -0
  77. package/dist/scheduler/locks/RedisLockBackend.js +83 -0
  78. package/dist/scheduler/locks/RedisLockBackend.js.map +1 -0
  79. package/dist/scheduler/locks/locks.d.ts +39 -0
  80. package/dist/scheduler/locks/locks.d.ts.map +1 -0
  81. package/dist/scheduler/locks/locks.js +42 -0
  82. package/dist/scheduler/locks/locks.js.map +1 -0
  83. package/dist/scheduler/locks/quasar.d.ts +17 -0
  84. package/dist/scheduler/locks/quasar.d.ts.map +1 -0
  85. package/dist/scheduler/locks/quasar.js +51 -0
  86. package/dist/scheduler/locks/quasar.js.map +1 -0
  87. package/dist/security/CookieSigner.d.ts +16 -13
  88. package/dist/security/CookieSigner.d.ts.map +1 -1
  89. package/dist/security/CookieSigner.js +33 -14
  90. package/dist/security/CookieSigner.js.map +1 -1
  91. package/dist/services/encryption.d.ts +12 -2
  92. package/dist/services/encryption.d.ts.map +1 -1
  93. package/dist/services/encryption.js +11 -1
  94. package/dist/services/encryption.js.map +1 -1
  95. package/dist/session/Session.d.ts +8 -7
  96. package/dist/session/Session.d.ts.map +1 -1
  97. package/dist/session/Session.js.map +1 -1
  98. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  99. package/dist/session/SessionMiddleware.js +4 -1
  100. package/dist/session/SessionMiddleware.js.map +1 -1
  101. package/dist/testing/ApiResponse.d.ts.map +1 -1
  102. package/dist/testing/ApiResponse.js +1 -1
  103. package/dist/testing/ApiResponse.js.map +1 -1
  104. package/dist/testing/RequestBuilder.d.ts.map +1 -1
  105. package/dist/testing/RequestBuilder.js +1 -1
  106. package/dist/testing/RequestBuilder.js.map +1 -1
  107. package/dist/utils/Macroable.d.ts.map +1 -1
  108. package/dist/utils/Macroable.js +1 -0
  109. package/dist/utils/Macroable.js.map +1 -1
  110. package/events.win32-x64-msvc.node +0 -0
  111. package/index.win32-x64-msvc.node +0 -0
  112. package/package.json +5 -1
  113. package/scheduler.win32-x64-msvc.node +0 -0
  114. package/src/Application.ts +42 -15
  115. package/src/ErrorBoundary.ts +3 -1
  116. package/src/Ignitor.ts +109 -27
  117. package/src/bodyparser/MultipartFile.ts +48 -1
  118. package/src/env/Env.ts +2 -1
  119. package/src/env/loadEnvFiles.ts +5 -1
  120. package/src/env/nodeEnv.ts +38 -0
  121. package/src/errors/ReamError.ts +9 -2
  122. package/src/graphql/GraphQLEngine.ts +3 -2
  123. package/src/http/Exception.ts +2 -1
  124. package/src/http/Request.ts +73 -8
  125. package/src/http/Response.ts +58 -19
  126. package/src/index.ts +8 -1
  127. package/src/router/Router.ts +14 -5
  128. package/src/rpc/RpcRouter.ts +2 -1
  129. package/src/scheduler/ScheduleProvider.ts +65 -15
  130. package/src/scheduler/config.ts +34 -0
  131. package/src/scheduler/index.ts +8 -0
  132. package/src/scheduler/locks/LockBackend.ts +26 -3
  133. package/src/scheduler/locks/MemoryLockBackend.ts +5 -19
  134. package/src/scheduler/locks/RedisLockBackend.ts +114 -0
  135. package/src/scheduler/locks/locks.ts +49 -0
  136. package/src/scheduler/locks/quasar.ts +67 -0
  137. package/src/security/CookieSigner.ts +36 -14
  138. package/src/services/encryption.ts +12 -2
  139. package/src/session/Session.ts +8 -7
  140. package/src/session/SessionMiddleware.ts +4 -1
  141. package/src/testing/ApiResponse.ts +1 -2
  142. package/src/testing/RequestBuilder.ts +1 -2
  143. package/src/utils/Macroable.ts +1 -0
@@ -110,6 +110,8 @@ export class MultipartFile {
110
110
 
111
111
  /** Magic-byte fingerprint — set by {@link detectType}; absent for content `file-type` can't detect (text: txt/csv/svg/json). */
112
112
  #detected?: { ext: string; mime: string }
113
+ /** Whether the magic-byte pass has run, however it turned out. */
114
+ #detectionRan = false
113
115
 
114
116
  constructor(options: {
115
117
  fieldName: string
@@ -136,9 +138,22 @@ export class MultipartFile {
136
138
  * extension in force — same fallback AdonisJS uses.
137
139
  */
138
140
  async detectType(): Promise<void> {
139
- const { fileTypeFromBuffer } = await import('file-type')
141
+ const { fileTypeFromBuffer, supportedExtensions } = await import('file-type')
142
+ detectableExtensions = supportedExtensions
140
143
  const result = await fileTypeFromBuffer(this.content)
141
144
  if (result) this.#detected = { ext: result.ext, mime: result.mime }
145
+ this.#detectionRan = true
146
+ }
147
+
148
+ /**
149
+ * Where {@link mime} and {@link extname} came from.
150
+ *
151
+ * `'detected'` means the bytes said so. `'claimed'` means they did not, and
152
+ * what is reported is the filename and the header the client sent — which
153
+ * the client chose.
154
+ */
155
+ get typeSource(): 'detected' | 'claimed' {
156
+ return this.#detected ? 'detected' : 'claimed'
142
157
  }
143
158
 
144
159
  /**
@@ -201,11 +216,35 @@ export class MultipartFile {
201
216
  this.errors.push(
202
217
  `Extension '${this.extname}' not allowed. Allowed: ${options.extnames.join(', ')}`,
203
218
  )
219
+ } else if (this.#claimsATypeItCannotBe(options.extnames)) {
220
+ // Every allowed format carries a signature, and none was found — so the
221
+ // bytes are definitively not one of them, whatever the name says. A
222
+ // shell script uploaded as `avatar.png` with `Content-Type: image/png`
223
+ // otherwise passes an image allowlist, because both halves of what was
224
+ // checked came from the client.
225
+ this.errors.push(
226
+ `Extension '${this.extname}' is claimed by the upload, not found in its content. ` +
227
+ `Every allowed format (${options.extnames.join(', ')}) has a signature, and this file carries none.`,
228
+ )
204
229
  }
205
230
  }
206
231
  return this.errors.length === 0
207
232
  }
208
233
 
234
+ /**
235
+ * Whether refusing this upload is warranted on the strength of the allowlist
236
+ * alone.
237
+ *
238
+ * Only when the magic-byte pass actually ran and found nothing, AND every
239
+ * allowed extension is one it would have recognised. For a list that allows
240
+ * `csv` or `txt`, finding nothing is the normal case and says nothing.
241
+ */
242
+ #claimsATypeItCannotBe(extnames: string[]): boolean {
243
+ if (this.#detected || !this.#detectionRan) return false
244
+ if (!detectableExtensions) return false
245
+ return extnames.every((extension) => detectableExtensions?.has(extension))
246
+ }
247
+
209
248
  get isValid(): boolean {
210
249
  return this.errors.length === 0
211
250
  }
@@ -363,3 +402,11 @@ export function hydrateMultipartPayload(payload: {
363
402
  )
364
403
  return { fields, files }
365
404
  }
405
+
406
+ /**
407
+ * The extensions `file-type` can recognise, captured on the first detection.
408
+ *
409
+ * Module-level because it is a property of the library, not of a file, and
410
+ * because `validate()` is synchronous while reading it is not.
411
+ */
412
+ let detectableExtensions: ReadonlySet<string> | undefined
package/src/env/Env.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  removeIdentifier,
6
6
  } from './interpolate.js'
7
7
  import { loadEnvFiles } from './loadEnvFiles.js'
8
+ import { normalizeNodeEnv } from './nodeEnv.js'
8
9
  import { type SchemaNode, schema } from './schema.js'
9
10
 
10
11
  /**
@@ -87,7 +88,7 @@ export class Env<Values extends Record<string, unknown>> {
87
88
  appRoot: URL,
88
89
  schema: S,
89
90
  ): Promise<Env<EnvRecord<S>>> {
90
- loadEnvFiles(appRoot, { skipEnvLocal: process.env.NODE_ENV === 'test' })
91
+ loadEnvFiles(appRoot, { skipEnvLocal: normalizeNodeEnv(process.env.NODE_ENV) === 'test' })
91
92
 
92
93
  return new Env(Env.rules(schema).validate(process.env))
93
94
  }
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { parseEnv } from 'node:util'
4
4
  import { interpolate } from './interpolate.js'
5
+ import { normalizeNodeEnv } from './nodeEnv.js'
5
6
 
6
7
  /**
7
8
  * Load `.env` files into `process.env` — the shared primitive behind both the
@@ -17,7 +18,10 @@ import { interpolate } from './interpolate.js'
17
18
  * developer's local overrides don't leak into tests).
18
19
  */
19
20
  export function loadEnvFiles(appRoot: URL, options: { skipEnvLocal?: boolean } = {}): void {
20
- const nodeEnv = process.env.NODE_ENV
21
+ // Normalised, so `NODE_ENV=prod` loads `.env.production` — the file the
22
+ // deployment actually wrote — instead of looking for `.env.prod`.
23
+ const raw = normalizeNodeEnv(process.env.NODE_ENV)
24
+ const nodeEnv = raw === 'unknown' ? undefined : raw
21
25
  const files = [
22
26
  nodeEnv ? `.env.${nodeEnv}.local` : null,
23
27
  options.skipEnvLocal ? null : '.env.local',
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Reading `NODE_ENV`, with the aliases people actually set.
3
+ *
4
+ * `NODE_ENV=prod` is a normal thing to find in a Dockerfile or a platform's
5
+ * dashboard, and a bare `=== 'production'` reads it as "not production": the
6
+ * app then serves without Secure cookies, with development error pages, and
7
+ * loads the wrong `.env` file. Normalising once, here, is what stops that
8
+ * spelling from deciding how an application behaves.
9
+ *
10
+ * The tables mirror upstream exactly. Anything outside them is handed back
11
+ * lowercased rather than forced into a bucket — `staging` stays `staging`, so
12
+ * `.env.staging` still resolves.
13
+ */
14
+
15
+ const DEV_ENVS = ['dev', 'develop', 'development']
16
+ const PROD_ENVS = ['prod', 'production']
17
+ const TEST_ENVS = ['test', 'testing']
18
+
19
+ /**
20
+ * The canonical name for whatever `NODE_ENV` holds.
21
+ *
22
+ * Returns `'unknown'` when it is unset — an absent environment is not the
23
+ * development one, and pretending otherwise turns a misconfigured deploy into
24
+ * a silently permissive one.
25
+ */
26
+ export function normalizeNodeEnv(value: string | undefined): string {
27
+ if (!value || typeof value !== 'string') return 'unknown'
28
+ const env = value.toLowerCase()
29
+ if (DEV_ENVS.includes(env)) return 'development'
30
+ if (PROD_ENVS.includes(env)) return 'production'
31
+ if (TEST_ENVS.includes(env)) return 'test'
32
+ return env
33
+ }
34
+
35
+ /** The current environment, normalised. */
36
+ export function currentNodeEnv(): string {
37
+ return normalizeNodeEnv(process.env.NODE_ENV)
38
+ }
@@ -7,6 +7,8 @@
7
7
  * @implements FR71, FR72, FR73, FR77
8
8
  */
9
9
 
10
+ import { currentNodeEnv } from '../env/nodeEnv.js'
11
+
10
12
  /**
11
13
  * Is this a Ream error, and one of these codes?
12
14
  *
@@ -39,7 +41,12 @@ export function isReamError(error: unknown, ...codes: string[]): boolean {
39
41
  }
40
42
 
41
43
  export class ReamError extends Error {
42
- /** Error code (e.g., "ATLAS_QUERY_ERROR", "CONTAINER_NOT_FOUND") */
44
+ /**
45
+ * Error code, `E_`-prefixed as every framework code is — an application
46
+ * matching on one should never have to remember which convention a
47
+ * particular package followed (e.g. "E_ATLAS_QUERY_ERROR",
48
+ * "E_CONTAINER_NOT_FOUND").
49
+ */
43
50
  readonly code: string
44
51
 
45
52
  /** Additional context key-value pairs */
@@ -164,6 +171,6 @@ export class ReamError extends Error {
164
171
  * Auto-format based on NODE_ENV.
165
172
  */
166
173
  toFormattedString(): string {
167
- return process.env.NODE_ENV === 'production' ? this.toProdString() : this.toDevString()
174
+ return currentNodeEnv() === 'production' ? this.toProdString() : this.toDevString()
168
175
  }
169
176
  }
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import * as fs from 'node:fs'
25
+ import { currentNodeEnv } from '../env/nodeEnv.js'
25
26
  import { loadNapi } from '../helpers/napi-loader.js'
26
27
  import type { HttpContext } from '../http/HttpContext.js'
27
28
 
@@ -231,7 +232,7 @@ export class GraphQLEngine {
231
232
  }
232
233
  this.#schemaSource = fs.readFileSync(config.schemaPath, 'utf8')
233
234
  this.path = config.path ?? '/graphql'
234
- this.#playground = config.playground ?? process.env.NODE_ENV !== 'production'
235
+ this.#playground = config.playground ?? currentNodeEnv() !== 'production'
235
236
  this.#maxQueryBytes = config.maxQueryBytes ?? 100_000
236
237
  this.#maxDepth = config.maxDepth ?? 12
237
238
  this.#maxComplexity = config.maxComplexity ?? 1000
@@ -288,7 +289,7 @@ export class GraphQLEngine {
288
289
  ctx.response.status(200).json(result)
289
290
  } catch (err) {
290
291
  const message =
291
- process.env.NODE_ENV !== 'production' && err instanceof Error
292
+ currentNodeEnv() !== 'production' && err instanceof Error
292
293
  ? err.message
293
294
  : 'Internal server error'
294
295
  ctx.response.status(500).json({ errors: [{ message }] })
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { format } from 'node:util'
10
+ import { currentNodeEnv } from '../env/nodeEnv.js'
10
11
  import { Macroable } from '../utils/Macroable.js'
11
12
  import type { HttpContext } from './HttpContext.js'
12
13
 
@@ -272,7 +273,7 @@ type LogLevel = 'error' | 'warn' | 'info'
272
273
  export class ExceptionHandler extends Macroable {
273
274
  protected debug: boolean
274
275
  /** Render `statusPages` (browser HTML). Defaults to production only (AdonisJS). */
275
- protected renderStatusPages: boolean = process.env.NODE_ENV === 'production'
276
+ protected renderStatusPages: boolean = currentNodeEnv() === 'production'
276
277
  /** Status → HTML renderer, keys may be single codes or `'500..599'` ranges. */
277
278
  protected statusPages: Record<string, StatusPageRenderer> = {}
278
279
  /** Master reporting switch (AdonisJS `reportErrors`). */
@@ -383,7 +383,14 @@ export class Request extends Macroable {
383
383
  cookie(name: string, defaultValue?: string): string | null
384
384
  cookie(name: string, defaultValue?: string): string | null {
385
385
  const raw = this.plainCookie<string>(name, undefined, { encoded: false })
386
- const value = raw === null ? null : this.#cookieSigner ? this.#cookieSigner.unsign(raw) : raw
386
+ // No signer means nothing here was ever signed. Handing the raw value back
387
+ // would present whatever the client wrote as a verified one — the reader
388
+ // cannot tell the difference, which is exactly the confusion signing
389
+ // exists to remove. `plainCookie()` is the way to read an unsigned value.
390
+ if (!this.#cookieSigner) return defaultValue ?? null
391
+ // The name is part of the signature, so a value moved to another cookie
392
+ // fails here rather than being honoured under its new name.
393
+ const value = raw === null ? null : this.#cookieSigner.unsign<string>(raw, name)
387
394
  return value ?? defaultValue ?? null
388
395
  }
389
396
 
@@ -675,20 +682,29 @@ export class Request extends Macroable {
675
682
  }
676
683
 
677
684
  /**
678
- * A JSON-safe view of the request (AdonisJS `serialize`), for logs and error
679
- * reports. Carries no body: it may hold credentials, and a log line is the
680
- * last place they should land.
685
+ * A JSON view of the request (AdonisJS `serialize`).
686
+ *
687
+ * The whole request, as upstream reports it: body, cookies and every header
688
+ * verbatim. It is a debugging dump, and it carries credentials — an
689
+ * `authorization` header, a session cookie, a password field in the body.
690
+ *
691
+ * Do not put it in a log line. {@link serializeSafe} is the one built for
692
+ * that.
681
693
  */
682
694
  serialize(): Record<string, unknown> {
683
695
  return {
684
696
  id: this.id(),
685
697
  url: this.url(true),
698
+ query: this.parsedUrl().search?.slice(1) ?? '',
699
+ body: this.body(),
700
+ params: this.params(),
701
+ headers: this.headers(),
686
702
  method: this.method(),
687
703
  protocol: this.protocol(),
688
- host: this.host(),
689
- headers: this.headers(),
690
- qs: this.qs(),
691
- params: this.params(),
704
+ cookies: this.cookies(),
705
+ hostname: this.hostname(),
706
+ ip: this.ip(),
707
+ subdomains: this.subdomains(),
692
708
  }
693
709
  }
694
710
 
@@ -697,6 +713,28 @@ export class Request extends Macroable {
697
713
  return this.serialize()
698
714
  }
699
715
 
716
+ /**
717
+ * A view fit for a log line or an error report.
718
+ *
719
+ * Not upstream's — it has no such method, and its `serialize()` is a full
720
+ * dump. This one carries no body and no cookies, and the headers that hold a
721
+ * credential come back as `[redacted]` rather than as their value: the header
722
+ * is still LISTED, because knowing a request arrived with an `authorization`
723
+ * header is most of what a reader wants, and its contents are none of it.
724
+ */
725
+ serializeSafe(): Record<string, unknown> {
726
+ return {
727
+ id: this.id(),
728
+ url: this.url(true),
729
+ method: this.method(),
730
+ protocol: this.protocol(),
731
+ host: this.host(),
732
+ headers: redactSensitiveHeaders(this.headers()),
733
+ qs: this.qs(),
734
+ params: this.params(),
735
+ }
736
+ }
737
+
700
738
  // ─── Query string ─────────────────────────────────────────
701
739
 
702
740
  /** Get parsed query string as an object. */
@@ -999,6 +1037,33 @@ function isPlainObject(value: unknown): value is Dict<unknown> {
999
1037
  }
1000
1038
 
1001
1039
  /** True for an IPv4 dotted-quad or a (bracketed) IPv6 literal — hosts with no subdomains. */
1040
+ /**
1041
+ * Headers whose VALUE is a credential. Listing them is useful; printing them
1042
+ * hands whoever reads the log a working key.
1043
+ */
1044
+ const SENSITIVE_HEADERS = new Set([
1045
+ 'authorization',
1046
+ 'proxy-authorization',
1047
+ 'cookie',
1048
+ 'set-cookie',
1049
+ 'x-api-key',
1050
+ 'x-auth-token',
1051
+ 'x-access-token',
1052
+ 'x-csrf-token',
1053
+ 'x-xsrf-token',
1054
+ 'x-session-token',
1055
+ 'x-amz-security-token',
1056
+ ])
1057
+
1058
+ /** Replace each credential-bearing header's value with a marker, keeping the name. */
1059
+ function redactSensitiveHeaders(headers: Dict): Dict {
1060
+ const out: Dict = {}
1061
+ for (const [name, value] of Object.entries(headers)) {
1062
+ out[name] = SENSITIVE_HEADERS.has(name.toLowerCase()) ? '[redacted]' : value
1063
+ }
1064
+ return out
1065
+ }
1066
+
1002
1067
  function isIpLiteral(host: string): boolean {
1003
1068
  if (host.startsWith('[') || host.includes('::')) return true
1004
1069
  return /^(\d{1,3}\.){3}\d{1,3}$/.test(host)
@@ -13,6 +13,7 @@ import { readFile, stat } from 'node:fs/promises'
13
13
  import { basename } from 'node:path'
14
14
  import etag from 'etag'
15
15
  import { contentType } from 'mime-types'
16
+ import { ReamError } from '../errors/ReamError.js'
16
17
  import { durationToSeconds } from '../helpers/duration.js'
17
18
 
18
19
  /**
@@ -60,8 +61,6 @@ function assertNoCRLF(name: string, value: string): void {
60
61
  */
61
62
  function contentDisposition(disposition: string, filename: string): string {
62
63
  const quoted = filename.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
63
- // biome-ignore lint/suspicious/noControlCharactersInRegex: matching control
64
- // characters is the point — they cannot travel in a header field.
65
64
  const ascii = quoted.replace(/[^\x20-\x7e]/g, '_')
66
65
  const base = `${disposition}; filename="${ascii}"`
67
66
  if (ascii === quoted) return base
@@ -285,7 +284,7 @@ export class Response extends Macroable {
285
284
  /** Send a JSON response. Sets content-type and stringifies. */
286
285
  json(data: unknown): void {
287
286
  this.#headers['content-type'] = 'application/json'
288
- this.#body = safeStringify(data)
287
+ this.#setBody(safeStringify(data))
289
288
  this.#finished = true
290
289
  }
291
290
 
@@ -315,7 +314,7 @@ export class Response extends Macroable {
315
314
  c === '\u2028' ? '\\u2028' : '\\u2029',
316
315
  )
317
316
  this.#headers['content-type'] = 'text/javascript; charset=utf-8'
318
- this.#body = `/**/ typeof ${safeCallback} === 'function' && ${safeCallback}(${json});`
317
+ this.#setBody(`/**/ typeof ${safeCallback} === 'function' && ${safeCallback}(${json});`)
319
318
  this.#finished = true
320
319
  }
321
320
 
@@ -330,7 +329,7 @@ export class Response extends Macroable {
330
329
  ? 'text/html; charset=utf-8'
331
330
  : 'text/plain; charset=utf-8'
332
331
  }
333
- this.#body = data
332
+ this.#setBody(data)
334
333
  } else if (Buffer.isBuffer(data)) {
335
334
  // Without this branch a Buffer would hit the `typeof === 'object'`
336
335
  // path and be JSON-stringified into `{"type":"Buffer","data":[...]}`
@@ -339,12 +338,12 @@ export class Response extends Macroable {
339
338
  return
340
339
  } else if (typeof data === 'object' && data !== null) {
341
340
  this.#headers['content-type'] = 'application/json'
342
- this.#body = safeStringify(data)
341
+ this.#setBody(safeStringify(data))
343
342
  } else if (data !== undefined && data !== null) {
344
343
  if (!this.#headers['content-type']) {
345
344
  this.#headers['content-type'] = 'text/plain'
346
345
  }
347
- this.#body = String(data)
346
+ this.#setBody(String(data))
348
347
  }
349
348
  this.#finished = true
350
349
  }
@@ -383,12 +382,37 @@ export class Response extends Macroable {
383
382
  * `@c9up/archive` instead and let the client fetch it from storage — the
384
383
  * bytes then never pass through the server at all.
385
384
  */
385
+ /**
386
+ * Assign the body, refusing one too large to hold.
387
+ *
388
+ * Every textual path goes through here — `json()`, `send()`, `jsonp()`, a
389
+ * middleware rewriting the body. The ceiling used to live only in
390
+ * `sendBuffer()`, so `json(hugeObject)` and `send(hugeString)` walked past
391
+ * it and the process grew until it died, which is the failure the ceiling
392
+ * exists to name.
393
+ *
394
+ * Measured in UTF-8 BYTES, not characters: a string of accented or CJK text
395
+ * is one and a half to three times its length on the wire.
396
+ */
397
+ #setBody(body: string): void {
398
+ this.#assertBodyFits(Buffer.byteLength(body, 'utf8'))
399
+ this.#body = body
400
+ }
401
+
386
402
  #assertBodyFits(bytes: number): void {
387
403
  if (bytes <= this.#maxBodyBytes) return
388
- throw new Error(
389
- `[E_RESPONSE_TOO_LARGE] Response body is ${Math.round(bytes / 1_048_576)}MB, over the ${Math.round(this.#maxBodyBytes / 1_048_576)}MB ceiling. ` +
390
- 'The body is held whole in memory (and base64-encoded, ~2.3x) because responses are not streamed yet. ' +
391
- 'Raise `maxResponseBytes` in the kernel config, or serve large files with a signed URL from @c9up/archive.',
404
+ throw new ReamError(
405
+ 'E_RESPONSE_TOO_LARGE',
406
+ `Response body is ${Math.round(bytes / 1_048_576)}MB, over the ${Math.round(this.#maxBodyBytes / 1_048_576)}MB ceiling.`,
407
+ {
408
+ context: {
409
+ bytes: String(bytes),
410
+ ceiling: String(this.#maxBodyBytes),
411
+ },
412
+ hint:
413
+ 'The body is held whole in memory (and base64-encoded, ~2.3x) because responses are not streamed yet. ' +
414
+ 'Raise `maxResponseBytes` in the kernel config, or serve large files with a signed URL from @c9up/archive.',
415
+ },
392
416
  )
393
417
  }
394
418
 
@@ -665,7 +689,11 @@ export class Response extends Macroable {
665
689
  */
666
690
  redirect(): RedirectBuilder
667
691
  redirect(path: string, forwardQueryString?: boolean, statusCode?: number): void
668
- redirect(path?: string, forwardQueryString = false, statusCode = 302): RedirectBuilder | void {
692
+ redirect(
693
+ path?: string,
694
+ forwardQueryString = false,
695
+ statusCode = 302,
696
+ ): RedirectBuilder | undefined {
669
697
  if (!this.#redirectBuilderFactory) {
670
698
  throw new Error(
671
699
  'redirect() requires an HttpContext. Response was created outside a request handler.',
@@ -1063,14 +1091,25 @@ export class Response extends Macroable {
1063
1091
  // ─── Cookies ──────────────────────────────────────────────
1064
1092
 
1065
1093
  /**
1066
- * Set a SIGNED response cookie (AdonisJS default). The value is HMAC-signed
1067
- * with the app's `APP_KEY` (via the injected signer) so tampering is detected
1068
- * on read; `request.cookie()` verifies + unwraps it. Falls back to a plain
1069
- * cookie when no APP_KEY / encryption service is configured.
1094
+ * Set a SIGNED response cookie (the default). The value is HMAC-signed with
1095
+ * the app's `APP_KEY`, so tampering is detected on read; `request.cookie()`
1096
+ * verifies and unwraps it.
1097
+ *
1098
+ * Requires APP_KEY, and REFUSES rather than falling back to a plain cookie.
1099
+ * A silent fallback is the worst of both: the caller asked for integrity,
1100
+ * the value ships without it, and `request.cookie()` on the far side hands
1101
+ * back whatever the client wrote as though it had been verified.
1102
+ *
1103
+ * The cookie's NAME is signed along with its value, so a signed cookie
1104
+ * cannot be lifted onto another name and still verify.
1070
1105
  */
1071
1106
  cookie(name: string, value: string, options?: CookieOptions): this {
1072
- const signed = this.#cookieSigner ? this.#cookieSigner.sign(value) : value
1073
- return this.#writeCookie(name, signed, options)
1107
+ if (!this.#cookieSigner) {
1108
+ throw new Error(
1109
+ 'cookie() signs the value and needs APP_KEY — set it so the encryption service is registered, or use plainCookie() when the value does not need signing.',
1110
+ )
1111
+ }
1112
+ return this.#writeCookie(name, this.#cookieSigner.sign(value, undefined, name), options)
1074
1113
  }
1075
1114
 
1076
1115
  /**
@@ -1262,7 +1301,7 @@ export class Response extends Macroable {
1262
1301
 
1263
1302
  /** @internal Set body directly (used by redirect, exception handler). */
1264
1303
  setBody(body: string): void {
1265
- this.#body = body
1304
+ this.#setBody(body)
1266
1305
  this.#finished = true
1267
1306
  }
1268
1307
 
package/src/index.ts CHANGED
@@ -347,12 +347,16 @@ export {
347
347
  export type {
348
348
  ErrorReporter,
349
349
  LockBackend,
350
+ LockBackendFactory,
351
+ LockRedisClient,
352
+ LockRedisResolver,
350
353
  RunTaskOutcome,
351
354
  ScheduleEvent,
352
355
  ScheduleEventSink,
353
356
  ScheduleInvocation,
354
357
  ScheduleMetadata,
355
358
  ScheduleProviderOptions,
359
+ SchedulerConfig,
356
360
  SchedulerOptions,
357
361
  ScheduleTaskCompletedEvent,
358
362
  ScheduleTaskFailedEvent,
@@ -362,8 +366,11 @@ export type {
362
366
  TaskStats,
363
367
  } from './scheduler/index.js'
364
368
  export {
369
+ defineSchedulerConfig,
365
370
  getScheduleMetadata,
371
+ locks,
366
372
  MemoryLockBackend,
373
+ RedisLockBackend,
367
374
  SCHEDULE_METADATA_KEY,
368
375
  Schedule,
369
376
  ScheduleProvider,
@@ -371,4 +378,4 @@ export {
371
378
  StatsTracker,
372
379
  } from './scheduler/index.js'
373
380
 
374
- export const VERSION = '0.2.9'
381
+ export const VERSION = '0.2.11'
@@ -1602,12 +1602,21 @@ function matchPath(pattern: string, actual: string): MatchedParams | null {
1602
1602
  return params
1603
1603
  }
1604
1604
 
1605
- /** Match domain pattern against actual host. Supports wildcards like *.example.com */
1605
+ /**
1606
+ * Match a domain pattern against the request's host. Supports wildcards like
1607
+ * `*.example.com`.
1608
+ *
1609
+ * Both sides are lowercased: a hostname is case-insensitive (RFC 4343), so a
1610
+ * client that sends `API.Example.COM` — which is legal, and what some proxies
1611
+ * and older clients do — must reach the same route as `api.example.com`.
1612
+ * Comparing them verbatim sends that request to a fallback, or to a 404.
1613
+ */
1606
1614
  function matchDomain(pattern: string, host: string): boolean {
1607
- const actualHost = host.split(':')[0] // strip port
1608
- if (pattern === actualHost) return true
1609
- if (pattern.startsWith('*.')) {
1610
- const suffix = pattern.slice(1) // ".example.com"
1615
+ const actualHost = host.split(':')[0].toLowerCase() // strip port
1616
+ const wanted = pattern.toLowerCase()
1617
+ if (wanted === actualHost) return true
1618
+ if (wanted.startsWith('*.')) {
1619
+ const suffix = wanted.slice(1) // ".example.com"
1611
1620
  return actualHost.endsWith(suffix) && actualHost.length > suffix.length
1612
1621
  }
1613
1622
  return false
@@ -18,6 +18,7 @@ import {
18
18
  parseRequest as parseRpcRequest,
19
19
  buildError as rpcError,
20
20
  } from '@c9up/comet'
21
+ import { currentNodeEnv } from '../env/nodeEnv.js'
21
22
  import type { HttpContext } from '../http/HttpContext.js'
22
23
  import type { MiddlewareRegistry, RuntimeValidator } from '../middleware/Pipeline.js'
23
24
  import { composeMiddleware, runValidator } from '../middleware/Pipeline.js'
@@ -316,7 +317,7 @@ export class RpcRouter {
316
317
  return rpcError(err.code, message, id, err.data)
317
318
  }
318
319
  // Otherwise don't leak internal error details to the caller.
319
- const isDev = process.env.NODE_ENV !== 'production'
320
+ const isDev = currentNodeEnv() !== 'production'
320
321
  const message = isDev && err instanceof Error ? err.message : 'Internal error'
321
322
  return rpcError(-32603, message, id)
322
323
  }