@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
@@ -16,6 +16,18 @@ export interface FileDriverOptions {
16
16
  location: string
17
17
  }
18
18
 
19
+ /** How long a writer waits for a session's lock before giving up. */
20
+ const LOCK_WAIT_MS = 2000
21
+ /**
22
+ * A lock older than this was left by a process that died holding it. Every
23
+ * operation under the lock is one small file read or write, far below it.
24
+ */
25
+ const LOCK_STALE_MS = 10_000
26
+
27
+ function hasCode(error: unknown, code: string): boolean {
28
+ return error instanceof Error && Reflect.get(error, 'code') === code
29
+ }
30
+
19
31
  export class FileDriver implements SessionDriver {
20
32
  readonly #location: string
21
33
 
@@ -37,6 +49,17 @@ export class FileDriver implements SessionDriver {
37
49
  }
38
50
 
39
51
  async read(sessionId: string): Promise<Record<string, unknown> | null> {
52
+ return this.#read(sessionId, () => this.destroy(sessionId))
53
+ }
54
+
55
+ /**
56
+ * Read, dropping an expired file through `drop`: `destroy()` from outside,
57
+ * a bare removal from inside the lock, which is already held there.
58
+ */
59
+ async #read(
60
+ sessionId: string,
61
+ drop: () => Promise<void>,
62
+ ): Promise<Record<string, unknown> | null> {
40
63
  let raw: string
41
64
  try {
42
65
  raw = await fs.promises.readFile(this.#pathFor(sessionId), 'utf8')
@@ -48,7 +71,7 @@ export class FileDriver implements SessionDriver {
48
71
  if (typeof parsed !== 'object' || parsed === null) return null
49
72
  const entry = parsed as { expiresAt?: number; data?: Record<string, unknown> }
50
73
  if (typeof entry.expiresAt === 'number' && entry.expiresAt < Date.now()) {
51
- await this.destroy(sessionId)
74
+ await drop()
52
75
  return null
53
76
  }
54
77
  return entry.data ?? {}
@@ -60,6 +83,43 @@ export class FileDriver implements SessionDriver {
60
83
  }
61
84
 
62
85
  async write(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<void> {
86
+ await this.#locked(sessionId, () => this.#replace(sessionId, data, ttl))
87
+ }
88
+
89
+ /**
90
+ * Written only while the file is still there. A filesystem has no
91
+ * conditional write, so every change to a session's file — this, `write`,
92
+ * `destroy` and `touch` — runs under that session's lock: a `destroy` from a
93
+ * logout cannot land between the check and the write, in this process or in
94
+ * another one sharing the directory.
95
+ */
96
+ async update(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<boolean> {
97
+ return this.#locked(sessionId, async () => {
98
+ if ((await this.#read(sessionId, () => this.#remove(sessionId))) === null) return false
99
+ await this.#replace(sessionId, data, ttl)
100
+ return true
101
+ })
102
+ }
103
+
104
+ async destroy(sessionId: string): Promise<void> {
105
+ await this.#locked(sessionId, () => this.#remove(sessionId))
106
+ }
107
+
108
+ async #remove(sessionId: string): Promise<void> {
109
+ await fs.promises.rm(this.#pathFor(sessionId), { force: true })
110
+ }
111
+
112
+ async touch(sessionId: string, ttl: number): Promise<void> {
113
+ await this.#locked(sessionId, async () => {
114
+ const data = await this.#read(sessionId, () => this.#remove(sessionId))
115
+ // Nothing stored: there is no expiry to slide, and writing here would
116
+ // create the very session that was not there.
117
+ if (data === null || Object.keys(data).length === 0) return
118
+ await this.#replace(sessionId, data, ttl)
119
+ })
120
+ }
121
+
122
+ async #replace(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<void> {
63
123
  const file = this.#pathFor(sessionId)
64
124
  const payload = JSON.stringify({ expiresAt: Date.now() + ttl * 1000, data })
65
125
  // Written to a temporary neighbour then renamed: a crash mid-write would
@@ -70,15 +130,35 @@ export class FileDriver implements SessionDriver {
70
130
  await fs.promises.rename(temp, file)
71
131
  }
72
132
 
73
- async destroy(sessionId: string): Promise<void> {
74
- await fs.promises.rm(this.#pathFor(sessionId), { force: true })
75
- }
76
-
77
- async touch(sessionId: string, ttl: number): Promise<void> {
78
- const data = await this.read(sessionId)
79
- // Nothing stored: there is no expiry to slide, and writing here would
80
- // create the very session that was not there.
81
- if (data === null || Object.keys(data).length === 0) return
82
- await this.write(sessionId, data, ttl)
133
+ /**
134
+ * Run `fn` holding the session's lock: a directory beside its file, which
135
+ * `mkdir` creates atomically or refuses — the one exclusive primitive every
136
+ * filesystem gives, across processes too.
137
+ */
138
+ async #locked<T>(sessionId: string, fn: () => Promise<T>): Promise<T> {
139
+ const lock = `${this.#pathFor(sessionId)}.lock`
140
+ const deadline = Date.now() + LOCK_WAIT_MS
141
+ for (;;) {
142
+ try {
143
+ await fs.promises.mkdir(lock)
144
+ break
145
+ } catch (error) {
146
+ if (!hasCode(error, 'EEXIST')) throw error
147
+ const held = await fs.promises.stat(lock).catch(() => null)
148
+ if (held !== null && Date.now() - held.mtimeMs > LOCK_STALE_MS) {
149
+ await fs.promises.rm(lock, { recursive: true, force: true })
150
+ continue
151
+ }
152
+ if (Date.now() > deadline) {
153
+ throw new Error(`Session file is locked: ${lock}`)
154
+ }
155
+ await new Promise((resolve) => setTimeout(resolve, 5))
156
+ }
157
+ }
158
+ try {
159
+ return await fn()
160
+ } finally {
161
+ await fs.promises.rmdir(lock).catch(() => {})
162
+ }
83
163
  }
84
164
  }
@@ -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
 
@@ -57,6 +58,15 @@ export class MemoryDriver implements SessionDriverWithTagging {
57
58
  }
58
59
  }
59
60
 
61
+ /** Atomic by construction: nothing else runs between the check and the set. */
62
+ async update(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<boolean> {
63
+ const entry = this.#store.get(sessionId)
64
+ if (!entry || entry.expiresAt < Date.now()) return false
65
+ this.#store.delete(sessionId)
66
+ this.#store.set(sessionId, { data, expiresAt: Date.now() + ttl * 1000 })
67
+ return true
68
+ }
69
+
60
70
  async destroy(sessionId: string): Promise<void> {
61
71
  this.#store.delete(sessionId)
62
72
  // Drop the tags too: a destroyed session left in the index would be
@@ -69,9 +79,14 @@ export class MemoryDriver implements SessionDriverWithTagging {
69
79
 
70
80
  async touch(sessionId: string, ttl: number): Promise<void> {
71
81
  const entry = this.#store.get(sessionId)
72
- if (entry) {
73
- entry.expiresAt = Date.now() + ttl * 1000
74
- }
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)
75
90
  }
76
91
 
77
92
  async tag(sessionId: string, userId: string | number): Promise<void> {
@@ -142,6 +142,13 @@ export class RedisDriver implements SessionDriverWithTagging {
142
142
  await client.set(this.#key(sessionId), JSON.stringify(data), 'EX', ttl)
143
143
  }
144
144
 
145
+ /** `XX` makes the existence check and the write one command. */
146
+ async update(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<boolean> {
147
+ const client = await this.#client()
148
+ const reply = await client.set(this.#key(sessionId), JSON.stringify(data), 'EX', ttl, 'XX')
149
+ return reply !== null
150
+ }
151
+
145
152
  async destroy(sessionId: string): Promise<void> {
146
153
  const client = await this.#client()
147
154
  await client.del(this.#key(sessionId))
@@ -25,6 +25,23 @@ export class E_SESSION_NOT_READY extends Exception {
25
25
  }
26
26
  }
27
27
 
28
+ /**
29
+ * A store with no `update()`: it cannot write a session back only while its
30
+ * entry still exists, so a request running across a logout could sign the
31
+ * user in again. Refused when the store is attached, not at the first race.
32
+ */
33
+ export class E_SESSION_STORE_NOT_CONDITIONAL extends Exception {
34
+ static override status = 500
35
+ static override code = 'E_SESSION_STORE_NOT_CONDITIONAL'
36
+
37
+ constructor() {
38
+ super(
39
+ 'This session store has no update(): it must write a session only over an entry that still exists, in one step. See "Writing a session store".',
40
+ { status: 500, code: 'E_SESSION_STORE_NOT_CONDITIONAL' },
41
+ )
42
+ }
43
+ }
44
+
28
45
  /** The configured store cannot tag sessions (cookie and file stores cannot). */
29
46
  export class E_SESSION_TAGGING_NOT_SUPPORTED extends Exception {
30
47
  static override status = 500
@@ -37,3 +54,34 @@ export class E_SESSION_TAGGING_NOT_SUPPORTED extends Exception {
37
54
  )
38
55
  }
39
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.