@c9up/ream 0.2.27 → 0.2.29
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/README.md +1 -0
- package/dist/Codemods.d.ts +16 -0
- package/dist/Codemods.d.ts.map +1 -1
- package/dist/Codemods.js +182 -0
- package/dist/Codemods.js.map +1 -1
- package/dist/Ignitor.d.ts +11 -1
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +27 -21
- package/dist/Ignitor.js.map +1 -1
- package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
- package/dist/bodyparser/BodyParserMiddleware.js +3 -0
- package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
- package/dist/bodyparser/qsParse.d.ts +10 -1
- package/dist/bodyparser/qsParse.d.ts.map +1 -1
- package/dist/bodyparser/qsParse.js +53 -15
- package/dist/bodyparser/qsParse.js.map +1 -1
- package/dist/codemods/source.d.ts +57 -0
- package/dist/codemods/source.d.ts.map +1 -0
- package/dist/codemods/source.js +282 -0
- package/dist/codemods/source.js.map +1 -0
- package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
- package/dist/graphql/GraphQLEngine.js +3 -2
- package/dist/graphql/GraphQLEngine.js.map +1 -1
- package/dist/http/Exception.d.ts +14 -1
- package/dist/http/Exception.d.ts.map +1 -1
- package/dist/http/Exception.js +10 -2
- package/dist/http/Exception.js.map +1 -1
- package/dist/http/HttpContext.d.ts +11 -0
- package/dist/http/HttpContext.d.ts.map +1 -1
- package/dist/http/HttpContext.js +2 -1
- package/dist/http/HttpContext.js.map +1 -1
- package/dist/http/RedirectBuilder.d.ts +9 -3
- package/dist/http/RedirectBuilder.d.ts.map +1 -1
- package/dist/http/RedirectBuilder.js +39 -37
- package/dist/http/RedirectBuilder.js.map +1 -1
- package/dist/http/Request.d.ts +42 -17
- package/dist/http/Request.d.ts.map +1 -1
- package/dist/http/Request.js +65 -29
- package/dist/http/Request.js.map +1 -1
- package/dist/http/SseStream.js +6 -7
- package/dist/http/SseStream.js.map +1 -1
- package/dist/http/permissions.d.ts +19 -0
- package/dist/http/permissions.d.ts.map +1 -0
- package/dist/http/permissions.js +28 -0
- package/dist/http/permissions.js.map +1 -0
- package/dist/http-client/HttpClient.d.ts +111 -0
- package/dist/http-client/HttpClient.d.ts.map +1 -0
- package/dist/http-client/HttpClient.js +386 -0
- package/dist/http-client/HttpClient.js.map +1 -0
- package/dist/http-client/addresses.d.ts +14 -0
- package/dist/http-client/addresses.d.ts.map +1 -0
- package/dist/http-client/addresses.js +57 -0
- package/dist/http-client/addresses.js.map +1 -0
- package/dist/http-client/fake.d.ts +50 -0
- package/dist/http-client/fake.d.ts.map +1 -0
- package/dist/http-client/fake.js +95 -0
- package/dist/http-client/fake.js.map +1 -0
- package/dist/http-client/index.d.ts +7 -0
- package/dist/http-client/index.d.ts.map +1 -0
- package/dist/http-client/index.js +7 -0
- package/dist/http-client/index.js.map +1 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/middleware/Pipeline.d.ts.map +1 -1
- package/dist/middleware/Pipeline.js +6 -3
- package/dist/middleware/Pipeline.js.map +1 -1
- package/dist/native/generated.d.ts +108 -3
- package/dist/native/generated.d.ts.map +1 -1
- package/dist/rpc/RpcProvider.d.ts +6 -0
- package/dist/rpc/RpcProvider.d.ts.map +1 -1
- package/dist/rpc/RpcProvider.js +4 -1
- package/dist/rpc/RpcProvider.js.map +1 -1
- package/dist/rpc/RpcRouter.d.ts +27 -14
- package/dist/rpc/RpcRouter.d.ts.map +1 -1
- package/dist/rpc/RpcRouter.js +95 -38
- package/dist/rpc/RpcRouter.js.map +1 -1
- package/dist/scheduler/locks/RedisLockBackend.d.ts.map +1 -1
- package/dist/scheduler/locks/RedisLockBackend.js +13 -2
- package/dist/scheduler/locks/RedisLockBackend.js.map +1 -1
- package/dist/security/CookieSigner.d.ts.map +1 -1
- package/dist/security/CookieSigner.js +17 -3
- package/dist/security/CookieSigner.js.map +1 -1
- package/dist/security/SignedUrl.d.ts.map +1 -1
- package/dist/security/SignedUrl.js +6 -1
- package/dist/security/SignedUrl.js.map +1 -1
- package/dist/server/Server.d.ts +75 -9
- package/dist/server/Server.d.ts.map +1 -1
- package/dist/server/Server.js +60 -9
- package/dist/server/Server.js.map +1 -1
- package/dist/server/nativeGrpc.d.ts +27 -0
- package/dist/server/nativeGrpc.d.ts.map +1 -0
- package/dist/server/nativeGrpc.js +47 -0
- package/dist/server/nativeGrpc.js.map +1 -0
- package/dist/server/nativeLimits.d.ts +49 -0
- package/dist/server/nativeLimits.d.ts.map +1 -0
- package/dist/server/nativeLimits.js +67 -0
- package/dist/server/nativeLimits.js.map +1 -0
- package/dist/session/Session.d.ts +45 -0
- package/dist/session/Session.d.ts.map +1 -1
- package/dist/session/Session.js +31 -2
- package/dist/session/Session.js.map +1 -1
- package/dist/session/SessionMiddleware.d.ts.map +1 -1
- package/dist/session/SessionMiddleware.js +52 -13
- package/dist/session/SessionMiddleware.js.map +1 -1
- package/dist/session/config.d.ts +5 -0
- package/dist/session/config.d.ts.map +1 -1
- package/dist/session/config.js +8 -0
- package/dist/session/config.js.map +1 -1
- package/dist/session/drivers/CookieDriver.d.ts +12 -3
- package/dist/session/drivers/CookieDriver.d.ts.map +1 -1
- package/dist/session/drivers/CookieDriver.js +20 -6
- package/dist/session/drivers/CookieDriver.js.map +1 -1
- package/dist/session/drivers/DatabaseDriver.d.ts +2 -0
- package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -1
- package/dist/session/drivers/DatabaseDriver.js +6 -0
- package/dist/session/drivers/DatabaseDriver.js.map +1 -1
- package/dist/session/drivers/FileDriver.d.ts +8 -0
- package/dist/session/drivers/FileDriver.d.ts.map +1 -1
- package/dist/session/drivers/FileDriver.js +85 -11
- package/dist/session/drivers/FileDriver.js.map +1 -1
- package/dist/session/drivers/MemoryDriver.d.ts +2 -0
- package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
- package/dist/session/drivers/MemoryDriver.js +21 -5
- package/dist/session/drivers/MemoryDriver.js.map +1 -1
- package/dist/session/drivers/RedisDriver.d.ts +2 -0
- package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
- package/dist/session/drivers/RedisDriver.js +6 -0
- package/dist/session/drivers/RedisDriver.js.map +1 -1
- package/dist/session/errors.d.ts +27 -0
- package/dist/session/errors.d.ts.map +1 -1
- package/dist/session/errors.js +33 -0
- package/dist/session/errors.js.map +1 -1
- package/dist/session/templateTags.d.ts +14 -0
- package/dist/session/templateTags.d.ts.map +1 -1
- package/dist/session/templateTags.js +45 -17
- package/dist/session/templateTags.js.map +1 -1
- package/dist/storage/StaticMiddleware.d.ts.map +1 -1
- package/dist/storage/StaticMiddleware.js +29 -18
- package/dist/storage/StaticMiddleware.js.map +1 -1
- package/events.darwin-arm64.node +0 -0
- package/events.darwin-x64.node +0 -0
- package/events.linux-arm64-gnu.node +0 -0
- package/events.linux-x64-gnu.node +0 -0
- package/events.win32-x64-msvc.node +0 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +11 -2
- package/scheduler.darwin-arm64.node +0 -0
- package/scheduler.darwin-x64.node +0 -0
- package/scheduler.linux-arm64-gnu.node +0 -0
- package/scheduler.linux-x64-gnu.node +0 -0
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/Codemods.ts +265 -0
- package/src/Ignitor.ts +48 -20
- package/src/bodyparser/BodyParserMiddleware.ts +3 -0
- package/src/bodyparser/qsParse.ts +69 -14
- package/src/codemods/source.ts +303 -0
- package/src/graphql/GraphQLEngine.ts +3 -2
- package/src/http/Exception.ts +17 -2
- package/src/http/HttpContext.ts +14 -1
- package/src/http/RedirectBuilder.ts +48 -37
- package/src/http/Request.ts +87 -29
- package/src/http/SseStream.ts +6 -7
- package/src/http/permissions.ts +31 -0
- package/src/http-client/HttpClient.ts +505 -0
- package/src/http-client/addresses.ts +58 -0
- package/src/http-client/fake.ts +141 -0
- package/src/http-client/index.ts +21 -0
- package/src/index.ts +9 -1
- package/src/middleware/Pipeline.ts +7 -3
- package/src/native/generated.ts +123 -3
- package/src/rpc/RpcProvider.ts +10 -1
- package/src/rpc/RpcRouter.ts +126 -42
- package/src/scheduler/locks/RedisLockBackend.ts +12 -4
- package/src/security/CookieSigner.ts +17 -3
- package/src/security/SignedUrl.ts +6 -1
- package/src/server/Server.ts +103 -9
- package/src/server/nativeGrpc.ts +59 -0
- package/src/server/nativeLimits.ts +94 -0
- package/src/session/Session.ts +72 -2
- package/src/session/SessionMiddleware.ts +53 -14
- package/src/session/config.ts +7 -0
- package/src/session/drivers/CookieDriver.ts +21 -6
- package/src/session/drivers/DatabaseDriver.ts +10 -0
- package/src/session/drivers/FileDriver.ts +91 -11
- package/src/session/drivers/MemoryDriver.ts +20 -5
- package/src/session/drivers/RedisDriver.ts +7 -0
- package/src/session/errors.ts +48 -0
- package/src/session/templateTags.ts +57 -0
- package/src/storage/StaticMiddleware.ts +28 -18
package/src/server/Server.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* - server.errorHandler(() => import('#exceptions/handler')) — custom exception handler
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { ReamError } from '../errors/ReamError.js'
|
|
9
10
|
import type { ExceptionHandler } from '../http/Exception.js'
|
|
10
11
|
import type { HttpContext } from '../http/HttpContext.js'
|
|
11
12
|
import type { MiddlewareFunction } from '../middleware/Pipeline.js'
|
|
@@ -32,28 +33,83 @@ export type MiddlewareClassConstructor = new (...args: never[]) => MiddlewareCla
|
|
|
32
33
|
/** What server.use() and router.use() accept: lazy imports or direct functions. */
|
|
33
34
|
export type MiddlewareEntry = LazyImport<MiddlewareClassConstructor> | MiddlewareFunction
|
|
34
35
|
|
|
36
|
+
/** One unary gRPC call, as the native server hands it over. */
|
|
37
|
+
export interface GrpcCall {
|
|
38
|
+
/** `/package.Service/Method`, as it arrived. */
|
|
39
|
+
path: string
|
|
40
|
+
/**
|
|
41
|
+
* Request metadata: the request headers, every value of a repeated one in
|
|
42
|
+
* order, names lowercased. Never the client's trailers.
|
|
43
|
+
*/
|
|
44
|
+
metadata: Record<string, string[]>
|
|
45
|
+
/** The request message, still encoded. */
|
|
46
|
+
message: Uint8Array
|
|
47
|
+
/** Resolved client IP, by the same trusted-proxy rules as `request.ip()`. */
|
|
48
|
+
ip: string
|
|
49
|
+
/**
|
|
50
|
+
* Aborted when the client gives up on the call — resets its stream or
|
|
51
|
+
* closes the connection — before the handler answered.
|
|
52
|
+
*/
|
|
53
|
+
signal: AbortSignal
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A reply message, still encoded — or a gRPC status code instead of one. */
|
|
57
|
+
export type GrpcReply = { message: Uint8Array } | { code: number; details: string }
|
|
58
|
+
|
|
59
|
+
export type GrpcHandler = (call: GrpcCall) => Promise<GrpcReply>
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Message size bounds of the gRPC calls, in bytes; `-1` for none. A request
|
|
63
|
+
* past its bound is refused, `RESOURCE_EXHAUSTED`, before it is read; a reply
|
|
64
|
+
* past its bound is answered `RESOURCE_EXHAUSTED` instead.
|
|
65
|
+
*/
|
|
66
|
+
export interface GrpcLimits {
|
|
67
|
+
/** Default 4 MiB. */
|
|
68
|
+
maxReceiveMessageLength?: number
|
|
69
|
+
/** Default `-1`: no bound. */
|
|
70
|
+
maxSendMessageLength?: number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function checkGrpcLimit(name: string, value: number | undefined): void {
|
|
74
|
+
if (value === undefined || value === -1 || (Number.isSafeInteger(value) && value >= 0)) return
|
|
75
|
+
throw new ReamError(
|
|
76
|
+
'E_SERVER_GRPC_INVALID_LIMIT',
|
|
77
|
+
`${name} must be a whole number of bytes, or -1 for no limit — got ${value}.`,
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
35
81
|
export class Server {
|
|
36
82
|
#router: Router
|
|
37
83
|
#serverMiddleware: MiddlewareFunction[] = []
|
|
38
84
|
#errorHandlerImporter?: LazyImport<ErrorHandlerClass>
|
|
39
85
|
#resolvedErrorHandler?: ExceptionHandler
|
|
40
86
|
#trustedProxies: string[] = []
|
|
87
|
+
#grpcHandler?: GrpcHandler
|
|
88
|
+
#grpcLimits: GrpcLimits = {}
|
|
41
89
|
|
|
42
90
|
constructor(router: Router) {
|
|
43
91
|
this.#router = router
|
|
44
92
|
}
|
|
45
93
|
|
|
46
94
|
/**
|
|
47
|
-
* Configure the trusted
|
|
48
|
-
* pre-resolve `request.ip` once per request — JS reads
|
|
49
|
-
* matching in TS.
|
|
95
|
+
* Configure the trusted proxies. The Rust HyperServer uses them to
|
|
96
|
+
* pre-resolve `request.ip()` / `request.ips()` once per request — JS reads
|
|
97
|
+
* the fields, no CIDR matching in TS.
|
|
50
98
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
99
|
+
* The chain is the socket peer, then the `X-Forwarded-For` entries read
|
|
100
|
+
* right to left; it stops at the first hop that is not a trusted proxy, and
|
|
101
|
+
* its last entry is the client IP. Proxies append what they saw, so the
|
|
102
|
+
* left-most entry — the one a client writes itself — is only reached when
|
|
103
|
+
* every hop after it is trusted. `X-Real-IP` is never read.
|
|
104
|
+
*
|
|
105
|
+
* Entries: addresses or CIDR ranges, IPv4 or IPv6 (`'10.0.0.0/8'`,
|
|
106
|
+
* `'fd00::/8'`), IPv4 netmasks (`'10.0.0.0/255.0.0.0'`), the named ranges
|
|
107
|
+
* `'loopback'`, `'linklocal'`, `'uniquelocal'`, or `'*'` to trust every hop
|
|
108
|
+
* (the left-most entry wins — any client can choose its IP). An entry that is
|
|
109
|
+
* none of these makes the server refuse to start.
|
|
110
|
+
*
|
|
111
|
+
* Empty list (default) is **strict fail-closed**: `X-Forwarded-For` is
|
|
112
|
+
* ignored and only the socket peer is used.
|
|
57
113
|
*/
|
|
58
114
|
trustedProxies(cidrs: string[]): this {
|
|
59
115
|
this.#trustedProxies = [...cidrs]
|
|
@@ -65,6 +121,44 @@ export class Server {
|
|
|
65
121
|
return this.#trustedProxies
|
|
66
122
|
}
|
|
67
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Serve gRPC on the HTTP port. Once a handler is set, a request whose
|
|
126
|
+
* content-type is `application/grpc*` is a gRPC call handed to it — ahead
|
|
127
|
+
* of the router and the middleware, which never see it. The native server
|
|
128
|
+
* still applies its rate limiter and security filter first. Without a
|
|
129
|
+
* handler such a request stays an ordinary HTTP request.
|
|
130
|
+
*
|
|
131
|
+
* No AdonisJS counterpart: it serves on Node's `http.Server`, where gRPC
|
|
132
|
+
* would attach a library of its own. Here the native server owns the
|
|
133
|
+
* socket, so the gRPC branch is part of it, and this is how a package
|
|
134
|
+
* reaches it — `@c9up/neutrino` registers its dispatcher here.
|
|
135
|
+
*
|
|
136
|
+
* One handler per server: a second one throws. Two packages both serving
|
|
137
|
+
* gRPC is a conflict to resolve, not to settle by load order.
|
|
138
|
+
*/
|
|
139
|
+
grpc(handler: GrpcHandler, limits: GrpcLimits = {}): this {
|
|
140
|
+
if (this.#grpcHandler) {
|
|
141
|
+
throw new ReamError('E_SERVER_GRPC_ALREADY_SET', 'A gRPC handler is already registered.', {
|
|
142
|
+
hint: 'Only one package can serve gRPC on the HTTP port.',
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
checkGrpcLimit('maxReceiveMessageLength', limits.maxReceiveMessageLength)
|
|
146
|
+
checkGrpcLimit('maxSendMessageLength', limits.maxSendMessageLength)
|
|
147
|
+
this.#grpcHandler = handler
|
|
148
|
+
this.#grpcLimits = { ...limits }
|
|
149
|
+
return this
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The message bounds given with the gRPC handler (consumed by Ignitor at boot). */
|
|
153
|
+
getGrpcLimits(): GrpcLimits {
|
|
154
|
+
return { ...this.#grpcLimits }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The gRPC handler, if one is set (consumed by Ignitor at boot). */
|
|
158
|
+
getGrpcHandler(): GrpcHandler | undefined {
|
|
159
|
+
return this.#grpcHandler
|
|
160
|
+
}
|
|
161
|
+
|
|
68
162
|
/**
|
|
69
163
|
* Register global server middleware.
|
|
70
164
|
* Accepts lazy imports of middleware classes (AdonisJS pattern):
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gRPC handler as the native server calls it.
|
|
3
|
+
*
|
|
4
|
+
* The native server numbers each call and, separately, reports the numbers
|
|
5
|
+
* of the calls a client gave up on — a reset stream or a closed connection —
|
|
6
|
+
* before the handler answered. This turns those reports into the `signal` of
|
|
7
|
+
* each {@link GrpcCall}, so that a handler can stop the work it started.
|
|
8
|
+
*
|
|
9
|
+
* Calls reach JavaScript in increasing id order, but a cancel travels on its
|
|
10
|
+
* own queue and can arrive before its call: one for an id not seen yet is
|
|
11
|
+
* kept until that call arrives; one for an id already seen and finished is
|
|
12
|
+
* dropped.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { GrpcCall, GrpcHandler, GrpcReply } from './Server.js'
|
|
16
|
+
|
|
17
|
+
/** A call as the native server hands it over: numbered, without a signal. */
|
|
18
|
+
export interface NativeGrpcCall extends Omit<GrpcCall, 'signal'> {
|
|
19
|
+
id: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type NativeGrpcHandler = (call: NativeGrpcCall) => Promise<GrpcReply>
|
|
23
|
+
|
|
24
|
+
export interface CancellableGrpc {
|
|
25
|
+
/** Give to the native server's `onGrpc`. */
|
|
26
|
+
onCall: NativeGrpcHandler
|
|
27
|
+
/** Give to the native server's `onGrpcCancel`. */
|
|
28
|
+
onCancel: (id: number) => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function cancellableGrpc(handler: GrpcHandler): CancellableGrpc {
|
|
32
|
+
const running = new Map<number, AbortController>()
|
|
33
|
+
// Cancels that arrived before their call.
|
|
34
|
+
const early = new Set<number>()
|
|
35
|
+
let lastSeen = 0
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
async onCall({ id, ...call }) {
|
|
39
|
+
lastSeen = Math.max(lastSeen, id)
|
|
40
|
+
const controller = new AbortController()
|
|
41
|
+
for (const cancelled of early) {
|
|
42
|
+
if (cancelled > id) continue
|
|
43
|
+
early.delete(cancelled)
|
|
44
|
+
if (cancelled === id) controller.abort()
|
|
45
|
+
}
|
|
46
|
+
running.set(id, controller)
|
|
47
|
+
try {
|
|
48
|
+
return await handler({ ...call, signal: controller.signal })
|
|
49
|
+
} finally {
|
|
50
|
+
running.delete(id)
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
onCancel(id) {
|
|
54
|
+
const controller = running.get(id)
|
|
55
|
+
if (controller !== undefined) controller.abort()
|
|
56
|
+
else if (id > lastSeen) early.add(id)
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The limits the native server applies at the socket, read from the
|
|
3
|
+
* application's configuration.
|
|
4
|
+
*
|
|
5
|
+
* - Body limits come from `config/bodyparser.ts`: the native server enforces
|
|
6
|
+
* each section's `limit` WHILE reading the body, so nothing past it is ever
|
|
7
|
+
* buffered. The request still reaches routing, without its body and marked
|
|
8
|
+
* `bodyTooLarge`; reading the body then throws the 413 the body parser
|
|
9
|
+
* raises. The body parser keeps its own check; the two read one config.
|
|
10
|
+
* - Timeouts and the connection cap come from `config/app.ts` → `http`, where
|
|
11
|
+
* an AdonisJS app sets `headersTimeout`, `requestTimeout` and
|
|
12
|
+
* `keepAliveTimeout` for Node's server. `maxConnections` has no AdonisJS
|
|
13
|
+
* option (Node's `server.maxConnections` is left unbounded there); it is a
|
|
14
|
+
* deliberate DoS bound, and past it new clients wait in the listen backlog.
|
|
15
|
+
*
|
|
16
|
+
* Why the body limits are applied natively at all (NAPI constraint): the
|
|
17
|
+
* native server hands JS a request whose body is already read, where AdonisJS
|
|
18
|
+
* leaves the body in Node's stream until the parser pulls it. Without a limit
|
|
19
|
+
* at the read, every body is buffered before any JS-side limit can apply.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { BodyParserConfig } from '../bodyparser/BodyParserMiddleware.js'
|
|
23
|
+
import { parseSize } from '../bodyparser/parseSize.js'
|
|
24
|
+
import { ReamError } from '../errors/ReamError.js'
|
|
25
|
+
|
|
26
|
+
/** One body-parser section as the native server takes it (bytes). */
|
|
27
|
+
export interface NativeBodyLimit {
|
|
28
|
+
limit?: number
|
|
29
|
+
types?: string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Body limits per body-parser section; an omitted section keeps its default. */
|
|
33
|
+
export interface NativeBodyLimits {
|
|
34
|
+
json?: NativeBodyLimit
|
|
35
|
+
form?: NativeBodyLimit
|
|
36
|
+
raw?: NativeBodyLimit
|
|
37
|
+
multipart?: NativeBodyLimit
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Timeouts in milliseconds (`0` disables one) and the connection cap (`0` = unbounded). */
|
|
41
|
+
export interface NativeConnectionConfig {
|
|
42
|
+
headersTimeout?: number
|
|
43
|
+
requestTimeout?: number
|
|
44
|
+
keepAliveTimeout?: number
|
|
45
|
+
maxConnections?: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const SECTIONS = ['json', 'form', 'raw', 'multipart'] as const
|
|
49
|
+
|
|
50
|
+
/** Translate `config/bodyparser.ts` into the native server's body limits. */
|
|
51
|
+
export function nativeBodyLimits(config: BodyParserConfig): NativeBodyLimits {
|
|
52
|
+
const limits: NativeBodyLimits = {}
|
|
53
|
+
for (const section of SECTIONS) {
|
|
54
|
+
const given = config[section]
|
|
55
|
+
if (!given) continue
|
|
56
|
+
const native: NativeBodyLimit = {}
|
|
57
|
+
if (given.limit !== undefined) native.limit = parseSize(given.limit)
|
|
58
|
+
if (given.types !== undefined) native.types = [...given.types]
|
|
59
|
+
limits[section] = native
|
|
60
|
+
}
|
|
61
|
+
return limits
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const CONNECTION_KEYS = [
|
|
65
|
+
'headersTimeout',
|
|
66
|
+
'requestTimeout',
|
|
67
|
+
'keepAliveTimeout',
|
|
68
|
+
'maxConnections',
|
|
69
|
+
] as const
|
|
70
|
+
|
|
71
|
+
const MAX_U32 = 2 ** 32 - 1
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Pick the connection settings out of the `http` block of `config/app.ts`.
|
|
75
|
+
* Throws on a value that is not a whole number of milliseconds (or
|
|
76
|
+
* connections) the native server can hold, rather than letting it wrap.
|
|
77
|
+
*/
|
|
78
|
+
export function nativeConnectionConfig(http: unknown): NativeConnectionConfig {
|
|
79
|
+
const config: NativeConnectionConfig = {}
|
|
80
|
+
if (typeof http !== 'object' || http === null) return config
|
|
81
|
+
for (const key of CONNECTION_KEYS) {
|
|
82
|
+
const value: unknown = Reflect.get(http, key)
|
|
83
|
+
if (value === undefined) continue
|
|
84
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > MAX_U32) {
|
|
85
|
+
throw new ReamError(
|
|
86
|
+
'E_IGNITOR_INVALID_HTTP_CONFIG',
|
|
87
|
+
`\`http.${key}\` must be a whole number between 0 and ${MAX_U32}, received ${String(value)}.`,
|
|
88
|
+
{ hint: 'Timeouts are in milliseconds; 0 disables the bound.' },
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
config[key] = value
|
|
92
|
+
}
|
|
93
|
+
return config
|
|
94
|
+
}
|
package/src/session/Session.ts
CHANGED
|
@@ -10,8 +10,13 @@
|
|
|
10
10
|
* Backed by pluggable drivers (cookie, memory, redis).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import type { CookieOptions } from '../http/Response.js'
|
|
13
14
|
import type { SessionRedisClientSource } from './drivers/RedisDriver.js'
|
|
14
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
E_SESSION_NOT_READY,
|
|
17
|
+
E_SESSION_STORE_NOT_CONDITIONAL,
|
|
18
|
+
E_SESSION_TAGGING_NOT_SUPPORTED,
|
|
19
|
+
} from './errors.js'
|
|
15
20
|
import { ReadOnlyValuesStore } from './ReadOnlyValuesStore.js'
|
|
16
21
|
|
|
17
22
|
export interface SessionDriver {
|
|
@@ -26,8 +31,27 @@ export interface SessionDriver {
|
|
|
26
31
|
*/
|
|
27
32
|
read(sessionId: string): Promise<Record<string, unknown> | null>
|
|
28
33
|
write(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<void>
|
|
34
|
+
/**
|
|
35
|
+
* Write only over an entry that is still there, in one step; `false` when it
|
|
36
|
+
* is gone, and nothing written.
|
|
37
|
+
*
|
|
38
|
+
* What `commit()` uses for a session that existed when the request came in.
|
|
39
|
+
* A request still running when the same session was logged out, rotated or
|
|
40
|
+
* destroyed everywhere would otherwise write its stale copy back — the user
|
|
41
|
+
* id included — and undo the logout. Upstream writes unconditionally and has
|
|
42
|
+
* that race; this is the named deviation that closes it.
|
|
43
|
+
*
|
|
44
|
+
* Required, and checked when the store is attached: a read followed by a
|
|
45
|
+
* write leaves the race open between the two calls.
|
|
46
|
+
*/
|
|
47
|
+
update(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<boolean>
|
|
29
48
|
destroy(sessionId: string): Promise<void>
|
|
30
49
|
touch(sessionId: string, ttl: number): Promise<void>
|
|
50
|
+
/**
|
|
51
|
+
* The session lives with the client, not here: nothing on the server can
|
|
52
|
+
* delete a copy of it. Only the cookie store sets it.
|
|
53
|
+
*/
|
|
54
|
+
readonly stateless?: boolean
|
|
31
55
|
}
|
|
32
56
|
|
|
33
57
|
/** One session belonging to a tagged user (AdonisJS `TaggedSession`). */
|
|
@@ -131,8 +155,23 @@ export interface SessionConfig {
|
|
|
131
155
|
client?: SessionRedisClientSource
|
|
132
156
|
/** `redis` driver only — key prefix. Defaults to `ream:session:`. */
|
|
133
157
|
prefix?: string
|
|
158
|
+
/**
|
|
159
|
+
* Attributes of the session cookie, for every store. Defaults:
|
|
160
|
+
* `{ path: '/', httpOnly: true, sameSite: 'lax' }`, and `secure` on when
|
|
161
|
+
* `NODE_ENV` is production. Its lifetime comes from {@link age} and
|
|
162
|
+
* {@link clearWithBrowser}, not from here. `httpOnly: false` is refused: a
|
|
163
|
+
* script able to read the session cookie is what turns an XSS into an
|
|
164
|
+
* account takeover.
|
|
165
|
+
*/
|
|
166
|
+
cookie?: SessionCookieOptions
|
|
134
167
|
}
|
|
135
168
|
|
|
169
|
+
/** The session cookie attributes an application may set. */
|
|
170
|
+
export type SessionCookieOptions = Pick<
|
|
171
|
+
CookieOptions,
|
|
172
|
+
'path' | 'httpOnly' | 'secure' | 'sameSite' | 'domain' | 'partitioned' | 'priority'
|
|
173
|
+
>
|
|
174
|
+
|
|
136
175
|
/**
|
|
137
176
|
* Generate a fresh session ID. Format mirrors AdonisJS / express-session —
|
|
138
177
|
* 32 bytes of base64url entropy (~256 bits). Defined here so `regenerate()`
|
|
@@ -179,6 +218,8 @@ export class Session {
|
|
|
179
218
|
#ttl = 0
|
|
180
219
|
/** Whether {@link initiate} already ran, so a second call is a no-op. */
|
|
181
220
|
#initiated = false
|
|
221
|
+
/** The entry vanished while this request ran — see {@link wasLost}. */
|
|
222
|
+
#lost = false
|
|
182
223
|
|
|
183
224
|
constructor(sessionId: string, data: Record<string, unknown> = {}) {
|
|
184
225
|
this.#sessionId = sessionId
|
|
@@ -228,6 +269,15 @@ export class Session {
|
|
|
228
269
|
return this.#regenerated
|
|
229
270
|
}
|
|
230
271
|
|
|
272
|
+
/**
|
|
273
|
+
* @internal — the stored entry was gone by the time {@link commit} ran: it
|
|
274
|
+
* was logged out, rotated or destroyed by another request meanwhile. Nothing
|
|
275
|
+
* was written, and the middleware must not hand the browser this id again.
|
|
276
|
+
*/
|
|
277
|
+
wasLost(): boolean {
|
|
278
|
+
return this.#lost
|
|
279
|
+
}
|
|
280
|
+
|
|
231
281
|
/** @internal — the id captured at request arrival; used to destroy the old driver entry post-regenerate. */
|
|
232
282
|
originalSessionId(): string {
|
|
233
283
|
return this.#originalSessionId
|
|
@@ -554,6 +604,11 @@ export class Session {
|
|
|
554
604
|
* {@link initiate} / {@link commit} then have nothing to do.
|
|
555
605
|
*/
|
|
556
606
|
setStore(driver: SessionDriver, options: { fresh: boolean; ttl: number }): void {
|
|
607
|
+
// A store written in JavaScript, or against the older contract, reaches
|
|
608
|
+
// here without the method the type requires.
|
|
609
|
+
if (typeof Reflect.get(driver, 'update') !== 'function') {
|
|
610
|
+
throw new E_SESSION_STORE_NOT_CONDITIONAL()
|
|
611
|
+
}
|
|
557
612
|
this.#driver = driver
|
|
558
613
|
this.#fresh = options.fresh
|
|
559
614
|
this.#ttl = options.ttl
|
|
@@ -600,12 +655,27 @@ export class Session {
|
|
|
600
655
|
return
|
|
601
656
|
}
|
|
602
657
|
if (this.#dirty) {
|
|
603
|
-
|
|
658
|
+
if (this.#fresh) {
|
|
659
|
+
await driver.write(this.#sessionId, this.toJSON(), this.#ttl)
|
|
660
|
+
return
|
|
661
|
+
}
|
|
662
|
+
this.#lost = !(await driver.update(this.#sessionId, this.toJSON(), this.#ttl))
|
|
604
663
|
return
|
|
605
664
|
}
|
|
606
665
|
if (!this.#fresh) await driver.touch(this.#sessionId, this.#ttl)
|
|
607
666
|
}
|
|
608
667
|
|
|
668
|
+
/**
|
|
669
|
+
* Whether ending this session on the server ends every copy of it.
|
|
670
|
+
*
|
|
671
|
+
* False under the cookie store, where the session travels with the client:
|
|
672
|
+
* a logout replaces the cookie in one browser and cannot reach a copy. What
|
|
673
|
+
* an auth guard reads to refuse signing in on a store it cannot revoke.
|
|
674
|
+
*/
|
|
675
|
+
get revocable(): boolean {
|
|
676
|
+
return this.#driver !== undefined && this.#driver.stateless !== true
|
|
677
|
+
}
|
|
678
|
+
|
|
609
679
|
/**
|
|
610
680
|
* Whether the store behind this session can tag sessions with a user
|
|
611
681
|
* (AdonisJS `supportsTagging`). False under the cookie and file drivers.
|
|
@@ -10,15 +10,17 @@ import { readModuleConfig } from '../config/moduleConfig.js'
|
|
|
10
10
|
import { currentNodeEnv } from '../env/nodeEnv.js'
|
|
11
11
|
import { durationToSeconds } from '../helpers/duration.js'
|
|
12
12
|
import type { HttpContext } from '../http/HttpContext.js'
|
|
13
|
+
import type { CookieOptions } from '../http/Response.js'
|
|
13
14
|
import type { SessionConfigInput } from './config.js'
|
|
14
15
|
import { CookieDriver } from './drivers/CookieDriver.js'
|
|
15
16
|
import { DatabaseDriver, type SessionDbConnection } from './drivers/DatabaseDriver.js'
|
|
16
17
|
import { FileDriver } from './drivers/FileDriver.js'
|
|
17
18
|
import { MemoryDriver } from './drivers/MemoryDriver.js'
|
|
18
19
|
import { RedisDriver } from './drivers/RedisDriver.js'
|
|
20
|
+
import { E_SESSION_COOKIE_NOT_HTTP_ONLY, E_SESSION_STORE_NOT_CONFIGURED } from './errors.js'
|
|
19
21
|
import { quasarConnection } from './quasar.js'
|
|
20
22
|
import { ReadOnlyValuesStore, type ValuePath } from './ReadOnlyValuesStore.js'
|
|
21
|
-
import type { SessionDriver } from './Session.js'
|
|
23
|
+
import type { SessionCookieOptions, SessionDriver } from './Session.js'
|
|
22
24
|
import { Session } from './Session.js'
|
|
23
25
|
|
|
24
26
|
interface ResolvedSessionConfig {
|
|
@@ -27,6 +29,7 @@ interface ResolvedSessionConfig {
|
|
|
27
29
|
maxAge: number
|
|
28
30
|
clearWithBrowser: boolean
|
|
29
31
|
rolling: boolean
|
|
32
|
+
cookie: SessionCookieOptions
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
/** Duck-typed check for a connection the database store can drive. */
|
|
@@ -61,8 +64,15 @@ export default class SessionMiddleware {
|
|
|
61
64
|
// `{ store: 'redis', stores: { redis: { driver: 'redis' } } }` works.
|
|
62
65
|
const selected = config?.store ?? config?.driver
|
|
63
66
|
const named = selected ? config?.stores?.[selected] : undefined
|
|
67
|
+
// No implicit store, in any environment — AdonisJS' session config
|
|
68
|
+
// refuses one without `store` too. The memory store lives in one process
|
|
69
|
+
// and is bounded: a restart, a second replica, or enough anonymous
|
|
70
|
+
// requests signs every user out, so it is used only when it is chosen
|
|
71
|
+
// (`store: 'memory'`).
|
|
72
|
+
if (!selected) throw new E_SESSION_STORE_NOT_CONFIGURED()
|
|
73
|
+
if (config?.cookie?.httpOnly === false) throw new E_SESSION_COOKIE_NOT_HTTP_ONLY()
|
|
64
74
|
this.#config = {
|
|
65
|
-
driver: named?.driver ?? selected
|
|
75
|
+
driver: named?.driver ?? selected,
|
|
66
76
|
cookieName: config?.cookieName ?? 'ream_session',
|
|
67
77
|
// `age` is the AdonisJS key and accepts a duration string.
|
|
68
78
|
maxAge:
|
|
@@ -71,6 +81,7 @@ export default class SessionMiddleware {
|
|
|
71
81
|
: (config?.maxAge ?? 7200),
|
|
72
82
|
clearWithBrowser: config?.clearWithBrowser ?? false,
|
|
73
83
|
rolling: config?.rolling ?? false,
|
|
84
|
+
cookie: { ...config?.cookie },
|
|
74
85
|
}
|
|
75
86
|
|
|
76
87
|
if (this.#config.driver === 'cookie') {
|
|
@@ -79,7 +90,7 @@ export default class SessionMiddleware {
|
|
|
79
90
|
'Cookie session driver requires a secret. Set session.secret in your config.',
|
|
80
91
|
)
|
|
81
92
|
}
|
|
82
|
-
this.#cookieDriver = new CookieDriver(config.secret)
|
|
93
|
+
this.#cookieDriver = new CookieDriver(config.secret, this.#config.cookieName)
|
|
83
94
|
this.#driver = this.#cookieDriver
|
|
84
95
|
} else if (this.#config.driver === 'memory') {
|
|
85
96
|
this.#driver = new MemoryDriver()
|
|
@@ -136,6 +147,9 @@ export default class SessionMiddleware {
|
|
|
136
147
|
|
|
137
148
|
// Cookie driver: the cookie value IS the session data (encrypted)
|
|
138
149
|
if (this.#cookieDriver) {
|
|
150
|
+
// When the data this request carries was last vouched for. See the
|
|
151
|
+
// re-emission below.
|
|
152
|
+
const readAt = Date.now()
|
|
139
153
|
const data = await this.#driver.read(sessionId ?? '')
|
|
140
154
|
sessionId = sessionId ?? generateSessionId()
|
|
141
155
|
// `null` means the cookie carried nothing readable — an empty session,
|
|
@@ -162,8 +176,20 @@ export default class SessionMiddleware {
|
|
|
162
176
|
// the cookie maxAge window slides forward (active users don't time out
|
|
163
177
|
// mid-session). Caller opts in via SessionConfig.rolling.
|
|
164
178
|
const shouldEmit = session.isDirty() || (rolling && hadIncomingCookie)
|
|
165
|
-
|
|
166
|
-
|
|
179
|
+
// The re-emitted cookie lives `maxAge` from when this request READ the
|
|
180
|
+
// session, not from its response. Counted from the response, a request
|
|
181
|
+
// that outlasted a logout re-issued the signed-in data with a full new
|
|
182
|
+
// lifetime — past the window a revocation is kept for, after which the
|
|
183
|
+
// sign-in it carried was accepted again. Counted from the read, no cookie
|
|
184
|
+
// carrying a sign-in outlives the logout by more than `maxAge`. A session
|
|
185
|
+
// regenerated during the request (a login) is new data, so it counts from
|
|
186
|
+
// now; so does a session that came in with no cookie.
|
|
187
|
+
const issuedAt = hadIncomingCookie && !session.wasRegenerated() ? readAt : Date.now()
|
|
188
|
+
const remaining = issuedAt + maxAge * 1000 - Date.now()
|
|
189
|
+
// Expired while the request ran: nothing to hand back, and the browser
|
|
190
|
+
// keeps whatever cookie it was given since.
|
|
191
|
+
if (shouldEmit && remaining > 0) {
|
|
192
|
+
const encoded = this.#cookieDriver.encode(session.toJSON(), remaining / 1000)
|
|
167
193
|
if (encoded.length > 4096) {
|
|
168
194
|
process.stderr.write(
|
|
169
195
|
`[session] Cookie session exceeds 4096 bytes (${encoded.length}). ` +
|
|
@@ -174,11 +200,8 @@ export default class SessionMiddleware {
|
|
|
174
200
|
// Already encrypted by the cookie driver; packing it again would
|
|
175
201
|
// only make the cookie bigger and change nothing.
|
|
176
202
|
encode: false,
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
httpOnly: true,
|
|
180
|
-
sameSite: 'lax',
|
|
181
|
-
secure: isProduction,
|
|
203
|
+
...this.#cookieAttributes(isProduction),
|
|
204
|
+
maxAge: this.#config.clearWithBrowser ? undefined : Math.ceil(remaining / 1000),
|
|
182
205
|
})
|
|
183
206
|
}
|
|
184
207
|
return
|
|
@@ -216,6 +239,10 @@ export default class SessionMiddleware {
|
|
|
216
239
|
// no server-side row (touch() is a no-op without a backing entry),
|
|
217
240
|
// defeats HTTP caching, and leaks a Set-Cookie to CDNs. This mirrors
|
|
218
241
|
// the cookie-driver path above (which already omits `isNew`).
|
|
242
|
+
// A session logged out or rotated by another request while this one ran
|
|
243
|
+
// was not written back (see `Session.wasLost`); handing the browser its id
|
|
244
|
+
// again would only point it at nothing, over the cookie it got since.
|
|
245
|
+
if (session.wasLost()) return
|
|
219
246
|
if (session.isDirty() || regenerated || (rolling && hadIncomingCookie)) {
|
|
220
247
|
// SIGNED, as AdonisJS signs it (`response.cookie(cookieName, id, …)`).
|
|
221
248
|
// The id is 192 bits of entropy, so it was never guessable — what the
|
|
@@ -223,14 +250,26 @@ export default class SessionMiddleware {
|
|
|
223
250
|
// read, before any store lookup, and that an id from another deployment
|
|
224
251
|
// (a different APP_KEY) cannot be presented here.
|
|
225
252
|
ctx.response.cookie(cookieName, session.sessionId, {
|
|
253
|
+
...this.#cookieAttributes(isProduction),
|
|
226
254
|
maxAge: this.#config.clearWithBrowser ? undefined : maxAge,
|
|
227
|
-
path: '/',
|
|
228
|
-
httpOnly: true,
|
|
229
|
-
sameSite: 'lax',
|
|
230
|
-
secure: isProduction,
|
|
231
255
|
})
|
|
232
256
|
}
|
|
233
257
|
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The session cookie's attributes: the configured `cookie` over the
|
|
261
|
+
* defaults. `secure` follows the environment unless the config sets it.
|
|
262
|
+
*/
|
|
263
|
+
#cookieAttributes(isProduction: boolean): CookieOptions {
|
|
264
|
+
const configured = this.#config.cookie
|
|
265
|
+
return {
|
|
266
|
+
path: '/',
|
|
267
|
+
sameSite: 'lax',
|
|
268
|
+
...configured,
|
|
269
|
+
httpOnly: true,
|
|
270
|
+
secure: configured.secure ?? isProduction,
|
|
271
|
+
}
|
|
272
|
+
}
|
|
234
273
|
}
|
|
235
274
|
|
|
236
275
|
function generateSessionId(): string {
|
package/src/session/config.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* })
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { E_SESSION_STORE_NOT_CONFIGURED } from './errors.js'
|
|
16
17
|
import type { SessionConfig } from './Session.js'
|
|
17
18
|
|
|
18
19
|
/** What a session config file may declare. */
|
|
@@ -24,6 +25,12 @@ export type SessionConfigInput = SessionConfig & {
|
|
|
24
25
|
secret?: string
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Validate and return a session config. A config without `store` (or its
|
|
30
|
+
* older spelling `driver`) is refused here, at load time, as AdonisJS'
|
|
31
|
+
* `defineConfig` refuses it: there is no implicit store.
|
|
32
|
+
*/
|
|
27
33
|
export function defineConfig(config: SessionConfigInput): SessionConfigInput {
|
|
34
|
+
if (!config.store && !config.driver) throw new E_SESSION_STORE_NOT_CONFIGURED()
|
|
28
35
|
return config
|
|
29
36
|
}
|
|
@@ -5,23 +5,33 @@ import type { SessionDriver } from '../Session.js'
|
|
|
5
5
|
* Cookie session driver — stores session data in an encrypted cookie (AES-256-GCM).
|
|
6
6
|
* Stateless on the server. Limited by cookie size (~4KB).
|
|
7
7
|
* Requires a secret for encryption.
|
|
8
|
+
*
|
|
9
|
+
* Sealed for the cookie's name, as upstream seals every encrypted cookie, so a
|
|
10
|
+
* value encrypted for another cookie under the same key cannot be presented as
|
|
11
|
+
* the session. And sealed with its expiry — the named deviation: upstream
|
|
12
|
+
* leaves expiry to the browser's `maxAge`, which a copied cookie does not
|
|
13
|
+
* obey, so a session lifted from a browser stayed valid for as long as the key
|
|
14
|
+
* did. Here a value older than the session's lifetime opens as nothing.
|
|
8
15
|
*/
|
|
9
16
|
export class CookieDriver implements SessionDriver {
|
|
10
17
|
#signer: CookieSigner
|
|
18
|
+
readonly #purpose: string
|
|
19
|
+
readonly stateless = true
|
|
11
20
|
|
|
12
|
-
constructor(secret: string) {
|
|
21
|
+
constructor(secret: string, cookieName = 'ream_session') {
|
|
13
22
|
if (!secret) {
|
|
14
23
|
throw new Error(
|
|
15
24
|
'CookieDriver requires a secret for session encryption. Set session.secret in your config.',
|
|
16
25
|
)
|
|
17
26
|
}
|
|
18
27
|
this.#signer = new CookieSigner(secret)
|
|
28
|
+
this.#purpose = cookieName
|
|
19
29
|
}
|
|
20
30
|
|
|
21
31
|
async read(sessionId: string): Promise<Record<string, unknown> | null> {
|
|
22
32
|
if (!sessionId) return null
|
|
23
|
-
const decrypted = this.#signer.decrypt(sessionId)
|
|
24
|
-
if (
|
|
33
|
+
const decrypted = this.#signer.decrypt(sessionId, this.#purpose)
|
|
34
|
+
if (typeof decrypted !== 'string') return null
|
|
25
35
|
try {
|
|
26
36
|
return JSON.parse(decrypted)
|
|
27
37
|
} catch {
|
|
@@ -33,6 +43,11 @@ export class CookieDriver implements SessionDriver {
|
|
|
33
43
|
// Cookie driver writes via the response cookie — handled by middleware
|
|
34
44
|
}
|
|
35
45
|
|
|
46
|
+
async update(_sessionId: string, _data: Record<string, unknown>, _ttl: number): Promise<boolean> {
|
|
47
|
+
// Cookie driver writes via the response cookie — handled by middleware
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
50
|
+
|
|
36
51
|
async destroy(_sessionId: string): Promise<void> {
|
|
37
52
|
// Cookie deletion handled by middleware setting maxAge=0
|
|
38
53
|
}
|
|
@@ -41,8 +56,8 @@ export class CookieDriver implements SessionDriver {
|
|
|
41
56
|
// Cookie renewal handled by middleware
|
|
42
57
|
}
|
|
43
58
|
|
|
44
|
-
/** Encode session data for cookie storage (encrypted). */
|
|
45
|
-
encode(data: Record<string, unknown
|
|
46
|
-
return this.#signer.encrypt(JSON.stringify(data))
|
|
59
|
+
/** Encode session data for cookie storage (encrypted), valid for `ttl` seconds. */
|
|
60
|
+
encode(data: Record<string, unknown>, ttl: number): string {
|
|
61
|
+
return this.#signer.encrypt(JSON.stringify(data), ttl * 1000, this.#purpose)
|
|
47
62
|
}
|
|
48
63
|
}
|
|
@@ -143,6 +143,16 @@ export class DatabaseDriver implements SessionDriverWithTagging {
|
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/** One UPDATE: a row deleted or expired meanwhile matches nothing. */
|
|
147
|
+
async update(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<boolean> {
|
|
148
|
+
const now = Date.now()
|
|
149
|
+
const updated = await this.#db.execute(
|
|
150
|
+
`UPDATE ${this.#table} SET data = ?, expires_at = ? WHERE id = ? AND expires_at >= ?`,
|
|
151
|
+
[JSON.stringify(data), now + ttl * 1000, sessionId, now],
|
|
152
|
+
)
|
|
153
|
+
return updated.rowsAffected > 0
|
|
154
|
+
}
|
|
155
|
+
|
|
146
156
|
async destroy(sessionId: string): Promise<void> {
|
|
147
157
|
await this.#db.execute(`DELETE FROM ${this.#table} WHERE id = ?`, [sessionId])
|
|
148
158
|
}
|