@c9up/ream 0.2.28 → 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 (168) hide show
  1. package/dist/Codemods.d.ts +16 -0
  2. package/dist/Codemods.d.ts.map +1 -1
  3. package/dist/Codemods.js +182 -0
  4. package/dist/Codemods.js.map +1 -1
  5. package/dist/Ignitor.d.ts +10 -2
  6. package/dist/Ignitor.d.ts.map +1 -1
  7. package/dist/Ignitor.js +21 -22
  8. package/dist/Ignitor.js.map +1 -1
  9. package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
  10. package/dist/bodyparser/BodyParserMiddleware.js +3 -0
  11. package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
  12. package/dist/bodyparser/qsParse.d.ts +10 -1
  13. package/dist/bodyparser/qsParse.d.ts.map +1 -1
  14. package/dist/bodyparser/qsParse.js +53 -15
  15. package/dist/bodyparser/qsParse.js.map +1 -1
  16. package/dist/codemods/source.d.ts +57 -0
  17. package/dist/codemods/source.d.ts.map +1 -0
  18. package/dist/codemods/source.js +282 -0
  19. package/dist/codemods/source.js.map +1 -0
  20. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  21. package/dist/graphql/GraphQLEngine.js +3 -2
  22. package/dist/graphql/GraphQLEngine.js.map +1 -1
  23. package/dist/http/Exception.d.ts +14 -1
  24. package/dist/http/Exception.d.ts.map +1 -1
  25. package/dist/http/Exception.js +10 -2
  26. package/dist/http/Exception.js.map +1 -1
  27. package/dist/http/HttpContext.d.ts +11 -0
  28. package/dist/http/HttpContext.d.ts.map +1 -1
  29. package/dist/http/HttpContext.js +2 -1
  30. package/dist/http/HttpContext.js.map +1 -1
  31. package/dist/http/RedirectBuilder.d.ts +9 -3
  32. package/dist/http/RedirectBuilder.d.ts.map +1 -1
  33. package/dist/http/RedirectBuilder.js +39 -37
  34. package/dist/http/RedirectBuilder.js.map +1 -1
  35. package/dist/http/Request.d.ts +42 -17
  36. package/dist/http/Request.d.ts.map +1 -1
  37. package/dist/http/Request.js +65 -29
  38. package/dist/http/Request.js.map +1 -1
  39. package/dist/http/SseStream.js +6 -7
  40. package/dist/http/SseStream.js.map +1 -1
  41. package/dist/http/permissions.d.ts +19 -0
  42. package/dist/http/permissions.d.ts.map +1 -0
  43. package/dist/http/permissions.js +28 -0
  44. package/dist/http/permissions.js.map +1 -0
  45. package/dist/http-client/HttpClient.d.ts +111 -0
  46. package/dist/http-client/HttpClient.d.ts.map +1 -0
  47. package/dist/http-client/HttpClient.js +386 -0
  48. package/dist/http-client/HttpClient.js.map +1 -0
  49. package/dist/http-client/addresses.d.ts +14 -0
  50. package/dist/http-client/addresses.d.ts.map +1 -0
  51. package/dist/http-client/addresses.js +57 -0
  52. package/dist/http-client/addresses.js.map +1 -0
  53. package/dist/http-client/fake.d.ts +50 -0
  54. package/dist/http-client/fake.d.ts.map +1 -0
  55. package/dist/http-client/fake.js +95 -0
  56. package/dist/http-client/fake.js.map +1 -0
  57. package/dist/http-client/index.d.ts +7 -0
  58. package/dist/http-client/index.d.ts.map +1 -0
  59. package/dist/http-client/index.js +7 -0
  60. package/dist/http-client/index.js.map +1 -0
  61. package/dist/index.d.ts +4 -3
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +1 -1
  64. package/dist/index.js.map +1 -1
  65. package/dist/middleware/Pipeline.d.ts.map +1 -1
  66. package/dist/middleware/Pipeline.js +6 -3
  67. package/dist/middleware/Pipeline.js.map +1 -1
  68. package/dist/native/generated.d.ts +78 -7
  69. package/dist/native/generated.d.ts.map +1 -1
  70. package/dist/rpc/RpcProvider.d.ts +6 -0
  71. package/dist/rpc/RpcProvider.d.ts.map +1 -1
  72. package/dist/rpc/RpcProvider.js +4 -1
  73. package/dist/rpc/RpcProvider.js.map +1 -1
  74. package/dist/rpc/RpcRouter.d.ts +27 -14
  75. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  76. package/dist/rpc/RpcRouter.js +95 -38
  77. package/dist/rpc/RpcRouter.js.map +1 -1
  78. package/dist/scheduler/locks/RedisLockBackend.d.ts.map +1 -1
  79. package/dist/scheduler/locks/RedisLockBackend.js +13 -2
  80. package/dist/scheduler/locks/RedisLockBackend.js.map +1 -1
  81. package/dist/security/CookieSigner.d.ts.map +1 -1
  82. package/dist/security/CookieSigner.js +17 -3
  83. package/dist/security/CookieSigner.js.map +1 -1
  84. package/dist/security/SignedUrl.d.ts.map +1 -1
  85. package/dist/security/SignedUrl.js +6 -1
  86. package/dist/security/SignedUrl.js.map +1 -1
  87. package/dist/server/Server.d.ts +41 -12
  88. package/dist/server/Server.d.ts.map +1 -1
  89. package/dist/server/Server.js +31 -10
  90. package/dist/server/Server.js.map +1 -1
  91. package/dist/server/nativeGrpc.d.ts +27 -0
  92. package/dist/server/nativeGrpc.d.ts.map +1 -0
  93. package/dist/server/nativeGrpc.js +47 -0
  94. package/dist/server/nativeGrpc.js.map +1 -0
  95. package/dist/server/nativeLimits.d.ts +49 -0
  96. package/dist/server/nativeLimits.d.ts.map +1 -0
  97. package/dist/server/nativeLimits.js +67 -0
  98. package/dist/server/nativeLimits.js.map +1 -0
  99. package/dist/session/Session.d.ts +12 -0
  100. package/dist/session/Session.d.ts.map +1 -1
  101. package/dist/session/Session.js.map +1 -1
  102. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  103. package/dist/session/SessionMiddleware.js +28 -9
  104. package/dist/session/SessionMiddleware.js.map +1 -1
  105. package/dist/session/config.d.ts +5 -0
  106. package/dist/session/config.d.ts.map +1 -1
  107. package/dist/session/config.js +8 -0
  108. package/dist/session/config.js.map +1 -1
  109. package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
  110. package/dist/session/drivers/MemoryDriver.js +12 -5
  111. package/dist/session/drivers/MemoryDriver.js.map +1 -1
  112. package/dist/session/errors.d.ts +17 -0
  113. package/dist/session/errors.d.ts.map +1 -1
  114. package/dist/session/errors.js +21 -0
  115. package/dist/session/errors.js.map +1 -1
  116. package/dist/session/templateTags.d.ts +14 -0
  117. package/dist/session/templateTags.d.ts.map +1 -1
  118. package/dist/session/templateTags.js +45 -17
  119. package/dist/session/templateTags.js.map +1 -1
  120. package/dist/storage/StaticMiddleware.d.ts.map +1 -1
  121. package/dist/storage/StaticMiddleware.js +29 -18
  122. package/dist/storage/StaticMiddleware.js.map +1 -1
  123. package/events.darwin-arm64.node +0 -0
  124. package/events.darwin-x64.node +0 -0
  125. package/events.win32-x64-msvc.node +0 -0
  126. package/index.darwin-arm64.node +0 -0
  127. package/index.darwin-x64.node +0 -0
  128. package/index.linux-arm64-gnu.node +0 -0
  129. package/index.linux-x64-gnu.node +0 -0
  130. package/index.win32-x64-msvc.node +0 -0
  131. package/package.json +11 -2
  132. package/scheduler.darwin-arm64.node +0 -0
  133. package/scheduler.darwin-x64.node +0 -0
  134. package/scheduler.win32-x64-msvc.node +0 -0
  135. package/src/Codemods.ts +265 -0
  136. package/src/Ignitor.ts +37 -22
  137. package/src/bodyparser/BodyParserMiddleware.ts +3 -0
  138. package/src/bodyparser/qsParse.ts +69 -14
  139. package/src/codemods/source.ts +303 -0
  140. package/src/graphql/GraphQLEngine.ts +3 -2
  141. package/src/http/Exception.ts +17 -2
  142. package/src/http/HttpContext.ts +14 -1
  143. package/src/http/RedirectBuilder.ts +48 -37
  144. package/src/http/Request.ts +87 -29
  145. package/src/http/SseStream.ts +6 -7
  146. package/src/http/permissions.ts +31 -0
  147. package/src/http-client/HttpClient.ts +505 -0
  148. package/src/http-client/addresses.ts +58 -0
  149. package/src/http-client/fake.ts +141 -0
  150. package/src/http-client/index.ts +21 -0
  151. package/src/index.ts +5 -1
  152. package/src/middleware/Pipeline.ts +7 -3
  153. package/src/native/generated.ts +89 -7
  154. package/src/rpc/RpcProvider.ts +10 -1
  155. package/src/rpc/RpcRouter.ts +126 -42
  156. package/src/scheduler/locks/RedisLockBackend.ts +12 -4
  157. package/src/security/CookieSigner.ts +17 -3
  158. package/src/security/SignedUrl.ts +6 -1
  159. package/src/server/Server.ts +57 -12
  160. package/src/server/nativeGrpc.ts +59 -0
  161. package/src/server/nativeLimits.ts +94 -0
  162. package/src/session/Session.ts +16 -0
  163. package/src/session/SessionMiddleware.ts +30 -10
  164. package/src/session/config.ts +7 -0
  165. package/src/session/drivers/MemoryDriver.ts +11 -5
  166. package/src/session/errors.ts +31 -0
  167. package/src/session/templateTags.ts +57 -0
  168. package/src/storage/StaticMiddleware.ts +28 -18
@@ -37,12 +37,20 @@ export type MiddlewareEntry = LazyImport<MiddlewareClassConstructor> | Middlewar
37
37
  export interface GrpcCall {
38
38
  /** `/package.Service/Method`, as it arrived. */
39
39
  path: string
40
- /** Request metadata, header names lowercased. */
41
- metadata: Record<string, 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[]>
42
45
  /** The request message, still encoded. */
43
46
  message: Uint8Array
44
47
  /** Resolved client IP, by the same trusted-proxy rules as `request.ip()`. */
45
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
46
54
  }
47
55
 
48
56
  /** A reply message, still encoded — or a gRPC status code instead of one. */
@@ -50,6 +58,26 @@ export type GrpcReply = { message: Uint8Array } | { code: number; details: strin
50
58
 
51
59
  export type GrpcHandler = (call: GrpcCall) => Promise<GrpcReply>
52
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
+
53
81
  export class Server {
54
82
  #router: Router
55
83
  #serverMiddleware: MiddlewareFunction[] = []
@@ -57,22 +85,31 @@ export class Server {
57
85
  #resolvedErrorHandler?: ExceptionHandler
58
86
  #trustedProxies: string[] = []
59
87
  #grpcHandler?: GrpcHandler
88
+ #grpcLimits: GrpcLimits = {}
60
89
 
61
90
  constructor(router: Router) {
62
91
  this.#router = router
63
92
  }
64
93
 
65
94
  /**
66
- * Configure the trusted-proxy CIDR list. The Rust HyperServer uses it to
67
- * pre-resolve `request.ip` once per request — JS reads the field, no CIDR
68
- * 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.
98
+ *
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.
69
104
  *
70
- * Empty list (default, 2026-05+) is **strict fail-closed**: `X-Forwarded-For`
71
- * and `X-Real-IP` are ignored entirely; only the socket peer is used.
72
- * Apps deployed behind a reverse proxy must list the proxy's CIDR
73
- * (`'10.0.0.0/8'`, `'192.0.2.0/24'`, etc.) or pass the `'*'` sentinel
74
- * for permissive mode (accepts spoofing as the explicit trade-off when
75
- * proxy CIDRs can't be enumerated).
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.
76
113
  */
77
114
  trustedProxies(cidrs: string[]): this {
78
115
  this.#trustedProxies = [...cidrs]
@@ -99,16 +136,24 @@ export class Server {
99
136
  * One handler per server: a second one throws. Two packages both serving
100
137
  * gRPC is a conflict to resolve, not to settle by load order.
101
138
  */
102
- grpc(handler: GrpcHandler): this {
139
+ grpc(handler: GrpcHandler, limits: GrpcLimits = {}): this {
103
140
  if (this.#grpcHandler) {
104
141
  throw new ReamError('E_SERVER_GRPC_ALREADY_SET', 'A gRPC handler is already registered.', {
105
142
  hint: 'Only one package can serve gRPC on the HTTP port.',
106
143
  })
107
144
  }
145
+ checkGrpcLimit('maxReceiveMessageLength', limits.maxReceiveMessageLength)
146
+ checkGrpcLimit('maxSendMessageLength', limits.maxSendMessageLength)
108
147
  this.#grpcHandler = handler
148
+ this.#grpcLimits = { ...limits }
109
149
  return this
110
150
  }
111
151
 
152
+ /** The message bounds given with the gRPC handler (consumed by Ignitor at boot). */
153
+ getGrpcLimits(): GrpcLimits {
154
+ return { ...this.#grpcLimits }
155
+ }
156
+
112
157
  /** The gRPC handler, if one is set (consumed by Ignitor at boot). */
113
158
  getGrpcHandler(): GrpcHandler | undefined {
114
159
  return this.#grpcHandler
@@ -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,6 +10,7 @@
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
15
  import {
15
16
  E_SESSION_NOT_READY,
@@ -154,8 +155,23 @@ export interface SessionConfig {
154
155
  client?: SessionRedisClientSource
155
156
  /** `redis` driver only — key prefix. Defaults to `ream:session:`. */
156
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
157
167
  }
158
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
+
159
175
  /**
160
176
  * Generate a fresh session ID. Format mirrors AdonisJS / express-session —
161
177
  * 32 bytes of base64url entropy (~256 bits). Defined here so `regenerate()`
@@ -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') {
@@ -189,11 +200,8 @@ export default class SessionMiddleware {
189
200
  // Already encrypted by the cookie driver; packing it again would
190
201
  // only make the cookie bigger and change nothing.
191
202
  encode: false,
203
+ ...this.#cookieAttributes(isProduction),
192
204
  maxAge: this.#config.clearWithBrowser ? undefined : Math.ceil(remaining / 1000),
193
- path: '/',
194
- httpOnly: true,
195
- sameSite: 'lax',
196
- secure: isProduction,
197
205
  })
198
206
  }
199
207
  return
@@ -242,14 +250,26 @@ export default class SessionMiddleware {
242
250
  // read, before any store lookup, and that an id from another deployment
243
251
  // (a different APP_KEY) cannot be presented here.
244
252
  ctx.response.cookie(cookieName, session.sessionId, {
253
+ ...this.#cookieAttributes(isProduction),
245
254
  maxAge: this.#config.clearWithBrowser ? undefined : maxAge,
246
- path: '/',
247
- httpOnly: true,
248
- sameSite: 'lax',
249
- secure: isProduction,
250
255
  })
251
256
  }
252
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
+ }
253
273
  }
254
274
 
255
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
  }
@@ -9,8 +9,9 @@ const SWEEP_THRESHOLD = 1000
9
9
  * The sweep alone only removes EXPIRED entries, so within the TTL window
10
10
  * nothing bounded growth: anyone able to request a page could mint sessions
11
11
  * until the process ran out of memory, without ever logging in. Past the cap
12
- * the oldest sessions are dropped — a bounded store that forgets is strictly
13
- * better than an unbounded one that dies.
12
+ * the least recently used sessions are dropped (a write, an update or a
13
+ * touch moves a session to the back of the line) — a bounded store that
14
+ * forgets is strictly better than an unbounded one that dies.
14
15
  */
15
16
  const DEFAULT_MAX_ENTRIES = 10_000
16
17
 
@@ -78,9 +79,14 @@ export class MemoryDriver implements SessionDriverWithTagging {
78
79
 
79
80
  async touch(sessionId: string, ttl: number): Promise<void> {
80
81
  const entry = this.#store.get(sessionId)
81
- if (entry) {
82
- entry.expiresAt = Date.now() + ttl * 1000
83
- }
82
+ if (!entry || entry.expiresAt < Date.now()) return
83
+ // Re-inserted, not updated in place: eviction drops the head of the map,
84
+ // so a session in use has to move to the tail. Updated in place, a
85
+ // signed-in user who keeps browsing stayed first in line, and ten
86
+ // thousand anonymous requests evicted them.
87
+ entry.expiresAt = Date.now() + ttl * 1000
88
+ this.#store.delete(sessionId)
89
+ this.#store.set(sessionId, entry)
84
90
  }
85
91
 
86
92
  async tag(sessionId: string, userId: string | number): Promise<void> {
@@ -54,3 +54,34 @@ export class E_SESSION_TAGGING_NOT_SUPPORTED extends Exception {
54
54
  )
55
55
  }
56
56
  }
57
+
58
+ /**
59
+ * A session config without a store (AdonisJS raises "Missing store" for the
60
+ * same config). There is no implicit fallback: the memory store is per
61
+ * process and bounded — a restart, a second replica or enough anonymous
62
+ * traffic signs every user out — so it is only used when it is chosen.
63
+ */
64
+ export class E_SESSION_STORE_NOT_CONFIGURED extends Exception {
65
+ static override status = 500
66
+ static override code = 'E_SESSION_STORE_NOT_CONFIGURED'
67
+
68
+ constructor() {
69
+ super(
70
+ "No session store is configured. Set `store` in config/session.ts (redis, database, file or cookie), or `store: 'memory'` for sessions kept in this process only (tests, a single development instance).",
71
+ { status: 500, code: 'E_SESSION_STORE_NOT_CONFIGURED' },
72
+ )
73
+ }
74
+ }
75
+
76
+ /** `cookie.httpOnly: false` — the session id would be readable by any script on the page. */
77
+ export class E_SESSION_COOKIE_NOT_HTTP_ONLY extends Exception {
78
+ static override status = 500
79
+ static override code = 'E_SESSION_COOKIE_NOT_HTTP_ONLY'
80
+
81
+ constructor() {
82
+ super(
83
+ 'The session cookie must be httpOnly: a script able to read it turns any XSS into an account takeover. Remove `cookie.httpOnly: false` from the session config.',
84
+ { status: 500, code: 'E_SESSION_COOKIE_NOT_HTTP_ONLY' },
85
+ )
86
+ }
87
+ }
@@ -1,3 +1,5 @@
1
+ import { ReamError } from '../errors/ReamError.js'
2
+
1
3
  /**
2
4
  * The flash-message tags AdonisJS's session publishes to templates.
3
5
  *
@@ -122,3 +124,58 @@ export function registerSessionTemplateTags(engine: TemplateTagEngine): void {
122
124
  },
123
125
  })
124
126
  }
127
+
128
+ /** Loads the template engine package. Swappable so a test needs no engine. */
129
+ export type EnginePackageLoader = () => Promise<unknown>
130
+
131
+ const loadInker: EnginePackageLoader = () => {
132
+ // Built rather than written inline: inker is an OPTIONAL peer, and a literal
133
+ // `import('@c9up/inker')` makes a bundler resolve it at build time for an
134
+ // application that never installed it. Computed, it only runs behind the
135
+ // `usingInker` flag.
136
+ const specifier = '@c9up/inker'
137
+ return import(/* @vite-ignore */ specifier)
138
+ }
139
+
140
+ function unusable(detail: string): ReamError {
141
+ return new ReamError(
142
+ 'E_SESSION_TEMPLATE_ENGINE_UNUSABLE',
143
+ `The template engine is installed but the session tags cannot be registered: ${detail}`,
144
+ {
145
+ hint: '`@c9up/inker` must default-export its plugin registrar (`inker.use(plugin)`), whose plugins receive an engine with `registerTag()`.',
146
+ },
147
+ )
148
+ }
149
+
150
+ /**
151
+ * Publish the session tags into the template engine, the way AdonisJS's
152
+ * session does with Edge: through the engine package's MODULE SINGLETON
153
+ * (`inker.use(plugin)`), guarded by the host's `usingInker` flag — never by
154
+ * resolving a container binding. `use()` queues the plugin, and the engine
155
+ * runs it before its next render, whenever the engine was built.
156
+ *
157
+ * Returns whether the tags were handed over. A registrar or an engine of the
158
+ * wrong shape is an error, not a skip: a template using `@flashMessage`
159
+ * would otherwise render it as plain text with no warning.
160
+ */
161
+ export async function installSessionTemplateTags(
162
+ usingInker: boolean,
163
+ load: EnginePackageLoader = loadInker,
164
+ ): Promise<boolean> {
165
+ if (!usingInker) return false
166
+ const module: unknown = await load()
167
+ const registrar =
168
+ typeof module === 'object' && module !== null ? Reflect.get(module, 'default') : undefined
169
+ const use = Reflect.get(Object(registrar), 'use')
170
+ if (typeof use !== 'function') throw unusable('its default export has no use()')
171
+ use.call(registrar, (engine: unknown) => {
172
+ const register = Reflect.get(Object(engine), 'registerTag')
173
+ if (typeof register !== 'function') throw unusable('the engine has no registerTag()')
174
+ registerSessionTemplateTags({
175
+ registerTag: (tag) => {
176
+ register.call(engine, tag)
177
+ },
178
+ })
179
+ })
180
+ return true
181
+ }
@@ -150,8 +150,11 @@ export class StaticMiddleware {
150
150
  const reqPath = ctx.request.path()
151
151
 
152
152
  // A dotfile under `deny` is answered, not passed on: falling through would
153
- // leak its existence through whatever the router answers next.
154
- if (this.#dotFiles === 'deny' && this.#hasDotSegment(reqPath)) {
153
+ // leak its existence through whatever the router answers next. The check
154
+ // runs on the DECODED path — the name that would be opened — or `/%2eenv`
155
+ // reaches `.env`. A path that does not decode is checked raw; it is never
156
+ // served anyway.
157
+ if (this.#dotFiles === 'deny' && this.#hasDotSegment(decodePath(reqPath) ?? reqPath)) {
155
158
  ctx.response.status(403).send('Forbidden')
156
159
  return
157
160
  }
@@ -335,25 +338,21 @@ export class StaticMiddleware {
335
338
  reqPath = reqPath.slice(this.#prefix.length) || '/'
336
339
  }
337
340
 
338
- if (this.#dotFiles === 'ignore' && this.#hasDotSegment(reqPath)) return null
339
-
340
- // The candidates, in upstream's order: the path itself, then the path with
341
- // each fallback extension, then the index files for a directory request.
342
- //
343
- // Percent-decoding happens BEFORE resolution on purpose: `%2e%2e%2f` is
344
- // `../` and has to be normalised away by `path.resolve` like any other
345
- // traversal, not carried through as an opaque segment. `decodeURIComponent`
346
- // THROWS on a malformed sequence (a lone `%`), which would otherwise become
347
- // a 500 on a request anyone can send.
348
- let decoded: string
349
- try {
350
- decoded = decodeURIComponent(reqPath)
351
- } catch {
352
- return null
353
- }
341
+ // Percent-decoding happens BEFORE any check and BEFORE resolution on
342
+ // purpose: the dot-file policy has to see the name that will be opened
343
+ // (`%2eenv` is `.env`), and `%2e%2e%2f` is `../`, which has to be
344
+ // normalised away by `path.resolve` like any other traversal, not carried
345
+ // through as an opaque segment. A malformed sequence (a lone `%`) is not
346
+ // servable.
347
+ const decoded = decodePath(reqPath)
348
+ if (decoded === null) return null
354
349
  // A NUL truncates the path in the syscall layer of some runtimes; Node
355
350
  // rejects it, but refusing here keeps the decision in one place.
356
351
  if (decoded.includes('\0')) return null
352
+ if (this.#dotFiles !== 'allow' && this.#hasDotSegment(decoded)) return null
353
+
354
+ // The candidates, in upstream's order: the path itself, then the path with
355
+ // each fallback extension, then the index files for a directory request.
357
356
  const relative = decoded.replace(/^\/+/, '')
358
357
  const candidates: string[] = []
359
358
  if (relative !== '') {
@@ -367,6 +366,8 @@ export class StaticMiddleware {
367
366
  }
368
367
 
369
368
  for (const candidate of candidates) {
369
+ // A configured index or extension can itself name a dot file.
370
+ if (this.#dotFiles !== 'allow' && this.#hasDotSegment(candidate)) continue
370
371
  const opened = await this.#openInRoot(candidate)
371
372
  if (opened) return opened
372
373
  }
@@ -426,6 +427,15 @@ export class StaticMiddleware {
426
427
  }
427
428
  }
428
429
 
430
+ /** `decodeURIComponent` that answers `null` instead of throwing on a malformed sequence. */
431
+ function decodePath(reqPath: string): string | null {
432
+ try {
433
+ return decodeURIComponent(reqPath)
434
+ } catch {
435
+ return null
436
+ }
437
+ }
438
+
429
439
  /**
430
440
  * Static config with the defaults filled in — `@adonisjs/static`'s
431
441
  * `defineConfig`, value for value.