@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.
- package/dist/Application.d.ts +27 -0
- package/dist/Application.d.ts.map +1 -1
- package/dist/Application.js +29 -0
- package/dist/Application.js.map +1 -1
- package/dist/ErrorBoundary.d.ts.map +1 -1
- package/dist/ErrorBoundary.js +13 -1
- package/dist/ErrorBoundary.js.map +1 -1
- package/dist/HttpKernel.d.ts +8 -0
- package/dist/HttpKernel.d.ts.map +1 -1
- package/dist/HttpKernel.js +35 -12
- package/dist/HttpKernel.js.map +1 -1
- package/dist/Ignitor.d.ts +8 -0
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +39 -0
- package/dist/Ignitor.js.map +1 -1
- package/dist/bodyparser/BodyParserMiddleware.d.ts +11 -4
- package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
- package/dist/bodyparser/BodyParserMiddleware.js +55 -43
- package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
- package/dist/bodyparser/qsParse.d.ts +52 -0
- package/dist/bodyparser/qsParse.d.ts.map +1 -0
- package/dist/bodyparser/qsParse.js +161 -0
- package/dist/bodyparser/qsParse.js.map +1 -0
- package/dist/bootstrap/hyper-server.d.ts.map +1 -1
- package/dist/bootstrap/hyper-server.js +2 -8
- package/dist/bootstrap/hyper-server.js.map +1 -1
- package/dist/container/Container.d.ts +41 -0
- package/dist/container/Container.d.ts.map +1 -1
- package/dist/container/Container.js +193 -26
- package/dist/container/Container.js.map +1 -1
- package/dist/container/ContainerResolver.d.ts +46 -0
- package/dist/container/ContainerResolver.d.ts.map +1 -0
- package/dist/container/ContainerResolver.js +60 -0
- package/dist/container/ContainerResolver.js.map +1 -0
- package/dist/container/ContextualBindingsBuilder.d.ts +20 -0
- package/dist/container/ContextualBindingsBuilder.d.ts.map +1 -0
- package/dist/container/ContextualBindingsBuilder.js +31 -0
- package/dist/container/ContextualBindingsBuilder.js.map +1 -0
- package/dist/container/types.d.ts +9 -6
- package/dist/container/types.d.ts.map +1 -1
- package/dist/decorators/Service.d.ts +1 -5
- package/dist/decorators/Service.d.ts.map +1 -1
- package/dist/decorators/Service.js +10 -1
- package/dist/decorators/Service.js.map +1 -1
- package/dist/events/Emitter.d.ts +66 -6
- package/dist/events/Emitter.d.ts.map +1 -1
- package/dist/events/Emitter.js +165 -28
- package/dist/events/Emitter.js.map +1 -1
- package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
- package/dist/graphql/GraphQLEngine.js +5 -5
- package/dist/graphql/GraphQLEngine.js.map +1 -1
- package/dist/helpers/duration.d.ts +32 -0
- package/dist/helpers/duration.d.ts.map +1 -0
- package/dist/helpers/duration.js +74 -0
- package/dist/helpers/duration.js.map +1 -0
- package/dist/helpers/napi-loader.d.ts +10 -1
- package/dist/helpers/napi-loader.d.ts.map +1 -1
- package/dist/helpers/napi-loader.js +18 -3
- package/dist/helpers/napi-loader.js.map +1 -1
- package/dist/helpers/types.d.ts +1 -1
- package/dist/helpers/types.d.ts.map +1 -1
- package/dist/http/Exception.d.ts.map +1 -1
- package/dist/http/Exception.js +17 -5
- package/dist/http/Exception.js.map +1 -1
- package/dist/http/HttpContext.d.ts +16 -5
- package/dist/http/HttpContext.d.ts.map +1 -1
- package/dist/http/HttpContext.js +4 -2
- package/dist/http/HttpContext.js.map +1 -1
- package/dist/http/RedirectBuilder.d.ts +48 -2
- package/dist/http/RedirectBuilder.d.ts.map +1 -1
- package/dist/http/RedirectBuilder.js +95 -14
- package/dist/http/RedirectBuilder.js.map +1 -1
- package/dist/http/Request.d.ts +128 -8
- package/dist/http/Request.d.ts.map +1 -1
- package/dist/http/Request.js +259 -8
- package/dist/http/Request.js.map +1 -1
- package/dist/http/Response.d.ts +74 -3
- package/dist/http/Response.d.ts.map +1 -1
- package/dist/http/Response.js +177 -4
- package/dist/http/Response.js.map +1 -1
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/router/Router.d.ts +36 -7
- package/dist/router/Router.d.ts.map +1 -1
- package/dist/router/Router.js +66 -22
- package/dist/router/Router.js.map +1 -1
- package/dist/security/CookieSigner.d.ts +35 -4
- package/dist/security/CookieSigner.d.ts.map +1 -1
- package/dist/security/CookieSigner.js +99 -10
- package/dist/security/CookieSigner.js.map +1 -1
- package/dist/server/Server.d.ts +2 -3
- package/dist/server/Server.d.ts.map +1 -1
- package/dist/server/Server.js +30 -13
- package/dist/server/Server.js.map +1 -1
- package/dist/session/ReadOnlyValuesStore.d.ts +24 -0
- package/dist/session/ReadOnlyValuesStore.d.ts.map +1 -0
- package/dist/session/ReadOnlyValuesStore.js +45 -0
- package/dist/session/ReadOnlyValuesStore.js.map +1 -0
- package/dist/session/Session.d.ts +181 -9
- package/dist/session/Session.d.ts.map +1 -1
- package/dist/session/Session.js +284 -33
- package/dist/session/Session.js.map +1 -1
- package/dist/session/SessionMiddleware.d.ts.map +1 -1
- package/dist/session/SessionMiddleware.js +87 -33
- package/dist/session/SessionMiddleware.js.map +1 -1
- package/dist/session/drivers/DatabaseDriver.d.ts +66 -0
- package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -0
- package/dist/session/drivers/DatabaseDriver.js +132 -0
- package/dist/session/drivers/DatabaseDriver.js.map +1 -0
- package/dist/session/drivers/FileDriver.d.ts +22 -0
- package/dist/session/drivers/FileDriver.d.ts.map +1 -0
- package/dist/session/drivers/FileDriver.js +66 -0
- package/dist/session/drivers/FileDriver.js.map +1 -0
- package/dist/session/drivers/MemoryDriver.d.ts +10 -2
- package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
- package/dist/session/drivers/MemoryDriver.js +70 -0
- package/dist/session/drivers/MemoryDriver.js.map +1 -1
- package/dist/session/drivers/RedisDriver.d.ts +10 -2
- package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
- package/dist/session/drivers/RedisDriver.js +58 -0
- package/dist/session/drivers/RedisDriver.js.map +1 -1
- package/dist/session/templateTags.d.ts +42 -0
- package/dist/session/templateTags.d.ts.map +1 -0
- package/dist/session/templateTags.js +101 -0
- package/dist/session/templateTags.js.map +1 -0
- package/dist/testing/RequestBuilder.d.ts +38 -1
- package/dist/testing/RequestBuilder.d.ts.map +1 -1
- package/dist/testing/RequestBuilder.js +40 -1
- package/dist/testing/RequestBuilder.js.map +1 -1
- package/dist/testing/TestClient.d.ts +3 -1
- package/dist/testing/TestClient.d.ts.map +1 -1
- package/dist/testing/TestClient.js +3 -1
- package/dist/testing/TestClient.js.map +1 -1
- package/dist/types/helpers.d.ts +1 -1
- package/dist/types/helpers.d.ts.map +1 -1
- package/dist/utils/Macroable.d.ts +14 -5
- package/dist/utils/Macroable.d.ts.map +1 -1
- package/dist/utils/Macroable.js +20 -18
- package/dist/utils/Macroable.js.map +1 -1
- package/events.win32-x64-msvc.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +1 -1
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/Application.ts +39 -0
- package/src/ErrorBoundary.ts +13 -1
- package/src/HttpKernel.ts +45 -19
- package/src/Ignitor.ts +39 -0
- package/src/bodyparser/BodyParserMiddleware.ts +66 -44
- package/src/bodyparser/qsParse.ts +179 -0
- package/src/bootstrap/hyper-server.ts +2 -9
- package/src/container/Container.ts +265 -25
- package/src/container/ContainerResolver.ts +84 -0
- package/src/container/ContextualBindingsBuilder.ts +41 -0
- package/src/container/types.ts +11 -7
- package/src/decorators/Service.ts +16 -3
- package/src/events/Emitter.ts +200 -25
- package/src/graphql/GraphQLEngine.ts +7 -8
- package/src/helpers/duration.ts +71 -0
- package/src/helpers/napi-loader.ts +19 -3
- package/src/helpers/types.ts +4 -1
- package/src/http/Exception.ts +17 -5
- package/src/http/HttpContext.ts +20 -5
- package/src/http/RedirectBuilder.ts +126 -11
- package/src/http/Request.ts +283 -13
- package/src/http/Response.ts +213 -6
- package/src/index.ts +17 -3
- package/src/router/Router.ts +98 -33
- package/src/security/CookieSigner.ts +102 -10
- package/src/server/Server.ts +36 -16
- package/src/session/ReadOnlyValuesStore.ts +52 -0
- package/src/session/Session.ts +364 -37
- package/src/session/SessionMiddleware.ts +93 -30
- package/src/session/drivers/DatabaseDriver.ts +184 -0
- package/src/session/drivers/FileDriver.ts +82 -0
- package/src/session/drivers/MemoryDriver.ts +75 -2
- package/src/session/drivers/RedisDriver.ts +77 -2
- package/src/session/templateTags.ts +124 -0
- package/src/testing/RequestBuilder.ts +76 -0
- package/src/testing/TestClient.ts +10 -2
- package/src/types/helpers.ts +4 -1
- package/src/utils/Macroable.ts +23 -8
- package/dist/utils/inflect.d.ts +0 -17
- package/dist/utils/inflect.d.ts.map +0 -1
- package/dist/utils/inflect.js +0 -32
- package/dist/utils/inflect.js.map +0 -1
- package/src/utils/inflect.ts +0 -29
package/src/session/Session.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
/**
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
/**
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
318
|
+
this.flash(FLASH_INPUT_KEY, picked)
|
|
198
319
|
}
|
|
199
320
|
|
|
200
|
-
/**
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
332
|
+
this.flash(FLASH_INPUT_KEY, kept)
|
|
206
333
|
}
|
|
207
334
|
|
|
208
|
-
/**
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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:
|
|
51
|
+
driver: named?.driver ?? selected ?? 'memory',
|
|
33
52
|
cookieName: config?.cookieName ?? 'ream_session',
|
|
34
|
-
|
|
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
|
|
113
|
-
|
|
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
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|