@c9up/ream 0.2.2 → 0.2.4

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 (122) hide show
  1. package/dist/container/Container.d.ts +18 -0
  2. package/dist/container/Container.d.ts.map +1 -1
  3. package/dist/container/Container.js +29 -0
  4. package/dist/container/Container.js.map +1 -1
  5. package/dist/env/Env.d.ts +11 -0
  6. package/dist/env/Env.d.ts.map +1 -1
  7. package/dist/env/Env.js +33 -17
  8. package/dist/env/Env.js.map +1 -1
  9. package/dist/env/Secret.d.ts +17 -0
  10. package/dist/env/Secret.d.ts.map +1 -1
  11. package/dist/env/Secret.js +23 -0
  12. package/dist/env/Secret.js.map +1 -1
  13. package/dist/errors/aggregate.d.ts +3 -0
  14. package/dist/errors/aggregate.d.ts.map +1 -1
  15. package/dist/errors/aggregate.js +3 -0
  16. package/dist/errors/aggregate.js.map +1 -1
  17. package/dist/events/Emitter.d.ts +20 -1
  18. package/dist/events/Emitter.d.ts.map +1 -1
  19. package/dist/events/Emitter.js +28 -6
  20. package/dist/events/Emitter.js.map +1 -1
  21. package/dist/helpers/MessageBuilder.d.ts +29 -0
  22. package/dist/helpers/MessageBuilder.d.ts.map +1 -0
  23. package/dist/helpers/MessageBuilder.js +63 -0
  24. package/dist/helpers/MessageBuilder.js.map +1 -0
  25. package/dist/helpers/VerificationToken.d.ts +56 -0
  26. package/dist/helpers/VerificationToken.d.ts.map +1 -0
  27. package/dist/helpers/VerificationToken.js +70 -0
  28. package/dist/helpers/VerificationToken.js.map +1 -0
  29. package/dist/helpers/defineStaticProperty.d.ts +15 -0
  30. package/dist/helpers/defineStaticProperty.d.ts.map +1 -0
  31. package/dist/helpers/defineStaticProperty.js +47 -0
  32. package/dist/helpers/defineStaticProperty.js.map +1 -0
  33. package/dist/helpers/duration.d.ts +20 -0
  34. package/dist/helpers/duration.d.ts.map +1 -1
  35. package/dist/helpers/duration.js +71 -0
  36. package/dist/helpers/duration.js.map +1 -1
  37. package/dist/helpers/fs.d.ts +39 -0
  38. package/dist/helpers/fs.d.ts.map +1 -0
  39. package/dist/helpers/fs.js +70 -0
  40. package/dist/helpers/fs.js.map +1 -0
  41. package/dist/helpers/index.d.ts +8 -0
  42. package/dist/helpers/index.d.ts.map +1 -1
  43. package/dist/helpers/index.js +7 -0
  44. package/dist/helpers/index.js.map +1 -1
  45. package/dist/helpers/safeTiming.d.ts +14 -0
  46. package/dist/helpers/safeTiming.d.ts.map +1 -0
  47. package/dist/helpers/safeTiming.js +42 -0
  48. package/dist/helpers/safeTiming.js.map +1 -0
  49. package/dist/helpers/string.d.ts +85 -0
  50. package/dist/helpers/string.d.ts.map +1 -1
  51. package/dist/helpers/string.js +127 -1
  52. package/dist/helpers/string.js.map +1 -1
  53. package/dist/http/HttpContext.d.ts +14 -1
  54. package/dist/http/HttpContext.d.ts.map +1 -1
  55. package/dist/http/HttpContext.js.map +1 -1
  56. package/dist/http/RedirectBuilder.d.ts +16 -0
  57. package/dist/http/RedirectBuilder.d.ts.map +1 -1
  58. package/dist/http/RedirectBuilder.js +34 -9
  59. package/dist/http/RedirectBuilder.js.map +1 -1
  60. package/dist/http/Request.d.ts.map +1 -1
  61. package/dist/http/Request.js +5 -30
  62. package/dist/http/Request.js.map +1 -1
  63. package/dist/index.d.ts +3 -2
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +2 -1
  66. package/dist/index.js.map +1 -1
  67. package/dist/router/Router.d.ts +54 -0
  68. package/dist/router/Router.d.ts.map +1 -1
  69. package/dist/router/Router.js +72 -3
  70. package/dist/router/Router.js.map +1 -1
  71. package/dist/session/ReadOnlyValuesStore.d.ts +11 -0
  72. package/dist/session/ReadOnlyValuesStore.d.ts.map +1 -1
  73. package/dist/session/ReadOnlyValuesStore.js +17 -0
  74. package/dist/session/ReadOnlyValuesStore.js.map +1 -1
  75. package/dist/session/Session.d.ts +11 -3
  76. package/dist/session/Session.d.ts.map +1 -1
  77. package/dist/session/Session.js +15 -6
  78. package/dist/session/Session.js.map +1 -1
  79. package/dist/session/SessionMiddleware.js +2 -1
  80. package/dist/session/SessionMiddleware.js.map +1 -1
  81. package/dist/session/errors.d.ts +25 -0
  82. package/dist/session/errors.d.ts.map +1 -0
  83. package/dist/session/errors.js +32 -0
  84. package/dist/session/errors.js.map +1 -0
  85. package/dist/types/{container.d.ts → index.d.ts} +47 -18
  86. package/dist/types/index.d.ts.map +1 -0
  87. package/dist/types/index.js +14 -0
  88. package/dist/types/index.js.map +1 -0
  89. package/events.darwin-arm64.node +0 -0
  90. package/events.darwin-x64.node +0 -0
  91. package/events.linux-arm64-gnu.node +0 -0
  92. package/events.linux-x64-gnu.node +0 -0
  93. package/events.win32-x64-msvc.node +0 -0
  94. package/index.win32-x64-msvc.node +0 -0
  95. package/package.json +7 -3
  96. package/scheduler.win32-x64-msvc.node +0 -0
  97. package/src/container/Container.ts +31 -0
  98. package/src/env/Env.ts +34 -15
  99. package/src/env/Secret.ts +26 -0
  100. package/src/errors/aggregate.ts +3 -0
  101. package/src/events/Emitter.ts +33 -5
  102. package/src/helpers/MessageBuilder.ts +69 -0
  103. package/src/helpers/VerificationToken.ts +93 -0
  104. package/src/helpers/defineStaticProperty.ts +51 -0
  105. package/src/helpers/duration.ts +75 -0
  106. package/src/helpers/fs.ts +104 -0
  107. package/src/helpers/index.ts +8 -0
  108. package/src/helpers/safeTiming.ts +45 -0
  109. package/src/helpers/string.ts +145 -1
  110. package/src/http/HttpContext.ts +14 -1
  111. package/src/http/RedirectBuilder.ts +38 -8
  112. package/src/http/Request.ts +5 -29
  113. package/src/index.ts +11 -2
  114. package/src/router/Router.ts +86 -3
  115. package/src/session/ReadOnlyValuesStore.ts +20 -0
  116. package/src/session/Session.ts +16 -11
  117. package/src/session/SessionMiddleware.ts +2 -1
  118. package/src/session/errors.ts +39 -0
  119. package/src/types/{container.ts → index.ts} +48 -17
  120. package/dist/types/container.d.ts.map +0 -1
  121. package/dist/types/container.js +0 -23
  122. package/dist/types/container.js.map +0 -1
@@ -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
@@ -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/index.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
  /**
@@ -7,6 +7,7 @@
7
7
  * @implements FR21
8
8
  */
9
9
 
10
+ import { parseQueryString } from '../bodyparser/qsParse.js'
10
11
  import type { CookieSigner } from '../security/CookieSigner.js'
11
12
  import type { SignedUrl } from '../security/SignedUrl.js'
12
13
  import type { Dict } from '../types/helpers.js'
@@ -701,6 +702,10 @@ export class Request extends Macroable {
701
702
  /** Get parsed query string as an object. */
702
703
  qs(): Dict<unknown> {
703
704
  if (!this.#parsedQs) {
705
+ // The same parser the body parser uses. A local one lived here and split
706
+ // on "&" only: `?filter[status]=open` stayed the literal key
707
+ // "filter[status]", and `?tags[]=a&tags[]=b` silently kept only "b" —
708
+ // both are ordinary shapes for a filter or an HTML multi-select.
704
709
  this.#parsedQs = parseQueryString(this.#raw.query)
705
710
  }
706
711
  return { ...this.#parsedQs }
@@ -999,35 +1004,6 @@ function isIpLiteral(host: string): boolean {
999
1004
  return /^(\d{1,3}\.){3}\d{1,3}$/.test(host)
1000
1005
  }
1001
1006
 
1002
- /**
1003
- * Decode a percent-encoded segment without throwing on malformed input.
1004
- * Browsers happily send `q=%E0%A4%A` (truncated UTF-8 sequences) — refusing
1005
- * the whole request would let any client knock the route handler offline.
1006
- */
1007
- function safeDecode(value: string): string {
1008
- try {
1009
- return decodeURIComponent(value)
1010
- } catch {
1011
- return value
1012
- }
1013
- }
1014
-
1015
- function parseQueryString(qs: string): Dict<unknown> {
1016
- if (!qs) return {}
1017
- const result: Dict<unknown> = {}
1018
- for (const pair of qs.split('&')) {
1019
- const eqIdx = pair.indexOf('=')
1020
- if (eqIdx === -1) {
1021
- result[safeDecode(pair)] = ''
1022
- } else {
1023
- const key = safeDecode(pair.slice(0, eqIdx))
1024
- const value = safeDecode(pair.slice(eqIdx + 1))
1025
- result[key] = value
1026
- }
1027
- }
1028
- return result
1029
- }
1030
-
1031
1007
  interface AcceptEntry {
1032
1008
  value: string
1033
1009
  q: number
package/src/index.ts CHANGED
@@ -81,6 +81,10 @@ export type {
81
81
  SessionRedisClientSource,
82
82
  } from './session/drivers/RedisDriver.js'
83
83
  export { RedisDriver as SessionRedisDriver } from './session/drivers/RedisDriver.js'
84
+ export {
85
+ E_SESSION_NOT_READY,
86
+ E_SESSION_TAGGING_NOT_SUPPORTED,
87
+ } from './session/errors.js'
84
88
  export type { ValuePath } from './session/ReadOnlyValuesStore.js'
85
89
  export { ReadOnlyValuesStore } from './session/ReadOnlyValuesStore.js'
86
90
  export type {
@@ -252,7 +256,12 @@ export { ConsoleKernel, defineConfig, Ignitor, prettyPrintError } from './Ignito
252
256
  export { errors } from './errors/aggregate.js'
253
257
  export type { AppContext, ProviderContract } from './Provider.js'
254
258
  export { Provider } from './Provider.js'
255
- export type { ContainerBinding, ContainerBindings } from './types/container.js'
259
+ export type {
260
+ AuthenticatorName,
261
+ Authenticators,
262
+ ContainerBinding,
263
+ ContainerBindings,
264
+ } from './types/index.js'
256
265
 
257
266
  // ─── Codemods ───────────────────────────────────────────────
258
267
 
@@ -362,4 +371,4 @@ export {
362
371
  StatsTracker,
363
372
  } from './scheduler/index.js'
364
373
 
365
- export const VERSION = '0.2.2'
374
+ export const VERSION = '0.2.4'
@@ -402,6 +402,54 @@ export class RouteBuilder extends Macroable {
402
402
  getDefinition(): RouteDefinition {
403
403
  return this.#primary()
404
404
  }
405
+
406
+ /**
407
+ * The handler this route dispatches to (AdonisJS `route.getHandler`).
408
+ *
409
+ * Either the inline function or the controller/method pair — a route always
410
+ * has exactly one of the two.
411
+ */
412
+ getHandler(): RouteHandlerFunction | { target: AnyConstructor; method: string } | null {
413
+ const route = this.#primary()
414
+ return route.controller ?? route.handler
415
+ }
416
+
417
+ /**
418
+ * Every middleware attached to this route (AdonisJS `route.getMiddleware`).
419
+ *
420
+ * Named entries and inline functions in one list, in the order they run.
421
+ */
422
+ getMiddleware(): Array<string | MiddlewareFunction> {
423
+ const route = this.#primary()
424
+ return [...route.middleware, ...route.inlineMiddleware]
425
+ }
426
+
427
+ /**
428
+ * A serialisable description of this route (AdonisJS `route.toJSON`).
429
+ *
430
+ * What a routes-list command or a route-manifest generator reads, and why it
431
+ * carries names rather than the functions themselves.
432
+ */
433
+ toJSON(): {
434
+ name?: string
435
+ pattern: string
436
+ methods: string[]
437
+ handler: RouteHandlerFunction | { target: AnyConstructor; method: string } | null
438
+ middleware: string[]
439
+ domain?: string
440
+ matchers: Record<string, ParamMatcher>
441
+ } {
442
+ const route = this.#primary()
443
+ return {
444
+ ...(route.name === undefined ? {} : { name: route.name }),
445
+ pattern: route.path,
446
+ methods: this.#routes.map((entry) => entry.method),
447
+ handler: this.getHandler(),
448
+ middleware: [...route.middleware],
449
+ ...(route.domain === undefined ? {} : { domain: route.domain }),
450
+ matchers: { ...route.matchers },
451
+ }
452
+ }
405
453
  }
406
454
 
407
455
  // ─── GroupBuilder ───────────────────────────────────────────
@@ -415,6 +463,16 @@ export class GroupBuilder extends Macroable {
415
463
  this.#routes = routes
416
464
  }
417
465
 
466
+ /**
467
+ * The routes this group wraps (AdonisJS `group.routes`).
468
+ *
469
+ * Read-only view: a group applies to its members, it does not hand out the
470
+ * backing array for someone else to splice.
471
+ */
472
+ get routes(): readonly RouteDefinition[] {
473
+ return [...this.#routes]
474
+ }
475
+
418
476
  /** Set URL prefix for all routes in the group. */
419
477
  prefix(p: string): this {
420
478
  for (const route of this.#routes) {
@@ -705,6 +763,8 @@ function isViewEngine(value: unknown): value is ViewEngine {
705
763
  export class OnRouteBuilder {
706
764
  #router: Router
707
765
  #path: string
766
+ /** The route built by `render`/`redirect`/`redirectToPath`/`setHandler`. */
767
+ #route: RouteBuilder | undefined
708
768
 
709
769
  constructor(router: Router, path: string) {
710
770
  this.#router = router
@@ -721,7 +781,7 @@ export class OnRouteBuilder {
721
781
  * seeds its own engine there.
722
782
  */
723
783
  render(view: string, data?: Record<string, unknown>): RouteBuilder {
724
- return this.#router.get(this.#path, async (ctx) => {
784
+ this.#route = this.#router.get(this.#path, async (ctx) => {
725
785
  const candidate = Reflect.get(Object(ctx), 'view') ?? ctx.store.get('view')
726
786
  if (!isViewEngine(candidate)) {
727
787
  throw new Error(
@@ -732,6 +792,7 @@ export class OnRouteBuilder {
732
792
  const html = await candidate.render(view, data)
733
793
  ctx.response.type('text/html; charset=utf-8').send(html)
734
794
  })
795
+ return this.#route
735
796
  }
736
797
 
737
798
  /**
@@ -740,16 +801,38 @@ export class OnRouteBuilder {
740
801
  * for a fixed URL.
741
802
  */
742
803
  redirect(name: string, params?: Record<string, string>, status = 302): RouteBuilder {
743
- return this.#router.get(this.#path, async (ctx) => {
804
+ this.#route = this.#router.get(this.#path, async (ctx) => {
744
805
  ctx.response.redirect().status(status).toRoute(name, params)
745
806
  })
807
+ return this.#route
746
808
  }
747
809
 
748
810
  /** Redirect to a fixed path/URL (AdonisJS brisk `redirectToPath`). */
749
811
  redirectToPath(target: string, status = 302): RouteBuilder {
750
- return this.#router.get(this.#path, async (ctx) => {
812
+ this.#route = this.#router.get(this.#path, async (ctx) => {
751
813
  ctx.response.redirect().status(status).toPath(target)
752
814
  })
815
+ return this.#route
816
+ }
817
+
818
+ /**
819
+ * Register a handler for this path (AdonisJS brisk `setHandler`).
820
+ *
821
+ * The escape hatch from the canned briskness: `render` and `redirect` cover
822
+ * the two common cases, this covers the third without going back through
823
+ * `router.get()`.
824
+ */
825
+ setHandler(handler: RouteHandlerFunction): RouteBuilder {
826
+ this.#route = this.#router.get(this.#path, handler)
827
+ return this.#route
828
+ }
829
+
830
+ /**
831
+ * The route the last `render`/`redirect`/`setHandler` created, or
832
+ * `undefined` while none has been called (AdonisJS brisk `route`).
833
+ */
834
+ get route(): RouteBuilder | undefined {
835
+ return this.#route
753
836
  }
754
837
  }
755
838
 
@@ -49,4 +49,24 @@ export class ReadOnlyValuesStore {
49
49
  has(path: ValuePath): boolean {
50
50
  return this.get(path) !== undefined
51
51
  }
52
+
53
+ /**
54
+ * Every value, as a plain object (AdonisJS `toObject`).
55
+ *
56
+ * Same content as {@link all}; the two names exist because AdonisJS ships
57
+ * both and a migrated template may reach for either.
58
+ */
59
+ toObject(): Record<string, unknown> {
60
+ return this.all()
61
+ }
62
+
63
+ /** Every value — what `JSON.stringify(flashMessages)` emits. */
64
+ toJSON(): Record<string, unknown> {
65
+ return this.all()
66
+ }
67
+
68
+ /** JSON text, so `{{ flashMessages }}` in a template renders the contents. */
69
+ toString(): string {
70
+ return JSON.stringify(this.#values)
71
+ }
52
72
  }
@@ -11,6 +11,8 @@
11
11
  */
12
12
 
13
13
  import type { SessionRedisClientSource } from './drivers/RedisDriver.js'
14
+ import { E_SESSION_NOT_READY, E_SESSION_TAGGING_NOT_SUPPORTED } from './errors.js'
15
+ import { ReadOnlyValuesStore } from './ReadOnlyValuesStore.js'
14
16
 
15
17
  export interface SessionDriver {
16
18
  /**
@@ -435,9 +437,16 @@ export class Session {
435
437
  this.forget(INTENDED_URL_KEY)
436
438
  }
437
439
 
438
- /** Get a flashed value from the previous request. */
439
- flashMessages(): Record<string, unknown> {
440
- return { ...this.#previousFlash }
440
+ /**
441
+ * What the PREVIOUS request flashed for this one (AdonisJS `flashMessages`).
442
+ *
443
+ * A property holding a store, not a method returning a record: that is the
444
+ * AdonisJS shape, and it is what lets a controller or template read a nested
445
+ * failure with `session.flashMessages.get('errors.email')`. `all()` gives
446
+ * back the plain object.
447
+ */
448
+ get flashMessages(): ReadOnlyValuesStore {
449
+ return new ReadOnlyValuesStore(this.#previousFlash)
441
450
  }
442
451
 
443
452
  /** Get a specific flash message from the previous request. */
@@ -519,8 +528,8 @@ export class Session {
519
528
  * live. `flashMessages` is the other half — what the PREVIOUS request left
520
529
  * for this one.
521
530
  */
522
- get responseFlashMessages(): Record<string, unknown> {
523
- return { ...this.#flashData }
531
+ get responseFlashMessages(): ReadOnlyValuesStore {
532
+ return new ReadOnlyValuesStore(this.#flashData)
524
533
  }
525
534
 
526
535
  /**
@@ -625,14 +634,10 @@ export class Session {
625
634
  */
626
635
  #taggingDriver(method: string): SessionDriverWithTagging {
627
636
  if (this.#driver === undefined) {
628
- throw new Error(
629
- `session.${method}() needs a session store — this session was built without one.`,
630
- )
637
+ throw new E_SESSION_NOT_READY(method)
631
638
  }
632
639
  if (!supportsTagging(this.#driver)) {
633
- throw new Error(
634
- `session.${method}() is not supported by the configured session store. Use the memory, redis, or database store.`,
635
- )
640
+ throw new E_SESSION_TAGGING_NOT_SUPPORTED(method)
636
641
  }
637
642
  return this.#driver
638
643
  }
@@ -232,7 +232,8 @@ function shareSessionWithView(ctx: HttpContext, session: Session): void {
232
232
  const view = Reflect.get(Object(ctx), 'view')
233
233
  const share = Reflect.get(Object(view), 'share')
234
234
  if (typeof share !== 'function') return
235
- const flashMessages = new ReadOnlyValuesStore(session.flashMessages())
235
+ // The session hands back a store already — no need to rebuild one here.
236
+ const flashMessages = session.flashMessages
236
237
  share.call(view, {
237
238
  session: new ReadOnlyValuesStore(session.all()),
238
239
  flashMessages,
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Session exceptions, under the codes AdonisJS raises.
3
+ *
4
+ * The conditions were already guarded — they just threw bare `Error`s, so an
5
+ * app could only match on the message text.
6
+ *
7
+ * `E_SESSION_NOT_MUTABLE` has no counterpart here on purpose: it is what
8
+ * AdonisJS raises for a session initiated in read-only mode, and ream has no
9
+ * read-only mode. Declaring the class with nothing able to throw it would be
10
+ * worse than its absence — an app would guard a branch that never runs.
11
+ */
12
+
13
+ import { Exception } from '../http/Exception.js'
14
+
15
+ /** The session was used before a store was attached to it. */
16
+ export class E_SESSION_NOT_READY extends Exception {
17
+ static override status = 500
18
+ static override code = 'E_SESSION_NOT_READY'
19
+
20
+ constructor(method: string) {
21
+ super(`session.${method}() needs a session store — this session was built without one.`, {
22
+ status: 500,
23
+ code: 'E_SESSION_NOT_READY',
24
+ })
25
+ }
26
+ }
27
+
28
+ /** The configured store cannot tag sessions (cookie and file stores cannot). */
29
+ export class E_SESSION_TAGGING_NOT_SUPPORTED extends Exception {
30
+ static override status = 500
31
+ static override code = 'E_SESSION_TAGGING_NOT_SUPPORTED'
32
+
33
+ constructor(method: string) {
34
+ super(
35
+ `session.${method}() is not supported by the configured session store. Use the memory, redis, or database store.`,
36
+ { status: 500, code: 'E_SESSION_TAGGING_NOT_SUPPORTED' },
37
+ )
38
+ }
39
+ }