@c9up/ream 0.1.32 → 0.1.33

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 (188) hide show
  1. package/dist/Application.d.ts +27 -0
  2. package/dist/Application.d.ts.map +1 -1
  3. package/dist/Application.js +29 -0
  4. package/dist/Application.js.map +1 -1
  5. package/dist/ErrorBoundary.d.ts.map +1 -1
  6. package/dist/ErrorBoundary.js +13 -1
  7. package/dist/ErrorBoundary.js.map +1 -1
  8. package/dist/HttpKernel.d.ts +8 -0
  9. package/dist/HttpKernel.d.ts.map +1 -1
  10. package/dist/HttpKernel.js +35 -12
  11. package/dist/HttpKernel.js.map +1 -1
  12. package/dist/Ignitor.d.ts +8 -0
  13. package/dist/Ignitor.d.ts.map +1 -1
  14. package/dist/Ignitor.js +39 -0
  15. package/dist/Ignitor.js.map +1 -1
  16. package/dist/bodyparser/BodyParserMiddleware.d.ts +11 -4
  17. package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
  18. package/dist/bodyparser/BodyParserMiddleware.js +55 -43
  19. package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
  20. package/dist/bodyparser/qsParse.d.ts +52 -0
  21. package/dist/bodyparser/qsParse.d.ts.map +1 -0
  22. package/dist/bodyparser/qsParse.js +161 -0
  23. package/dist/bodyparser/qsParse.js.map +1 -0
  24. package/dist/bootstrap/hyper-server.d.ts.map +1 -1
  25. package/dist/bootstrap/hyper-server.js +2 -8
  26. package/dist/bootstrap/hyper-server.js.map +1 -1
  27. package/dist/container/Container.d.ts +41 -0
  28. package/dist/container/Container.d.ts.map +1 -1
  29. package/dist/container/Container.js +193 -26
  30. package/dist/container/Container.js.map +1 -1
  31. package/dist/container/ContainerResolver.d.ts +46 -0
  32. package/dist/container/ContainerResolver.d.ts.map +1 -0
  33. package/dist/container/ContainerResolver.js +60 -0
  34. package/dist/container/ContainerResolver.js.map +1 -0
  35. package/dist/container/ContextualBindingsBuilder.d.ts +20 -0
  36. package/dist/container/ContextualBindingsBuilder.d.ts.map +1 -0
  37. package/dist/container/ContextualBindingsBuilder.js +31 -0
  38. package/dist/container/ContextualBindingsBuilder.js.map +1 -0
  39. package/dist/container/types.d.ts +9 -6
  40. package/dist/container/types.d.ts.map +1 -1
  41. package/dist/decorators/Service.d.ts +1 -5
  42. package/dist/decorators/Service.d.ts.map +1 -1
  43. package/dist/decorators/Service.js +10 -1
  44. package/dist/decorators/Service.js.map +1 -1
  45. package/dist/events/Emitter.d.ts +66 -6
  46. package/dist/events/Emitter.d.ts.map +1 -1
  47. package/dist/events/Emitter.js +165 -28
  48. package/dist/events/Emitter.js.map +1 -1
  49. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  50. package/dist/graphql/GraphQLEngine.js +5 -5
  51. package/dist/graphql/GraphQLEngine.js.map +1 -1
  52. package/dist/helpers/duration.d.ts +32 -0
  53. package/dist/helpers/duration.d.ts.map +1 -0
  54. package/dist/helpers/duration.js +74 -0
  55. package/dist/helpers/duration.js.map +1 -0
  56. package/dist/helpers/napi-loader.d.ts +10 -1
  57. package/dist/helpers/napi-loader.d.ts.map +1 -1
  58. package/dist/helpers/napi-loader.js +18 -3
  59. package/dist/helpers/napi-loader.js.map +1 -1
  60. package/dist/helpers/types.d.ts +1 -1
  61. package/dist/helpers/types.d.ts.map +1 -1
  62. package/dist/http/Exception.d.ts.map +1 -1
  63. package/dist/http/Exception.js +17 -5
  64. package/dist/http/Exception.js.map +1 -1
  65. package/dist/http/HttpContext.d.ts +16 -5
  66. package/dist/http/HttpContext.d.ts.map +1 -1
  67. package/dist/http/HttpContext.js +4 -2
  68. package/dist/http/HttpContext.js.map +1 -1
  69. package/dist/http/RedirectBuilder.d.ts +48 -2
  70. package/dist/http/RedirectBuilder.d.ts.map +1 -1
  71. package/dist/http/RedirectBuilder.js +95 -14
  72. package/dist/http/RedirectBuilder.js.map +1 -1
  73. package/dist/http/Request.d.ts +128 -8
  74. package/dist/http/Request.d.ts.map +1 -1
  75. package/dist/http/Request.js +259 -8
  76. package/dist/http/Request.js.map +1 -1
  77. package/dist/http/Response.d.ts +74 -3
  78. package/dist/http/Response.d.ts.map +1 -1
  79. package/dist/http/Response.js +177 -4
  80. package/dist/http/Response.js.map +1 -1
  81. package/dist/index.d.ts +9 -3
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +5 -1
  84. package/dist/index.js.map +1 -1
  85. package/dist/router/Router.d.ts +36 -7
  86. package/dist/router/Router.d.ts.map +1 -1
  87. package/dist/router/Router.js +66 -22
  88. package/dist/router/Router.js.map +1 -1
  89. package/dist/security/CookieSigner.d.ts +35 -4
  90. package/dist/security/CookieSigner.d.ts.map +1 -1
  91. package/dist/security/CookieSigner.js +99 -10
  92. package/dist/security/CookieSigner.js.map +1 -1
  93. package/dist/server/Server.d.ts +2 -3
  94. package/dist/server/Server.d.ts.map +1 -1
  95. package/dist/server/Server.js +30 -13
  96. package/dist/server/Server.js.map +1 -1
  97. package/dist/session/ReadOnlyValuesStore.d.ts +24 -0
  98. package/dist/session/ReadOnlyValuesStore.d.ts.map +1 -0
  99. package/dist/session/ReadOnlyValuesStore.js +45 -0
  100. package/dist/session/ReadOnlyValuesStore.js.map +1 -0
  101. package/dist/session/Session.d.ts +181 -9
  102. package/dist/session/Session.d.ts.map +1 -1
  103. package/dist/session/Session.js +284 -33
  104. package/dist/session/Session.js.map +1 -1
  105. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  106. package/dist/session/SessionMiddleware.js +87 -33
  107. package/dist/session/SessionMiddleware.js.map +1 -1
  108. package/dist/session/drivers/DatabaseDriver.d.ts +66 -0
  109. package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -0
  110. package/dist/session/drivers/DatabaseDriver.js +132 -0
  111. package/dist/session/drivers/DatabaseDriver.js.map +1 -0
  112. package/dist/session/drivers/FileDriver.d.ts +22 -0
  113. package/dist/session/drivers/FileDriver.d.ts.map +1 -0
  114. package/dist/session/drivers/FileDriver.js +66 -0
  115. package/dist/session/drivers/FileDriver.js.map +1 -0
  116. package/dist/session/drivers/MemoryDriver.d.ts +10 -2
  117. package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
  118. package/dist/session/drivers/MemoryDriver.js +70 -0
  119. package/dist/session/drivers/MemoryDriver.js.map +1 -1
  120. package/dist/session/drivers/RedisDriver.d.ts +10 -2
  121. package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
  122. package/dist/session/drivers/RedisDriver.js +58 -0
  123. package/dist/session/drivers/RedisDriver.js.map +1 -1
  124. package/dist/session/templateTags.d.ts +42 -0
  125. package/dist/session/templateTags.d.ts.map +1 -0
  126. package/dist/session/templateTags.js +101 -0
  127. package/dist/session/templateTags.js.map +1 -0
  128. package/dist/testing/RequestBuilder.d.ts +38 -1
  129. package/dist/testing/RequestBuilder.d.ts.map +1 -1
  130. package/dist/testing/RequestBuilder.js +40 -1
  131. package/dist/testing/RequestBuilder.js.map +1 -1
  132. package/dist/testing/TestClient.d.ts +3 -1
  133. package/dist/testing/TestClient.d.ts.map +1 -1
  134. package/dist/testing/TestClient.js +3 -1
  135. package/dist/testing/TestClient.js.map +1 -1
  136. package/dist/types/helpers.d.ts +1 -1
  137. package/dist/types/helpers.d.ts.map +1 -1
  138. package/dist/utils/Macroable.d.ts +14 -5
  139. package/dist/utils/Macroable.d.ts.map +1 -1
  140. package/dist/utils/Macroable.js +20 -18
  141. package/dist/utils/Macroable.js.map +1 -1
  142. package/events.win32-x64-msvc.node +0 -0
  143. package/index.win32-x64-msvc.node +0 -0
  144. package/package.json +1 -1
  145. package/scheduler.win32-x64-msvc.node +0 -0
  146. package/src/Application.ts +39 -0
  147. package/src/ErrorBoundary.ts +13 -1
  148. package/src/HttpKernel.ts +45 -19
  149. package/src/Ignitor.ts +39 -0
  150. package/src/bodyparser/BodyParserMiddleware.ts +66 -44
  151. package/src/bodyparser/qsParse.ts +179 -0
  152. package/src/bootstrap/hyper-server.ts +2 -9
  153. package/src/container/Container.ts +265 -25
  154. package/src/container/ContainerResolver.ts +84 -0
  155. package/src/container/ContextualBindingsBuilder.ts +41 -0
  156. package/src/container/types.ts +11 -7
  157. package/src/decorators/Service.ts +16 -3
  158. package/src/events/Emitter.ts +200 -25
  159. package/src/graphql/GraphQLEngine.ts +7 -8
  160. package/src/helpers/duration.ts +71 -0
  161. package/src/helpers/napi-loader.ts +19 -3
  162. package/src/helpers/types.ts +4 -1
  163. package/src/http/Exception.ts +17 -5
  164. package/src/http/HttpContext.ts +20 -5
  165. package/src/http/RedirectBuilder.ts +126 -11
  166. package/src/http/Request.ts +283 -13
  167. package/src/http/Response.ts +213 -6
  168. package/src/index.ts +17 -3
  169. package/src/router/Router.ts +98 -33
  170. package/src/security/CookieSigner.ts +102 -10
  171. package/src/server/Server.ts +36 -16
  172. package/src/session/ReadOnlyValuesStore.ts +52 -0
  173. package/src/session/Session.ts +364 -37
  174. package/src/session/SessionMiddleware.ts +93 -30
  175. package/src/session/drivers/DatabaseDriver.ts +184 -0
  176. package/src/session/drivers/FileDriver.ts +82 -0
  177. package/src/session/drivers/MemoryDriver.ts +75 -2
  178. package/src/session/drivers/RedisDriver.ts +77 -2
  179. package/src/session/templateTags.ts +124 -0
  180. package/src/testing/RequestBuilder.ts +76 -0
  181. package/src/testing/TestClient.ts +10 -2
  182. package/src/types/helpers.ts +4 -1
  183. package/src/utils/Macroable.ts +23 -8
  184. package/dist/utils/inflect.d.ts +0 -17
  185. package/dist/utils/inflect.d.ts.map +0 -1
  186. package/dist/utils/inflect.js +0 -32
  187. package/dist/utils/inflect.js.map +0 -1
  188. package/src/utils/inflect.ts +0 -29
@@ -19,9 +19,75 @@ export interface SessionDriver {
19
19
  touch(sessionId: string, ttl: number): Promise<void>
20
20
  }
21
21
 
22
+ /** One session belonging to a tagged user (AdonisJS `TaggedSession`). */
23
+ export interface TaggedSession {
24
+ id: string
25
+ data: Record<string, unknown>
26
+ }
27
+
28
+ /**
29
+ * A driver that can associate a session with a user, so every session a user
30
+ * holds can be listed or destroyed — "log me out everywhere", and "these are
31
+ * your active devices" (AdonisJS `SessionStoreWithTaggingContract`).
32
+ *
33
+ * Only the drivers that keep a queryable index can answer: memory, redis and
34
+ * database. The cookie driver holds no server-side state at all, and the file
35
+ * driver would have to scan the directory on every lookup.
36
+ */
37
+ export interface SessionDriverWithTagging extends SessionDriver {
38
+ tag(sessionId: string, userId: string | number): Promise<void>
39
+ untag(sessionId: string, userId: string | number): Promise<void>
40
+ /** Every non-expired session tagged with this user. */
41
+ tagged(userId: string | number): Promise<TaggedSession[]>
42
+ }
43
+
44
+ /** Whether a driver carries the tagging half of the contract. */
45
+ export function supportsTagging(driver: SessionDriver): driver is SessionDriverWithTagging {
46
+ return (
47
+ 'tag' in driver &&
48
+ typeof (driver as SessionDriverWithTagging).tag === 'function' &&
49
+ 'tagged' in driver &&
50
+ typeof (driver as SessionDriverWithTagging).tagged === 'function'
51
+ )
52
+ }
53
+
54
+ /** Where `flashAll` / `flashOnly` / `flashExcept` put the request input. */
55
+ const FLASH_INPUT_KEY = 'input'
56
+
57
+ /** Where the intended URL is stored, out of the way of app keys. */
58
+ const INTENDED_URL_KEY = '__intended_url'
59
+
22
60
  export interface SessionConfig {
23
- driver: string
61
+ /**
62
+ * Which store to use. AdonisJS names this key `store`; `driver` is ream's
63
+ * older spelling and both are accepted, so a migrated `config/session.ts`
64
+ * runs with its imports rewritten and nothing else.
65
+ */
66
+ driver?: string
67
+ /** AdonisJS spelling of {@link driver}. */
68
+ store?: string
69
+ /**
70
+ * Named stores, AdonisJS-style: `{ store: 'redis', stores: { redis: … } }`.
71
+ * The selected entry supplies the driver name and its options.
72
+ */
73
+ stores?: Record<string, { driver: string } & Record<string, unknown>>
74
+ /** `file` driver only — the directory session files are written to. */
75
+ location?: string
76
+ /** `database` driver only — the connection to store sessions on. */
77
+ dbConnection?: unknown
78
+ /** `database` driver only — the table holding them (default `sessions`). */
79
+ tableName?: string
80
+ /**
81
+ * `database` driver only — percent chance that a write also sweeps expired
82
+ * rows (AdonisJS `gcProbability`, default 2). `0` turns it off.
83
+ */
84
+ gcProbability?: number
24
85
  cookieName?: string
86
+ /**
87
+ * Session lifetime. A bare number is SECONDS; a string carries a unit
88
+ * (`'2h'`), which is how AdonisJS writes it (`age: string | number`).
89
+ */
90
+ age?: number | string
25
91
  maxAge?: number // seconds, default 7200 (2h)
26
92
  clearWithBrowser?: boolean
27
93
  /**
@@ -64,11 +130,18 @@ function generateSessionId(): string {
64
130
  return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
65
131
  }
66
132
 
133
+ /** A non-null, non-array object — the shape flash data must have to be keyed. */
134
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
135
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
136
+ }
137
+
67
138
  export class Session {
68
139
  #data: Record<string, unknown> = {}
69
140
  #flashData: Record<string, unknown> = {}
70
141
  #previousFlash: Record<string, unknown> = {}
71
142
  #dirty = false
143
+ /** Reads the request's original input; absent outside a request. */
144
+ #readInput?: () => Record<string, unknown>
72
145
  #sessionId: string
73
146
  /**
74
147
  * The session id active when the request arrived, captured at construction.
@@ -78,17 +151,32 @@ export class Session {
78
151
  */
79
152
  readonly #originalSessionId: string
80
153
  #regenerated = false
154
+ /** Whether this session was created for THIS request (no incoming cookie). */
155
+ #fresh = false
156
+ /** The store behind it, when there is one — absent for a bare unit-test session. */
157
+ #driver?: SessionDriver
158
+ /** Session lifetime in seconds, used by {@link commit}. */
159
+ #ttl = 0
160
+ /** Whether {@link initiate} already ran, so a second call is a no-op. */
161
+ #initiated = false
81
162
 
82
163
  constructor(sessionId: string, data: Record<string, unknown> = {}) {
83
164
  this.#sessionId = sessionId
84
165
  this.#originalSessionId = sessionId
166
+ this.#hydrate(data)
167
+ }
168
+
169
+ /**
170
+ * Seat stored data: the payload becomes the session, and the flash bag it
171
+ * carried becomes THIS request's `old()` / `flashMessages`.
172
+ *
173
+ * Shared by the constructor and {@link initiate}, so a session loaded from
174
+ * the store lands in exactly the same state as one handed its data directly.
175
+ */
176
+ #hydrate(data: Record<string, unknown>): void {
85
177
  this.#data = { ...data }
86
178
  const flash = data.__flash
87
- this.#previousFlash =
88
- typeof flash === 'object' && flash !== null && !Array.isArray(flash)
89
- ? // biome-ignore lint/suspicious/noExplicitAny: flash narrowed to non-null non-array object; branded as Record for key access
90
- (flash as any as Record<string, unknown>)
91
- : {}
179
+ this.#previousFlash = isPlainRecord(flash) ? { ...flash } : {}
92
180
  delete this.#data.__flash
93
181
  }
94
182
 
@@ -127,12 +215,11 @@ export class Session {
127
215
 
128
216
  /** Get a session value. */
129
217
  get<T = unknown>(key: string, defaultValue?: T): T {
130
- if (key in this.#data) {
131
- // biome-ignore lint/suspicious/noExplicitAny: session data stored as unknown; caller brands the value type via T
132
- return this.#data[key] as any as T
133
- }
134
- // biome-ignore lint/suspicious/noExplicitAny: generic default — caller brands the value type via T
135
- return defaultValue as any as T
218
+ // The store is untyped by nature, so `T` is the CALLER's claim about what
219
+ // it put there — one assertion from `unknown`, which is what `T` means
220
+ // here. Going through `any` first widened nothing and hid the intent.
221
+ if (key in this.#data) return this.#data[key] as T
222
+ return defaultValue as T
136
223
  }
137
224
 
138
225
  /** Set a session value. */
@@ -183,35 +270,152 @@ export class Session {
183
270
 
184
271
  // ─── Flash data ───────────────────────────────────────────
185
272
 
186
- /** Set flash data (available only on the next request). */
187
- flash(key: string, value: unknown): void {
188
- this.#flashData[key] = value
273
+ /**
274
+ * @internal Give the session read access to the request's ORIGINAL input.
275
+ *
276
+ * AdonisJS's session holds the HTTP context and reads `request.original()`;
277
+ * ream's Session stays free of it, so the middleware injects a reader. Absent
278
+ * (a session built outside a request), the input-flashing methods flash
279
+ * nothing rather than throwing.
280
+ */
281
+ setInputReader(read: () => Record<string, unknown>): void {
282
+ this.#readInput = read
283
+ }
284
+
285
+ /** The request's original input, or nothing when there is no request. */
286
+ #input(): Record<string, unknown> {
287
+ return this.#readInput?.() ?? {}
288
+ }
289
+
290
+ /**
291
+ * Set flash data, available only on the next request.
292
+ *
293
+ * Takes a key and a value, or an object of both (AdonisJS accepts either).
294
+ */
295
+ flash(key: string | Record<string, unknown>, value?: unknown): void {
296
+ if (typeof key === 'string') this.#flashData[key] = value
297
+ else Object.assign(this.#flashData, key)
189
298
  this.#dirty = true
190
299
  }
191
300
 
192
- /** Flash all current input. */
193
- flashAll(input: Record<string, unknown>): void {
194
- for (const [k, v] of Object.entries(input)) {
195
- this.#flashData[k] = v
301
+ /**
302
+ * Flash the whole request input for the next request (AdonisJS `flashAll`).
303
+ *
304
+ * Takes NO argument: it reads the request's original input itself, which is
305
+ * what makes `session.flashAll()` before a redirect-back repopulate a form.
306
+ */
307
+ flashAll(): void {
308
+ this.flash(FLASH_INPUT_KEY, this.#input())
309
+ }
310
+
311
+ /** Flash only these input keys (AdonisJS `flashOnly`). */
312
+ flashOnly(keys: string[]): void {
313
+ const input = this.#input()
314
+ const picked: Record<string, unknown> = {}
315
+ for (const key of keys) {
316
+ if (Object.hasOwn(input, key)) picked[key] = input[key]
196
317
  }
197
- this.#dirty = true
318
+ this.flash(FLASH_INPUT_KEY, picked)
198
319
  }
199
320
 
200
- /** Flash only specific keys. */
201
- flashOnly(input: Record<string, unknown>, keys: string[]): void {
202
- for (const k of keys) {
203
- if (k in input) this.#flashData[k] = input[k]
321
+ /**
322
+ * Flash the request input except these keys (AdonisJS `flashExcept`).
323
+ *
324
+ * session.flashExcept(['password', '_csrf'])
325
+ */
326
+ flashExcept(keys: string[]): void {
327
+ const omit = new Set(keys)
328
+ const kept: Record<string, unknown> = {}
329
+ for (const [key, value] of Object.entries(this.#input())) {
330
+ if (!omit.has(key)) kept[key] = value
204
331
  }
205
- this.#dirty = true
332
+ this.flash(FLASH_INPUT_KEY, kept)
206
333
  }
207
334
 
208
- /** Flash all except specific keys. */
209
- flashExcept(input: Record<string, unknown>, keys: string[]): void {
210
- const keySet = new Set(keys)
211
- for (const [k, v] of Object.entries(input)) {
212
- if (!keySet.has(k)) this.#flashData[k] = v
335
+ /**
336
+ * Flash an error collection under `errorsBag` (AdonisJS `flashErrors`) —
337
+ * where the `@error` / `@errors` template tags read from.
338
+ */
339
+ flashErrors(errors: Record<string, string | string[]>): void {
340
+ this.flash({ errorsBag: errors })
341
+ }
342
+
343
+ /**
344
+ * Flash a validation failure the way a form expects it (AdonisJS
345
+ * `flashValidationErrors`): the per-field messages under `inputErrorsBag`,
346
+ * a summary under `errorsBag`, and — unless told otherwise — the input, so
347
+ * the redisplayed form keeps what the user typed.
348
+ */
349
+ flashValidationErrors(
350
+ error: { code?: string; messages?: Array<{ field?: string; message?: string }> },
351
+ withInput = true,
352
+ ): void {
353
+ const bag: Record<string, string[]> = {}
354
+ for (const message of error.messages ?? []) {
355
+ if (message.field === undefined || message.message === undefined) continue
356
+ const existing = bag[message.field] ?? []
357
+ existing.push(message.message)
358
+ bag[message.field] = existing
213
359
  }
214
- this.#dirty = true
360
+ this.flash('inputErrorsBag', bag)
361
+ this.flashErrors({
362
+ [error.code ?? 'E_VALIDATION_ERROR']: Object.values(bag).flat(),
363
+ })
364
+ if (withInput) this.flashAll()
365
+ }
366
+
367
+ /**
368
+ * Keep the PREVIOUS request's flash data for one more request (AdonisJS
369
+ * `reflash`) — what a redirect chain needs so a message survives the hop.
370
+ */
371
+ reflash(): void {
372
+ this.flash(this.#previousFlash)
373
+ }
374
+
375
+ /** Keep only these keys of the previous flash data (AdonisJS `reflashOnly`). */
376
+ reflashOnly(keys: string[]): void {
377
+ for (const key of keys) {
378
+ if (Object.hasOwn(this.#previousFlash, key)) {
379
+ this.flash(key, this.#previousFlash[key])
380
+ }
381
+ }
382
+ }
383
+
384
+ /** Keep the previous flash data except these keys (AdonisJS `reflashExcept`). */
385
+ reflashExcept(keys: string[]): void {
386
+ const omit = new Set(keys)
387
+ for (const [key, value] of Object.entries(this.#previousFlash)) {
388
+ if (!omit.has(key)) this.flash(key, value)
389
+ }
390
+ }
391
+
392
+ // ─── Intended URL ─────────────────────────────────────────
393
+
394
+ /**
395
+ * Remember where the user was heading (AdonisJS `setIntendedUrl`).
396
+ *
397
+ * An auth middleware stores it before redirecting to the login page, so the
398
+ * user lands back on the page they asked for instead of a generic dashboard.
399
+ */
400
+ setIntendedUrl(url: string): void {
401
+ this.put(INTENDED_URL_KEY, url)
402
+ }
403
+
404
+ /** The remembered URL, or null. */
405
+ getIntendedUrl(): string | null {
406
+ const url = this.get<string>(INTENDED_URL_KEY)
407
+ return typeof url === 'string' ? url : null
408
+ }
409
+
410
+ /** Read it and forget it — the usual call right after a successful login. */
411
+ pullIntendedUrl(): string | null {
412
+ const url = this.getIntendedUrl()
413
+ this.clearIntendedUrl()
414
+ return url
415
+ }
416
+
417
+ clearIntendedUrl(): void {
418
+ this.forget(INTENDED_URL_KEY)
215
419
  }
216
420
 
217
421
  /** Get a flashed value from the previous request. */
@@ -221,12 +425,10 @@ export class Session {
221
425
 
222
426
  /** Get a specific flash message from the previous request. */
223
427
  old<T = unknown>(key: string, defaultValue?: T): T {
224
- if (key in this.#previousFlash) {
225
- // biome-ignore lint/suspicious/noExplicitAny: flash data stored as unknown; caller brands the value type via T
226
- return this.#previousFlash[key] as any as T
227
- }
228
- // biome-ignore lint/suspicious/noExplicitAny: generic default — caller brands the value type via T
229
- return defaultValue as any as T
428
+ // Same contract as `get`: `T` is the caller's claim about a store that is
429
+ // untyped by nature.
430
+ if (key in this.#previousFlash) return this.#previousFlash[key] as T
431
+ return defaultValue as T
230
432
  }
231
433
 
232
434
  // ─── Serialization ────────────────────────────────────────
@@ -243,4 +445,129 @@ export class Session {
243
445
  isDirty(): boolean {
244
446
  return this.#dirty
245
447
  }
448
+
449
+ /** AdonisJS spelling of {@link isDirty}. */
450
+ get hasBeenModified(): boolean {
451
+ return this.#dirty
452
+ }
453
+
454
+ /**
455
+ * Whether this session was created during THIS request — no session cookie
456
+ * came in with it (AdonisJS `fresh`).
457
+ */
458
+ get fresh(): boolean {
459
+ return this.#fresh
460
+ }
461
+
462
+ /**
463
+ * Whether the session holds nothing (AdonisJS `isEmpty`). Flash data counts:
464
+ * a session carrying only a flash message still has something to write.
465
+ */
466
+ get isEmpty(): boolean {
467
+ return Object.keys(this.#data).length === 0 && Object.keys(this.#flashData).length === 0
468
+ }
469
+
470
+ /**
471
+ * Whether writes are refused (AdonisJS `readonly`). Always false here: ream
472
+ * has no read-only session mode, and reporting `true` would tell a caller
473
+ * its writes were dropped when they were not.
474
+ */
475
+ get readonly(): boolean {
476
+ return false
477
+ }
478
+
479
+ /**
480
+ * @internal Wire the store this session lives in. Called by
481
+ * `SessionMiddleware`; a session built by hand in a test has none, and
482
+ * {@link initiate} / {@link commit} then have nothing to do.
483
+ */
484
+ setStore(driver: SessionDriver, options: { fresh: boolean; ttl: number }): void {
485
+ this.#driver = driver
486
+ this.#fresh = options.fresh
487
+ this.#ttl = options.ttl
488
+ }
489
+
490
+ /**
491
+ * Load the session from its store (AdonisJS `initiate`).
492
+ *
493
+ * Idempotent, as upstream: calling it twice is a no-op, so a middleware that
494
+ * runs on a sub-request cannot wipe what the outer one already read.
495
+ */
496
+ async initiate(): Promise<void> {
497
+ if (this.#initiated || this.#driver === undefined) return
498
+ this.#initiated = true
499
+ this.#hydrate(await this.#driver.read(this.#sessionId))
500
+ }
501
+
502
+ /**
503
+ * Persist the session to its store (AdonisJS `commit`).
504
+ *
505
+ * - regenerated: write under the NEW id first, then drop the old entry, so a
506
+ * crash between the two leaves the user with a valid session under one id
507
+ * rather than none.
508
+ * - modified: write.
509
+ * - untouched but pre-existing: touch, to slide the expiry.
510
+ * - untouched and brand new: nothing. Writing here would hand out a session
511
+ * id pointing at a row nobody asked for.
512
+ */
513
+ async commit(): Promise<void> {
514
+ const driver = this.#driver
515
+ if (driver === undefined) return
516
+ if (this.#regenerated) {
517
+ await driver.write(this.#sessionId, this.toJSON(), this.#ttl)
518
+ try {
519
+ await driver.destroy(this.#originalSessionId)
520
+ } catch {
521
+ // Benign: a stale entry the TTL reclaims. The session is already live
522
+ // under the new id, and failing the request here would be worse.
523
+ }
524
+ return
525
+ }
526
+ if (this.#dirty) {
527
+ await driver.write(this.#sessionId, this.toJSON(), this.#ttl)
528
+ return
529
+ }
530
+ if (!this.#fresh) await driver.touch(this.#sessionId, this.#ttl)
531
+ }
532
+
533
+ /**
534
+ * Whether the store behind this session can tag sessions with a user
535
+ * (AdonisJS `supportsTagging`). False under the cookie and file drivers.
536
+ */
537
+ supportsTagging(): boolean {
538
+ return this.#driver !== undefined && supportsTagging(this.#driver)
539
+ }
540
+
541
+ /**
542
+ * Associate this session with a user, so every session that user holds can
543
+ * be found later (AdonisJS `tag`) — what "log out from all my devices" and
544
+ * "your active sessions" are built on. Call it at login.
545
+ */
546
+ async tag(userId: string | number): Promise<void> {
547
+ await this.#taggingDriver('tag').tag(this.#sessionId, userId)
548
+ }
549
+
550
+ /** Drop the association made by {@link tag} (AdonisJS `untag`). Call it at logout. */
551
+ async untag(userId: string | number): Promise<void> {
552
+ await this.#taggingDriver('untag').untag(this.#sessionId, userId)
553
+ }
554
+
555
+ /**
556
+ * The store, asserted to support tagging. Throws naming the method rather
557
+ * than resolving to a no-op: a login that believes it tagged the session
558
+ * would leave "log out everywhere" silently doing nothing.
559
+ */
560
+ #taggingDriver(method: string): SessionDriverWithTagging {
561
+ if (this.#driver === undefined) {
562
+ throw new Error(
563
+ `session.${method}() needs a session store — this session was built without one.`,
564
+ )
565
+ }
566
+ if (!supportsTagging(this.#driver)) {
567
+ throw new Error(
568
+ `session.${method}() is not supported by the configured session store. Use the memory, redis, or database store.`,
569
+ )
570
+ }
571
+ return this.#driver
572
+ }
246
573
  }
@@ -6,11 +6,15 @@
6
6
  */
7
7
 
8
8
  import { randomBytes } from 'node:crypto'
9
+ import { durationToSeconds } from '../helpers/duration.js'
9
10
  import type { HttpContext } from '../http/HttpContext.js'
10
11
  import { CookieDriver } from './drivers/CookieDriver.js'
12
+ import { DatabaseDriver, type SessionDbConnection } from './drivers/DatabaseDriver.js'
13
+ import { FileDriver } from './drivers/FileDriver.js'
11
14
  import { MemoryDriver } from './drivers/MemoryDriver.js'
12
15
  import { RedisDriver } from './drivers/RedisDriver.js'
13
16
  import { quasarConnection } from './quasar.js'
17
+ import { ReadOnlyValuesStore, type ValuePath } from './ReadOnlyValuesStore.js'
14
18
  import type { SessionConfig, SessionDriver } from './Session.js'
15
19
  import { Session } from './Session.js'
16
20
 
@@ -22,16 +26,35 @@ interface ResolvedSessionConfig {
22
26
  rolling: boolean
23
27
  }
24
28
 
29
+ /** Duck-typed check for a connection the database store can drive. */
30
+ function isSessionDbConnection(value: unknown): value is SessionDbConnection {
31
+ return (
32
+ typeof value === 'object' &&
33
+ value !== null &&
34
+ typeof Reflect.get(value, 'query') === 'function' &&
35
+ typeof Reflect.get(value, 'execute') === 'function'
36
+ )
37
+ }
38
+
25
39
  export default class SessionMiddleware {
26
40
  #driver: SessionDriver
27
41
  #cookieDriver: CookieDriver | null = null
28
42
  #config: ResolvedSessionConfig
29
43
 
30
44
  constructor(config?: SessionConfig & { secret?: string }) {
45
+ // `store` + `stores` (AdonisJS) and `driver` (ream) name the same thing.
46
+ // A named store supplies the driver and its own options, so
47
+ // `{ store: 'redis', stores: { redis: { driver: 'redis' } } }` works.
48
+ const selected = config?.store ?? config?.driver
49
+ const named = selected ? config?.stores?.[selected] : undefined
31
50
  this.#config = {
32
- driver: config?.driver ?? 'memory',
51
+ driver: named?.driver ?? selected ?? 'memory',
33
52
  cookieName: config?.cookieName ?? 'ream_session',
34
- maxAge: config?.maxAge ?? 7200,
53
+ // `age` is the AdonisJS key and accepts a duration string.
54
+ maxAge:
55
+ config?.age !== undefined
56
+ ? durationToSeconds(config.age, 'a session age')
57
+ : (config?.maxAge ?? 7200),
35
58
  clearWithBrowser: config?.clearWithBrowser ?? false,
36
59
  rolling: config?.rolling ?? false,
37
60
  }
@@ -46,6 +69,26 @@ export default class SessionMiddleware {
46
69
  this.#driver = this.#cookieDriver
47
70
  } else if (this.#config.driver === 'memory') {
48
71
  this.#driver = new MemoryDriver()
72
+ } else if (this.#config.driver === 'file') {
73
+ const location = config?.location ?? named?.location
74
+ if (typeof location !== 'string') {
75
+ throw new Error(
76
+ 'File session driver requires a `location`. Set session.location (or stores.<name>.location) to a writable directory.',
77
+ )
78
+ }
79
+ this.#driver = new FileDriver({ location })
80
+ } else if (this.#config.driver === 'database') {
81
+ const connection = config?.dbConnection ?? named?.connection
82
+ if (!isSessionDbConnection(connection)) {
83
+ throw new Error(
84
+ 'Database session driver requires a `connection` exposing query() and execute().',
85
+ )
86
+ }
87
+ this.#driver = new DatabaseDriver({
88
+ connection,
89
+ tableName: typeof config?.tableName === 'string' ? config.tableName : undefined,
90
+ gcProbability: typeof config?.gcProbability === 'number' ? config.gcProbability : undefined,
91
+ })
49
92
  } else if (this.#config.driver === 'redis') {
50
93
  // Either the app hands a client in, or it names a quasar connection —
51
94
  // the same choice echo, bay and warden offer. Nothing is dialled here:
@@ -64,7 +107,9 @@ export default class SessionMiddleware {
64
107
  const isProduction = process.env.NODE_ENV === 'production'
65
108
 
66
109
  // Read session ID from cookie — pre-parsed by the Rust HyperServer.
67
- let sessionId = ctx.request.plainCookie(cookieName)
110
+ let sessionId = ctx.request.plainCookie<string>(cookieName, undefined, {
111
+ encoded: false,
112
+ })
68
113
  const hadIncomingCookie = sessionId !== null
69
114
 
70
115
  // Cookie driver: the cookie value IS the session data (encrypted)
@@ -72,8 +117,16 @@ export default class SessionMiddleware {
72
117
  const data = await this.#driver.read(sessionId ?? '')
73
118
  sessionId = sessionId ?? generateSessionId()
74
119
  const session = new Session(sessionId, data)
120
+ // No store to commit to: the cookie written below IS the storage, so the
121
+ // session is seated without one and `commit()` has nothing to do.
122
+ session.setStore(this.#driver, { fresh: !hadIncomingCookie, ttl: maxAge })
75
123
  ctx.store.set('session', session)
76
124
  ctx.session = session
125
+ session.setInputReader(() => ctx.request.original())
126
+ // Same as the server-side branch below: without it `{{ flashMessages }}`
127
+ // and the `@error` / `@errors` / `@inputError` tags saw nothing at all
128
+ // under the cookie driver, so a form silently lost its messages.
129
+ shareSessionWithView(ctx, session)
77
130
 
78
131
  await next()
79
132
 
@@ -94,6 +147,9 @@ export default class SessionMiddleware {
94
147
  )
95
148
  }
96
149
  ctx.response.plainCookie(cookieName, encoded, {
150
+ // Already encrypted by the cookie driver; packing it again would
151
+ // only make the cookie bigger and change nothing.
152
+ encode: false,
97
153
  maxAge: this.#config.clearWithBrowser ? undefined : maxAge,
98
154
  path: '/',
99
155
  httpOnly: true,
@@ -109,39 +165,23 @@ export default class SessionMiddleware {
109
165
  sessionId = generateSessionId()
110
166
  }
111
167
 
112
- const data = await this.#driver.read(sessionId)
113
- const session = new Session(sessionId, data)
168
+ const session = new Session(sessionId)
169
+ session.setStore(this.#driver, { fresh: !hadIncomingCookie, ttl: maxAge })
170
+ await session.initiate()
114
171
  ctx.store.set('session', session)
115
172
  ctx.session = session
173
+ // `flashAll()` takes no argument (AdonisJS): it reads the request's own
174
+ // input, which is what repopulates a form after a redirect-back.
175
+ session.setInputReader(() => ctx.request.original())
176
+ shareSessionWithView(ctx, session)
116
177
 
117
178
  await next()
118
179
 
119
- // Session-fixation mitigation: if the handler called `session.regenerate()`
120
- // (typically via Warden's SessionStrategy.login), the session id has been
121
- // rotated. Migrate the driver storage atomically — write under the new
122
- // id first, then destroy the old entry so a crash between calls leaves
123
- // the user with a valid session under one of the two ids rather than
124
- // none. The cookie below picks up `session.sessionId` which already
125
- // returns the new value.
180
+ // The store half of the lifecycle — including the session-fixation
181
+ // migration when the handler called `regenerate()` — belongs to the
182
+ // session, as it does upstream.
126
183
  const regenerated = session.wasRegenerated()
127
- const isNew = !hadIncomingCookie
128
- if (regenerated) {
129
- const originalId = session.originalSessionId()
130
- const currentId = session.sessionId
131
- await this.#driver.write(currentId, session.toJSON(), maxAge)
132
- // Best-effort: a destroy failure leaves a stale entry that TTL will
133
- // sweep, but should not crash the request. The new session is already
134
- // live under `currentId`.
135
- try {
136
- await this.#driver.destroy(originalId)
137
- } catch {
138
- // benign — TTL will reclaim it.
139
- }
140
- } else if (session.isDirty()) {
141
- await this.#driver.write(sessionId, session.toJSON(), maxAge)
142
- } else if (!isNew) {
143
- await this.#driver.touch(sessionId, maxAge)
144
- }
184
+ await session.commit()
145
185
 
146
186
  // Emit Set-Cookie only when there is server-side state worth
147
187
  // pinning to the browser: the session was modified (isDirty → we
@@ -154,6 +194,9 @@ export default class SessionMiddleware {
154
194
  // the cookie-driver path above (which already omits `isNew`).
155
195
  if (session.isDirty() || regenerated || (rolling && hadIncomingCookie)) {
156
196
  ctx.response.plainCookie(cookieName, session.sessionId, {
197
+ // An opaque id — packing it would invalidate every session cookie
198
+ // already in a browser for no gain.
199
+ encode: false,
157
200
  maxAge: this.#config.clearWithBrowser ? undefined : maxAge,
158
201
  path: '/',
159
202
  httpOnly: true,
@@ -167,3 +210,23 @@ export default class SessionMiddleware {
167
210
  function generateSessionId(): string {
168
211
  return randomBytes(24).toString('base64url')
169
212
  }
213
+
214
+ /**
215
+ * Share the session with the request's view, as AdonisJS's session does.
216
+ *
217
+ * A migrated template reads `{{ old('email') }}`, `@error('email')` and
218
+ * `{{ flashMessages.get(...) }}` unchanged, so the three names and their shapes
219
+ * have to match. Skipped when the app installed no template layer.
220
+ */
221
+ function shareSessionWithView(ctx: HttpContext, session: Session): void {
222
+ const view = Reflect.get(Object(ctx), 'view')
223
+ const share = Reflect.get(Object(view), 'share')
224
+ if (typeof share !== 'function') return
225
+ const flashMessages = new ReadOnlyValuesStore(session.flashMessages())
226
+ share.call(view, {
227
+ session: new ReadOnlyValuesStore(session.all()),
228
+ flashMessages,
229
+ // Adonis exposes the previous request's input under `old`.
230
+ old: (key: ValuePath, defaultValue?: unknown): unknown => flashMessages.get(key, defaultValue),
231
+ })
232
+ }