@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.
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +15 -1
- package/dist/Ignitor.js.map +1 -1
- package/dist/container/Container.d.ts +18 -0
- package/dist/container/Container.d.ts.map +1 -1
- package/dist/container/Container.js +29 -0
- package/dist/container/Container.js.map +1 -1
- package/dist/env/Env.d.ts +11 -0
- package/dist/env/Env.d.ts.map +1 -1
- package/dist/env/Env.js +33 -17
- package/dist/env/Env.js.map +1 -1
- package/dist/env/Secret.d.ts +17 -0
- package/dist/env/Secret.d.ts.map +1 -1
- package/dist/env/Secret.js +23 -0
- package/dist/env/Secret.js.map +1 -1
- package/dist/errors/aggregate.d.ts +27 -0
- package/dist/errors/aggregate.d.ts.map +1 -0
- package/dist/errors/aggregate.js +27 -0
- package/dist/errors/aggregate.js.map +1 -0
- package/dist/events/Emitter.d.ts +18 -1
- package/dist/events/Emitter.d.ts.map +1 -1
- package/dist/events/Emitter.js +25 -6
- package/dist/events/Emitter.js.map +1 -1
- package/dist/events/services/main.d.ts.map +1 -1
- package/dist/events/services/main.js +3 -10
- package/dist/events/services/main.js.map +1 -1
- package/dist/helpers/MessageBuilder.d.ts +29 -0
- package/dist/helpers/MessageBuilder.d.ts.map +1 -0
- package/dist/helpers/MessageBuilder.js +63 -0
- package/dist/helpers/MessageBuilder.js.map +1 -0
- package/dist/helpers/VerificationToken.d.ts +56 -0
- package/dist/helpers/VerificationToken.d.ts.map +1 -0
- package/dist/helpers/VerificationToken.js +70 -0
- package/dist/helpers/VerificationToken.js.map +1 -0
- package/dist/helpers/defineStaticProperty.d.ts +15 -0
- package/dist/helpers/defineStaticProperty.d.ts.map +1 -0
- package/dist/helpers/defineStaticProperty.js +47 -0
- package/dist/helpers/defineStaticProperty.js.map +1 -0
- package/dist/helpers/duration.d.ts +20 -0
- package/dist/helpers/duration.d.ts.map +1 -1
- package/dist/helpers/duration.js +71 -0
- package/dist/helpers/duration.js.map +1 -1
- package/dist/helpers/fs.d.ts +39 -0
- package/dist/helpers/fs.d.ts.map +1 -0
- package/dist/helpers/fs.js +70 -0
- package/dist/helpers/fs.js.map +1 -0
- package/dist/helpers/index.d.ts +8 -0
- package/dist/helpers/index.d.ts.map +1 -1
- package/dist/helpers/index.js +7 -0
- package/dist/helpers/index.js.map +1 -1
- package/dist/helpers/safeTiming.d.ts +14 -0
- package/dist/helpers/safeTiming.d.ts.map +1 -0
- package/dist/helpers/safeTiming.js +42 -0
- package/dist/helpers/safeTiming.js.map +1 -0
- package/dist/helpers/string.d.ts +85 -0
- package/dist/helpers/string.d.ts.map +1 -1
- package/dist/helpers/string.js +127 -1
- package/dist/helpers/string.js.map +1 -1
- package/dist/http/Exception.d.ts +76 -6
- package/dist/http/Exception.d.ts.map +1 -1
- package/dist/http/Exception.js +178 -85
- package/dist/http/Exception.js.map +1 -1
- package/dist/http/HttpContext.d.ts +14 -1
- package/dist/http/HttpContext.d.ts.map +1 -1
- package/dist/http/HttpContext.js.map +1 -1
- package/dist/http/RedirectBuilder.d.ts +16 -0
- package/dist/http/RedirectBuilder.d.ts.map +1 -1
- package/dist/http/RedirectBuilder.js +34 -9
- package/dist/http/RedirectBuilder.js.map +1 -1
- package/dist/http/Request.d.ts.map +1 -1
- package/dist/http/Request.js +5 -17
- package/dist/http/Request.js.map +1 -1
- package/dist/index.d.ts +11 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -4
- package/dist/index.js.map +1 -1
- package/dist/router/Router.d.ts +54 -0
- package/dist/router/Router.d.ts.map +1 -1
- package/dist/router/Router.js +73 -4
- package/dist/router/Router.js.map +1 -1
- package/dist/services/app.d.ts.map +1 -1
- package/dist/services/app.js +3 -21
- package/dist/services/app.js.map +1 -1
- package/dist/services/console.d.ts.map +1 -1
- package/dist/services/console.js +3 -19
- package/dist/services/console.js.map +1 -1
- package/dist/services/createServiceProxy.d.ts +19 -0
- package/dist/services/createServiceProxy.d.ts.map +1 -0
- package/dist/services/createServiceProxy.js +44 -0
- package/dist/services/createServiceProxy.js.map +1 -0
- package/dist/services/router.d.ts.map +1 -1
- package/dist/services/router.js +3 -12
- package/dist/services/router.js.map +1 -1
- package/dist/services/server.d.ts.map +1 -1
- package/dist/services/server.js +3 -22
- package/dist/services/server.js.map +1 -1
- package/dist/session/ReadOnlyValuesStore.d.ts +11 -0
- package/dist/session/ReadOnlyValuesStore.d.ts.map +1 -1
- package/dist/session/ReadOnlyValuesStore.js +17 -0
- package/dist/session/ReadOnlyValuesStore.js.map +1 -1
- package/dist/session/Session.d.ts +11 -3
- package/dist/session/Session.d.ts.map +1 -1
- package/dist/session/Session.js +15 -6
- package/dist/session/Session.js.map +1 -1
- package/dist/session/SessionMiddleware.js +2 -1
- package/dist/session/SessionMiddleware.js.map +1 -1
- package/dist/session/errors.d.ts +25 -0
- package/dist/session/errors.d.ts.map +1 -0
- package/dist/session/errors.js +32 -0
- package/dist/session/errors.js.map +1 -0
- package/dist/types/authenticators.d.ts +24 -0
- package/dist/types/authenticators.d.ts.map +1 -0
- package/dist/types/authenticators.js +2 -0
- package/dist/types/authenticators.js.map +1 -0
- package/dist/types/container.d.ts +64 -0
- package/dist/types/container.d.ts.map +1 -0
- package/dist/types/container.js +23 -0
- package/dist/types/container.js.map +1 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +9 -0
- package/dist/types/index.js.map +1 -0
- package/events.darwin-arm64.node +0 -0
- package/events.darwin-x64.node +0 -0
- package/events.linux-arm64-gnu.node +0 -0
- package/events.linux-x64-gnu.node +0 -0
- package/events.win32-x64-msvc.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +13 -1
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/Ignitor.ts +15 -1
- package/src/container/Container.ts +31 -0
- package/src/env/Env.ts +34 -15
- package/src/env/Secret.ts +26 -0
- package/src/errors/aggregate.ts +36 -0
- package/src/events/Emitter.ts +30 -5
- package/src/events/services/main.ts +6 -12
- package/src/helpers/MessageBuilder.ts +69 -0
- package/src/helpers/VerificationToken.ts +93 -0
- package/src/helpers/defineStaticProperty.ts +51 -0
- package/src/helpers/duration.ts +75 -0
- package/src/helpers/fs.ts +104 -0
- package/src/helpers/index.ts +8 -0
- package/src/helpers/safeTiming.ts +45 -0
- package/src/helpers/string.ts +145 -1
- package/src/http/Exception.ts +225 -99
- package/src/http/HttpContext.ts +14 -1
- package/src/http/RedirectBuilder.ts +38 -8
- package/src/http/Request.ts +5 -16
- package/src/index.ts +24 -2
- package/src/router/Router.ts +87 -4
- package/src/services/app.ts +6 -23
- package/src/services/console.ts +6 -21
- package/src/services/createServiceProxy.ts +47 -0
- package/src/services/router.ts +6 -14
- package/src/services/server.ts +6 -24
- package/src/session/ReadOnlyValuesStore.ts +20 -0
- package/src/session/Session.ts +16 -11
- package/src/session/SessionMiddleware.ts +2 -1
- package/src/session/errors.ts +39 -0
- package/src/types/authenticators.ts +24 -0
- package/src/types/container.ts +66 -0
- package/src/types/index.ts +10 -0
package/src/helpers/string.ts
CHANGED
|
@@ -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
|
package/src/http/Exception.ts
CHANGED
|
@@ -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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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:
|
|
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(
|
|
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:
|
|
305
|
+
protected shouldReport(error: HttpError): boolean {
|
|
279
306
|
if (!this.reportErrors) return false
|
|
280
|
-
|
|
281
|
-
if (this.
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
/**
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
const payload: Record<string, unknown> = {
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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
|
-
|
|
342
|
-
|
|
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({
|
|
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
|
-
|
|
350
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
376
|
-
await
|
|
498
|
+
if (typeof httpError.report === 'function') {
|
|
499
|
+
await httpError.report(httpError, ctx)
|
|
377
500
|
return
|
|
378
501
|
}
|
|
379
502
|
|
|
380
|
-
|
|
381
|
-
|
|
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,
|
package/src/http/HttpContext.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|