@c9up/ream 0.2.28 → 0.2.30

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 (293) hide show
  1. package/README.md +5 -6
  2. package/dist/Codemods.d.ts +24 -0
  3. package/dist/Codemods.d.ts.map +1 -1
  4. package/dist/Codemods.js +211 -1
  5. package/dist/Codemods.js.map +1 -1
  6. package/dist/HttpKernel.d.ts.map +1 -1
  7. package/dist/HttpKernel.js +17 -5
  8. package/dist/HttpKernel.js.map +1 -1
  9. package/dist/Ignitor.d.ts +15 -2
  10. package/dist/Ignitor.d.ts.map +1 -1
  11. package/dist/Ignitor.js +33 -23
  12. package/dist/Ignitor.js.map +1 -1
  13. package/dist/bodyparser/BodyParserMiddleware.d.ts +4 -4
  14. package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
  15. package/dist/bodyparser/BodyParserMiddleware.js +41 -11
  16. package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
  17. package/dist/bodyparser/MultipartFile.d.ts +21 -15
  18. package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
  19. package/dist/bodyparser/MultipartFile.js +40 -58
  20. package/dist/bodyparser/MultipartFile.js.map +1 -1
  21. package/dist/bodyparser/parseSize.d.ts +7 -4
  22. package/dist/bodyparser/parseSize.d.ts.map +1 -1
  23. package/dist/bodyparser/parseSize.js +12 -17
  24. package/dist/bodyparser/parseSize.js.map +1 -1
  25. package/dist/bodyparser/qsParse.d.ts +20 -1
  26. package/dist/bodyparser/qsParse.d.ts.map +1 -1
  27. package/dist/bodyparser/qsParse.js +78 -25
  28. package/dist/bodyparser/qsParse.js.map +1 -1
  29. package/dist/codemods/source.d.ts +57 -0
  30. package/dist/codemods/source.d.ts.map +1 -0
  31. package/dist/codemods/source.js +282 -0
  32. package/dist/codemods/source.js.map +1 -0
  33. package/dist/console/prompts.d.ts.map +1 -1
  34. package/dist/console/prompts.js +3 -1
  35. package/dist/console/prompts.js.map +1 -1
  36. package/dist/container/Container.d.ts.map +1 -1
  37. package/dist/container/Container.js +61 -71
  38. package/dist/container/Container.js.map +1 -1
  39. package/dist/events/Emitter.d.ts +12 -0
  40. package/dist/events/Emitter.d.ts.map +1 -1
  41. package/dist/events/Emitter.js +36 -9
  42. package/dist/events/Emitter.js.map +1 -1
  43. package/dist/events/helix/helpers.d.ts.map +1 -1
  44. package/dist/events/helix/helpers.js +19 -9
  45. package/dist/events/helix/helpers.js.map +1 -1
  46. package/dist/graphql/GraphQLEngine.d.ts +8 -0
  47. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  48. package/dist/graphql/GraphQLEngine.js +79 -12
  49. package/dist/graphql/GraphQLEngine.js.map +1 -1
  50. package/dist/helpers/MessageBuilder.d.ts +4 -2
  51. package/dist/helpers/MessageBuilder.d.ts.map +1 -1
  52. package/dist/helpers/MessageBuilder.js +4 -2
  53. package/dist/helpers/MessageBuilder.js.map +1 -1
  54. package/dist/helpers/errors.d.ts +11 -0
  55. package/dist/helpers/errors.d.ts.map +1 -0
  56. package/dist/helpers/errors.js +17 -0
  57. package/dist/helpers/errors.js.map +1 -0
  58. package/dist/helpers/napi-loader.d.ts.map +1 -1
  59. package/dist/helpers/napi-loader.js +3 -2
  60. package/dist/helpers/napi-loader.js.map +1 -1
  61. package/dist/http/Exception.d.ts +14 -1
  62. package/dist/http/Exception.d.ts.map +1 -1
  63. package/dist/http/Exception.js +10 -2
  64. package/dist/http/Exception.js.map +1 -1
  65. package/dist/http/HttpContext.d.ts +31 -2
  66. package/dist/http/HttpContext.d.ts.map +1 -1
  67. package/dist/http/HttpContext.js +12 -5
  68. package/dist/http/HttpContext.js.map +1 -1
  69. package/dist/http/RedirectBuilder.d.ts +9 -3
  70. package/dist/http/RedirectBuilder.d.ts.map +1 -1
  71. package/dist/http/RedirectBuilder.js +43 -37
  72. package/dist/http/RedirectBuilder.js.map +1 -1
  73. package/dist/http/Request.d.ts +42 -17
  74. package/dist/http/Request.d.ts.map +1 -1
  75. package/dist/http/Request.js +80 -58
  76. package/dist/http/Request.js.map +1 -1
  77. package/dist/http/Response.d.ts +7 -4
  78. package/dist/http/Response.d.ts.map +1 -1
  79. package/dist/http/Response.js +14 -6
  80. package/dist/http/Response.js.map +1 -1
  81. package/dist/http/SseStream.js +6 -7
  82. package/dist/http/SseStream.js.map +1 -1
  83. package/dist/http/permissions.d.ts +25 -0
  84. package/dist/http/permissions.d.ts.map +1 -0
  85. package/dist/http/permissions.js +38 -0
  86. package/dist/http/permissions.js.map +1 -0
  87. package/dist/http-client/HttpClient.d.ts +111 -0
  88. package/dist/http-client/HttpClient.d.ts.map +1 -0
  89. package/dist/http-client/HttpClient.js +386 -0
  90. package/dist/http-client/HttpClient.js.map +1 -0
  91. package/dist/http-client/addresses.d.ts +14 -0
  92. package/dist/http-client/addresses.d.ts.map +1 -0
  93. package/dist/http-client/addresses.js +57 -0
  94. package/dist/http-client/addresses.js.map +1 -0
  95. package/dist/http-client/fake.d.ts +50 -0
  96. package/dist/http-client/fake.d.ts.map +1 -0
  97. package/dist/http-client/fake.js +95 -0
  98. package/dist/http-client/fake.js.map +1 -0
  99. package/dist/http-client/index.d.ts +7 -0
  100. package/dist/http-client/index.d.ts.map +1 -0
  101. package/dist/http-client/index.js +7 -0
  102. package/dist/http-client/index.js.map +1 -0
  103. package/dist/index.d.ts +4 -3
  104. package/dist/index.d.ts.map +1 -1
  105. package/dist/index.js +1 -1
  106. package/dist/index.js.map +1 -1
  107. package/dist/middleware/Pipeline.d.ts.map +1 -1
  108. package/dist/middleware/Pipeline.js +6 -3
  109. package/dist/middleware/Pipeline.js.map +1 -1
  110. package/dist/native/generated.d.ts +93 -36
  111. package/dist/native/generated.d.ts.map +1 -1
  112. package/dist/profiler/Profiler.d.ts.map +1 -1
  113. package/dist/profiler/Profiler.js +4 -1
  114. package/dist/profiler/Profiler.js.map +1 -1
  115. package/dist/profiler/client.generated.d.ts +5 -0
  116. package/dist/profiler/client.generated.d.ts.map +1 -0
  117. package/dist/profiler/client.generated.js +6 -0
  118. package/dist/profiler/client.generated.js.map +1 -0
  119. package/dist/profiler/collectors.d.ts +92 -13
  120. package/dist/profiler/collectors.d.ts.map +1 -1
  121. package/dist/profiler/collectors.js +106 -19
  122. package/dist/profiler/collectors.js.map +1 -1
  123. package/dist/profiler/endpoint.d.ts +2 -1
  124. package/dist/profiler/endpoint.d.ts.map +1 -1
  125. package/dist/profiler/endpoint.js +25 -1
  126. package/dist/profiler/endpoint.js.map +1 -1
  127. package/dist/profiler/index.d.ts +8 -4
  128. package/dist/profiler/index.d.ts.map +1 -1
  129. package/dist/profiler/index.js +11 -4
  130. package/dist/profiler/index.js.map +1 -1
  131. package/dist/profiler/middleware.d.ts.map +1 -1
  132. package/dist/profiler/middleware.js +76 -10
  133. package/dist/profiler/middleware.js.map +1 -1
  134. package/dist/profiler/preview.d.ts +17 -0
  135. package/dist/profiler/preview.d.ts.map +1 -0
  136. package/dist/profiler/preview.js +100 -0
  137. package/dist/profiler/preview.js.map +1 -0
  138. package/dist/profiler/toolbar.d.ts +15 -23
  139. package/dist/profiler/toolbar.d.ts.map +1 -1
  140. package/dist/profiler/toolbar.js +25 -91
  141. package/dist/profiler/toolbar.js.map +1 -1
  142. package/dist/profiler/types.d.ts +6 -1
  143. package/dist/profiler/types.d.ts.map +1 -1
  144. package/dist/router/Router.d.ts +6 -0
  145. package/dist/router/Router.d.ts.map +1 -1
  146. package/dist/router/Router.js +115 -12
  147. package/dist/router/Router.js.map +1 -1
  148. package/dist/rpc/RpcProvider.d.ts +6 -0
  149. package/dist/rpc/RpcProvider.d.ts.map +1 -1
  150. package/dist/rpc/RpcProvider.js +4 -1
  151. package/dist/rpc/RpcProvider.js.map +1 -1
  152. package/dist/rpc/RpcRouter.d.ts +27 -14
  153. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  154. package/dist/rpc/RpcRouter.js +97 -40
  155. package/dist/rpc/RpcRouter.js.map +1 -1
  156. package/dist/scheduler/Scheduler.js +1 -1
  157. package/dist/scheduler/Scheduler.js.map +1 -1
  158. package/dist/scheduler/locks/RedisLockBackend.d.ts.map +1 -1
  159. package/dist/scheduler/locks/RedisLockBackend.js +13 -2
  160. package/dist/scheduler/locks/RedisLockBackend.js.map +1 -1
  161. package/dist/security/CookieSigner.d.ts.map +1 -1
  162. package/dist/security/CookieSigner.js +32 -8
  163. package/dist/security/CookieSigner.js.map +1 -1
  164. package/dist/security/SignedUrl.d.ts.map +1 -1
  165. package/dist/security/SignedUrl.js +16 -19
  166. package/dist/security/SignedUrl.js.map +1 -1
  167. package/dist/server/Server.d.ts +44 -12
  168. package/dist/server/Server.d.ts.map +1 -1
  169. package/dist/server/Server.js +31 -10
  170. package/dist/server/Server.js.map +1 -1
  171. package/dist/server/nativeGrpc.d.ts +27 -0
  172. package/dist/server/nativeGrpc.d.ts.map +1 -0
  173. package/dist/server/nativeGrpc.js +47 -0
  174. package/dist/server/nativeGrpc.js.map +1 -0
  175. package/dist/server/nativeLimits.d.ts +57 -0
  176. package/dist/server/nativeLimits.d.ts.map +1 -0
  177. package/dist/server/nativeLimits.js +75 -0
  178. package/dist/server/nativeLimits.js.map +1 -0
  179. package/dist/session/Session.d.ts +12 -0
  180. package/dist/session/Session.d.ts.map +1 -1
  181. package/dist/session/Session.js +13 -4
  182. package/dist/session/Session.js.map +1 -1
  183. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  184. package/dist/session/SessionMiddleware.js +28 -9
  185. package/dist/session/SessionMiddleware.js.map +1 -1
  186. package/dist/session/config.d.ts +5 -0
  187. package/dist/session/config.d.ts.map +1 -1
  188. package/dist/session/config.js +8 -0
  189. package/dist/session/config.js.map +1 -1
  190. package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
  191. package/dist/session/drivers/MemoryDriver.js +26 -7
  192. package/dist/session/drivers/MemoryDriver.js.map +1 -1
  193. package/dist/session/errors.d.ts +17 -0
  194. package/dist/session/errors.d.ts.map +1 -1
  195. package/dist/session/errors.js +21 -0
  196. package/dist/session/errors.js.map +1 -1
  197. package/dist/session/templateTags.d.ts +14 -0
  198. package/dist/session/templateTags.d.ts.map +1 -1
  199. package/dist/session/templateTags.js +45 -17
  200. package/dist/session/templateTags.js.map +1 -1
  201. package/dist/storage/StaticMiddleware.d.ts.map +1 -1
  202. package/dist/storage/StaticMiddleware.js +49 -27
  203. package/dist/storage/StaticMiddleware.js.map +1 -1
  204. package/dist/testing/ApiResponse.d.ts.map +1 -1
  205. package/dist/testing/ApiResponse.js +7 -1
  206. package/dist/testing/ApiResponse.js.map +1 -1
  207. package/dist/testing/TestClient.d.ts.map +1 -1
  208. package/dist/testing/TestClient.js +16 -4
  209. package/dist/testing/TestClient.js.map +1 -1
  210. package/dist/utils/Macroable.d.ts +1 -1
  211. package/dist/utils/Macroable.js +2 -2
  212. package/dist/utils/Macroable.js.map +1 -1
  213. package/events.darwin-arm64.node +0 -0
  214. package/events.darwin-x64.node +0 -0
  215. package/events.linux-arm64-gnu.node +0 -0
  216. package/events.linux-x64-gnu.node +0 -0
  217. package/events.win32-x64-msvc.node +0 -0
  218. package/index.darwin-arm64.node +0 -0
  219. package/index.darwin-x64.node +0 -0
  220. package/index.linux-arm64-gnu.node +0 -0
  221. package/index.linux-x64-gnu.node +0 -0
  222. package/index.win32-x64-msvc.node +0 -0
  223. package/package.json +11 -3
  224. package/scheduler.darwin-arm64.node +0 -0
  225. package/scheduler.darwin-x64.node +0 -0
  226. package/scheduler.linux-arm64-gnu.node +0 -0
  227. package/scheduler.linux-x64-gnu.node +0 -0
  228. package/scheduler.win32-x64-msvc.node +0 -0
  229. package/src/Codemods.ts +314 -3
  230. package/src/HttpKernel.ts +18 -7
  231. package/src/Ignitor.ts +58 -26
  232. package/src/bodyparser/BodyParserMiddleware.ts +45 -16
  233. package/src/bodyparser/MultipartFile.ts +46 -67
  234. package/src/bodyparser/parseSize.ts +17 -17
  235. package/src/bodyparser/qsParse.ts +96 -26
  236. package/src/codemods/source.ts +303 -0
  237. package/src/console/prompts.ts +3 -1
  238. package/src/container/Container.ts +97 -81
  239. package/src/events/Emitter.ts +37 -10
  240. package/src/events/helix/helpers.ts +24 -19
  241. package/src/graphql/GraphQLEngine.ts +91 -14
  242. package/src/helpers/MessageBuilder.ts +4 -2
  243. package/src/helpers/errors.ts +16 -0
  244. package/src/helpers/napi-loader.ts +3 -2
  245. package/src/http/Exception.ts +17 -2
  246. package/src/http/HttpContext.ts +34 -5
  247. package/src/http/RedirectBuilder.ts +51 -37
  248. package/src/http/Request.ts +102 -58
  249. package/src/http/Response.ts +15 -7
  250. package/src/http/SseStream.ts +6 -7
  251. package/src/http/permissions.ts +47 -0
  252. package/src/http-client/HttpClient.ts +505 -0
  253. package/src/http-client/addresses.ts +58 -0
  254. package/src/http-client/fake.ts +141 -0
  255. package/src/http-client/index.ts +21 -0
  256. package/src/index.ts +6 -1
  257. package/src/middleware/Pipeline.ts +7 -3
  258. package/src/native/generated.ts +106 -48
  259. package/src/profiler/Profiler.ts +4 -1
  260. package/src/profiler/client/toolbar.css +595 -0
  261. package/src/profiler/client/toolbar.js +1299 -0
  262. package/src/profiler/client.generated.ts +7 -0
  263. package/src/profiler/collectors.ts +156 -19
  264. package/src/profiler/endpoint.ts +27 -1
  265. package/src/profiler/index.ts +17 -3
  266. package/src/profiler/middleware.ts +74 -9
  267. package/src/profiler/preview.ts +104 -0
  268. package/src/profiler/toolbar.ts +28 -91
  269. package/src/profiler/types.ts +6 -1
  270. package/src/router/Router.ts +136 -13
  271. package/src/rpc/RpcProvider.ts +10 -1
  272. package/src/rpc/RpcRouter.ts +129 -45
  273. package/src/scheduler/Scheduler.ts +1 -1
  274. package/src/scheduler/locks/RedisLockBackend.ts +12 -4
  275. package/src/security/CookieSigner.ts +30 -8
  276. package/src/security/SignedUrl.ts +18 -17
  277. package/src/server/Server.ts +60 -12
  278. package/src/server/nativeGrpc.ts +59 -0
  279. package/src/server/nativeLimits.ts +111 -0
  280. package/src/session/Session.ts +29 -4
  281. package/src/session/SessionMiddleware.ts +30 -10
  282. package/src/session/config.ts +7 -0
  283. package/src/session/drivers/MemoryDriver.ts +26 -7
  284. package/src/session/errors.ts +31 -0
  285. package/src/session/templateTags.ts +57 -0
  286. package/src/storage/StaticMiddleware.ts +46 -27
  287. package/src/testing/ApiResponse.ts +7 -1
  288. package/src/testing/TestClient.ts +14 -5
  289. package/src/utils/Macroable.ts +2 -2
  290. package/scripts/build-napi-types.mjs +0 -69
  291. package/scripts/copy-napi.mjs +0 -121
  292. package/scripts/generate-mime.mjs +0 -119
  293. package/scripts/generate-napi-types.mjs +0 -187
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Usage:
7
7
  * rpc.method('task.validate', handler).guard('jwt').role('cs_member')
8
- * rpc.namespace('task', TaskController) // every public method, DI-resolved per call
8
+ * rpc.namespace('task', TaskController, ['show', 'update']) // listed methods, DI-resolved per call
9
9
  * rpc.group({ guard: 'jwt' }, (r) => { r.method('user.find', handler) })
10
10
  *
11
11
  * @implements MISS-27
@@ -15,11 +15,14 @@ import {
15
15
  buildSuccess,
16
16
  isNotification,
17
17
  isRpcShapedError,
18
+ type JsonRpcId,
18
19
  parseRequest as parseRpcRequest,
19
20
  buildError as rpcError,
20
21
  } from '@c9up/comet'
21
22
  import { currentNodeEnv } from '../env/nodeEnv.js'
23
+ import { isReamError, ReamError } from '../errors/ReamError.js'
22
24
  import type { HttpContext } from '../http/HttpContext.js'
25
+ import { holdsPermissions } from '../http/permissions.js'
23
26
  import type { MiddlewareRegistry, RuntimeValidator } from '../middleware/Pipeline.js'
24
27
  import { composeMiddleware, runValidator } from '../middleware/Pipeline.js'
25
28
 
@@ -79,17 +82,58 @@ export class RpcMethodBuilder {
79
82
  }
80
83
 
81
84
  /**
82
- * Unwrap the `{ _body: [...] }` envelope `request.body()` puts around a
83
- * top-level JSON array (it only treats plain objects as the body verbatim). A
84
- * JSON-RPC batch arrives this way; a single request is a plain object and is
85
- * returned unchanged.
85
+ * A -32603 whose detail reaches the caller only outside production: an
86
+ * internal failure's message is where hosts, credentials and queries end up.
86
87
  */
87
- function unwrapBatchBody(value: unknown): unknown {
88
- if (typeof value === 'object' && value !== null && '_body' in value) {
89
- const inner = value._body
90
- if (Array.isArray(inner)) return inner
88
+ function internalError(detail: string, id: JsonRpcId) {
89
+ return rpcError(-32603, currentNodeEnv() !== 'production' ? detail : 'Internal error', id)
90
+ }
91
+
92
+ /** The public method names of a controller instance type. */
93
+ export type ControllerMethodName<T> = {
94
+ [K in keyof T]: T[K] extends (...args: never[]) => unknown ? K : never
95
+ }[keyof T] &
96
+ string
97
+
98
+ /**
99
+ * Whether `name` is a plain method of the controller's own class chain.
100
+ * The walk stops before `Object.prototype` and reads descriptors, so
101
+ * neither `toString` nor a getter (whose read would run code) qualifies.
102
+ */
103
+ function isControllerMethod(proto: unknown, name: unknown): boolean {
104
+ if (typeof name !== 'string' || name === '' || name === 'constructor') return false
105
+ let current: unknown = proto
106
+ while (typeof current === 'object' && current !== null && current !== Object.prototype) {
107
+ const descriptor = Object.getOwnPropertyDescriptor(current, name)
108
+ if (descriptor) return typeof descriptor.value === 'function'
109
+ current = Object.getPrototypeOf(current)
91
110
  }
92
- return value
111
+ return false
112
+ }
113
+
114
+ /**
115
+ * The JSON media types JSON-RPC over HTTP is sent with. None of them is a
116
+ * CORS-safelisted request type, so a foreign page cannot send one without a
117
+ * preflight.
118
+ */
119
+ const DEFAULT_CONTENT_TYPES = [
120
+ 'application/json',
121
+ 'application/json-rpc',
122
+ 'application/jsonrequest',
123
+ ]
124
+
125
+ /** `Application/JSON; charset=utf-8` → `application/json`. */
126
+ function mediaType(contentType: string | undefined): string {
127
+ return (contentType?.split(';')[0] ?? '').trim().toLowerCase()
128
+ }
129
+
130
+ export interface RpcRouterOptions {
131
+ /**
132
+ * Media types `handle()` accepts a request body under; anything else is
133
+ * answered 415 before the body is read. Default: `application/json`,
134
+ * `application/json-rpc`, `application/jsonrequest`.
135
+ */
136
+ contentTypes?: readonly string[]
93
137
  }
94
138
 
95
139
  /**
@@ -97,10 +141,10 @@ function unwrapBatchBody(value: unknown): unknown {
97
141
  * Returns the JSON-RPC error code+message to reject with, or undefined when the
98
142
  * caller is authorized.
99
143
  */
100
- function checkRpcAuthorization(
144
+ async function checkRpcAuthorization(
101
145
  ctx: HttpContext,
102
146
  def: RpcMethodDefinition,
103
- ): { code: number; message: string } | undefined {
147
+ ): Promise<{ code: number; message: string } | undefined> {
104
148
  const needsAuth = def.guards.length > 0 || def.roles.length > 0 || def.permissions.length > 0
105
149
  if (needsAuth && !ctx.auth?.isAuthenticated) {
106
150
  return { code: -32003, message: 'Unauthorized' }
@@ -113,9 +157,9 @@ function checkRpcAuthorization(
113
157
  return { code: -32003, message: 'Insufficient role' }
114
158
  }
115
159
  }
160
+ // A token-authenticated call also needs the token to allow each permission.
116
161
  if (def.permissions.length > 0) {
117
- const userPerms = ctx.auth?.permissions ?? ctx.auth?.user?.permissions ?? []
118
- if (!def.permissions.every((p: string) => userPerms.includes(p))) {
162
+ if (!(await holdsPermissions(ctx.auth, def.permissions))) {
119
163
  return { code: -32003, message: 'Insufficient permissions' }
120
164
  }
121
165
  }
@@ -126,6 +170,13 @@ export class RpcRouter {
126
170
  #methods: Map<string, RpcMethodDefinition> = new Map()
127
171
  #groupConfig: { guards: string[]; roles: string[]; middleware: string[] } | null = null
128
172
  #container?: RpcContainer
173
+ readonly #contentTypes: ReadonlySet<string>
174
+
175
+ constructor(options: RpcRouterOptions = {}) {
176
+ this.#contentTypes = new Set(
177
+ (options.contentTypes ?? DEFAULT_CONTENT_TYPES).map((type) => mediaType(type)),
178
+ )
179
+ }
129
180
 
130
181
  /**
131
182
  * Set the IoC container used to resolve `namespace()` controllers — mirrors
@@ -137,8 +188,23 @@ export class RpcRouter {
137
188
  this.#container = container
138
189
  }
139
190
 
140
- /** Register an RPC method. */
191
+ /**
192
+ * Register an RPC method. JSON-RPC 2.0 §4 reserves names starting with
193
+ * `rpc.` for the protocol itself, so they are refused here, as an empty
194
+ * name is.
195
+ */
141
196
  method(name: string, handler: RpcHandler): RpcMethodBuilder {
197
+ if (typeof name !== 'string' || name === '') {
198
+ throw new ReamError(
199
+ 'E_RPC_INVALID_METHOD_NAME',
200
+ 'An RPC method name must be a non-empty string',
201
+ )
202
+ }
203
+ if (name.startsWith('rpc.')) {
204
+ throw new ReamError('E_RPC_RESERVED_METHOD_NAME', `'${name}' is a reserved RPC method name`, {
205
+ hint: 'JSON-RPC 2.0 reserves names starting with "rpc." for rpc-internal methods; pick another prefix.',
206
+ })
207
+ }
142
208
  const def: RpcMethodDefinition = {
143
209
  name,
144
210
  handler,
@@ -152,24 +218,28 @@ export class RpcRouter {
152
218
  }
153
219
 
154
220
  /**
155
- * Register methods from a controller — auto-registers all public methods.
221
+ * Register the listed methods of a controller as `<prefix>.<method>`.
156
222
  *
157
- * The parameter used to demand
158
- * `new (...args: unknown[]) => Record<string, (...args: unknown[]) => unknown>`,
159
- * which NO real controller satisfies: a class with any non-method property
160
- * fails the `Record`, and a method declared `bump(): number` fails the
161
- * `unknown[]` parameters, which are compared contravariantly. So the
162
- * documented call — `rpc.namespace('task', TaskController)` — did not
163
- * typecheck, and nothing noticed because the tests were outside `tsc`.
164
- *
165
- * Any class, then. The body already checks that what it found is callable
166
- * and names the method when it is not.
223
+ * The list is explicit, like a route binding one `[Controller, 'method']`
224
+ * pair: TypeScript `private`/`protected` vanish at runtime, so a namespace
225
+ * that published "every method on the prototype" made helpers the author
226
+ * never meant to expose remotely callable. The type admits only public
227
+ * method names; the runtime check refuses a name with no plain method
228
+ * behind it (a getter, `constructor`, anything from `Object.prototype`).
167
229
  */
168
- namespace(prefix: string, controller: abstract new (...args: never[]) => object): void {
169
- const proto = controller.prototype
170
- const methods = Object.getOwnPropertyNames(proto).filter(
171
- (m) => m !== 'constructor' && typeof proto[m] === 'function',
172
- )
230
+ namespace<T extends object>(
231
+ prefix: string,
232
+ controller: abstract new (...args: never[]) => T,
233
+ methods: ReadonlyArray<ControllerMethodName<T>>,
234
+ ): void {
235
+ for (const methodName of methods) {
236
+ if (!isControllerMethod(controller.prototype, methodName)) {
237
+ throw new ReamError(
238
+ 'E_RPC_UNKNOWN_CONTROLLER_METHOD',
239
+ `${controller.name} has no method '${String(methodName)}' to expose as an RPC method`,
240
+ )
241
+ }
242
+ }
173
243
  for (const methodName of methods) {
174
244
  this.method(`${prefix}.${methodName}`, async (ctx, params) => {
175
245
  // Resolve through the container on every call (fresh DI per request,
@@ -212,10 +282,26 @@ export class RpcRouter {
212
282
 
213
283
  /** Handle an incoming JSON-RPC request. */
214
284
  async handle(ctx: HttpContext): Promise<void> {
215
- // `request.body()` wraps a top-level JSON array — a JSON-RPC batch — as its
216
- // non-object envelope `{ _body: [...] }`. Unwrap it so batch detection works;
217
- // a single request is a plain object and passes through untouched.
218
- const body = unwrapBatchBody(ctx.request.body())
285
+ // A form post, a text/plain body or one with no Content-Type is a CORS
286
+ // simple request: a foreign page sends it with the victim's cookies and
287
+ // no preflight. Refused before the body is read, as JSON-RPC servers
288
+ // answer 415 to anything but JSON.
289
+ if (!this.#contentTypes.has(mediaType(ctx.request.header('content-type')))) {
290
+ ctx.response.status(415).send('')
291
+ return
292
+ }
293
+
294
+ // Parsed here, not through `request.body()`, which turns malformed JSON
295
+ // into `{}` — and §5.1 answers invalid JSON with -32700, not -32600.
296
+ // `raw()` stays outside the try: its 413 is not a parse error.
297
+ const raw = ctx.request.raw()
298
+ let body: unknown
299
+ try {
300
+ body = JSON.parse(raw)
301
+ } catch {
302
+ ctx.response.status(200).json(rpcError(-32700, 'Parse error', null))
303
+ return
304
+ }
219
305
 
220
306
  // Batch support (max 50 to prevent DoS)
221
307
  if (Array.isArray(body)) {
@@ -295,7 +381,7 @@ export class RpcRouter {
295
381
  if (!def) return rpcError(-32601, 'Method not found', id)
296
382
 
297
383
  try {
298
- const denied = checkRpcAuthorization(ctx, def)
384
+ const denied = await checkRpcAuthorization(ctx, def)
299
385
  if (denied) return rpcError(denied.code, denied.message, id)
300
386
 
301
387
  // Run declared named middleware (e.g. guards) — they throw to deny, mapped
@@ -310,8 +396,7 @@ export class RpcRouter {
310
396
  if (def.validator) {
311
397
  const token = `validator:${def.validator}`
312
398
  if (!this.#container?.has(token)) {
313
- return rpcError(
314
- -32603,
399
+ return internalError(
315
400
  `Validator '${def.validator}' is not registered (bind container.singleton('${token}', () => schema)).`,
316
401
  id,
317
402
  )
@@ -324,9 +409,11 @@ export class RpcRouter {
324
409
  try {
325
410
  outcome = await runValidator(validator, params)
326
411
  } catch (err) {
327
- return rpcError(
328
- -32603,
329
- err instanceof TypeError
412
+ // Caught here rather than below so a validator's error is never
413
+ // honoured as a domain error: a rule hitting the database throws
414
+ // whatever the driver throws.
415
+ return internalError(
416
+ isReamError(err, 'E_VALIDATOR_UNUSABLE')
330
417
  ? `Validator '${def.validator}' exposes no usable validate method.`
331
418
  : `Validator '${def.validator}' failed: ${err instanceof Error ? err.message : String(err)}`,
332
419
  id,
@@ -346,10 +433,7 @@ export class RpcRouter {
346
433
  const message = typeof err.message === 'string' ? err.message : 'RPC error'
347
434
  return rpcError(err.code, message, id, err.data)
348
435
  }
349
- // Otherwise don't leak internal error details to the caller.
350
- const isDev = currentNodeEnv() !== 'production'
351
- const message = isDev && err instanceof Error ? err.message : 'Internal error'
352
- return rpcError(-32603, message, id)
436
+ return internalError(err instanceof Error ? err.message : 'Internal error', id)
353
437
  }
354
438
  }
355
439
 
@@ -144,7 +144,7 @@ function loadNative(): NativeModule {
144
144
  * so scheduler context is never lost.
145
145
  */
146
146
  function translateNativeError(e: unknown): ReamError {
147
- const asError = e as Error
147
+ const asError = e instanceof Error ? e : new Error(String(e))
148
148
  const parsed = ReamError.fromNapi(asError)
149
149
  // `fromNapi` returns an `E_UNKNOWN`-coded error when the message is not
150
150
  // a JSON payload. Preserve that context under a scheduler-specific
@@ -67,12 +67,20 @@ export class RedisLockBackend implements LockBackend {
67
67
  this.#prefix = options.prefix ?? 'ream:schedule:lock:'
68
68
  }
69
69
 
70
- /** The client, resolved once and kept. */
70
+ /**
71
+ * The client, resolved once and kept. Only a success is kept: a lookup
72
+ * that fails (the connection is not registered yet at boot, Redis is
73
+ * briefly down) is forgotten so the next task retries it, instead of
74
+ * every lock failing with the same error until the process restarts.
75
+ */
71
76
  #client(): Promise<LockRedisClient> {
72
77
  if (!this.#resolved) {
73
- this.#resolved = Promise.resolve(
74
- typeof this.#source === 'function' ? this.#source() : this.#source,
75
- )
78
+ const source = this.#source
79
+ const pending = (async () => (typeof source === 'function' ? source() : source))()
80
+ this.#resolved = pending
81
+ pending.catch(() => {
82
+ if (this.#resolved === pending) this.#resolved = undefined
83
+ })
76
84
  }
77
85
  return this.#resolved
78
86
  }
@@ -23,6 +23,11 @@
23
23
  import { createCipheriv, createDecipheriv, createHmac, randomBytes } from 'node:crypto'
24
24
  import { hmacSign, hmacVerify } from './crypto.js'
25
25
 
26
+ /** The 96-bit nonce GCM is specified for. */
27
+ const GCM_IV_BYTES = 12
28
+ /** The full 128-bit authentication tag — never a truncated one. */
29
+ const GCM_TAG_BYTES = 16
30
+
26
31
  /** Raised when no APP_KEY is configured (AdonisJS `E_MISSING_APP_KEY`). */
27
32
  export class E_MISSING_APP_KEY extends Error {
28
33
  readonly code = 'E_MISSING_APP_KEY' as const
@@ -120,14 +125,22 @@ export class CookieSigner {
120
125
  return null
121
126
  }
122
127
  if (typeof parsed !== 'object' || parsed === null) return null
123
- const envelope = parsed as Envelope
124
128
  // The KEY must be there, whatever it holds — that is what distinguishes an
125
129
  // envelope from an arbitrary JSON document, and a signed `null` or `0` is a
126
130
  // legitimate payload.
127
- if (!('m' in envelope)) return null
128
- if (envelope.e !== undefined && envelope.e < Date.now()) return null
129
- if ((envelope.p ?? undefined) !== purpose) return null
130
- return envelope.m
131
+ if (!('m' in parsed)) return null
132
+ // Each field checked for the type `#seal` writes before it is compared: an
133
+ // expiry that is not a number (`"e": "9999"`, `"e": null`) or a purpose
134
+ // that is not a string must not slip past the comparisons below.
135
+ const expiry: unknown = 'e' in parsed ? parsed.e : undefined
136
+ if (expiry !== undefined) {
137
+ if (typeof expiry !== 'number' || !Number.isFinite(expiry)) return null
138
+ if (expiry < Date.now()) return null
139
+ }
140
+ const sealedFor: unknown = 'p' in parsed ? parsed.p : undefined
141
+ if (sealedFor !== undefined && typeof sealedFor !== 'string') return null
142
+ if (sealedFor !== purpose) return null
143
+ return parsed.m
131
144
  }
132
145
 
133
146
  /**
@@ -217,8 +230,10 @@ export class CookieSigner {
217
230
  }
218
231
 
219
232
  encrypt(value: unknown, expiresIn?: number, purpose?: string): string {
220
- const iv = randomBytes(12)
221
- const cipher = createCipheriv('aes-256-gcm', this.#keyBuffer, iv)
233
+ const iv = randomBytes(GCM_IV_BYTES)
234
+ const cipher = createCipheriv('aes-256-gcm', this.#keyBuffer, iv, {
235
+ authTagLength: GCM_TAG_BYTES,
236
+ })
222
237
  const sealed = this.#seal(value, expiresIn, purpose)
223
238
  const encrypted = Buffer.concat([cipher.update(sealed, 'utf8'), cipher.final()])
224
239
  const tag = cipher.getAuthTag()
@@ -242,7 +257,14 @@ export class CookieSigner {
242
257
  const iv = Buffer.from(ivPart, 'base64url')
243
258
  const data = Buffer.from(dataPart, 'base64url')
244
259
  const tag = Buffer.from(tagPart, 'base64url')
245
- const decipher = createDecipheriv('aes-256-gcm', this.#keyBuffer, iv)
260
+ // GCM verifies only as many tag bytes as it is handed: a client that
261
+ // trims the tag to 4 bytes would cut forgery cost from 2^128 to 2^32
262
+ // guesses. Only what `encrypt` produces is accepted — a 12-byte IV and
263
+ // the full 16-byte tag — and the decipher is pinned to that length too.
264
+ if (iv.length !== GCM_IV_BYTES || tag.length !== GCM_TAG_BYTES) return null
265
+ const decipher = createDecipheriv('aes-256-gcm', this.#keyBuffer, iv, {
266
+ authTagLength: GCM_TAG_BYTES,
267
+ })
246
268
  decipher.setAuthTag(tag)
247
269
  const raw = Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
248
270
  return this.#open(raw, purpose) as T | null
@@ -3,6 +3,7 @@
3
3
  * Uses Rust NAPI via crypto facade when available.
4
4
  */
5
5
 
6
+ import { durationToSeconds } from '../helpers/duration.js'
6
7
  import { constantTimeEq, hmacSign } from './crypto.js'
7
8
 
8
9
  export interface SignedUrlConfig {
@@ -43,7 +44,12 @@ export class SignedUrl {
43
44
  if (!providedSig) return false
44
45
  const expires = url.searchParams.get('expires')
45
46
  if (expires && Math.floor(Date.now() / 1000) > parseInt(expires, 10)) return false
46
- if (purpose && url.searchParams.get('purpose') !== purpose) return false
47
+ // Exact match both ways: a URL minted for `password-reset` must not pass a
48
+ // check that names no purpose (or another one), and a purpose-less URL
49
+ // must not pass a check that names one. An empty purpose is no purpose,
50
+ // as in `make`.
51
+ const signedPurpose = url.searchParams.get('purpose') || undefined
52
+ if (signedPurpose !== (purpose || undefined)) return false
47
53
 
48
54
  url.searchParams.delete('signature')
49
55
  const expectedSig = hmacSign(url.pathname + url.search, this.#secret)
@@ -51,22 +57,17 @@ export class SignedUrl {
51
57
  }
52
58
  }
53
59
 
60
+ /**
61
+ * The lifetime of a signed URL in seconds. Read with the duration grammar
62
+ * used everywhere else (`'30 minutes'`, `'1 week'`, `'2h'`), and refused when
63
+ * it cannot be read: signing with a default instead turned "one week" into
64
+ * one hour without a word.
65
+ */
54
66
  function parseExpiry(value: string | number): number {
55
- if (typeof value === 'number') return value
56
- const match = value.match(/^(\d+)(s|m|h|d)$/)
57
- if (!match) return 3600
58
- const [, digits, unit] = match
59
- const num = parseInt(digits ?? '0', 10)
60
- switch (unit) {
61
- case 's':
62
- return num
63
- case 'm':
64
- return num * 60
65
- case 'h':
66
- return num * 3600
67
- case 'd':
68
- return num * 86400
69
- default:
70
- return 3600
67
+ if (typeof value === 'number' && !Number.isFinite(value)) {
68
+ throw new Error(
69
+ `Cannot read ${value} as a signed URL expiry: it must be a finite number of seconds.`,
70
+ )
71
71
  }
72
+ return durationToSeconds(value, 'a signed URL expiry')
72
73
  }
@@ -37,12 +37,23 @@ 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. A `-bin` value is
43
+ * the base64 text that arrived: this is the HTTP/2 header layer, and the
44
+ * gRPC package that registers the handler decodes it, as a gRPC library's
45
+ * metadata class does over Node's HTTP/2 headers.
46
+ */
47
+ metadata: Record<string, string[]>
42
48
  /** The request message, still encoded. */
43
49
  message: Uint8Array
44
50
  /** Resolved client IP, by the same trusted-proxy rules as `request.ip()`. */
45
51
  ip: string
52
+ /**
53
+ * Aborted when the client gives up on the call — resets its stream or
54
+ * closes the connection — before the handler answered.
55
+ */
56
+ signal: AbortSignal
46
57
  }
47
58
 
48
59
  /** A reply message, still encoded — or a gRPC status code instead of one. */
@@ -50,6 +61,26 @@ export type GrpcReply = { message: Uint8Array } | { code: number; details: strin
50
61
 
51
62
  export type GrpcHandler = (call: GrpcCall) => Promise<GrpcReply>
52
63
 
64
+ /**
65
+ * Message size bounds of the gRPC calls, in bytes; `-1` for none. A request
66
+ * past its bound is refused, `RESOURCE_EXHAUSTED`, before it is read; a reply
67
+ * past its bound is answered `RESOURCE_EXHAUSTED` instead.
68
+ */
69
+ export interface GrpcLimits {
70
+ /** Default 4 MiB. */
71
+ maxReceiveMessageLength?: number
72
+ /** Default `-1`: no bound. */
73
+ maxSendMessageLength?: number
74
+ }
75
+
76
+ function checkGrpcLimit(name: string, value: number | undefined): void {
77
+ if (value === undefined || value === -1 || (Number.isSafeInteger(value) && value >= 0)) return
78
+ throw new ReamError(
79
+ 'E_SERVER_GRPC_INVALID_LIMIT',
80
+ `${name} must be a whole number of bytes, or -1 for no limit — got ${value}.`,
81
+ )
82
+ }
83
+
53
84
  export class Server {
54
85
  #router: Router
55
86
  #serverMiddleware: MiddlewareFunction[] = []
@@ -57,22 +88,31 @@ export class Server {
57
88
  #resolvedErrorHandler?: ExceptionHandler
58
89
  #trustedProxies: string[] = []
59
90
  #grpcHandler?: GrpcHandler
91
+ #grpcLimits: GrpcLimits = {}
60
92
 
61
93
  constructor(router: Router) {
62
94
  this.#router = router
63
95
  }
64
96
 
65
97
  /**
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.
98
+ * Configure the trusted proxies. The Rust HyperServer uses them to
99
+ * pre-resolve `request.ip()` / `request.ips()` once per request — JS reads
100
+ * the fields, no CIDR matching in TS.
101
+ *
102
+ * The chain is the socket peer, then the `X-Forwarded-For` entries read
103
+ * right to left; it stops at the first hop that is not a trusted proxy, and
104
+ * its last entry is the client IP. Proxies append what they saw, so the
105
+ * left-most entry — the one a client writes itself — is only reached when
106
+ * every hop after it is trusted. `X-Real-IP` is never read.
69
107
  *
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).
108
+ * Entries: addresses or CIDR ranges, IPv4 or IPv6 (`'10.0.0.0/8'`,
109
+ * `'fd00::/8'`), IPv4 netmasks (`'10.0.0.0/255.0.0.0'`), the named ranges
110
+ * `'loopback'`, `'linklocal'`, `'uniquelocal'`, or `'*'` to trust every hop
111
+ * (the left-most entry wins — any client can choose its IP). An entry that is
112
+ * none of these makes the server refuse to start.
113
+ *
114
+ * Empty list (default) is **strict fail-closed**: `X-Forwarded-For` is
115
+ * ignored and only the socket peer is used.
76
116
  */
77
117
  trustedProxies(cidrs: string[]): this {
78
118
  this.#trustedProxies = [...cidrs]
@@ -99,16 +139,24 @@ export class Server {
99
139
  * One handler per server: a second one throws. Two packages both serving
100
140
  * gRPC is a conflict to resolve, not to settle by load order.
101
141
  */
102
- grpc(handler: GrpcHandler): this {
142
+ grpc(handler: GrpcHandler, limits: GrpcLimits = {}): this {
103
143
  if (this.#grpcHandler) {
104
144
  throw new ReamError('E_SERVER_GRPC_ALREADY_SET', 'A gRPC handler is already registered.', {
105
145
  hint: 'Only one package can serve gRPC on the HTTP port.',
106
146
  })
107
147
  }
148
+ checkGrpcLimit('maxReceiveMessageLength', limits.maxReceiveMessageLength)
149
+ checkGrpcLimit('maxSendMessageLength', limits.maxSendMessageLength)
108
150
  this.#grpcHandler = handler
151
+ this.#grpcLimits = { ...limits }
109
152
  return this
110
153
  }
111
154
 
155
+ /** The message bounds given with the gRPC handler (consumed by Ignitor at boot). */
156
+ getGrpcLimits(): GrpcLimits {
157
+ return { ...this.#grpcLimits }
158
+ }
159
+
112
160
  /** The gRPC handler, if one is set (consumed by Ignitor at boot). */
113
161
  getGrpcHandler(): GrpcHandler | undefined {
114
162
  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
+ }