@c9up/ream 0.2.28 → 0.2.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/dist/Codemods.d.ts +16 -0
  2. package/dist/Codemods.d.ts.map +1 -1
  3. package/dist/Codemods.js +182 -0
  4. package/dist/Codemods.js.map +1 -1
  5. package/dist/Ignitor.d.ts +10 -2
  6. package/dist/Ignitor.d.ts.map +1 -1
  7. package/dist/Ignitor.js +21 -22
  8. package/dist/Ignitor.js.map +1 -1
  9. package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
  10. package/dist/bodyparser/BodyParserMiddleware.js +3 -0
  11. package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
  12. package/dist/bodyparser/qsParse.d.ts +10 -1
  13. package/dist/bodyparser/qsParse.d.ts.map +1 -1
  14. package/dist/bodyparser/qsParse.js +53 -15
  15. package/dist/bodyparser/qsParse.js.map +1 -1
  16. package/dist/codemods/source.d.ts +57 -0
  17. package/dist/codemods/source.d.ts.map +1 -0
  18. package/dist/codemods/source.js +282 -0
  19. package/dist/codemods/source.js.map +1 -0
  20. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  21. package/dist/graphql/GraphQLEngine.js +3 -2
  22. package/dist/graphql/GraphQLEngine.js.map +1 -1
  23. package/dist/http/Exception.d.ts +14 -1
  24. package/dist/http/Exception.d.ts.map +1 -1
  25. package/dist/http/Exception.js +10 -2
  26. package/dist/http/Exception.js.map +1 -1
  27. package/dist/http/HttpContext.d.ts +11 -0
  28. package/dist/http/HttpContext.d.ts.map +1 -1
  29. package/dist/http/HttpContext.js +2 -1
  30. package/dist/http/HttpContext.js.map +1 -1
  31. package/dist/http/RedirectBuilder.d.ts +9 -3
  32. package/dist/http/RedirectBuilder.d.ts.map +1 -1
  33. package/dist/http/RedirectBuilder.js +39 -37
  34. package/dist/http/RedirectBuilder.js.map +1 -1
  35. package/dist/http/Request.d.ts +42 -17
  36. package/dist/http/Request.d.ts.map +1 -1
  37. package/dist/http/Request.js +65 -29
  38. package/dist/http/Request.js.map +1 -1
  39. package/dist/http/SseStream.js +6 -7
  40. package/dist/http/SseStream.js.map +1 -1
  41. package/dist/http/permissions.d.ts +19 -0
  42. package/dist/http/permissions.d.ts.map +1 -0
  43. package/dist/http/permissions.js +28 -0
  44. package/dist/http/permissions.js.map +1 -0
  45. package/dist/http-client/HttpClient.d.ts +111 -0
  46. package/dist/http-client/HttpClient.d.ts.map +1 -0
  47. package/dist/http-client/HttpClient.js +386 -0
  48. package/dist/http-client/HttpClient.js.map +1 -0
  49. package/dist/http-client/addresses.d.ts +14 -0
  50. package/dist/http-client/addresses.d.ts.map +1 -0
  51. package/dist/http-client/addresses.js +57 -0
  52. package/dist/http-client/addresses.js.map +1 -0
  53. package/dist/http-client/fake.d.ts +50 -0
  54. package/dist/http-client/fake.d.ts.map +1 -0
  55. package/dist/http-client/fake.js +95 -0
  56. package/dist/http-client/fake.js.map +1 -0
  57. package/dist/http-client/index.d.ts +7 -0
  58. package/dist/http-client/index.d.ts.map +1 -0
  59. package/dist/http-client/index.js +7 -0
  60. package/dist/http-client/index.js.map +1 -0
  61. package/dist/index.d.ts +4 -3
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +1 -1
  64. package/dist/index.js.map +1 -1
  65. package/dist/middleware/Pipeline.d.ts.map +1 -1
  66. package/dist/middleware/Pipeline.js +6 -3
  67. package/dist/middleware/Pipeline.js.map +1 -1
  68. package/dist/native/generated.d.ts +78 -7
  69. package/dist/native/generated.d.ts.map +1 -1
  70. package/dist/rpc/RpcProvider.d.ts +6 -0
  71. package/dist/rpc/RpcProvider.d.ts.map +1 -1
  72. package/dist/rpc/RpcProvider.js +4 -1
  73. package/dist/rpc/RpcProvider.js.map +1 -1
  74. package/dist/rpc/RpcRouter.d.ts +27 -14
  75. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  76. package/dist/rpc/RpcRouter.js +95 -38
  77. package/dist/rpc/RpcRouter.js.map +1 -1
  78. package/dist/scheduler/locks/RedisLockBackend.d.ts.map +1 -1
  79. package/dist/scheduler/locks/RedisLockBackend.js +13 -2
  80. package/dist/scheduler/locks/RedisLockBackend.js.map +1 -1
  81. package/dist/security/CookieSigner.d.ts.map +1 -1
  82. package/dist/security/CookieSigner.js +17 -3
  83. package/dist/security/CookieSigner.js.map +1 -1
  84. package/dist/security/SignedUrl.d.ts.map +1 -1
  85. package/dist/security/SignedUrl.js +6 -1
  86. package/dist/security/SignedUrl.js.map +1 -1
  87. package/dist/server/Server.d.ts +41 -12
  88. package/dist/server/Server.d.ts.map +1 -1
  89. package/dist/server/Server.js +31 -10
  90. package/dist/server/Server.js.map +1 -1
  91. package/dist/server/nativeGrpc.d.ts +27 -0
  92. package/dist/server/nativeGrpc.d.ts.map +1 -0
  93. package/dist/server/nativeGrpc.js +47 -0
  94. package/dist/server/nativeGrpc.js.map +1 -0
  95. package/dist/server/nativeLimits.d.ts +49 -0
  96. package/dist/server/nativeLimits.d.ts.map +1 -0
  97. package/dist/server/nativeLimits.js +67 -0
  98. package/dist/server/nativeLimits.js.map +1 -0
  99. package/dist/session/Session.d.ts +12 -0
  100. package/dist/session/Session.d.ts.map +1 -1
  101. package/dist/session/Session.js.map +1 -1
  102. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  103. package/dist/session/SessionMiddleware.js +28 -9
  104. package/dist/session/SessionMiddleware.js.map +1 -1
  105. package/dist/session/config.d.ts +5 -0
  106. package/dist/session/config.d.ts.map +1 -1
  107. package/dist/session/config.js +8 -0
  108. package/dist/session/config.js.map +1 -1
  109. package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
  110. package/dist/session/drivers/MemoryDriver.js +12 -5
  111. package/dist/session/drivers/MemoryDriver.js.map +1 -1
  112. package/dist/session/errors.d.ts +17 -0
  113. package/dist/session/errors.d.ts.map +1 -1
  114. package/dist/session/errors.js +21 -0
  115. package/dist/session/errors.js.map +1 -1
  116. package/dist/session/templateTags.d.ts +14 -0
  117. package/dist/session/templateTags.d.ts.map +1 -1
  118. package/dist/session/templateTags.js +45 -17
  119. package/dist/session/templateTags.js.map +1 -1
  120. package/dist/storage/StaticMiddleware.d.ts.map +1 -1
  121. package/dist/storage/StaticMiddleware.js +29 -18
  122. package/dist/storage/StaticMiddleware.js.map +1 -1
  123. package/events.darwin-arm64.node +0 -0
  124. package/events.darwin-x64.node +0 -0
  125. package/events.win32-x64-msvc.node +0 -0
  126. package/index.darwin-arm64.node +0 -0
  127. package/index.darwin-x64.node +0 -0
  128. package/index.linux-arm64-gnu.node +0 -0
  129. package/index.linux-x64-gnu.node +0 -0
  130. package/index.win32-x64-msvc.node +0 -0
  131. package/package.json +11 -2
  132. package/scheduler.darwin-arm64.node +0 -0
  133. package/scheduler.darwin-x64.node +0 -0
  134. package/scheduler.win32-x64-msvc.node +0 -0
  135. package/src/Codemods.ts +265 -0
  136. package/src/Ignitor.ts +37 -22
  137. package/src/bodyparser/BodyParserMiddleware.ts +3 -0
  138. package/src/bodyparser/qsParse.ts +69 -14
  139. package/src/codemods/source.ts +303 -0
  140. package/src/graphql/GraphQLEngine.ts +3 -2
  141. package/src/http/Exception.ts +17 -2
  142. package/src/http/HttpContext.ts +14 -1
  143. package/src/http/RedirectBuilder.ts +48 -37
  144. package/src/http/Request.ts +87 -29
  145. package/src/http/SseStream.ts +6 -7
  146. package/src/http/permissions.ts +31 -0
  147. package/src/http-client/HttpClient.ts +505 -0
  148. package/src/http-client/addresses.ts +58 -0
  149. package/src/http-client/fake.ts +141 -0
  150. package/src/http-client/index.ts +21 -0
  151. package/src/index.ts +5 -1
  152. package/src/middleware/Pipeline.ts +7 -3
  153. package/src/native/generated.ts +89 -7
  154. package/src/rpc/RpcProvider.ts +10 -1
  155. package/src/rpc/RpcRouter.ts +126 -42
  156. package/src/scheduler/locks/RedisLockBackend.ts +12 -4
  157. package/src/security/CookieSigner.ts +17 -3
  158. package/src/security/SignedUrl.ts +6 -1
  159. package/src/server/Server.ts +57 -12
  160. package/src/server/nativeGrpc.ts +59 -0
  161. package/src/server/nativeLimits.ts +94 -0
  162. package/src/session/Session.ts +16 -0
  163. package/src/session/SessionMiddleware.ts +30 -10
  164. package/src/session/config.ts +7 -0
  165. package/src/session/drivers/MemoryDriver.ts +11 -5
  166. package/src/session/errors.ts +31 -0
  167. package/src/session/templateTags.ts +57 -0
  168. package/src/storage/StaticMiddleware.ts +28 -18
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `@c9up/ream/http-client` — outbound HTTP with an SSRF guard, and its test fake.
3
+ */
4
+
5
+ export { isPublicAddress } from './addresses.js'
6
+ export {
7
+ FakeHttpClient,
8
+ type FakeMatcher,
9
+ type FakeResponse,
10
+ fakeHttp,
11
+ type RecordedRequest,
12
+ } from './fake.js'
13
+ export {
14
+ HttpClient,
15
+ HttpClientError,
16
+ type HttpClientFailure,
17
+ type HttpClientOptions,
18
+ type HttpRequestOptions,
19
+ HttpResponse,
20
+ type OutgoingRequest,
21
+ } from './HttpClient.js'
package/src/index.ts CHANGED
@@ -219,10 +219,12 @@ export { createHttpKernel } from './HttpKernel.js'
219
219
 
220
220
  // ─── Server ─────────────────────────────────────────────────
221
221
 
222
+ export type { NativeGrpcCall, NativeGrpcHandler } from './server/nativeGrpc.js'
222
223
  export type {
223
224
  ErrorHandlerClass,
224
225
  GrpcCall,
225
226
  GrpcHandler,
227
+ GrpcLimits,
226
228
  GrpcReply,
227
229
  LazyImport,
228
230
  MiddlewareClass,
@@ -285,7 +287,9 @@ export type {
285
287
  export {
286
288
  type Codemods,
287
289
  createCodemods,
290
+ type EnvValidationNode,
288
291
  type GeneratedStub,
292
+ type PreloadEnvironment,
289
293
  type StubState,
290
294
  } from './Codemods.js'
291
295
  export {
@@ -409,4 +413,4 @@ export {
409
413
  StatsTracker,
410
414
  } from './scheduler/index.js'
411
415
 
412
- export const VERSION = '0.2.28'
416
+ export const VERSION = '0.2.29'
@@ -15,6 +15,7 @@ import { debugHttp } from '../debug.js'
15
15
  import { ReamError } from '../errors/ReamError.js'
16
16
  import { E_FORBIDDEN, E_UNAUTHORIZED, E_VALIDATION_ERROR } from '../http/Exception.js'
17
17
  import type { HttpContext } from '../http/HttpContext.js'
18
+ import { holdsPermissions } from '../http/permissions.js'
18
19
 
19
20
  export type MiddlewareFunction = (
20
21
  ctx: HttpContext,
@@ -96,7 +97,10 @@ export async function runValidator(
96
97
  }
97
98
  }
98
99
 
99
- throw new TypeError(
100
+ // A dedicated code, not a TypeError: callers tell this configuration
101
+ // mistake apart from a TypeError raised by a bug inside a rule.
102
+ throw new ReamError(
103
+ 'E_VALIDATOR_UNUSABLE',
100
104
  'Route validator exposes none of validateResultAsync(), validateResult() or validate().',
101
105
  )
102
106
  }
@@ -239,9 +243,9 @@ function createGuardMiddleware(
239
243
  }
240
244
  }
241
245
 
246
+ // A token-authenticated request also needs the token to allow each one.
242
247
  if (permissions && permissions.length > 0) {
243
- const userPerms = ctx.auth.permissions ?? ctx.auth.user?.permissions ?? []
244
- if (!permissions.every((p) => userPerms.includes(p))) {
248
+ if (!holdsPermissions(ctx.auth, permissions)) {
245
249
  throw new E_FORBIDDEN('Insufficient permissions', permissions)
246
250
  }
247
251
  }
@@ -31,10 +31,18 @@ export interface NapiResponse {
31
31
  */
32
32
 
33
33
  export interface NapiGrpcCall {
34
+ /**
35
+ * This call, in the ids `onGrpcCancel` reports. Increasing, in the order
36
+ * the calls reach JavaScript.
37
+ */
38
+ id: number
34
39
  /** `/package.Service/Method`. */
35
40
  path: string
36
- /** Request metadata, header names lowercased. */
37
- metadata: Record<string, string>
41
+ /**
42
+ * Request metadata: the request headers, every value of a repeated one
43
+ * in order, names lowercased.
44
+ */
45
+ metadata: Record<string, Array<string>>
38
46
  /** The request message, still encoded. */
39
47
  message: Uint8Array
40
48
  /** Resolved client IP, by the same trusted-proxy rules as `request.ip`. */
@@ -52,6 +60,16 @@ export interface NapiGrpcReply {
52
60
  details?: string
53
61
  }
54
62
 
63
+ /**
64
+ * Message size bounds of the gRPC calls, in bytes; `-1` for none. Omitted:
65
+ * 4 MiB received, no bound sent.
66
+ */
67
+
68
+ export interface NapiGrpcLimits {
69
+ maxReceiveMessageLength?: number
70
+ maxSendMessageLength?: number
71
+ }
72
+
55
73
  /**
56
74
  * Configuration for the wire-level shield filter. Mirrors the Rust
57
75
  * `ShieldConfig` shape; the JS layer constructs it from `ShieldMiddleware`
@@ -74,6 +92,45 @@ export interface NapiRateLimitConfig {
74
92
  windowSecs: number
75
93
  }
76
94
 
95
+ /**
96
+ * The limit of one body parser and the content types it claims. `limit` is
97
+ * in bytes; an omitted field keeps the default.
98
+ */
99
+
100
+ export interface NapiBodyLimit {
101
+ limit?: number
102
+ types?: Array<string>
103
+ }
104
+
105
+ /**
106
+ * Request-body limits per content type, the body parser's sections. Each
107
+ * omitted section keeps the `@adonisjs/bodyparser` default: json, form and
108
+ * raw (`text/*`) 1 MB, multipart 20 MB. A content type no section claims
109
+ * gets the raw limit.
110
+ */
111
+
112
+ export interface NapiBodyLimits {
113
+ json?: NapiBodyLimit
114
+ form?: NapiBodyLimit
115
+ raw?: NapiBodyLimit
116
+ multipart?: NapiBodyLimit
117
+ }
118
+
119
+ /**
120
+ * Timeouts (milliseconds) and the connection cap, named after the Node
121
+ * `http.Server` settings an Adonis app configures. `0` disables a bound;
122
+ * an omitted field keeps the default: `headersTimeout` 60 000,
123
+ * `requestTimeout` 300 000, `keepAliveTimeout` 5 000, `maxConnections`
124
+ * 10 000.
125
+ */
126
+
127
+ export interface NapiConnectionConfig {
128
+ headersTimeout?: number
129
+ requestTimeout?: number
130
+ keepAliveTimeout?: number
131
+ maxConnections?: number
132
+ }
133
+
77
134
  /**
78
135
  * NAPI-exposed event bus.
79
136
  * Uses shared Tokio runtime (no per-instance runtime).
@@ -130,15 +187,40 @@ export declare class HyperServer {
130
187
  * HTTP requests. Must be called BEFORE `listen()`.
131
188
  *
132
189
  * A rejected promise is answered `INTERNAL`, with nothing of the error
133
- * sent to the client.
190
+ * sent to the client. `limits` bounds the request and reply messages;
191
+ * a request is read, and refused, before the handler is called.
134
192
  */
135
- onGrpc(callback: (arg: NapiGrpcCall) => Promise<NapiGrpcReply>): void
193
+ onGrpc(
194
+ callback: (arg: NapiGrpcCall) => Promise<NapiGrpcReply>,
195
+ limits?: NapiGrpcLimits | undefined | null,
196
+ ): void
197
+ /**
198
+ * Register the function told, with its `id`, about a gRPC call the
199
+ * client gave up on — a reset stream or a closed connection — before the
200
+ * handler answered. A cancel can reach JavaScript before its call does.
201
+ */
202
+ onGrpcCancel(callback: (arg: number) => void): void
136
203
  /**
137
- * Configure the trusted-proxy CIDR list used by `request.ip` resolution.
138
- * Must be called BEFORE `listen()`. Empty list = legacy permissive mode
139
- * (any peer can populate `X-Forwarded-For`).
204
+ * Configure the trusted proxies used by `request.ip` / `request.ips`
205
+ * resolution. Must be called BEFORE `listen()`. Entries are addresses,
206
+ * CIDR ranges (IPv4 or IPv6), IPv4 netmask ranges, the named ranges
207
+ * `loopback` / `linklocal` / `uniquelocal`, or `"*"` (trust every hop).
208
+ * Empty list (the default) = `X-Forwarded-For` ignored, the socket peer
209
+ * is the client. Throws on an entry that is none of these.
140
210
  */
141
211
  configureTrustedProxies(cidrs: Array<string>): void
212
+ /**
213
+ * Configure the request-body limits, applied while the body is read:
214
+ * nothing past a limit is kept, and the request reaches JS without its
215
+ * body and with `bodyTooLarge` set, for the body parser to answer 413
216
+ * once the route is known. Must be called BEFORE `listen()`.
217
+ */
218
+ configureBodyLimits(config: NapiBodyLimits): void
219
+ /**
220
+ * Configure the header / body / keep-alive timeouts and the connection
221
+ * cap. Must be called BEFORE `listen()`.
222
+ */
223
+ configureConnections(config: NapiConnectionConfig): void
142
224
  /**
143
225
  * Install (or replace) the wire-level rate limiter. Pass `None` to
144
226
  * disable. Must be called BEFORE `listen()`.
@@ -39,6 +39,12 @@ export interface RpcConfig {
39
39
  * it as public and never populates `ctx.auth` (every guarded method 401s).
40
40
  */
41
41
  guards?: string[]
42
+ /**
43
+ * Media types the endpoint accepts a request body under; any other is
44
+ * answered 415. Default: `application/json`, `application/json-rpc`,
45
+ * `application/jsonrequest`.
46
+ */
47
+ contentTypes?: string[]
42
48
  }
43
49
 
44
50
  export class RpcProvider extends Provider {
@@ -47,7 +53,10 @@ export class RpcProvider extends Provider {
47
53
 
48
54
  constructor(app: AppContext, options: RpcProviderOptions = {}) {
49
55
  super(app)
50
- this.rpc = options.router ?? new RpcRouter()
56
+ // Config is loaded before providers are constructed.
57
+ this.rpc =
58
+ options.router ??
59
+ new RpcRouter({ contentTypes: app.config.get<RpcConfig>('rpc')?.contentTypes })
51
60
  }
52
61
 
53
62
  override register(): void {
@@ -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
  /**
@@ -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 (!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)) {
@@ -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
 
@@ -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
@@ -217,8 +222,10 @@ export class CookieSigner {
217
222
  }
218
223
 
219
224
  encrypt(value: unknown, expiresIn?: number, purpose?: string): string {
220
- const iv = randomBytes(12)
221
- const cipher = createCipheriv('aes-256-gcm', this.#keyBuffer, iv)
225
+ const iv = randomBytes(GCM_IV_BYTES)
226
+ const cipher = createCipheriv('aes-256-gcm', this.#keyBuffer, iv, {
227
+ authTagLength: GCM_TAG_BYTES,
228
+ })
222
229
  const sealed = this.#seal(value, expiresIn, purpose)
223
230
  const encrypted = Buffer.concat([cipher.update(sealed, 'utf8'), cipher.final()])
224
231
  const tag = cipher.getAuthTag()
@@ -242,7 +249,14 @@ export class CookieSigner {
242
249
  const iv = Buffer.from(ivPart, 'base64url')
243
250
  const data = Buffer.from(dataPart, 'base64url')
244
251
  const tag = Buffer.from(tagPart, 'base64url')
245
- const decipher = createDecipheriv('aes-256-gcm', this.#keyBuffer, iv)
252
+ // GCM verifies only as many tag bytes as it is handed: a client that
253
+ // trims the tag to 4 bytes would cut forgery cost from 2^128 to 2^32
254
+ // guesses. Only what `encrypt` produces is accepted — a 12-byte IV and
255
+ // the full 16-byte tag — and the decipher is pinned to that length too.
256
+ if (iv.length !== GCM_IV_BYTES || tag.length !== GCM_TAG_BYTES) return null
257
+ const decipher = createDecipheriv('aes-256-gcm', this.#keyBuffer, iv, {
258
+ authTagLength: GCM_TAG_BYTES,
259
+ })
246
260
  decipher.setAuthTag(tag)
247
261
  const raw = Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
248
262
  return this.#open(raw, purpose) as T | null
@@ -43,7 +43,12 @@ export class SignedUrl {
43
43
  if (!providedSig) return false
44
44
  const expires = url.searchParams.get('expires')
45
45
  if (expires && Math.floor(Date.now() / 1000) > parseInt(expires, 10)) return false
46
- if (purpose && url.searchParams.get('purpose') !== purpose) return false
46
+ // Exact match both ways: a URL minted for `password-reset` must not pass a
47
+ // check that names no purpose (or another one), and a purpose-less URL
48
+ // must not pass a check that names one. An empty purpose is no purpose,
49
+ // as in `make`.
50
+ const signedPurpose = url.searchParams.get('purpose') || undefined
51
+ if (signedPurpose !== (purpose || undefined)) return false
47
52
 
48
53
  url.searchParams.delete('signature')
49
54
  const expectedSig = hmacSign(url.pathname + url.search, this.#secret)