@c9up/ream 0.2.1 → 0.2.3

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 (163) hide show
  1. package/dist/Ignitor.d.ts.map +1 -1
  2. package/dist/Ignitor.js +15 -1
  3. package/dist/Ignitor.js.map +1 -1
  4. package/dist/container/Container.d.ts +18 -0
  5. package/dist/container/Container.d.ts.map +1 -1
  6. package/dist/container/Container.js +29 -0
  7. package/dist/container/Container.js.map +1 -1
  8. package/dist/env/Env.d.ts +11 -0
  9. package/dist/env/Env.d.ts.map +1 -1
  10. package/dist/env/Env.js +33 -17
  11. package/dist/env/Env.js.map +1 -1
  12. package/dist/env/Secret.d.ts +17 -0
  13. package/dist/env/Secret.d.ts.map +1 -1
  14. package/dist/env/Secret.js +23 -0
  15. package/dist/env/Secret.js.map +1 -1
  16. package/dist/errors/aggregate.d.ts +27 -0
  17. package/dist/errors/aggregate.d.ts.map +1 -0
  18. package/dist/errors/aggregate.js +27 -0
  19. package/dist/errors/aggregate.js.map +1 -0
  20. package/dist/events/Emitter.d.ts +18 -1
  21. package/dist/events/Emitter.d.ts.map +1 -1
  22. package/dist/events/Emitter.js +25 -6
  23. package/dist/events/Emitter.js.map +1 -1
  24. package/dist/events/services/main.d.ts.map +1 -1
  25. package/dist/events/services/main.js +3 -10
  26. package/dist/events/services/main.js.map +1 -1
  27. package/dist/helpers/MessageBuilder.d.ts +29 -0
  28. package/dist/helpers/MessageBuilder.d.ts.map +1 -0
  29. package/dist/helpers/MessageBuilder.js +63 -0
  30. package/dist/helpers/MessageBuilder.js.map +1 -0
  31. package/dist/helpers/VerificationToken.d.ts +56 -0
  32. package/dist/helpers/VerificationToken.d.ts.map +1 -0
  33. package/dist/helpers/VerificationToken.js +70 -0
  34. package/dist/helpers/VerificationToken.js.map +1 -0
  35. package/dist/helpers/defineStaticProperty.d.ts +15 -0
  36. package/dist/helpers/defineStaticProperty.d.ts.map +1 -0
  37. package/dist/helpers/defineStaticProperty.js +47 -0
  38. package/dist/helpers/defineStaticProperty.js.map +1 -0
  39. package/dist/helpers/duration.d.ts +20 -0
  40. package/dist/helpers/duration.d.ts.map +1 -1
  41. package/dist/helpers/duration.js +71 -0
  42. package/dist/helpers/duration.js.map +1 -1
  43. package/dist/helpers/fs.d.ts +39 -0
  44. package/dist/helpers/fs.d.ts.map +1 -0
  45. package/dist/helpers/fs.js +70 -0
  46. package/dist/helpers/fs.js.map +1 -0
  47. package/dist/helpers/index.d.ts +8 -0
  48. package/dist/helpers/index.d.ts.map +1 -1
  49. package/dist/helpers/index.js +7 -0
  50. package/dist/helpers/index.js.map +1 -1
  51. package/dist/helpers/safeTiming.d.ts +14 -0
  52. package/dist/helpers/safeTiming.d.ts.map +1 -0
  53. package/dist/helpers/safeTiming.js +42 -0
  54. package/dist/helpers/safeTiming.js.map +1 -0
  55. package/dist/helpers/string.d.ts +85 -0
  56. package/dist/helpers/string.d.ts.map +1 -1
  57. package/dist/helpers/string.js +127 -1
  58. package/dist/helpers/string.js.map +1 -1
  59. package/dist/http/Exception.d.ts +76 -6
  60. package/dist/http/Exception.d.ts.map +1 -1
  61. package/dist/http/Exception.js +178 -85
  62. package/dist/http/Exception.js.map +1 -1
  63. package/dist/http/HttpContext.d.ts +14 -1
  64. package/dist/http/HttpContext.d.ts.map +1 -1
  65. package/dist/http/HttpContext.js.map +1 -1
  66. package/dist/http/RedirectBuilder.d.ts +16 -0
  67. package/dist/http/RedirectBuilder.d.ts.map +1 -1
  68. package/dist/http/RedirectBuilder.js +34 -9
  69. package/dist/http/RedirectBuilder.js.map +1 -1
  70. package/dist/http/Request.d.ts.map +1 -1
  71. package/dist/http/Request.js +5 -17
  72. package/dist/http/Request.js.map +1 -1
  73. package/dist/index.d.ts +11 -4
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +9 -4
  76. package/dist/index.js.map +1 -1
  77. package/dist/router/Router.d.ts +54 -0
  78. package/dist/router/Router.d.ts.map +1 -1
  79. package/dist/router/Router.js +73 -4
  80. package/dist/router/Router.js.map +1 -1
  81. package/dist/services/app.d.ts.map +1 -1
  82. package/dist/services/app.js +3 -21
  83. package/dist/services/app.js.map +1 -1
  84. package/dist/services/console.d.ts.map +1 -1
  85. package/dist/services/console.js +3 -19
  86. package/dist/services/console.js.map +1 -1
  87. package/dist/services/createServiceProxy.d.ts +19 -0
  88. package/dist/services/createServiceProxy.d.ts.map +1 -0
  89. package/dist/services/createServiceProxy.js +44 -0
  90. package/dist/services/createServiceProxy.js.map +1 -0
  91. package/dist/services/router.d.ts.map +1 -1
  92. package/dist/services/router.js +3 -12
  93. package/dist/services/router.js.map +1 -1
  94. package/dist/services/server.d.ts.map +1 -1
  95. package/dist/services/server.js +3 -22
  96. package/dist/services/server.js.map +1 -1
  97. package/dist/session/ReadOnlyValuesStore.d.ts +11 -0
  98. package/dist/session/ReadOnlyValuesStore.d.ts.map +1 -1
  99. package/dist/session/ReadOnlyValuesStore.js +17 -0
  100. package/dist/session/ReadOnlyValuesStore.js.map +1 -1
  101. package/dist/session/Session.d.ts +11 -3
  102. package/dist/session/Session.d.ts.map +1 -1
  103. package/dist/session/Session.js +15 -6
  104. package/dist/session/Session.js.map +1 -1
  105. package/dist/session/SessionMiddleware.js +2 -1
  106. package/dist/session/SessionMiddleware.js.map +1 -1
  107. package/dist/session/errors.d.ts +25 -0
  108. package/dist/session/errors.d.ts.map +1 -0
  109. package/dist/session/errors.js +32 -0
  110. package/dist/session/errors.js.map +1 -0
  111. package/dist/types/authenticators.d.ts +24 -0
  112. package/dist/types/authenticators.d.ts.map +1 -0
  113. package/dist/types/authenticators.js +2 -0
  114. package/dist/types/authenticators.js.map +1 -0
  115. package/dist/types/container.d.ts +64 -0
  116. package/dist/types/container.d.ts.map +1 -0
  117. package/dist/types/container.js +23 -0
  118. package/dist/types/container.js.map +1 -0
  119. package/dist/types/index.d.ts +10 -0
  120. package/dist/types/index.d.ts.map +1 -0
  121. package/dist/types/index.js +9 -0
  122. package/dist/types/index.js.map +1 -0
  123. package/events.darwin-arm64.node +0 -0
  124. package/events.darwin-x64.node +0 -0
  125. package/events.linux-arm64-gnu.node +0 -0
  126. package/events.linux-x64-gnu.node +0 -0
  127. package/events.win32-x64-msvc.node +0 -0
  128. package/index.win32-x64-msvc.node +0 -0
  129. package/package.json +13 -1
  130. package/scheduler.win32-x64-msvc.node +0 -0
  131. package/src/Ignitor.ts +15 -1
  132. package/src/container/Container.ts +31 -0
  133. package/src/env/Env.ts +34 -15
  134. package/src/env/Secret.ts +26 -0
  135. package/src/errors/aggregate.ts +36 -0
  136. package/src/events/Emitter.ts +30 -5
  137. package/src/events/services/main.ts +6 -12
  138. package/src/helpers/MessageBuilder.ts +69 -0
  139. package/src/helpers/VerificationToken.ts +93 -0
  140. package/src/helpers/defineStaticProperty.ts +51 -0
  141. package/src/helpers/duration.ts +75 -0
  142. package/src/helpers/fs.ts +104 -0
  143. package/src/helpers/index.ts +8 -0
  144. package/src/helpers/safeTiming.ts +45 -0
  145. package/src/helpers/string.ts +145 -1
  146. package/src/http/Exception.ts +225 -99
  147. package/src/http/HttpContext.ts +14 -1
  148. package/src/http/RedirectBuilder.ts +38 -8
  149. package/src/http/Request.ts +5 -16
  150. package/src/index.ts +24 -2
  151. package/src/router/Router.ts +87 -4
  152. package/src/services/app.ts +6 -23
  153. package/src/services/console.ts +6 -21
  154. package/src/services/createServiceProxy.ts +47 -0
  155. package/src/services/router.ts +6 -14
  156. package/src/services/server.ts +6 -24
  157. package/src/session/ReadOnlyValuesStore.ts +20 -0
  158. package/src/session/Session.ts +16 -11
  159. package/src/session/SessionMiddleware.ts +2 -1
  160. package/src/session/errors.ts +39 -0
  161. package/src/types/authenticators.ts +24 -0
  162. package/src/types/container.ts +66 -0
  163. package/src/types/index.ts +10 -0
@@ -1,5 +1,6 @@
1
- import { randomBytes } from 'node:crypto'
1
+ import { randomBytes, randomUUID } from 'node:crypto'
2
2
  import bytes from './bytes.js'
3
+ import { milliseconds, seconds } from './duration.js'
3
4
 
4
5
  // ---------------------------------------------------------------------------
5
6
  // Internal helpers
@@ -79,6 +80,135 @@ export function titleCase(s: string): string {
79
80
  .join(' ')
80
81
  }
81
82
 
83
+ /**
84
+ * Space-separated lowercase words.
85
+ * @example noCase('userName') // 'user name'
86
+ */
87
+ export function noCase(s: string): string {
88
+ return splitWords(s).join(' ')
89
+ }
90
+
91
+ /**
92
+ * Dot-separated lowercase words.
93
+ * @example dotCase('userName') // 'user.name'
94
+ */
95
+ export function dotCase(s: string): string {
96
+ return splitWords(s).join('.')
97
+ }
98
+
99
+ /**
100
+ * Every word capitalised, space separated.
101
+ * @example capitalCase('user_name') // 'User Name'
102
+ */
103
+ export function capitalCase(s: string): string {
104
+ return titleCase(s)
105
+ }
106
+
107
+ /**
108
+ * First word capitalised, the rest lowercase.
109
+ * @example sentenceCase('userName') // 'User name'
110
+ */
111
+ export function sentenceCase(s: string): string {
112
+ const words = splitWords(s)
113
+ if (words.length === 0) return ''
114
+ const [first, ...rest] = words
115
+ return [(first ?? '').charAt(0).toUpperCase() + (first ?? '').slice(1), ...rest].join(' ')
116
+ }
117
+
118
+ /**
119
+ * Join values the way a sentence does.
120
+ * @example sentence(['a', 'b', 'c']) // 'a, b, and c'
121
+ */
122
+ export function sentence(
123
+ values: readonly unknown[],
124
+ options: { separator?: string; pairSeparator?: string; lastSeparator?: string } = {},
125
+ ): string {
126
+ const parts = values.map((value) => String(value))
127
+ if (parts.length === 0) return ''
128
+ if (parts.length === 1) return parts[0] ?? ''
129
+ const separator = options.separator ?? ', '
130
+ const pairSeparator = options.pairSeparator ?? ' and '
131
+ const lastSeparator = options.lastSeparator ?? ', and '
132
+ if (parts.length === 2) return parts.join(pairSeparator)
133
+ return parts.slice(0, -1).join(separator) + lastSeparator + parts[parts.length - 1]
134
+ }
135
+
136
+ /**
137
+ * Wrap text at `width`, breaking on spaces.
138
+ *
139
+ * A word longer than the width is left whole rather than cut: breaking an
140
+ * identifier or a URL mid-token is worse than an over-long line.
141
+ */
142
+ export function wordWrap(
143
+ value: string,
144
+ options: { width: number; indent?: string; newLine?: string },
145
+ ): string {
146
+ const width = Math.max(1, Math.floor(options.width))
147
+ const indent = options.indent ?? ''
148
+ const newLine = options.newLine ?? '\n'
149
+ const lines: string[] = []
150
+ let line = ''
151
+ for (const word of value.split(/\s+/).filter(Boolean)) {
152
+ if (line.length === 0) {
153
+ line = word
154
+ } else if (line.length + 1 + word.length <= width) {
155
+ line += ` ${word}`
156
+ } else {
157
+ lines.push(line)
158
+ line = word
159
+ }
160
+ }
161
+ if (line.length > 0) lines.push(line)
162
+ return lines.map((entry, index) => (index === 0 ? entry : indent + entry)).join(newLine)
163
+ }
164
+
165
+ /**
166
+ * Pad every column to the same width.
167
+ * @example justify(['a', 'bb'], { width: 4 }) // ['a ', 'bb ']
168
+ */
169
+ export function justify(
170
+ columns: readonly string[],
171
+ options: { width: number; align?: 'left' | 'right'; indent?: string },
172
+ ): string[] {
173
+ const width = Math.max(0, Math.floor(options.width))
174
+ const indent = options.indent ?? ''
175
+ return columns.map((column) => {
176
+ const padded =
177
+ options.align === 'right' ? column.padStart(width, ' ') : column.padEnd(width, ' ')
178
+ return indent + padded
179
+ })
180
+ }
181
+
182
+ /** A RFC 4122 v4 UUID. */
183
+ export function uuid(): string {
184
+ return randomUUID()
185
+ }
186
+
187
+ /** `ordinal` under the name AdonisJS also ships it as. */
188
+ export function ordinalize(value: number): string {
189
+ return ordinal(value)
190
+ }
191
+
192
+ /** `sentence` under the name AdonisJS also ships it as. */
193
+ export function toSentence(
194
+ values: readonly unknown[],
195
+ options?: { separator?: string; pairSeparator?: string; lastSeparator?: string },
196
+ ): string {
197
+ return sentence(values, options)
198
+ }
199
+
200
+ /** `escapeHTML` under the name @poppinss/string ships it as. */
201
+ export function htmlEscape(value: string): string {
202
+ return escapeHTML(value)
203
+ }
204
+
205
+ /**
206
+ * Pluralise `word`, honouring a count: `pluralize('item', 1)` stays singular.
207
+ */
208
+ export function pluralize(word: string, count?: number): string {
209
+ return count === 1 ? singular(word) : plural(word)
210
+ }
211
+
82
212
  /**
83
213
  * Converts a snake_case/camelCase string to a human-readable form.
84
214
  * @example humanize('user_name') // 'User name'
@@ -466,6 +596,20 @@ const string = {
466
596
  prettyHrTime,
467
597
  toUnixSlash,
468
598
  interpolate,
599
+ noCase,
600
+ dotCase,
601
+ capitalCase,
602
+ sentenceCase,
603
+ sentence,
604
+ toSentence,
605
+ wordWrap,
606
+ justify,
607
+ uuid,
608
+ ordinalize,
609
+ htmlEscape,
610
+ pluralize,
611
+ seconds,
612
+ milliseconds,
469
613
  }
470
614
 
471
615
  export default string
@@ -154,15 +154,16 @@ export class E_VALIDATION_ERROR extends Exception {
154
154
  static override status = 422
155
155
  static override code = 'E_VALIDATION_ERROR'
156
156
 
157
- errors: unknown[]
158
-
159
- constructor(errors: unknown[]) {
160
- super('Validation failed', { status: 422, code: 'E_VALIDATION_ERROR' })
161
- this.errors = errors
162
- }
163
-
164
- override handle(_error: this, ctx: HttpContext): void {
165
- ctx.response.status(422).json({ errors: this.errors })
157
+ /**
158
+ * Per-field failures. Named `messages` because that is what VineJS, rune and
159
+ * AdonisJS's own validation renderers read — a handler copied from an Adonis
160
+ * app reaches for `error.messages`.
161
+ */
162
+ messages: unknown[]
163
+
164
+ constructor(messages: unknown[]) {
165
+ super('Validation failure', { status: 422, code: 'E_VALIDATION_ERROR' })
166
+ this.messages = messages
166
167
  }
167
168
  }
168
169
 
@@ -233,8 +234,34 @@ export class E_HTTP_REQUEST_ABORTED extends Exception {
233
234
  * 2. Else → this.handle() → content negotiation (JSON or HTML)
234
235
  * 3. Then → this.report() → logging/monitoring
235
236
  */
237
+ /**
238
+ * A thrown value, normalised into the shape every renderer reads.
239
+ *
240
+ * AdonisJS hands this to `renderError*` / `renderValidationError*` and to the
241
+ * status-page renderers, so an app that overrides one of them receives the same
242
+ * object it would there — a plain record, never an `Error` subclass, because
243
+ * what is thrown is not always one.
244
+ */
245
+ export type HttpError = {
246
+ message: string
247
+ status: number
248
+ code: string
249
+ stack?: string
250
+ cause?: unknown
251
+ /** Per-field failures, on a validation error (VineJS / rune shape). */
252
+ messages?: unknown
253
+ /** Extra detail an exception chose to carry. */
254
+ errors?: unknown
255
+ /** Set when the thrown value handles its own response. */
256
+ handle?: (error: unknown, ctx: HttpContext) => Promise<void> | void
257
+ /** Set when the thrown value reports itself. */
258
+ report?: (error: unknown, ctx: HttpContext) => Promise<void> | void
259
+ /** A hint appended to the message, when the exception carries one. */
260
+ help?: string
261
+ }
262
+
236
263
  /** Renders an HTML error page for a status (AdonisJS `StatusPageRenderer`). */
237
- export type StatusPageRenderer = (error: unknown, ctx: HttpContext) => Promise<string> | string
264
+ export type StatusPageRenderer = (error: HttpError, ctx: HttpContext) => Promise<string> | string
238
265
 
239
266
  /** A constructor usable in `instanceof` for {@link ExceptionHandler.ignoreExceptions}. */
240
267
  export type ExceptionClass = new (...args: never[]) => Error
@@ -268,18 +295,17 @@ export class ExceptionHandler extends Macroable {
268
295
  }
269
296
 
270
297
  /** Log level for an error by status: 5xx→error, 4xx→warn, else info (AdonisJS). */
271
- protected getErrorLogLevel(status: number): LogLevel {
272
- if (status >= 500) return 'error'
273
- if (status >= 400) return 'warn'
298
+ protected getErrorLogLevel(error: HttpError): LogLevel {
299
+ if (error.status >= 500) return 'error'
300
+ if (error.status >= 400) return 'warn'
274
301
  return 'info'
275
302
  }
276
303
 
277
304
  /** Whether an error should be reported — honours every ignore list (AdonisJS `shouldReport`). */
278
- protected shouldReport(error: unknown): boolean {
305
+ protected shouldReport(error: HttpError): boolean {
279
306
  if (!this.reportErrors) return false
280
- const { status, code } = extractErrorMeta(error)
281
- if (this.ignoreStatuses.includes(status)) return false
282
- if (this.ignoreCodes.includes(code)) return false
307
+ if (this.ignoreStatuses.includes(error.status)) return false
308
+ if (this.ignoreCodes.includes(error.code)) return false
283
309
  if (this.ignoreExceptions.some((exception) => error instanceof exception)) return false
284
310
  return true
285
311
  }
@@ -297,95 +323,185 @@ export class ExceptionHandler extends Macroable {
297
323
  }
298
324
 
299
325
  /** Convert an exception to an HTTP response. */
300
- async handle(error: unknown, ctx: HttpContext): Promise<void> {
301
- // Self-handled errors take over entirely (duck-typed, per AdonisJS — any
302
- // thrown value exposing a `handle()` method, not just `Exception` instances).
303
- if (isSelfHandling(error)) {
304
- await error.handle(error, ctx)
305
- return
306
- }
307
-
308
- // Negotiation. DEVIATION (named): ream lists `json` FIRST, so a request
309
- // with no/`*/*` Accept defaults to JSON (API-first). AdonisJS lists `html`
310
- // first (full-stack default). Explicit `text/html` / `vnd.api+json` are
311
- // honoured identically.
312
- switch (ctx.request.accepts(['json', 'html', 'application/vnd.api+json'])) {
313
- case 'application/vnd.api+json':
314
- this.#sendJsonApiError(error, ctx)
315
- return
316
- case 'html':
317
- await this.#sendHtmlError(error, ctx)
318
- return
319
- default:
320
- this.#sendJsonError(error, ctx)
321
- }
326
+ /**
327
+ * Normalise any thrown value into an {@link HttpError}.
328
+ *
329
+ * Everything downstream — the renderers, the status pages, the report path —
330
+ * reads this shape, so a handler override never has to re-sniff whether it
331
+ * was handed an `Exception`, a plain object, or a string.
332
+ */
333
+ protected toHttpError(error: unknown): HttpError {
334
+ // AdonisJS returns the thrown object ITSELF, filled in — not a copy. That
335
+ // identity is load-bearing: `ignoreExceptions` matches with `instanceof`,
336
+ // and `handle`/`report` have to stay bound to their own instance. A copy
337
+ // would quietly break both.
338
+ const source: object =
339
+ typeof error === 'object' && error !== null ? error : new Error(String(error))
340
+ const { status, code } = extractErrorMeta(error)
341
+ const message =
342
+ 'message' in source && typeof source.message === 'string' && source.message.length > 0
343
+ ? source.message
344
+ : 'Internal server error'
345
+ return Object.assign(source, { status, code, message })
322
346
  }
323
347
 
324
- /** Pick the user-facing message — full detail only in debug mode. */
325
- #errorMessage(error: unknown, ctx: HttpContext): string {
326
- if (this.isDebuggingEnabled(ctx) && error instanceof Error) return error.message
327
- if (error instanceof Exception) return error.message
348
+ /**
349
+ * The message a client is allowed to see.
350
+ *
351
+ * DEVIATION (named): outside debug mode ream only echoes the message of a
352
+ * deliberate `Exception`; anything else collapses to a generic line. AdonisJS
353
+ * echoes `error.message` whatever was thrown, which leaks the text of driver
354
+ * and library errors to the client.
355
+ */
356
+ protected publicMessage(error: HttpError, ctx: HttpContext): string {
357
+ if (this.isDebuggingEnabled(ctx)) return error.message
358
+ if (error.code !== 'E_UNKNOWN') return error.message
328
359
  return 'An internal error occurred'
329
360
  }
330
361
 
331
- #sendJsonError(error: unknown, ctx: HttpContext): void {
332
- const { status, code } = extractErrorMeta(error)
333
- const payload: Record<string, unknown> = { code, message: this.#errorMessage(error, ctx) }
334
- if (error instanceof Exception && error.help) payload.help = error.help
335
- if (this.isDebuggingEnabled(ctx) && error instanceof Error && error.stack) {
336
- payload.stack = error.stack
362
+ /** Render an error as JSON. */
363
+ async renderErrorAsJSON(error: HttpError, ctx: HttpContext): Promise<void> {
364
+ const payload: Record<string, unknown> = {
365
+ code: error.code,
366
+ message: this.publicMessage(error, ctx),
337
367
  }
338
- ctx.response.status(status).json({ error: payload })
368
+ if (error.help) payload.help = error.help
369
+ if (this.isDebuggingEnabled(ctx) && error.stack) payload.stack = error.stack
370
+ ctx.response.status(error.status).json({ error: payload })
339
371
  }
340
372
 
341
- #sendJsonApiError(error: unknown, ctx: HttpContext): void {
342
- const { status, code } = extractErrorMeta(error)
373
+ /** Render an error as a JSON:API document. */
374
+ async renderErrorAsJSONAPI(error: HttpError, ctx: HttpContext): Promise<void> {
343
375
  ctx.response
344
- .status(status)
376
+ .status(error.status)
345
377
  .type('application/vnd.api+json')
346
- .json({ errors: [{ title: this.#errorMessage(error, ctx), code, status: String(status) }] })
378
+ .json({
379
+ errors: [
380
+ {
381
+ title: this.publicMessage(error, ctx),
382
+ code: error.code,
383
+ status: String(error.status),
384
+ },
385
+ ],
386
+ })
347
387
  }
348
388
 
349
- async #sendHtmlError(error: unknown, ctx: HttpContext): Promise<void> {
350
- const { status } = extractErrorMeta(error)
351
- // A registered status page wins (production browser error pages).
389
+ /** Render an error as HTML, preferring a registered status page. */
390
+ async renderErrorAsHTML(error: HttpError, ctx: HttpContext): Promise<void> {
352
391
  if (this.renderStatusPages) {
353
- const renderer = this.#expandStatusPages()[status]
392
+ const renderer = this.#expandStatusPages()[error.status]
354
393
  if (renderer) {
355
394
  const html = await renderer(error, ctx)
356
- ctx.response.status(status).type('text/html; charset=utf-8').send(html)
395
+ ctx.response.status(error.status).type('text/html; charset=utf-8').send(html)
357
396
  return
358
397
  }
359
398
  }
360
- const message = this.#errorMessage(error, ctx)
399
+ const message = this.publicMessage(error, ctx)
361
400
  ctx.response
362
- .status(status)
401
+ .status(error.status)
363
402
  .type('text/html; charset=utf-8')
364
403
  .send(
365
- `<!DOCTYPE html><html><head><title>Error ${status}</title></head>` +
366
- `<body><h1>${status}</h1><p>${escapeHtml(message)}</p></body></html>`,
404
+ `<!DOCTYPE html><html><head><title>Error ${error.status}</title></head>` +
405
+ `<body><h1>${error.status}</h1><p>${escapeHtml(message)}</p></body></html>`,
367
406
  )
368
407
  }
369
408
 
409
+ /** Render per-field validation failures as JSON. */
410
+ async renderValidationErrorAsJSON(error: HttpError, ctx: HttpContext): Promise<void> {
411
+ ctx.response.status(error.status).json({ errors: error.messages })
412
+ }
413
+
414
+ /** Render per-field validation failures as a JSON:API document. */
415
+ async renderValidationErrorAsJSONAPI(error: HttpError, ctx: HttpContext): Promise<void> {
416
+ const messages = Array.isArray(error.messages) ? error.messages : []
417
+ ctx.response
418
+ .status(error.status)
419
+ .type('application/vnd.api+json')
420
+ .json({ errors: messages.map(toJsonApiValidationError) })
421
+ }
422
+
423
+ /** Render per-field validation failures as HTML. */
424
+ async renderValidationErrorAsHTML(error: HttpError, ctx: HttpContext): Promise<void> {
425
+ const messages = Array.isArray(error.messages) ? error.messages : []
426
+ ctx.response
427
+ .status(error.status)
428
+ .type('text/html; charset=utf-8')
429
+ .send(
430
+ messages
431
+ .map((message) => {
432
+ const { field, text } = readValidationMessage(message)
433
+ return `${escapeHtml(field)} - ${escapeHtml(text)}`
434
+ })
435
+ .join('<br />'),
436
+ )
437
+ }
438
+
439
+ /**
440
+ * Pick a renderer by content negotiation.
441
+ *
442
+ * DEVIATION (named): ream lists `json` FIRST, so a request with no or `*` /`*`
443
+ * Accept defaults to JSON (API-first). AdonisJS lists `html` first
444
+ * (full-stack default). Explicit `text/html` / `vnd.api+json` are honoured
445
+ * identically.
446
+ */
447
+ async renderError(error: HttpError, ctx: HttpContext): Promise<void> {
448
+ switch (ctx.request.accepts(['json', 'html', 'application/vnd.api+json'])) {
449
+ case 'application/vnd.api+json':
450
+ return this.renderErrorAsJSONAPI(error, ctx)
451
+ case 'html':
452
+ return this.renderErrorAsHTML(error, ctx)
453
+ default:
454
+ return this.renderErrorAsJSON(error, ctx)
455
+ }
456
+ }
457
+
458
+ /** Pick a validation renderer by content negotiation. */
459
+ async renderValidationError(error: HttpError, ctx: HttpContext): Promise<void> {
460
+ switch (ctx.request.accepts(['json', 'html', 'application/vnd.api+json'])) {
461
+ case 'application/vnd.api+json':
462
+ return this.renderValidationErrorAsJSONAPI(error, ctx)
463
+ case 'html':
464
+ return this.renderValidationErrorAsHTML(error, ctx)
465
+ default:
466
+ return this.renderValidationErrorAsJSON(error, ctx)
467
+ }
468
+ }
469
+
470
+ /** Convert an exception to an HTTP response. */
471
+ async handle(error: unknown, ctx: HttpContext): Promise<void> {
472
+ const httpError = this.toHttpError(error)
473
+
474
+ // Self-handled errors take over entirely (duck-typed, per AdonisJS — any
475
+ // thrown value exposing a `handle()` method, not just `Exception` instances).
476
+ if (typeof httpError.handle === 'function') {
477
+ await httpError.handle(httpError, ctx)
478
+ return
479
+ }
480
+
481
+ // Validation failures have per-field detail and their own renderers. Keyed
482
+ // on the code plus the presence of `messages`, exactly as AdonisJS does, so
483
+ // rune's and VineJS's errors both land here.
484
+ if (httpError.code === 'E_VALIDATION_ERROR' && httpError.messages !== undefined) {
485
+ await this.renderValidationError(httpError, ctx)
486
+ return
487
+ }
488
+
489
+ await this.renderError(httpError, ctx)
490
+ }
491
+
370
492
  /** Log/report an exception. Override for custom monitoring. */
371
493
  async report(error: unknown, ctx: HttpContext): Promise<void> {
372
- if (!this.shouldReport(error)) return
494
+ const httpError = this.toHttpError(error)
495
+ if (!this.shouldReport(httpError)) return
373
496
 
374
497
  // Self-reported exceptions handle their own reporting.
375
- if (isSelfReporting(error)) {
376
- await error.report(error, ctx)
498
+ if (typeof httpError.report === 'function') {
499
+ await httpError.report(httpError, ctx)
377
500
  return
378
501
  }
379
502
 
380
- // Default: log at the status-appropriate level.
381
- const { status } = extractErrorMeta(error)
382
- const level = this.getErrorLogLevel(status)
383
- const context = this.context(ctx)
384
- if (error instanceof Error) {
385
- console[level](`[${new Date().toISOString()}] ${error.message}`, context, error.stack)
386
- } else {
387
- console[level](`[${new Date().toISOString()}] Unknown error:`, error, context)
388
- }
503
+ const level = this.getErrorLogLevel(httpError)
504
+ ctx.logger[level](httpError.message, this.context(ctx))
389
505
  }
390
506
 
391
507
  /** Provide additional context for error reports. Override for custom data. */
@@ -399,6 +515,40 @@ export class ExceptionHandler extends Macroable {
399
515
  }
400
516
  }
401
517
 
518
+ /**
519
+ * The fields a validation message carries (VineJS / rune `RuneErrorNode`).
520
+ *
521
+ * Read defensively: the messages come from whichever validator the app wired,
522
+ * and a renderer must not throw while rendering someone else's error.
523
+ */
524
+ function readValidationMessage(message: unknown): {
525
+ field: string
526
+ text: string
527
+ rule: string
528
+ meta: unknown
529
+ } {
530
+ if (message === null || typeof message !== 'object') {
531
+ return { field: '', text: String(message), rule: '', meta: undefined }
532
+ }
533
+ const field = 'field' in message && typeof message.field === 'string' ? message.field : ''
534
+ const text = 'message' in message && typeof message.message === 'string' ? message.message : ''
535
+ const rule = 'rule' in message && typeof message.rule === 'string' ? message.rule : ''
536
+ const meta = 'meta' in message ? message.meta : undefined
537
+ return { field, text, rule, meta }
538
+ }
539
+
540
+ /** One validation message, as a JSON:API error object (AdonisJS shape). */
541
+ function toJsonApiValidationError(message: unknown): Record<string, unknown> {
542
+ const { field, text, rule, meta } = readValidationMessage(message)
543
+ const error: Record<string, unknown> = {
544
+ title: text,
545
+ code: rule,
546
+ source: { pointer: field },
547
+ }
548
+ if (meta !== undefined) error.meta = meta
549
+ return error
550
+ }
551
+
402
552
  /** Extract status + code from an Exception, ReamError, or duck-typed error. */
403
553
  function extractErrorMeta(error: unknown): { status: number; code: string } {
404
554
  let status = 500
@@ -413,30 +563,6 @@ function extractErrorMeta(error: unknown): { status: number; code: string } {
413
563
  return { status, code }
414
564
  }
415
565
 
416
- /** True when a thrown value self-handles via a `handle()` method (AdonisJS duck-type). */
417
- function isSelfHandling(
418
- error: unknown,
419
- ): error is { handle: (error: unknown, ctx: HttpContext) => Promise<void> | void } {
420
- return (
421
- error !== null &&
422
- typeof error === 'object' &&
423
- 'handle' in error &&
424
- typeof error.handle === 'function'
425
- )
426
- }
427
-
428
- /** True when a thrown value self-reports via a `report()` method. */
429
- function isSelfReporting(
430
- error: unknown,
431
- ): error is { report: (error: unknown, ctx: HttpContext) => Promise<void> | void } {
432
- return (
433
- error !== null &&
434
- typeof error === 'object' &&
435
- 'report' in error &&
436
- typeof error.report === 'function'
437
- )
438
- }
439
-
440
566
  /** Expand a status-page key into a per-code map: `'500..599'` → {500,…,599}, `'404'` → {404}. */
441
567
  function parseStatusRange(
442
568
  range: string,
@@ -14,6 +14,7 @@ import type { MatchedParams } from '../router/Router.js'
14
14
  import type { CookieSigner } from '../security/CookieSigner.js'
15
15
  import type { SignedUrl } from '../security/SignedUrl.js'
16
16
  import type { Session } from '../session/Session.js'
17
+ import type { Authenticators } from '../types/authenticators.js'
17
18
  import { Macroable } from '../utils/Macroable.js'
18
19
  import type { RouteUrlResolver } from './RedirectBuilder.js'
19
20
  import { RedirectBuilder } from './RedirectBuilder.js'
@@ -47,7 +48,19 @@ export interface AuthState {
47
48
  check?(): Promise<boolean>
48
49
  authenticateUsing?(guards?: string[], options?: { loginRoute?: string }): Promise<void>
49
50
  getUserOrFail?(): AuthState['user']
50
- use?(name: string): unknown
51
+ /**
52
+ * The guard behind `name` (AdonisJS `auth.use('web')`).
53
+ *
54
+ * Typed through the augmentable `Authenticators` interface in
55
+ * `@c9up/ream/types`: an app whose auth package augments it gets the guard's
56
+ * real type back, while a host that augments nothing still resolves to
57
+ * `unknown` rather than failing to compile. Without this, reaching a guard
58
+ * needed a cast that lies about a contract ream does not own.
59
+ */
60
+ use?: {
61
+ <Name extends keyof Authenticators>(name: Name): Authenticators[Name]
62
+ (name: string): unknown
63
+ }
51
64
  readonly authenticationAttempted?: boolean
52
65
  readonly authenticatedViaGuard?: string
53
66
  }
@@ -13,16 +13,27 @@ import type { Response } from './Response.js'
13
13
 
14
14
  export type RouteUrlResolver = (name: string, params?: Record<string, string>) => string
15
15
 
16
- function isSameOriginOrRelative(referer: string, requestUrl?: string): boolean {
16
+ function isSameOriginOrRelative(
17
+ referer: string,
18
+ requestUrl?: string,
19
+ allowedHosts: readonly string[] = [],
20
+ ): boolean {
17
21
  // A relative path is trusted only when it starts with a single "/" and has no
18
22
  // backslash: browsers normalise "\" to "/", so "/\evil.com" would become the
19
23
  // protocol-relative "//evil.com" that a lone `!startsWith("//")` check misses.
20
24
  if (referer.startsWith('/') && !referer.startsWith('//') && !referer.includes('\\')) return true
25
+ let ref: URL
26
+ try {
27
+ ref = new URL(referer)
28
+ } catch {
29
+ return false
30
+ }
31
+ // An explicitly allowed host is trusted whatever the request's own origin is
32
+ // — that is what the list is for (AdonisJS `allowedHosts`).
33
+ if (allowedHosts.includes(ref.host)) return true
21
34
  if (!requestUrl) return false
22
35
  try {
23
- const ref = new URL(referer)
24
- const req = new URL(requestUrl)
25
- return ref.origin === req.origin
36
+ return ref.origin === new URL(requestUrl).origin
26
37
  } catch {
27
38
  return false
28
39
  }
@@ -35,6 +46,14 @@ export interface IntendedUrlStore {
35
46
  }
36
47
 
37
48
  export class RedirectBuilder {
49
+ /**
50
+ * Hosts a redirect-back may leave the origin for (AdonisJS `allowedHosts`).
51
+ *
52
+ * Empty by default, which keeps `back()` same-origin only — an open-redirect
53
+ * is exactly what an unvalidated Referer buys.
54
+ */
55
+ allowedHosts: string[] = []
56
+
38
57
  #response: Response
39
58
  #status = 302
40
59
  #qs: Record<string, unknown> = {}
@@ -193,12 +212,23 @@ export class RedirectBuilder {
193
212
  * to `fallback` to prevent open redirect attacks.
194
213
  */
195
214
  back(fallback = '/'): void {
215
+ this.toPath(this.getPreviousUrl(fallback))
216
+ }
217
+
218
+ /**
219
+ * Where "back" goes (AdonisJS `getPreviousUrl`).
220
+ *
221
+ * Reads the Referer and validates its host, returning `fallback` when there
222
+ * is nothing trustworthy. Public and overridable on purpose: resolving the
223
+ * previous URL from the session instead of the header is the documented
224
+ * reason to replace it.
225
+ */
226
+ getPreviousUrl(fallback = '/'): string {
196
227
  const referer = this.#requestReferer
197
- if (referer && isSameOriginOrRelative(referer, this.#requestUrl)) {
198
- this.toPath(referer)
199
- } else {
200
- this.toPath(fallback)
228
+ if (referer && isSameOriginOrRelative(referer, this.#requestUrl, this.allowedHosts)) {
229
+ return referer
201
230
  }
231
+ return fallback
202
232
  }
203
233
 
204
234
  /**