@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.
- package/README.md +1 -0
- package/dist/Codemods.d.ts +16 -0
- package/dist/Codemods.d.ts.map +1 -1
- package/dist/Codemods.js +182 -0
- package/dist/Codemods.js.map +1 -1
- package/dist/Ignitor.d.ts +11 -1
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +27 -21
- package/dist/Ignitor.js.map +1 -1
- package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
- package/dist/bodyparser/BodyParserMiddleware.js +3 -0
- package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
- package/dist/bodyparser/qsParse.d.ts +10 -1
- package/dist/bodyparser/qsParse.d.ts.map +1 -1
- package/dist/bodyparser/qsParse.js +53 -15
- package/dist/bodyparser/qsParse.js.map +1 -1
- package/dist/codemods/source.d.ts +57 -0
- package/dist/codemods/source.d.ts.map +1 -0
- package/dist/codemods/source.js +282 -0
- package/dist/codemods/source.js.map +1 -0
- package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
- package/dist/graphql/GraphQLEngine.js +3 -2
- package/dist/graphql/GraphQLEngine.js.map +1 -1
- package/dist/http/Exception.d.ts +14 -1
- package/dist/http/Exception.d.ts.map +1 -1
- package/dist/http/Exception.js +10 -2
- package/dist/http/Exception.js.map +1 -1
- package/dist/http/HttpContext.d.ts +11 -0
- package/dist/http/HttpContext.d.ts.map +1 -1
- package/dist/http/HttpContext.js +2 -1
- package/dist/http/HttpContext.js.map +1 -1
- package/dist/http/RedirectBuilder.d.ts +9 -3
- package/dist/http/RedirectBuilder.d.ts.map +1 -1
- package/dist/http/RedirectBuilder.js +39 -37
- package/dist/http/RedirectBuilder.js.map +1 -1
- package/dist/http/Request.d.ts +42 -17
- package/dist/http/Request.d.ts.map +1 -1
- package/dist/http/Request.js +65 -29
- package/dist/http/Request.js.map +1 -1
- package/dist/http/SseStream.js +6 -7
- package/dist/http/SseStream.js.map +1 -1
- package/dist/http/permissions.d.ts +19 -0
- package/dist/http/permissions.d.ts.map +1 -0
- package/dist/http/permissions.js +28 -0
- package/dist/http/permissions.js.map +1 -0
- package/dist/http-client/HttpClient.d.ts +111 -0
- package/dist/http-client/HttpClient.d.ts.map +1 -0
- package/dist/http-client/HttpClient.js +386 -0
- package/dist/http-client/HttpClient.js.map +1 -0
- package/dist/http-client/addresses.d.ts +14 -0
- package/dist/http-client/addresses.d.ts.map +1 -0
- package/dist/http-client/addresses.js +57 -0
- package/dist/http-client/addresses.js.map +1 -0
- package/dist/http-client/fake.d.ts +50 -0
- package/dist/http-client/fake.d.ts.map +1 -0
- package/dist/http-client/fake.js +95 -0
- package/dist/http-client/fake.js.map +1 -0
- package/dist/http-client/index.d.ts +7 -0
- package/dist/http-client/index.d.ts.map +1 -0
- package/dist/http-client/index.js +7 -0
- package/dist/http-client/index.js.map +1 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/middleware/Pipeline.d.ts.map +1 -1
- package/dist/middleware/Pipeline.js +6 -3
- package/dist/middleware/Pipeline.js.map +1 -1
- package/dist/native/generated.d.ts +108 -3
- package/dist/native/generated.d.ts.map +1 -1
- package/dist/rpc/RpcProvider.d.ts +6 -0
- package/dist/rpc/RpcProvider.d.ts.map +1 -1
- package/dist/rpc/RpcProvider.js +4 -1
- package/dist/rpc/RpcProvider.js.map +1 -1
- package/dist/rpc/RpcRouter.d.ts +27 -14
- package/dist/rpc/RpcRouter.d.ts.map +1 -1
- package/dist/rpc/RpcRouter.js +95 -38
- package/dist/rpc/RpcRouter.js.map +1 -1
- package/dist/scheduler/locks/RedisLockBackend.d.ts.map +1 -1
- package/dist/scheduler/locks/RedisLockBackend.js +13 -2
- package/dist/scheduler/locks/RedisLockBackend.js.map +1 -1
- package/dist/security/CookieSigner.d.ts.map +1 -1
- package/dist/security/CookieSigner.js +17 -3
- package/dist/security/CookieSigner.js.map +1 -1
- package/dist/security/SignedUrl.d.ts.map +1 -1
- package/dist/security/SignedUrl.js +6 -1
- package/dist/security/SignedUrl.js.map +1 -1
- package/dist/server/Server.d.ts +75 -9
- package/dist/server/Server.d.ts.map +1 -1
- package/dist/server/Server.js +60 -9
- package/dist/server/Server.js.map +1 -1
- package/dist/server/nativeGrpc.d.ts +27 -0
- package/dist/server/nativeGrpc.d.ts.map +1 -0
- package/dist/server/nativeGrpc.js +47 -0
- package/dist/server/nativeGrpc.js.map +1 -0
- package/dist/server/nativeLimits.d.ts +49 -0
- package/dist/server/nativeLimits.d.ts.map +1 -0
- package/dist/server/nativeLimits.js +67 -0
- package/dist/server/nativeLimits.js.map +1 -0
- package/dist/session/Session.d.ts +45 -0
- package/dist/session/Session.d.ts.map +1 -1
- package/dist/session/Session.js +31 -2
- package/dist/session/Session.js.map +1 -1
- package/dist/session/SessionMiddleware.d.ts.map +1 -1
- package/dist/session/SessionMiddleware.js +52 -13
- package/dist/session/SessionMiddleware.js.map +1 -1
- package/dist/session/config.d.ts +5 -0
- package/dist/session/config.d.ts.map +1 -1
- package/dist/session/config.js +8 -0
- package/dist/session/config.js.map +1 -1
- package/dist/session/drivers/CookieDriver.d.ts +12 -3
- package/dist/session/drivers/CookieDriver.d.ts.map +1 -1
- package/dist/session/drivers/CookieDriver.js +20 -6
- package/dist/session/drivers/CookieDriver.js.map +1 -1
- package/dist/session/drivers/DatabaseDriver.d.ts +2 -0
- package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -1
- package/dist/session/drivers/DatabaseDriver.js +6 -0
- package/dist/session/drivers/DatabaseDriver.js.map +1 -1
- package/dist/session/drivers/FileDriver.d.ts +8 -0
- package/dist/session/drivers/FileDriver.d.ts.map +1 -1
- package/dist/session/drivers/FileDriver.js +85 -11
- package/dist/session/drivers/FileDriver.js.map +1 -1
- package/dist/session/drivers/MemoryDriver.d.ts +2 -0
- package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
- package/dist/session/drivers/MemoryDriver.js +21 -5
- package/dist/session/drivers/MemoryDriver.js.map +1 -1
- package/dist/session/drivers/RedisDriver.d.ts +2 -0
- package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
- package/dist/session/drivers/RedisDriver.js +6 -0
- package/dist/session/drivers/RedisDriver.js.map +1 -1
- package/dist/session/errors.d.ts +27 -0
- package/dist/session/errors.d.ts.map +1 -1
- package/dist/session/errors.js +33 -0
- package/dist/session/errors.js.map +1 -1
- package/dist/session/templateTags.d.ts +14 -0
- package/dist/session/templateTags.d.ts.map +1 -1
- package/dist/session/templateTags.js +45 -17
- package/dist/session/templateTags.js.map +1 -1
- package/dist/storage/StaticMiddleware.d.ts.map +1 -1
- package/dist/storage/StaticMiddleware.js +29 -18
- package/dist/storage/StaticMiddleware.js.map +1 -1
- package/events.darwin-arm64.node +0 -0
- package/events.darwin-x64.node +0 -0
- package/events.linux-arm64-gnu.node +0 -0
- package/events.linux-x64-gnu.node +0 -0
- package/events.win32-x64-msvc.node +0 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +11 -2
- package/scheduler.darwin-arm64.node +0 -0
- package/scheduler.darwin-x64.node +0 -0
- package/scheduler.linux-arm64-gnu.node +0 -0
- package/scheduler.linux-x64-gnu.node +0 -0
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/Codemods.ts +265 -0
- package/src/Ignitor.ts +48 -20
- package/src/bodyparser/BodyParserMiddleware.ts +3 -0
- package/src/bodyparser/qsParse.ts +69 -14
- package/src/codemods/source.ts +303 -0
- package/src/graphql/GraphQLEngine.ts +3 -2
- package/src/http/Exception.ts +17 -2
- package/src/http/HttpContext.ts +14 -1
- package/src/http/RedirectBuilder.ts +48 -37
- package/src/http/Request.ts +87 -29
- package/src/http/SseStream.ts +6 -7
- package/src/http/permissions.ts +31 -0
- package/src/http-client/HttpClient.ts +505 -0
- package/src/http-client/addresses.ts +58 -0
- package/src/http-client/fake.ts +141 -0
- package/src/http-client/index.ts +21 -0
- package/src/index.ts +9 -1
- package/src/middleware/Pipeline.ts +7 -3
- package/src/native/generated.ts +123 -3
- package/src/rpc/RpcProvider.ts +10 -1
- package/src/rpc/RpcRouter.ts +126 -42
- package/src/scheduler/locks/RedisLockBackend.ts +12 -4
- package/src/security/CookieSigner.ts +17 -3
- package/src/security/SignedUrl.ts +6 -1
- package/src/server/Server.ts +103 -9
- package/src/server/nativeGrpc.ts +59 -0
- package/src/server/nativeLimits.ts +94 -0
- package/src/session/Session.ts +72 -2
- package/src/session/SessionMiddleware.ts +53 -14
- package/src/session/config.ts +7 -0
- package/src/session/drivers/CookieDriver.ts +21 -6
- package/src/session/drivers/DatabaseDriver.ts +10 -0
- package/src/session/drivers/FileDriver.ts +91 -11
- package/src/session/drivers/MemoryDriver.ts +20 -5
- package/src/session/drivers/RedisDriver.ts +7 -0
- package/src/session/errors.ts +48 -0
- package/src/session/templateTags.ts +57 -0
- 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
|
@@ -91,6 +91,7 @@ export type {
|
|
|
91
91
|
export { RedisDriver as SessionRedisDriver } from './session/drivers/RedisDriver.js'
|
|
92
92
|
export {
|
|
93
93
|
E_SESSION_NOT_READY,
|
|
94
|
+
E_SESSION_STORE_NOT_CONDITIONAL,
|
|
94
95
|
E_SESSION_TAGGING_NOT_SUPPORTED,
|
|
95
96
|
} from './session/errors.js'
|
|
96
97
|
export type { ValuePath } from './session/ReadOnlyValuesStore.js'
|
|
@@ -218,8 +219,13 @@ export { createHttpKernel } from './HttpKernel.js'
|
|
|
218
219
|
|
|
219
220
|
// ─── Server ─────────────────────────────────────────────────
|
|
220
221
|
|
|
222
|
+
export type { NativeGrpcCall, NativeGrpcHandler } from './server/nativeGrpc.js'
|
|
221
223
|
export type {
|
|
222
224
|
ErrorHandlerClass,
|
|
225
|
+
GrpcCall,
|
|
226
|
+
GrpcHandler,
|
|
227
|
+
GrpcLimits,
|
|
228
|
+
GrpcReply,
|
|
223
229
|
LazyImport,
|
|
224
230
|
MiddlewareClass,
|
|
225
231
|
MiddlewareEntry,
|
|
@@ -281,7 +287,9 @@ export type {
|
|
|
281
287
|
export {
|
|
282
288
|
type Codemods,
|
|
283
289
|
createCodemods,
|
|
290
|
+
type EnvValidationNode,
|
|
284
291
|
type GeneratedStub,
|
|
292
|
+
type PreloadEnvironment,
|
|
285
293
|
type StubState,
|
|
286
294
|
} from './Codemods.js'
|
|
287
295
|
export {
|
|
@@ -405,4 +413,4 @@ export {
|
|
|
405
413
|
StatsTracker,
|
|
406
414
|
} from './scheduler/index.js'
|
|
407
415
|
|
|
408
|
-
export const VERSION = '0.2.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/native/generated.ts
CHANGED
|
@@ -23,6 +23,53 @@ export interface NapiResponse {
|
|
|
23
23
|
streamId?: string
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* One gRPC call, handed to the JS handler registered with `onGrpc`.
|
|
28
|
+
*
|
|
29
|
+
* The message crosses as BYTES — never through the `String` (base64 for
|
|
30
|
+
* binary) body of the HTTP path.
|
|
31
|
+
*/
|
|
32
|
+
|
|
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
|
|
39
|
+
/** `/package.Service/Method`. */
|
|
40
|
+
path: 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>>
|
|
46
|
+
/** The request message, still encoded. */
|
|
47
|
+
message: Uint8Array
|
|
48
|
+
/** Resolved client IP, by the same trusted-proxy rules as `request.ip`. */
|
|
49
|
+
ip: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What the gRPC handler answers: a reply `message`, or a status `code`
|
|
54
|
+
* (with its `details`) instead of one.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
export interface NapiGrpcReply {
|
|
58
|
+
message?: Uint8Array
|
|
59
|
+
code?: number
|
|
60
|
+
details?: string
|
|
61
|
+
}
|
|
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
|
+
|
|
26
73
|
/**
|
|
27
74
|
* Configuration for the wire-level shield filter. Mirrors the Rust
|
|
28
75
|
* `ShieldConfig` shape; the JS layer constructs it from `ShieldMiddleware`
|
|
@@ -45,6 +92,45 @@ export interface NapiRateLimitConfig {
|
|
|
45
92
|
windowSecs: number
|
|
46
93
|
}
|
|
47
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
|
+
|
|
48
134
|
/**
|
|
49
135
|
* NAPI-exposed event bus.
|
|
50
136
|
* Uses shared Tokio runtime (no per-instance runtime).
|
|
@@ -96,11 +182,45 @@ export declare class HyperServer {
|
|
|
96
182
|
*/
|
|
97
183
|
onRequest(callback: (request: Record<string, unknown>) => Promise<NapiResponse>): void
|
|
98
184
|
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
185
|
+
* Register the gRPC handler. From `listen()` on, `application/grpc`
|
|
186
|
+
* requests on this port are gRPC calls handed to it; without it they stay
|
|
187
|
+
* HTTP requests. Must be called BEFORE `listen()`.
|
|
188
|
+
*
|
|
189
|
+
* A rejected promise is answered `INTERNAL`, with nothing of the error
|
|
190
|
+
* sent to the client. `limits` bounds the request and reply messages;
|
|
191
|
+
* a request is read, and refused, before the handler is called.
|
|
192
|
+
*/
|
|
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
|
|
203
|
+
/**
|
|
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.
|
|
102
210
|
*/
|
|
103
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
|
|
104
224
|
/**
|
|
105
225
|
* Install (or replace) the wire-level rate limiter. Pass `None` to
|
|
106
226
|
* disable. Must be called BEFORE `listen()`.
|
package/src/rpc/RpcProvider.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/src/rpc/RpcRouter.ts
CHANGED
|
@@ -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) //
|
|
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
|
-
*
|
|
83
|
-
*
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
221
|
+
* Register the listed methods of a controller as `<prefix>.<method>`.
|
|
156
222
|
*
|
|
157
|
-
* The
|
|
158
|
-
* `
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
|
|
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
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
74
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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)
|