@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
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Just enough of a TypeScript scanner for the configure codemods: find a
3
+ * call or an object/array literal in CODE (never inside a string, a template
4
+ * or a comment), split a literal into its top-level entries, and append to
5
+ * one. Upstream does this with a full AST; ream ships no compiler, and the
6
+ * files these codemods edit (`start/env.ts`, `reamrc.ts`) are object and
7
+ * array literals.
8
+ *
9
+ * Not handled: regular-expression literals (a quote inside one reads as the
10
+ * start of a string). Neither file has a reason to contain one.
11
+ */
12
+
13
+ const CLOSERS: Record<string, string> = { '{': '}', '[': ']', '(': ')' }
14
+
15
+ function isIdentifierChar(char: string | undefined): boolean {
16
+ return char !== undefined && /[A-Za-z0-9_$]/.test(char)
17
+ }
18
+
19
+ /**
20
+ * The index just past the string, template or comment starting at `i`, or
21
+ * `i` itself when none starts there. An unterminated one runs to the end.
22
+ */
23
+ function skipNonCode(src: string, i: number): number {
24
+ const char = src[i]
25
+ const next = src[i + 1]
26
+ if (char === '/' && next === '/') {
27
+ const end = src.indexOf('\n', i)
28
+ return end === -1 ? src.length : end
29
+ }
30
+ if (char === '/' && next === '*') {
31
+ const end = src.indexOf('*/', i + 2)
32
+ return end === -1 ? src.length : end + 2
33
+ }
34
+ if (char === "'" || char === '"') {
35
+ let j = i + 1
36
+ while (j < src.length && src[j] !== char && src[j] !== '\n') {
37
+ j += src[j] === '\\' ? 2 : 1
38
+ }
39
+ return j + 1
40
+ }
41
+ if (char === '`') {
42
+ let j = i + 1
43
+ while (j < src.length && src[j] !== '`') {
44
+ if (src[j] === '\\') {
45
+ j += 2
46
+ } else if (src[j] === '$' && src[j + 1] === '{') {
47
+ const close = matchClose(src, j + 1)
48
+ j = close === -1 ? src.length : close + 1
49
+ } else {
50
+ j += 1
51
+ }
52
+ }
53
+ return j + 1
54
+ }
55
+ return i
56
+ }
57
+
58
+ /** Whether the string, template or block comment starting at `i` is closed. */
59
+ function isTerminated(src: string, i: number): boolean {
60
+ const char = src[i]
61
+ if (char === '/') return src.indexOf('*/', i + 2) !== -1
62
+ const end = skipNonCode(src, i)
63
+ return end <= src.length && end - 1 > i && src[end - 1] === char
64
+ }
65
+
66
+ /** Index of the bracket closing the one at `open`, or -1. */
67
+ export function matchClose(src: string, open: number): number {
68
+ const first = CLOSERS[src[open] ?? '']
69
+ if (first === undefined) return -1
70
+ const stack = [first]
71
+ let i = open + 1
72
+ while (i < src.length) {
73
+ const skipped = skipNonCode(src, i)
74
+ if (skipped !== i) {
75
+ i = skipped
76
+ continue
77
+ }
78
+ const char = src[i] ?? ''
79
+ const closer = CLOSERS[char]
80
+ if (closer !== undefined) {
81
+ stack.push(closer)
82
+ } else if (char === stack[stack.length - 1]) {
83
+ stack.pop()
84
+ if (stack.length === 0) return i
85
+ } else if (char === '}' || char === ']' || char === ')') {
86
+ return -1
87
+ }
88
+ i += 1
89
+ }
90
+ return -1
91
+ }
92
+
93
+ /** First code character at or after `i` (before `end`), skipping whitespace and comments. */
94
+ function firstCode(src: string, i: number, end: number): number {
95
+ let j = i
96
+ while (j < end) {
97
+ const char = src[j] ?? ''
98
+ if (/\s/.test(char)) {
99
+ j += 1
100
+ continue
101
+ }
102
+ if (char === '/' && (src[j + 1] === '/' || src[j + 1] === '*')) {
103
+ j = skipNonCode(src, j)
104
+ continue
105
+ }
106
+ return j
107
+ }
108
+ return end
109
+ }
110
+
111
+ /** One top-level entry of a literal: the text between two commas. */
112
+ export interface Segment {
113
+ start: number
114
+ end: number
115
+ /** First code character, or `end` when the entry is only trivia. */
116
+ codeStart: number
117
+ /** Just past the last code character (comments and whitespace excluded). */
118
+ codeEnd: number
119
+ }
120
+
121
+ /** Split `src[start, end)` on its top-level commas. */
122
+ export function segments(src: string, start: number, end: number): Segment[] {
123
+ const out: Segment[] = []
124
+ let segmentStart = start
125
+ let codeEnd = start
126
+ let depth = 0
127
+ let i = start
128
+ const close = (at: number) => {
129
+ const codeStart = firstCode(src, segmentStart, at)
130
+ out.push({ start: segmentStart, end: at, codeStart, codeEnd: Math.max(codeEnd, codeStart) })
131
+ }
132
+ while (i < end) {
133
+ const char = src[i] ?? ''
134
+ if (char === '/' && (src[i + 1] === '/' || src[i + 1] === '*')) {
135
+ i = skipNonCode(src, i)
136
+ continue
137
+ }
138
+ const skipped = skipNonCode(src, i)
139
+ if (skipped !== i) {
140
+ i = skipped
141
+ codeEnd = i
142
+ continue
143
+ }
144
+ if (char === ',' && depth === 0) {
145
+ close(i)
146
+ segmentStart = i + 1
147
+ codeEnd = i + 1
148
+ i += 1
149
+ continue
150
+ }
151
+ if (CLOSERS[char] !== undefined) depth += 1
152
+ else if (char === '}' || char === ']' || char === ')') depth -= 1
153
+ if (!/\s/.test(char)) codeEnd = i + 1
154
+ i += 1
155
+ }
156
+ close(end)
157
+ return out
158
+ }
159
+
160
+ /**
161
+ * Whether `code` is one complete expression that cannot reach outside the
162
+ * slot it is written into: brackets balanced and never closing past where
163
+ * they opened, strings and comments terminated, no top-level comma.
164
+ */
165
+ export function isSingleExpression(code: string): boolean {
166
+ if (code.trim() === '') return false
167
+ let depth = 0
168
+ let i = 0
169
+ while (i < code.length) {
170
+ const skipped = skipNonCode(code, i)
171
+ if (skipped !== i) {
172
+ // A line comment would swallow the comma written after the value.
173
+ const lineComment = code[i] === '/' && code[i + 1] === '/'
174
+ if (lineComment || !isTerminated(code, i)) return false
175
+ i = skipped
176
+ continue
177
+ }
178
+ const char = code[i] ?? ''
179
+ if (CLOSERS[char] !== undefined) depth += 1
180
+ else if (char === '}' || char === ']' || char === ')') {
181
+ depth -= 1
182
+ if (depth < 0) return false
183
+ } else if (char === ',' && depth === 0) return false
184
+ i += 1
185
+ }
186
+ return depth === 0
187
+ }
188
+
189
+ /**
190
+ * Index of the `(` of the first call to `callee` in code, or -1. A mention
191
+ * inside a string or a comment, or as the tail of a longer name
192
+ * (`MyEnv.create`), does not count.
193
+ */
194
+ export function findCall(src: string, callee: string): number {
195
+ let i = 0
196
+ while (i < src.length) {
197
+ const skipped = skipNonCode(src, i)
198
+ if (skipped !== i) {
199
+ i = skipped
200
+ continue
201
+ }
202
+ const before = src[i - 1]
203
+ if (src.startsWith(callee, i) && !isIdentifierChar(before) && before !== '.') {
204
+ const paren = firstCode(src, i + callee.length, src.length)
205
+ if (src[paren] === '(') return paren
206
+ }
207
+ i += 1
208
+ }
209
+ return -1
210
+ }
211
+
212
+ /** Index of the `{` after the first `export default` in code, or -1. */
213
+ export function findExportDefaultObject(src: string): number {
214
+ let i = 0
215
+ while (i < src.length) {
216
+ const skipped = skipNonCode(src, i)
217
+ if (skipped !== i) {
218
+ i = skipped
219
+ continue
220
+ }
221
+ if (src.startsWith('export', i) && !isIdentifierChar(src[i - 1])) {
222
+ const word = firstCode(src, i + 'export'.length, src.length)
223
+ if (src.startsWith('default', word) && !isIdentifierChar(src[word + 'default'.length])) {
224
+ const brace = firstCode(src, word + 'default'.length, src.length)
225
+ return src[brace] === '{' ? brace : -1
226
+ }
227
+ }
228
+ i += 1
229
+ }
230
+ return -1
231
+ }
232
+
233
+ /** A `key: value` entry of an object literal. */
234
+ export interface Property {
235
+ key: string
236
+ valueStart: number
237
+ valueEnd: number
238
+ }
239
+
240
+ /** The `key: value` entries of the object literal opening at `open`. */
241
+ export function properties(src: string, open: number, close: number): Property[] {
242
+ const out: Property[] = []
243
+ for (const segment of segments(src, open + 1, close)) {
244
+ if (segment.codeStart >= segment.end) continue
245
+ const text = src.slice(segment.codeStart, segment.end)
246
+ const match = /^(?:([A-Za-z_$][\w$]*)|'([^'\\\n]*)'|"([^"\\\n]*)")\s*:/.exec(text)
247
+ if (match === null) continue
248
+ const key = match[1] ?? match[2] ?? match[3] ?? ''
249
+ const valueStart = firstCode(src, segment.codeStart + match[0].length, segment.end)
250
+ out.push({ key, valueStart, valueEnd: segment.codeEnd })
251
+ }
252
+ return out
253
+ }
254
+
255
+ /** Leading whitespace of the line holding `index`. */
256
+ function lineIndent(src: string, index: number): string {
257
+ const lineStart = src.lastIndexOf('\n', index - 1) + 1
258
+ return /^[ \t]*/.exec(src.slice(lineStart))?.[0] ?? ''
259
+ }
260
+
261
+ /**
262
+ * Append entries to the literal `src[open..close]` and return the new text.
263
+ *
264
+ * `build` receives the indentation of the existing entries (or one level
265
+ * deeper than the closing bracket when there are none) and returns the lines
266
+ * to add, each already ending with its comma. `blankLine` separates the new
267
+ * entries from existing ones. A missing comma after the last existing entry
268
+ * is added.
269
+ */
270
+ export function appendToLiteral(
271
+ src: string,
272
+ open: number,
273
+ close: number,
274
+ eol: string,
275
+ build: (indent: string) => string[],
276
+ blankLine = false,
277
+ ): string {
278
+ const all = segments(src, open + 1, close)
279
+ const entries = all.filter((s) => s.codeStart < s.end)
280
+ const first = entries[0]
281
+ const closeIndent = lineIndent(src, close)
282
+ const firstOnOwnLine =
283
+ first !== undefined &&
284
+ /^[ \t]*$/.test(src.slice(src.lastIndexOf('\n', first.codeStart - 1) + 1, first.codeStart))
285
+ const indent =
286
+ first !== undefined && firstOnOwnLine ? lineIndent(src, first.codeStart) : `${closeIndent} `
287
+ const lines = build(indent)
288
+ const separator = blankLine && entries.length > 0 ? eol : ''
289
+
290
+ const closeLineStart = src.lastIndexOf('\n', close - 1) + 1
291
+ const closeOnOwnLine = /^[ \t]*$/.test(src.slice(closeLineStart, close))
292
+ let out = closeOnOwnLine
293
+ ? `${src.slice(0, closeLineStart)}${separator}${lines.join(eol)}${eol}${src.slice(closeLineStart)}`
294
+ : `${src.slice(0, close)}${eol}${separator}${lines.join(eol)}${eol}${closeIndent}${src.slice(close)}`
295
+
296
+ // The comma goes in last, as it sits before every insertion point above.
297
+ // No comma after the last entry when it is also the last segment.
298
+ const last = entries[entries.length - 1]
299
+ if (last !== undefined && all[all.length - 1] === last) {
300
+ out = `${out.slice(0, last.codeEnd)},${out.slice(last.codeEnd)}`
301
+ }
302
+ return out
303
+ }
@@ -25,6 +25,7 @@ import * as fs from 'node:fs'
25
25
  import { currentNodeEnv } from '../env/nodeEnv.js'
26
26
  import { loadNapi } from '../helpers/napi-loader.js'
27
27
  import type { HttpContext } from '../http/HttpContext.js'
28
+ import { holdsPermissions } from '../http/permissions.js'
28
29
 
29
30
  /** Rust parser output (the `ream-graphql` crate via the `index` NAPI binary). */
30
31
  interface RustParsedField {
@@ -455,9 +456,9 @@ export class GraphQLEngine {
455
456
  }
456
457
  }
457
458
 
459
+ // A token-authenticated request also needs the token to allow each one.
458
460
  if (permissions.length > 0) {
459
- const userPerms = ctx.auth?.permissions ?? ctx.auth?.user?.permissions ?? []
460
- if (!permissions.every((p) => userPerms.includes(p))) {
461
+ if (!holdsPermissions(ctx.auth, permissions)) {
461
462
  return 'Insufficient permissions'
462
463
  }
463
464
  }
@@ -259,6 +259,12 @@ export type HttpError = {
259
259
  report?: (error: unknown, ctx: HttpContext) => Promise<void> | void
260
260
  /** A hint appended to the message, when the exception carries one. */
261
261
  help?: string
262
+ /**
263
+ * Set by a thrown value that is not an {@link Exception} but whose message is
264
+ * written for clients (the `http-errors` convention). Shown outside debug
265
+ * mode even on a 5xx.
266
+ */
267
+ expose?: boolean
262
268
  }
263
269
 
264
270
  /** Renders an HTML error page for a status (AdonisJS `StatusPageRenderer`). */
@@ -352,13 +358,22 @@ export class ExceptionHandler extends Macroable {
352
358
  * The message a client is allowed to see.
353
359
  *
354
360
  * DEVIATION (named): outside debug mode ream only echoes the message of a
355
- * deliberate `Exception`; anything else collapses to a generic line. AdonisJS
361
+ * deliberate `Exception`, of a client error (status below 500, which a
362
+ * package raises on purpose: 401, 403, 413…), or of an error flagged
363
+ * `expose: true`; anything else collapses to a generic line. AdonisJS
356
364
  * echoes `error.message` whatever was thrown, which leaks the text of driver
357
365
  * and library errors to the client.
366
+ *
367
+ * A `code` is NOT a reason to show the message: Node system errors
368
+ * (`ECONNREFUSED`, `ENOENT`) and database drivers (`23505`) carry one, and
369
+ * their messages hold internal hosts, file paths, constraint names and
370
+ * stored values. The error itself is left untouched for reporting.
358
371
  */
359
372
  protected publicMessage(error: HttpError, ctx: HttpContext): string {
360
373
  if (this.isDebuggingEnabled(ctx)) return error.message
361
- if (error.code !== 'E_UNKNOWN') return error.message
374
+ if (error instanceof Exception || error.status < 500 || error.expose === true) {
375
+ return error.message
376
+ }
362
377
  return 'An internal error occurred'
363
378
  }
364
379
 
@@ -33,7 +33,14 @@ export interface AuthState {
33
33
  id: string
34
34
  email?: string
35
35
  roles?: string[]
36
+ /** The user's own permissions — never widened by a token's abilities. */
36
37
  permissions?: string[]
38
+ /**
39
+ * Set by a token guard (AdonisJS `user.currentAccessToken`): the token this
40
+ * request authenticated with. Its abilities restrict the user's
41
+ * permissions on every `.permission()` gate; they never add to them.
42
+ */
43
+ currentAccessToken?: AccessTokenAbilities
37
44
  [key: string]: unknown
38
45
  }
39
46
  roles?: string[]
@@ -65,6 +72,11 @@ export interface AuthState {
65
72
  readonly authenticatedViaGuard?: string
66
73
  }
67
74
 
75
+ /** What a permission gate needs from an access token: whether it allows an ability. */
76
+ export interface AccessTokenAbilities {
77
+ allows(ability: string): boolean
78
+ }
79
+
68
80
  /**
69
81
  * Per-request authorization entry point — populated by an authorization
70
82
  * middleware (e.g. `@c9up/warden`'s Bouncer initializer). A structural
@@ -440,8 +452,9 @@ export class HttpContext extends Macroable {
440
452
  this.response.setRedirectFactory(
441
453
  () =>
442
454
  new RedirectBuilder(this.response, {
443
- requestUrl: this.request.url(),
455
+ requestUrl: this.request.url(true),
444
456
  requestReferer: this.request.header('referer'),
457
+ requestAuthority: this.request.authority() ?? undefined,
445
458
  routeUrlResolver: this.#routeUrlResolver,
446
459
  }),
447
460
  )
@@ -13,30 +13,36 @@ import type { Response } from './Response.js'
13
13
 
14
14
  export type RouteUrlResolver = (name: string, params?: Record<string, string>) => string
15
15
 
16
- function isSameOriginOrRelative(
17
- referer: string,
18
- requestUrl?: string,
19
- allowedHosts: readonly string[] = [],
16
+ /**
17
+ * AdonisJS `isValidRedirectUrl`: a relative path, or an absolute URL whose host
18
+ * is the request authority or an allowed host.
19
+ */
20
+ function isTrustedRedirect(
21
+ url: string,
22
+ authority: string | undefined,
23
+ allowedHosts: readonly string[],
20
24
  ): boolean {
21
25
  // A relative path is trusted only when it starts with a single "/" and has no
22
26
  // backslash: browsers normalise "\" to "/", so "/\evil.com" would become the
23
27
  // protocol-relative "//evil.com" that a lone `!startsWith("//")` check misses.
24
- if (referer.startsWith('/') && !referer.startsWith('//') && !referer.includes('\\')) return true
25
- let ref: URL
26
- try {
27
- ref = new URL(referer)
28
- } catch {
29
- return false
30
- }
31
- // An explicitly allowed host is trusted whatever the request's own origin is
32
- // — that is what the list is for (AdonisJS `allowedHosts`).
33
- if (allowedHosts.includes(ref.host)) return true
34
- if (!requestUrl) return false
28
+ if (url.startsWith('/') && !url.startsWith('//') && !url.includes('\\')) return true
29
+ let parsed: URL
35
30
  try {
36
- return ref.origin === new URL(requestUrl).origin
31
+ parsed = new URL(url)
37
32
  } catch {
38
33
  return false
39
34
  }
35
+ if (allowedHosts.includes(parsed.host)) return true
36
+ // Upstream accepts any absolute URL when it knows no host to compare with;
37
+ // refusing it is the safer reading of an attacker-controlled header.
38
+ return authority !== undefined && parsed.host === authority
39
+ }
40
+
41
+ /** The query string of a path or URL, without the "?". */
42
+ function searchOf(url: string | undefined): string {
43
+ if (url === undefined) return ''
44
+ const index = url.indexOf('?')
45
+ return index === -1 ? '' : url.slice(index + 1)
40
46
  }
41
47
 
42
48
  /** The slice of the session the intended-URL methods use. */
@@ -60,6 +66,7 @@ export class RedirectBuilder {
60
66
  #forwardQs = false
61
67
  #requestUrl?: string
62
68
  #requestReferer?: string
69
+ #requestAuthority?: string
63
70
  #routeUrlResolver?: RouteUrlResolver
64
71
  #session?: IntendedUrlStore
65
72
  #isAjax = false
@@ -68,8 +75,14 @@ export class RedirectBuilder {
68
75
  constructor(
69
76
  response: Response,
70
77
  options?: {
78
+ /** The request path with its query string. */
71
79
  requestUrl?: string
72
80
  requestReferer?: string
81
+ /**
82
+ * The request authority (`:authority` or `Host`, never X-Forwarded-Host)
83
+ * an absolute redirect target must match.
84
+ */
85
+ requestAuthority?: string
73
86
  routeUrlResolver?: RouteUrlResolver
74
87
  /** The request session, for the intended-URL methods. */
75
88
  session?: IntendedUrlStore
@@ -82,6 +95,7 @@ export class RedirectBuilder {
82
95
  this.#response = response
83
96
  this.#requestUrl = options?.requestUrl
84
97
  this.#requestReferer = options?.requestReferer
98
+ this.#requestAuthority = options?.requestAuthority
85
99
  this.#routeUrlResolver = options?.routeUrlResolver
86
100
  this.#session = options?.session
87
101
  this.#isAjax = options?.isAjax ?? false
@@ -135,13 +149,9 @@ export class RedirectBuilder {
135
149
 
136
150
  #safeIntended<T extends string | null>(intended: string | null, fallback: T): string | T {
137
151
  if (!intended) return fallback
138
- if (this.#requestUrl && !isSameOriginOrRelative(intended, this.#requestUrl)) {
139
- return fallback
140
- }
141
- // A protocol-relative `//evil.com` is absolute to a browser even though it
142
- // looks relative — reject it whether or not a request URL is known.
143
- if (intended.startsWith('//')) return fallback
144
- return intended
152
+ return isTrustedRedirect(intended, this.#requestAuthority, this.allowedHosts)
153
+ ? intended
154
+ : fallback
145
155
  }
146
156
 
147
157
  /** Set redirect status code. */
@@ -191,7 +201,12 @@ export class RedirectBuilder {
191
201
 
192
202
  /** Redirect to an absolute or relative path. */
193
203
  toPath(path: string): void {
194
- const url = this.#appendQs(path)
204
+ this.#send(path, searchOf(this.#requestUrl))
205
+ }
206
+
207
+ /** Redirect with `withQs()` forwarding `forwardedSearch` (a query string without "?"). */
208
+ #send(path: string, forwardedSearch: string): void {
209
+ const url = this.#appendQs(path, forwardedSearch)
195
210
  this.#response.status(this.#status)
196
211
  this.#response.header('location', url)
197
212
  this.#response.setBody('')
@@ -207,12 +222,13 @@ export class RedirectBuilder {
207
222
  }
208
223
 
209
224
  /**
210
- * Redirect back to the previous page (Referer header).
211
- * Only trusts same-origin or relative referers — external URLs fall back
212
- * to `fallback` to prevent open redirect attacks.
225
+ * Redirect back to the previous page (AdonisJS `back`): the path of
226
+ * {@link getPreviousUrl}, so the redirect never leaves the site; `withQs()`
227
+ * forwards the previous page's query string, not the current request's.
213
228
  */
214
229
  back(fallback = '/'): void {
215
- this.toPath(this.getPreviousUrl(fallback))
230
+ const previous = new URL(this.getPreviousUrl(fallback), 'http://localhost')
231
+ this.#send(previous.pathname, previous.search.slice(1))
216
232
  }
217
233
 
218
234
  /**
@@ -225,7 +241,7 @@ export class RedirectBuilder {
225
241
  */
226
242
  getPreviousUrl(fallback = '/'): string {
227
243
  const referer = this.#requestReferer
228
- if (referer && isSameOriginOrRelative(referer, this.#requestUrl, this.allowedHosts)) {
244
+ if (referer && isTrustedRedirect(referer, this.#requestAuthority, this.allowedHosts)) {
229
245
  return referer
230
246
  }
231
247
  return fallback
@@ -236,15 +252,10 @@ export class RedirectBuilder {
236
252
  * uses, so `withQs()` plus `withQs('page', 2)` overrides an inbound `page`
237
253
  * rather than being overridden by it.
238
254
  */
239
- #appendQs(path: string): string {
255
+ #appendQs(path: string, forwardedSearch: string): string {
240
256
  const params = new URLSearchParams()
241
- if (this.#forwardQs && this.#requestUrl) {
242
- const qsIdx = this.#requestUrl.indexOf('?')
243
- if (qsIdx !== -1) {
244
- for (const [key, value] of new URLSearchParams(this.#requestUrl.slice(qsIdx + 1))) {
245
- params.append(key, value)
246
- }
247
- }
257
+ if (this.#forwardQs) {
258
+ for (const [key, value] of new URLSearchParams(forwardedSearch)) params.append(key, value)
248
259
  }
249
260
  for (const [key, value] of Object.entries(this.#qs)) {
250
261
  if (value === undefined || value === null) continue