@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.
Files changed (195) hide show
  1. package/README.md +1 -0
  2. package/dist/Codemods.d.ts +16 -0
  3. package/dist/Codemods.d.ts.map +1 -1
  4. package/dist/Codemods.js +182 -0
  5. package/dist/Codemods.js.map +1 -1
  6. package/dist/Ignitor.d.ts +11 -1
  7. package/dist/Ignitor.d.ts.map +1 -1
  8. package/dist/Ignitor.js +27 -21
  9. package/dist/Ignitor.js.map +1 -1
  10. package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
  11. package/dist/bodyparser/BodyParserMiddleware.js +3 -0
  12. package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
  13. package/dist/bodyparser/qsParse.d.ts +10 -1
  14. package/dist/bodyparser/qsParse.d.ts.map +1 -1
  15. package/dist/bodyparser/qsParse.js +53 -15
  16. package/dist/bodyparser/qsParse.js.map +1 -1
  17. package/dist/codemods/source.d.ts +57 -0
  18. package/dist/codemods/source.d.ts.map +1 -0
  19. package/dist/codemods/source.js +282 -0
  20. package/dist/codemods/source.js.map +1 -0
  21. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  22. package/dist/graphql/GraphQLEngine.js +3 -2
  23. package/dist/graphql/GraphQLEngine.js.map +1 -1
  24. package/dist/http/Exception.d.ts +14 -1
  25. package/dist/http/Exception.d.ts.map +1 -1
  26. package/dist/http/Exception.js +10 -2
  27. package/dist/http/Exception.js.map +1 -1
  28. package/dist/http/HttpContext.d.ts +11 -0
  29. package/dist/http/HttpContext.d.ts.map +1 -1
  30. package/dist/http/HttpContext.js +2 -1
  31. package/dist/http/HttpContext.js.map +1 -1
  32. package/dist/http/RedirectBuilder.d.ts +9 -3
  33. package/dist/http/RedirectBuilder.d.ts.map +1 -1
  34. package/dist/http/RedirectBuilder.js +39 -37
  35. package/dist/http/RedirectBuilder.js.map +1 -1
  36. package/dist/http/Request.d.ts +42 -17
  37. package/dist/http/Request.d.ts.map +1 -1
  38. package/dist/http/Request.js +65 -29
  39. package/dist/http/Request.js.map +1 -1
  40. package/dist/http/SseStream.js +6 -7
  41. package/dist/http/SseStream.js.map +1 -1
  42. package/dist/http/permissions.d.ts +19 -0
  43. package/dist/http/permissions.d.ts.map +1 -0
  44. package/dist/http/permissions.js +28 -0
  45. package/dist/http/permissions.js.map +1 -0
  46. package/dist/http-client/HttpClient.d.ts +111 -0
  47. package/dist/http-client/HttpClient.d.ts.map +1 -0
  48. package/dist/http-client/HttpClient.js +386 -0
  49. package/dist/http-client/HttpClient.js.map +1 -0
  50. package/dist/http-client/addresses.d.ts +14 -0
  51. package/dist/http-client/addresses.d.ts.map +1 -0
  52. package/dist/http-client/addresses.js +57 -0
  53. package/dist/http-client/addresses.js.map +1 -0
  54. package/dist/http-client/fake.d.ts +50 -0
  55. package/dist/http-client/fake.d.ts.map +1 -0
  56. package/dist/http-client/fake.js +95 -0
  57. package/dist/http-client/fake.js.map +1 -0
  58. package/dist/http-client/index.d.ts +7 -0
  59. package/dist/http-client/index.d.ts.map +1 -0
  60. package/dist/http-client/index.js +7 -0
  61. package/dist/http-client/index.js.map +1 -0
  62. package/dist/index.d.ts +5 -4
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +2 -2
  65. package/dist/index.js.map +1 -1
  66. package/dist/middleware/Pipeline.d.ts.map +1 -1
  67. package/dist/middleware/Pipeline.js +6 -3
  68. package/dist/middleware/Pipeline.js.map +1 -1
  69. package/dist/native/generated.d.ts +108 -3
  70. package/dist/native/generated.d.ts.map +1 -1
  71. package/dist/rpc/RpcProvider.d.ts +6 -0
  72. package/dist/rpc/RpcProvider.d.ts.map +1 -1
  73. package/dist/rpc/RpcProvider.js +4 -1
  74. package/dist/rpc/RpcProvider.js.map +1 -1
  75. package/dist/rpc/RpcRouter.d.ts +27 -14
  76. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  77. package/dist/rpc/RpcRouter.js +95 -38
  78. package/dist/rpc/RpcRouter.js.map +1 -1
  79. package/dist/scheduler/locks/RedisLockBackend.d.ts.map +1 -1
  80. package/dist/scheduler/locks/RedisLockBackend.js +13 -2
  81. package/dist/scheduler/locks/RedisLockBackend.js.map +1 -1
  82. package/dist/security/CookieSigner.d.ts.map +1 -1
  83. package/dist/security/CookieSigner.js +17 -3
  84. package/dist/security/CookieSigner.js.map +1 -1
  85. package/dist/security/SignedUrl.d.ts.map +1 -1
  86. package/dist/security/SignedUrl.js +6 -1
  87. package/dist/security/SignedUrl.js.map +1 -1
  88. package/dist/server/Server.d.ts +75 -9
  89. package/dist/server/Server.d.ts.map +1 -1
  90. package/dist/server/Server.js +60 -9
  91. package/dist/server/Server.js.map +1 -1
  92. package/dist/server/nativeGrpc.d.ts +27 -0
  93. package/dist/server/nativeGrpc.d.ts.map +1 -0
  94. package/dist/server/nativeGrpc.js +47 -0
  95. package/dist/server/nativeGrpc.js.map +1 -0
  96. package/dist/server/nativeLimits.d.ts +49 -0
  97. package/dist/server/nativeLimits.d.ts.map +1 -0
  98. package/dist/server/nativeLimits.js +67 -0
  99. package/dist/server/nativeLimits.js.map +1 -0
  100. package/dist/session/Session.d.ts +45 -0
  101. package/dist/session/Session.d.ts.map +1 -1
  102. package/dist/session/Session.js +31 -2
  103. package/dist/session/Session.js.map +1 -1
  104. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  105. package/dist/session/SessionMiddleware.js +52 -13
  106. package/dist/session/SessionMiddleware.js.map +1 -1
  107. package/dist/session/config.d.ts +5 -0
  108. package/dist/session/config.d.ts.map +1 -1
  109. package/dist/session/config.js +8 -0
  110. package/dist/session/config.js.map +1 -1
  111. package/dist/session/drivers/CookieDriver.d.ts +12 -3
  112. package/dist/session/drivers/CookieDriver.d.ts.map +1 -1
  113. package/dist/session/drivers/CookieDriver.js +20 -6
  114. package/dist/session/drivers/CookieDriver.js.map +1 -1
  115. package/dist/session/drivers/DatabaseDriver.d.ts +2 -0
  116. package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -1
  117. package/dist/session/drivers/DatabaseDriver.js +6 -0
  118. package/dist/session/drivers/DatabaseDriver.js.map +1 -1
  119. package/dist/session/drivers/FileDriver.d.ts +8 -0
  120. package/dist/session/drivers/FileDriver.d.ts.map +1 -1
  121. package/dist/session/drivers/FileDriver.js +85 -11
  122. package/dist/session/drivers/FileDriver.js.map +1 -1
  123. package/dist/session/drivers/MemoryDriver.d.ts +2 -0
  124. package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
  125. package/dist/session/drivers/MemoryDriver.js +21 -5
  126. package/dist/session/drivers/MemoryDriver.js.map +1 -1
  127. package/dist/session/drivers/RedisDriver.d.ts +2 -0
  128. package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
  129. package/dist/session/drivers/RedisDriver.js +6 -0
  130. package/dist/session/drivers/RedisDriver.js.map +1 -1
  131. package/dist/session/errors.d.ts +27 -0
  132. package/dist/session/errors.d.ts.map +1 -1
  133. package/dist/session/errors.js +33 -0
  134. package/dist/session/errors.js.map +1 -1
  135. package/dist/session/templateTags.d.ts +14 -0
  136. package/dist/session/templateTags.d.ts.map +1 -1
  137. package/dist/session/templateTags.js +45 -17
  138. package/dist/session/templateTags.js.map +1 -1
  139. package/dist/storage/StaticMiddleware.d.ts.map +1 -1
  140. package/dist/storage/StaticMiddleware.js +29 -18
  141. package/dist/storage/StaticMiddleware.js.map +1 -1
  142. package/events.darwin-arm64.node +0 -0
  143. package/events.darwin-x64.node +0 -0
  144. package/events.linux-arm64-gnu.node +0 -0
  145. package/events.linux-x64-gnu.node +0 -0
  146. package/events.win32-x64-msvc.node +0 -0
  147. package/index.darwin-arm64.node +0 -0
  148. package/index.darwin-x64.node +0 -0
  149. package/index.linux-arm64-gnu.node +0 -0
  150. package/index.linux-x64-gnu.node +0 -0
  151. package/index.win32-x64-msvc.node +0 -0
  152. package/package.json +11 -2
  153. package/scheduler.darwin-arm64.node +0 -0
  154. package/scheduler.darwin-x64.node +0 -0
  155. package/scheduler.linux-arm64-gnu.node +0 -0
  156. package/scheduler.linux-x64-gnu.node +0 -0
  157. package/scheduler.win32-x64-msvc.node +0 -0
  158. package/src/Codemods.ts +265 -0
  159. package/src/Ignitor.ts +48 -20
  160. package/src/bodyparser/BodyParserMiddleware.ts +3 -0
  161. package/src/bodyparser/qsParse.ts +69 -14
  162. package/src/codemods/source.ts +303 -0
  163. package/src/graphql/GraphQLEngine.ts +3 -2
  164. package/src/http/Exception.ts +17 -2
  165. package/src/http/HttpContext.ts +14 -1
  166. package/src/http/RedirectBuilder.ts +48 -37
  167. package/src/http/Request.ts +87 -29
  168. package/src/http/SseStream.ts +6 -7
  169. package/src/http/permissions.ts +31 -0
  170. package/src/http-client/HttpClient.ts +505 -0
  171. package/src/http-client/addresses.ts +58 -0
  172. package/src/http-client/fake.ts +141 -0
  173. package/src/http-client/index.ts +21 -0
  174. package/src/index.ts +9 -1
  175. package/src/middleware/Pipeline.ts +7 -3
  176. package/src/native/generated.ts +123 -3
  177. package/src/rpc/RpcProvider.ts +10 -1
  178. package/src/rpc/RpcRouter.ts +126 -42
  179. package/src/scheduler/locks/RedisLockBackend.ts +12 -4
  180. package/src/security/CookieSigner.ts +17 -3
  181. package/src/security/SignedUrl.ts +6 -1
  182. package/src/server/Server.ts +103 -9
  183. package/src/server/nativeGrpc.ts +59 -0
  184. package/src/server/nativeLimits.ts +94 -0
  185. package/src/session/Session.ts +72 -2
  186. package/src/session/SessionMiddleware.ts +53 -14
  187. package/src/session/config.ts +7 -0
  188. package/src/session/drivers/CookieDriver.ts +21 -6
  189. package/src/session/drivers/DatabaseDriver.ts +10 -0
  190. package/src/session/drivers/FileDriver.ts +91 -11
  191. package/src/session/drivers/MemoryDriver.ts +20 -5
  192. package/src/session/drivers/RedisDriver.ts +7 -0
  193. package/src/session/errors.ts +48 -0
  194. package/src/session/templateTags.ts +57 -0
  195. package/src/storage/StaticMiddleware.ts +28 -18
@@ -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-proxy CIDR list. The Rust HyperServer uses it to
48
- * pre-resolve `request.ip` once per request — JS reads the field, no CIDR
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
- * Empty list (default, 2026-05+) is **strict fail-closed**: `X-Forwarded-For`
52
- * and `X-Real-IP` are ignored entirely; only the socket peer is used.
53
- * Apps deployed behind a reverse proxy must list the proxy's CIDR
54
- * (`'10.0.0.0/8'`, `'192.0.2.0/24'`, etc.) or pass the `'*'` sentinel
55
- * for permissive mode (accepts spoofing as the explicit trade-off when
56
- * proxy CIDRs can't be enumerated).
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
+ }
@@ -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 { E_SESSION_NOT_READY, E_SESSION_TAGGING_NOT_SUPPORTED } from './errors.js'
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
- await driver.write(this.#sessionId, this.toJSON(), this.#ttl)
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 ?? 'memory',
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
- if (shouldEmit) {
166
- const encoded = this.#cookieDriver.encode(session.toJSON())
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
- maxAge: this.#config.clearWithBrowser ? undefined : maxAge,
178
- path: '/',
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 {
@@ -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 (!decrypted) return null
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>): string {
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
  }