@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.
- 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 +10 -2
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +21 -22
- 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 +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- 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 +78 -7
- 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 +41 -12
- package/dist/server/Server.d.ts.map +1 -1
- package/dist/server/Server.js +31 -10
- 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 +12 -0
- package/dist/session/Session.d.ts.map +1 -1
- package/dist/session/Session.js.map +1 -1
- package/dist/session/SessionMiddleware.d.ts.map +1 -1
- package/dist/session/SessionMiddleware.js +28 -9
- 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/MemoryDriver.d.ts.map +1 -1
- package/dist/session/drivers/MemoryDriver.js +12 -5
- package/dist/session/drivers/MemoryDriver.js.map +1 -1
- package/dist/session/errors.d.ts +17 -0
- package/dist/session/errors.d.ts.map +1 -1
- package/dist/session/errors.js +21 -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.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.win32-x64-msvc.node +0 -0
- package/src/Codemods.ts +265 -0
- package/src/Ignitor.ts +37 -22
- 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 +5 -1
- package/src/middleware/Pipeline.ts +7 -3
- package/src/native/generated.ts +89 -7
- 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 +57 -12
- package/src/server/nativeGrpc.ts +59 -0
- package/src/server/nativeLimits.ts +94 -0
- package/src/session/Session.ts +16 -0
- package/src/session/SessionMiddleware.ts +30 -10
- package/src/session/config.ts +7 -0
- package/src/session/drivers/MemoryDriver.ts +11 -5
- package/src/session/errors.ts +31 -0
- package/src/session/templateTags.ts +57 -0
- package/src/storage/StaticMiddleware.ts +28 -18
package/src/Ignitor.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { Application } from './Application.js'
|
|
17
|
+
import type { BodyParserConfig } from './bodyparser/BodyParserMiddleware.js'
|
|
17
18
|
import type { Console } from './console/Console.js'
|
|
18
19
|
import type { CommandLoader, Kernel as ConsoleKernelInstance } from './console/Kernel.js'
|
|
19
20
|
import { type CommandClass, isCommandClass } from './console/types.js'
|
|
@@ -51,13 +52,20 @@ import {
|
|
|
51
52
|
import { Router } from './router/Router.js'
|
|
52
53
|
import { CookieSigner } from './security/CookieSigner.js'
|
|
53
54
|
import { SignedUrl } from './security/SignedUrl.js'
|
|
54
|
-
import { type
|
|
55
|
+
import { cancellableGrpc, type NativeGrpcHandler } from './server/nativeGrpc.js'
|
|
56
|
+
import {
|
|
57
|
+
type NativeBodyLimits,
|
|
58
|
+
type NativeConnectionConfig,
|
|
59
|
+
nativeBodyLimits,
|
|
60
|
+
nativeConnectionConfig,
|
|
61
|
+
} from './server/nativeLimits.js'
|
|
62
|
+
import { type GrpcLimits, Server } from './server/Server.js'
|
|
55
63
|
import { clearApp, setApp } from './services/app.js'
|
|
56
64
|
import { clearEncryption, setEncryption } from './services/encryption.js'
|
|
57
65
|
import { clearLogger, setLogger } from './services/logger.js'
|
|
58
66
|
import { clearRouter, setRouter } from './services/router.js'
|
|
59
67
|
import { clearServer, setServer } from './services/server.js'
|
|
60
|
-
import {
|
|
68
|
+
import { installSessionTemplateTags } from './session/templateTags.js'
|
|
61
69
|
|
|
62
70
|
/** Application environment. */
|
|
63
71
|
/**
|
|
@@ -269,7 +277,9 @@ export function defineConfig(config: ReamrcConfig): ReamrcConfig {
|
|
|
269
277
|
export interface HyperServerLike {
|
|
270
278
|
onRequest(callback: (request: HttpKernelRequest) => Promise<HttpKernelResponse>): void
|
|
271
279
|
/** Optional — present on the real `HyperServer`; required once `server.grpc()` is used. */
|
|
272
|
-
onGrpc?(callback:
|
|
280
|
+
onGrpc?(callback: NativeGrpcHandler, limits?: GrpcLimits): void
|
|
281
|
+
/** Optional — reports the calls a client gave up on; without it no call's signal aborts. */
|
|
282
|
+
onGrpcCancel?(callback: (id: number) => void): void
|
|
273
283
|
/**
|
|
274
284
|
* Optional — present on the real `HyperServer` (Rust-backed), absent on
|
|
275
285
|
* trivial test mocks. Ignitor only calls it when a shield config is set.
|
|
@@ -279,6 +289,10 @@ export interface HyperServerLike {
|
|
|
279
289
|
configureTrustedProxies?(cidrs: string[]): void
|
|
280
290
|
/** Optional — same lifecycle as `configureShield`. */
|
|
281
291
|
configureRateLimit?(config: { max: number; windowSecs: number } | null): void
|
|
292
|
+
/** Optional — same lifecycle as `configureShield`. */
|
|
293
|
+
configureBodyLimits?(config: NativeBodyLimits): void
|
|
294
|
+
/** Optional — same lifecycle as `configureShield`. */
|
|
295
|
+
configureConnections?(config: NativeConnectionConfig): void
|
|
282
296
|
listen(): Promise<void>
|
|
283
297
|
port(): Promise<number>
|
|
284
298
|
close(): Promise<void>
|
|
@@ -684,26 +698,12 @@ export class Ignitor {
|
|
|
684
698
|
|
|
685
699
|
/**
|
|
686
700
|
* Give the template engine `@error` / `@errors` / `@inputError` /
|
|
687
|
-
* `@flashMessage`, as AdonisJS's session plugin does
|
|
688
|
-
*
|
|
689
|
-
*
|
|
690
|
-
* on it, and an app with no template layer simply skips this.
|
|
701
|
+
* `@flashMessage`, as AdonisJS's session plugin does — through the engine
|
|
702
|
+
* package's module singleton, guarded by `usingInker` (see
|
|
703
|
+
* `installSessionTemplateTags`). An app with no template layer skips this.
|
|
691
704
|
*/
|
|
692
705
|
async #registerSessionTemplateTags(): Promise<void> {
|
|
693
|
-
|
|
694
|
-
if (!this.#app.container.has(token)) continue
|
|
695
|
-
const binding: unknown = await this.#app.container.resolve(token)
|
|
696
|
-
for (const candidate of [binding, Reflect.get(Object(binding), '_templates')]) {
|
|
697
|
-
const register = Reflect.get(Object(candidate), 'registerTag')
|
|
698
|
-
if (typeof register !== 'function') continue
|
|
699
|
-
registerSessionTemplateTags({
|
|
700
|
-
registerTag: (tag) => {
|
|
701
|
-
register.call(candidate, tag)
|
|
702
|
-
},
|
|
703
|
-
})
|
|
704
|
-
return
|
|
705
|
-
}
|
|
706
|
-
}
|
|
706
|
+
await installSessionTemplateTags(this.#app.usingInker === true)
|
|
707
707
|
}
|
|
708
708
|
|
|
709
709
|
async #phaseRegister(): Promise<void> {
|
|
@@ -914,7 +914,9 @@ export class Ignitor {
|
|
|
914
914
|
{ hint: 'Use the native HyperServer, or a test server that implements onGrpc().' },
|
|
915
915
|
)
|
|
916
916
|
}
|
|
917
|
-
|
|
917
|
+
const calls = cancellableGrpc(grpc)
|
|
918
|
+
this.#_httpServer.onGrpcCancel?.(calls.onCancel)
|
|
919
|
+
this.#_httpServer.onGrpc(calls.onCall, this.#server.getGrpcLimits())
|
|
918
920
|
}
|
|
919
921
|
// Pre-resolve client IPs in Rust from the trusted-proxy CIDRs before
|
|
920
922
|
// listen. Security filtering itself lives in @c9up/blackhole.
|
|
@@ -925,6 +927,19 @@ export class Ignitor {
|
|
|
925
927
|
) {
|
|
926
928
|
this.#_httpServer.configureTrustedProxies([...trustedProxies])
|
|
927
929
|
}
|
|
930
|
+
// Body limits from `config/bodyparser.ts`, enforced while the native
|
|
931
|
+
// server reads a body: nothing past them is ever buffered.
|
|
932
|
+
const bodyparser = this.#app.config.get<BodyParserConfig>('bodyparser')
|
|
933
|
+
if (bodyparser && typeof this.#_httpServer.configureBodyLimits === 'function') {
|
|
934
|
+
this.#_httpServer.configureBodyLimits(nativeBodyLimits(bodyparser))
|
|
935
|
+
}
|
|
936
|
+
// AdonisJS `config/app.ts` → `http: { headersTimeout, requestTimeout,
|
|
937
|
+
// keepAliveTimeout }`, plus `maxConnections`.
|
|
938
|
+
if (typeof this.#_httpServer.configureConnections === 'function') {
|
|
939
|
+
this.#_httpServer.configureConnections(
|
|
940
|
+
nativeConnectionConfig(this.#app.config.get<unknown>('app.http')),
|
|
941
|
+
)
|
|
942
|
+
}
|
|
928
943
|
await this.#_httpServer.listen()
|
|
929
944
|
|
|
930
945
|
// Whoever opened the socket closes it. `app.terminate()` runs the
|
|
@@ -136,6 +136,9 @@ export default class BodyParserMiddleware {
|
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
const contentType = ctx.request.header('content-type') ?? ''
|
|
139
|
+
// A body over its native limit (`bodyTooLarge`) was never kept: `raw()`
|
|
140
|
+
// throws the same 413 below instead of handing back an empty string. The
|
|
141
|
+
// route is already matched here, so an unknown URL answered 404 first.
|
|
139
142
|
const rawBody = ctx.request.raw()
|
|
140
143
|
|
|
141
144
|
// Size check. Thrown rather than written: the app's exception handler
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* dependency it can write in a screenful, and the limits below are easier to
|
|
12
12
|
* defend when they are visible.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
14
|
+
* Four guards, all of which `qs` also applies:
|
|
15
15
|
*
|
|
16
16
|
* - **Prototype keys are dropped.** `__proto__[x]=1` in a form body is the
|
|
17
17
|
* classic prototype-pollution vector; the key never reaches an object.
|
|
@@ -19,11 +19,14 @@
|
|
|
19
19
|
* as a literal key rather than allocating unbounded nesting.
|
|
20
20
|
* - **Parameter count is capped** (`qs` defaults to 1000), so a body of a
|
|
21
21
|
* million `a[0]`, `a[1]`… cannot be turned into a million-entry object.
|
|
22
|
+
* - **Explicit array indices are capped** (`qs` `arrayLimit`, default 20).
|
|
23
|
+
* A larger index is an object key, so `ids[4294967294]=1` stays one entry.
|
|
22
24
|
*/
|
|
23
25
|
|
|
24
26
|
/** `qs` defaults, reproduced so the behaviour is the same out of the box. */
|
|
25
27
|
export const DEFAULT_DEPTH = 5
|
|
26
28
|
export const DEFAULT_PARAMETER_LIMIT = 1000
|
|
29
|
+
export const DEFAULT_ARRAY_LIMIT = 20
|
|
27
30
|
|
|
28
31
|
export interface QsParseOptions {
|
|
29
32
|
/**
|
|
@@ -34,6 +37,12 @@ export interface QsParseOptions {
|
|
|
34
37
|
allowDots?: boolean
|
|
35
38
|
depth?: number
|
|
36
39
|
parameterLimit?: number
|
|
40
|
+
/**
|
|
41
|
+
* Highest explicit index + 1 that still builds an array (`qs` defaults to
|
|
42
|
+
* 20). `ids[25]=x` becomes `{ ids: { '25': 'x' } }`, so a 16-byte request
|
|
43
|
+
* cannot allocate a 100-million-slot array.
|
|
44
|
+
*/
|
|
45
|
+
arrayLimit?: number
|
|
37
46
|
/** Turn `''` into `null`, as AdonisJS' `convertEmptyStringsToNull` does. */
|
|
38
47
|
convertEmptyStringsToNull?: boolean
|
|
39
48
|
/** Trim surrounding whitespace, as AdonisJS' `trimWhitespaces` does. */
|
|
@@ -77,15 +86,53 @@ function isIndex(segment: string): boolean {
|
|
|
77
86
|
return /^\d+$/.test(segment)
|
|
78
87
|
}
|
|
79
88
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
89
|
+
type Container = Record<string, unknown> | unknown[]
|
|
90
|
+
|
|
91
|
+
function isContainer(value: unknown): value is Container {
|
|
92
|
+
return typeof value === 'object' && value !== null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `{ '0': a, '1': b }` from `[a, b]`, holes left out — what `qs` produces on overflow. */
|
|
96
|
+
function arrayToObject(array: unknown[]): Record<string, unknown> {
|
|
97
|
+
const object: Record<string, unknown> = {}
|
|
98
|
+
array.forEach((item, index) => {
|
|
99
|
+
object[String(index)] = item
|
|
100
|
+
})
|
|
101
|
+
return object
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Assign `value` at `segments` inside `target`, creating containers as needed.
|
|
106
|
+
*
|
|
107
|
+
* An explicit index at or above `arrayLimit` never addresses an array: the
|
|
108
|
+
* container becomes (or is converted into) an object keyed by that index,
|
|
109
|
+
* as `qs` does. Without it, `ids[100000000]=1` allocates a sparse array whose
|
|
110
|
+
* iteration or serialisation blocks the event loop for seconds. Appends
|
|
111
|
+
* (`tags[]`, a repeated key) stay arrays — each one costs a parameter, so
|
|
112
|
+
* `parameterLimit` already bounds them, and a long checkbox group keeps the
|
|
113
|
+
* shape its handler expects (`qs` turns those into objects past the limit).
|
|
114
|
+
*/
|
|
115
|
+
function assign(
|
|
116
|
+
target: Record<string, unknown>,
|
|
117
|
+
segments: string[],
|
|
118
|
+
value: unknown,
|
|
119
|
+
arrayLimit: number,
|
|
120
|
+
): void {
|
|
121
|
+
let cursor: Container = target
|
|
122
|
+
// Swaps `cursor` inside its parent when an array has to become an object.
|
|
123
|
+
let replace: (next: Container) => void = () => {}
|
|
83
124
|
for (let i = 0; i < segments.length; i++) {
|
|
84
125
|
const segment = segments[i] ?? ''
|
|
85
126
|
const last = i === segments.length - 1
|
|
86
127
|
|
|
87
128
|
if (FORBIDDEN_KEYS.has(segment)) return
|
|
88
129
|
|
|
130
|
+
if (Array.isArray(cursor) && isIndex(segment) && Number(segment) >= arrayLimit) {
|
|
131
|
+
const converted = arrayToObject(cursor)
|
|
132
|
+
replace(converted)
|
|
133
|
+
cursor = converted
|
|
134
|
+
}
|
|
135
|
+
|
|
89
136
|
if (last) {
|
|
90
137
|
if (segment === '') {
|
|
91
138
|
// `tags[]` — append.
|
|
@@ -109,24 +156,31 @@ function assign(target: Record<string, unknown>, segments: string[], value: unkn
|
|
|
109
156
|
}
|
|
110
157
|
|
|
111
158
|
const nextSegment = segments[i + 1] ?? ''
|
|
112
|
-
const wantsArray =
|
|
159
|
+
const wantsArray =
|
|
160
|
+
nextSegment === '' || (isIndex(nextSegment) && Number(nextSegment) < arrayLimit)
|
|
113
161
|
|
|
114
162
|
if (Array.isArray(cursor)) {
|
|
115
163
|
if (!isIndex(segment)) return
|
|
164
|
+
const array: unknown[] = cursor
|
|
116
165
|
const index = Number(segment)
|
|
117
|
-
const existing =
|
|
118
|
-
|
|
119
|
-
|
|
166
|
+
const existing: unknown = array[index]
|
|
167
|
+
const child: Container = isContainer(existing) ? existing : wantsArray ? [] : {}
|
|
168
|
+
array[index] = child
|
|
169
|
+
replace = (next) => {
|
|
170
|
+
array[index] = next
|
|
120
171
|
}
|
|
121
|
-
cursor =
|
|
172
|
+
cursor = child
|
|
122
173
|
continue
|
|
123
174
|
}
|
|
124
175
|
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
176
|
+
const object: Record<string, unknown> = cursor
|
|
177
|
+
const existing: unknown = object[segment]
|
|
178
|
+
const child: Container = isContainer(existing) ? existing : wantsArray ? [] : {}
|
|
179
|
+
object[segment] = child
|
|
180
|
+
replace = (next) => {
|
|
181
|
+
object[segment] = next
|
|
128
182
|
}
|
|
129
|
-
cursor =
|
|
183
|
+
cursor = child
|
|
130
184
|
}
|
|
131
185
|
}
|
|
132
186
|
|
|
@@ -155,6 +209,7 @@ export function parseQueryString(
|
|
|
155
209
|
): Record<string, unknown> {
|
|
156
210
|
const depth = options.depth ?? DEFAULT_DEPTH
|
|
157
211
|
const parameterLimit = options.parameterLimit ?? DEFAULT_PARAMETER_LIMIT
|
|
212
|
+
const arrayLimit = options.arrayLimit ?? DEFAULT_ARRAY_LIMIT
|
|
158
213
|
const result: Record<string, unknown> = {}
|
|
159
214
|
if (!body) return result
|
|
160
215
|
|
|
@@ -173,7 +228,7 @@ export function parseQueryString(
|
|
|
173
228
|
if (options.trimWhitespaces && typeof value === 'string') value = value.trim()
|
|
174
229
|
if (options.convertEmptyStringsToNull && value === '') value = null
|
|
175
230
|
|
|
176
|
-
assign(result, splitKey(key, depth, options.allowDots ?? true), value)
|
|
231
|
+
assign(result, splitKey(key, depth, options.allowDots ?? true), value, arrayLimit)
|
|
177
232
|
}
|
|
178
233
|
return result
|
|
179
234
|
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Just enough of a TypeScript scanner for the configure codemods: find a
|
|
3
|
+
* call or an object/array literal in CODE (never inside a string, a template
|
|
4
|
+
* or a comment), split a literal into its top-level entries, and append to
|
|
5
|
+
* one. Upstream does this with a full AST; ream ships no compiler, and the
|
|
6
|
+
* files these codemods edit (`start/env.ts`, `reamrc.ts`) are object and
|
|
7
|
+
* array literals.
|
|
8
|
+
*
|
|
9
|
+
* Not handled: regular-expression literals (a quote inside one reads as the
|
|
10
|
+
* start of a string). Neither file has a reason to contain one.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const CLOSERS: Record<string, string> = { '{': '}', '[': ']', '(': ')' }
|
|
14
|
+
|
|
15
|
+
function isIdentifierChar(char: string | undefined): boolean {
|
|
16
|
+
return char !== undefined && /[A-Za-z0-9_$]/.test(char)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The index just past the string, template or comment starting at `i`, or
|
|
21
|
+
* `i` itself when none starts there. An unterminated one runs to the end.
|
|
22
|
+
*/
|
|
23
|
+
function skipNonCode(src: string, i: number): number {
|
|
24
|
+
const char = src[i]
|
|
25
|
+
const next = src[i + 1]
|
|
26
|
+
if (char === '/' && next === '/') {
|
|
27
|
+
const end = src.indexOf('\n', i)
|
|
28
|
+
return end === -1 ? src.length : end
|
|
29
|
+
}
|
|
30
|
+
if (char === '/' && next === '*') {
|
|
31
|
+
const end = src.indexOf('*/', i + 2)
|
|
32
|
+
return end === -1 ? src.length : end + 2
|
|
33
|
+
}
|
|
34
|
+
if (char === "'" || char === '"') {
|
|
35
|
+
let j = i + 1
|
|
36
|
+
while (j < src.length && src[j] !== char && src[j] !== '\n') {
|
|
37
|
+
j += src[j] === '\\' ? 2 : 1
|
|
38
|
+
}
|
|
39
|
+
return j + 1
|
|
40
|
+
}
|
|
41
|
+
if (char === '`') {
|
|
42
|
+
let j = i + 1
|
|
43
|
+
while (j < src.length && src[j] !== '`') {
|
|
44
|
+
if (src[j] === '\\') {
|
|
45
|
+
j += 2
|
|
46
|
+
} else if (src[j] === '$' && src[j + 1] === '{') {
|
|
47
|
+
const close = matchClose(src, j + 1)
|
|
48
|
+
j = close === -1 ? src.length : close + 1
|
|
49
|
+
} else {
|
|
50
|
+
j += 1
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return j + 1
|
|
54
|
+
}
|
|
55
|
+
return i
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Whether the string, template or block comment starting at `i` is closed. */
|
|
59
|
+
function isTerminated(src: string, i: number): boolean {
|
|
60
|
+
const char = src[i]
|
|
61
|
+
if (char === '/') return src.indexOf('*/', i + 2) !== -1
|
|
62
|
+
const end = skipNonCode(src, i)
|
|
63
|
+
return end <= src.length && end - 1 > i && src[end - 1] === char
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Index of the bracket closing the one at `open`, or -1. */
|
|
67
|
+
export function matchClose(src: string, open: number): number {
|
|
68
|
+
const first = CLOSERS[src[open] ?? '']
|
|
69
|
+
if (first === undefined) return -1
|
|
70
|
+
const stack = [first]
|
|
71
|
+
let i = open + 1
|
|
72
|
+
while (i < src.length) {
|
|
73
|
+
const skipped = skipNonCode(src, i)
|
|
74
|
+
if (skipped !== i) {
|
|
75
|
+
i = skipped
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
const char = src[i] ?? ''
|
|
79
|
+
const closer = CLOSERS[char]
|
|
80
|
+
if (closer !== undefined) {
|
|
81
|
+
stack.push(closer)
|
|
82
|
+
} else if (char === stack[stack.length - 1]) {
|
|
83
|
+
stack.pop()
|
|
84
|
+
if (stack.length === 0) return i
|
|
85
|
+
} else if (char === '}' || char === ']' || char === ')') {
|
|
86
|
+
return -1
|
|
87
|
+
}
|
|
88
|
+
i += 1
|
|
89
|
+
}
|
|
90
|
+
return -1
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** First code character at or after `i` (before `end`), skipping whitespace and comments. */
|
|
94
|
+
function firstCode(src: string, i: number, end: number): number {
|
|
95
|
+
let j = i
|
|
96
|
+
while (j < end) {
|
|
97
|
+
const char = src[j] ?? ''
|
|
98
|
+
if (/\s/.test(char)) {
|
|
99
|
+
j += 1
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
if (char === '/' && (src[j + 1] === '/' || src[j + 1] === '*')) {
|
|
103
|
+
j = skipNonCode(src, j)
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
return j
|
|
107
|
+
}
|
|
108
|
+
return end
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** One top-level entry of a literal: the text between two commas. */
|
|
112
|
+
export interface Segment {
|
|
113
|
+
start: number
|
|
114
|
+
end: number
|
|
115
|
+
/** First code character, or `end` when the entry is only trivia. */
|
|
116
|
+
codeStart: number
|
|
117
|
+
/** Just past the last code character (comments and whitespace excluded). */
|
|
118
|
+
codeEnd: number
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Split `src[start, end)` on its top-level commas. */
|
|
122
|
+
export function segments(src: string, start: number, end: number): Segment[] {
|
|
123
|
+
const out: Segment[] = []
|
|
124
|
+
let segmentStart = start
|
|
125
|
+
let codeEnd = start
|
|
126
|
+
let depth = 0
|
|
127
|
+
let i = start
|
|
128
|
+
const close = (at: number) => {
|
|
129
|
+
const codeStart = firstCode(src, segmentStart, at)
|
|
130
|
+
out.push({ start: segmentStart, end: at, codeStart, codeEnd: Math.max(codeEnd, codeStart) })
|
|
131
|
+
}
|
|
132
|
+
while (i < end) {
|
|
133
|
+
const char = src[i] ?? ''
|
|
134
|
+
if (char === '/' && (src[i + 1] === '/' || src[i + 1] === '*')) {
|
|
135
|
+
i = skipNonCode(src, i)
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
const skipped = skipNonCode(src, i)
|
|
139
|
+
if (skipped !== i) {
|
|
140
|
+
i = skipped
|
|
141
|
+
codeEnd = i
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
if (char === ',' && depth === 0) {
|
|
145
|
+
close(i)
|
|
146
|
+
segmentStart = i + 1
|
|
147
|
+
codeEnd = i + 1
|
|
148
|
+
i += 1
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
if (CLOSERS[char] !== undefined) depth += 1
|
|
152
|
+
else if (char === '}' || char === ']' || char === ')') depth -= 1
|
|
153
|
+
if (!/\s/.test(char)) codeEnd = i + 1
|
|
154
|
+
i += 1
|
|
155
|
+
}
|
|
156
|
+
close(end)
|
|
157
|
+
return out
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Whether `code` is one complete expression that cannot reach outside the
|
|
162
|
+
* slot it is written into: brackets balanced and never closing past where
|
|
163
|
+
* they opened, strings and comments terminated, no top-level comma.
|
|
164
|
+
*/
|
|
165
|
+
export function isSingleExpression(code: string): boolean {
|
|
166
|
+
if (code.trim() === '') return false
|
|
167
|
+
let depth = 0
|
|
168
|
+
let i = 0
|
|
169
|
+
while (i < code.length) {
|
|
170
|
+
const skipped = skipNonCode(code, i)
|
|
171
|
+
if (skipped !== i) {
|
|
172
|
+
// A line comment would swallow the comma written after the value.
|
|
173
|
+
const lineComment = code[i] === '/' && code[i + 1] === '/'
|
|
174
|
+
if (lineComment || !isTerminated(code, i)) return false
|
|
175
|
+
i = skipped
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
const char = code[i] ?? ''
|
|
179
|
+
if (CLOSERS[char] !== undefined) depth += 1
|
|
180
|
+
else if (char === '}' || char === ']' || char === ')') {
|
|
181
|
+
depth -= 1
|
|
182
|
+
if (depth < 0) return false
|
|
183
|
+
} else if (char === ',' && depth === 0) return false
|
|
184
|
+
i += 1
|
|
185
|
+
}
|
|
186
|
+
return depth === 0
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Index of the `(` of the first call to `callee` in code, or -1. A mention
|
|
191
|
+
* inside a string or a comment, or as the tail of a longer name
|
|
192
|
+
* (`MyEnv.create`), does not count.
|
|
193
|
+
*/
|
|
194
|
+
export function findCall(src: string, callee: string): number {
|
|
195
|
+
let i = 0
|
|
196
|
+
while (i < src.length) {
|
|
197
|
+
const skipped = skipNonCode(src, i)
|
|
198
|
+
if (skipped !== i) {
|
|
199
|
+
i = skipped
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
const before = src[i - 1]
|
|
203
|
+
if (src.startsWith(callee, i) && !isIdentifierChar(before) && before !== '.') {
|
|
204
|
+
const paren = firstCode(src, i + callee.length, src.length)
|
|
205
|
+
if (src[paren] === '(') return paren
|
|
206
|
+
}
|
|
207
|
+
i += 1
|
|
208
|
+
}
|
|
209
|
+
return -1
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Index of the `{` after the first `export default` in code, or -1. */
|
|
213
|
+
export function findExportDefaultObject(src: string): number {
|
|
214
|
+
let i = 0
|
|
215
|
+
while (i < src.length) {
|
|
216
|
+
const skipped = skipNonCode(src, i)
|
|
217
|
+
if (skipped !== i) {
|
|
218
|
+
i = skipped
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
if (src.startsWith('export', i) && !isIdentifierChar(src[i - 1])) {
|
|
222
|
+
const word = firstCode(src, i + 'export'.length, src.length)
|
|
223
|
+
if (src.startsWith('default', word) && !isIdentifierChar(src[word + 'default'.length])) {
|
|
224
|
+
const brace = firstCode(src, word + 'default'.length, src.length)
|
|
225
|
+
return src[brace] === '{' ? brace : -1
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
i += 1
|
|
229
|
+
}
|
|
230
|
+
return -1
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** A `key: value` entry of an object literal. */
|
|
234
|
+
export interface Property {
|
|
235
|
+
key: string
|
|
236
|
+
valueStart: number
|
|
237
|
+
valueEnd: number
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The `key: value` entries of the object literal opening at `open`. */
|
|
241
|
+
export function properties(src: string, open: number, close: number): Property[] {
|
|
242
|
+
const out: Property[] = []
|
|
243
|
+
for (const segment of segments(src, open + 1, close)) {
|
|
244
|
+
if (segment.codeStart >= segment.end) continue
|
|
245
|
+
const text = src.slice(segment.codeStart, segment.end)
|
|
246
|
+
const match = /^(?:([A-Za-z_$][\w$]*)|'([^'\\\n]*)'|"([^"\\\n]*)")\s*:/.exec(text)
|
|
247
|
+
if (match === null) continue
|
|
248
|
+
const key = match[1] ?? match[2] ?? match[3] ?? ''
|
|
249
|
+
const valueStart = firstCode(src, segment.codeStart + match[0].length, segment.end)
|
|
250
|
+
out.push({ key, valueStart, valueEnd: segment.codeEnd })
|
|
251
|
+
}
|
|
252
|
+
return out
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Leading whitespace of the line holding `index`. */
|
|
256
|
+
function lineIndent(src: string, index: number): string {
|
|
257
|
+
const lineStart = src.lastIndexOf('\n', index - 1) + 1
|
|
258
|
+
return /^[ \t]*/.exec(src.slice(lineStart))?.[0] ?? ''
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Append entries to the literal `src[open..close]` and return the new text.
|
|
263
|
+
*
|
|
264
|
+
* `build` receives the indentation of the existing entries (or one level
|
|
265
|
+
* deeper than the closing bracket when there are none) and returns the lines
|
|
266
|
+
* to add, each already ending with its comma. `blankLine` separates the new
|
|
267
|
+
* entries from existing ones. A missing comma after the last existing entry
|
|
268
|
+
* is added.
|
|
269
|
+
*/
|
|
270
|
+
export function appendToLiteral(
|
|
271
|
+
src: string,
|
|
272
|
+
open: number,
|
|
273
|
+
close: number,
|
|
274
|
+
eol: string,
|
|
275
|
+
build: (indent: string) => string[],
|
|
276
|
+
blankLine = false,
|
|
277
|
+
): string {
|
|
278
|
+
const all = segments(src, open + 1, close)
|
|
279
|
+
const entries = all.filter((s) => s.codeStart < s.end)
|
|
280
|
+
const first = entries[0]
|
|
281
|
+
const closeIndent = lineIndent(src, close)
|
|
282
|
+
const firstOnOwnLine =
|
|
283
|
+
first !== undefined &&
|
|
284
|
+
/^[ \t]*$/.test(src.slice(src.lastIndexOf('\n', first.codeStart - 1) + 1, first.codeStart))
|
|
285
|
+
const indent =
|
|
286
|
+
first !== undefined && firstOnOwnLine ? lineIndent(src, first.codeStart) : `${closeIndent} `
|
|
287
|
+
const lines = build(indent)
|
|
288
|
+
const separator = blankLine && entries.length > 0 ? eol : ''
|
|
289
|
+
|
|
290
|
+
const closeLineStart = src.lastIndexOf('\n', close - 1) + 1
|
|
291
|
+
const closeOnOwnLine = /^[ \t]*$/.test(src.slice(closeLineStart, close))
|
|
292
|
+
let out = closeOnOwnLine
|
|
293
|
+
? `${src.slice(0, closeLineStart)}${separator}${lines.join(eol)}${eol}${src.slice(closeLineStart)}`
|
|
294
|
+
: `${src.slice(0, close)}${eol}${separator}${lines.join(eol)}${eol}${closeIndent}${src.slice(close)}`
|
|
295
|
+
|
|
296
|
+
// The comma goes in last, as it sits before every insertion point above.
|
|
297
|
+
// No comma after the last entry when it is also the last segment.
|
|
298
|
+
const last = entries[entries.length - 1]
|
|
299
|
+
if (last !== undefined && all[all.length - 1] === last) {
|
|
300
|
+
out = `${out.slice(0, last.codeEnd)},${out.slice(last.codeEnd)}`
|
|
301
|
+
}
|
|
302
|
+
return out
|
|
303
|
+
}
|
|
@@ -25,6 +25,7 @@ import * as fs from 'node:fs'
|
|
|
25
25
|
import { currentNodeEnv } from '../env/nodeEnv.js'
|
|
26
26
|
import { loadNapi } from '../helpers/napi-loader.js'
|
|
27
27
|
import type { HttpContext } from '../http/HttpContext.js'
|
|
28
|
+
import { holdsPermissions } from '../http/permissions.js'
|
|
28
29
|
|
|
29
30
|
/** Rust parser output (the `ream-graphql` crate via the `index` NAPI binary). */
|
|
30
31
|
interface RustParsedField {
|
|
@@ -455,9 +456,9 @@ export class GraphQLEngine {
|
|
|
455
456
|
}
|
|
456
457
|
}
|
|
457
458
|
|
|
459
|
+
// A token-authenticated request also needs the token to allow each one.
|
|
458
460
|
if (permissions.length > 0) {
|
|
459
|
-
|
|
460
|
-
if (!permissions.every((p) => userPerms.includes(p))) {
|
|
461
|
+
if (!holdsPermissions(ctx.auth, permissions)) {
|
|
461
462
|
return 'Insufficient permissions'
|
|
462
463
|
}
|
|
463
464
|
}
|
package/src/http/Exception.ts
CHANGED
|
@@ -259,6 +259,12 @@ export type HttpError = {
|
|
|
259
259
|
report?: (error: unknown, ctx: HttpContext) => Promise<void> | void
|
|
260
260
|
/** A hint appended to the message, when the exception carries one. */
|
|
261
261
|
help?: string
|
|
262
|
+
/**
|
|
263
|
+
* Set by a thrown value that is not an {@link Exception} but whose message is
|
|
264
|
+
* written for clients (the `http-errors` convention). Shown outside debug
|
|
265
|
+
* mode even on a 5xx.
|
|
266
|
+
*/
|
|
267
|
+
expose?: boolean
|
|
262
268
|
}
|
|
263
269
|
|
|
264
270
|
/** Renders an HTML error page for a status (AdonisJS `StatusPageRenderer`). */
|
|
@@ -352,13 +358,22 @@ export class ExceptionHandler extends Macroable {
|
|
|
352
358
|
* The message a client is allowed to see.
|
|
353
359
|
*
|
|
354
360
|
* DEVIATION (named): outside debug mode ream only echoes the message of a
|
|
355
|
-
* deliberate `Exception
|
|
361
|
+
* deliberate `Exception`, of a client error (status below 500, which a
|
|
362
|
+
* package raises on purpose: 401, 403, 413…), or of an error flagged
|
|
363
|
+
* `expose: true`; anything else collapses to a generic line. AdonisJS
|
|
356
364
|
* echoes `error.message` whatever was thrown, which leaks the text of driver
|
|
357
365
|
* and library errors to the client.
|
|
366
|
+
*
|
|
367
|
+
* A `code` is NOT a reason to show the message: Node system errors
|
|
368
|
+
* (`ECONNREFUSED`, `ENOENT`) and database drivers (`23505`) carry one, and
|
|
369
|
+
* their messages hold internal hosts, file paths, constraint names and
|
|
370
|
+
* stored values. The error itself is left untouched for reporting.
|
|
358
371
|
*/
|
|
359
372
|
protected publicMessage(error: HttpError, ctx: HttpContext): string {
|
|
360
373
|
if (this.isDebuggingEnabled(ctx)) return error.message
|
|
361
|
-
if (error.
|
|
374
|
+
if (error instanceof Exception || error.status < 500 || error.expose === true) {
|
|
375
|
+
return error.message
|
|
376
|
+
}
|
|
362
377
|
return 'An internal error occurred'
|
|
363
378
|
}
|
|
364
379
|
|
package/src/http/HttpContext.ts
CHANGED
|
@@ -33,7 +33,14 @@ export interface AuthState {
|
|
|
33
33
|
id: string
|
|
34
34
|
email?: string
|
|
35
35
|
roles?: string[]
|
|
36
|
+
/** The user's own permissions — never widened by a token's abilities. */
|
|
36
37
|
permissions?: string[]
|
|
38
|
+
/**
|
|
39
|
+
* Set by a token guard (AdonisJS `user.currentAccessToken`): the token this
|
|
40
|
+
* request authenticated with. Its abilities restrict the user's
|
|
41
|
+
* permissions on every `.permission()` gate; they never add to them.
|
|
42
|
+
*/
|
|
43
|
+
currentAccessToken?: AccessTokenAbilities
|
|
37
44
|
[key: string]: unknown
|
|
38
45
|
}
|
|
39
46
|
roles?: string[]
|
|
@@ -65,6 +72,11 @@ export interface AuthState {
|
|
|
65
72
|
readonly authenticatedViaGuard?: string
|
|
66
73
|
}
|
|
67
74
|
|
|
75
|
+
/** What a permission gate needs from an access token: whether it allows an ability. */
|
|
76
|
+
export interface AccessTokenAbilities {
|
|
77
|
+
allows(ability: string): boolean
|
|
78
|
+
}
|
|
79
|
+
|
|
68
80
|
/**
|
|
69
81
|
* Per-request authorization entry point — populated by an authorization
|
|
70
82
|
* middleware (e.g. `@c9up/warden`'s Bouncer initializer). A structural
|
|
@@ -440,8 +452,9 @@ export class HttpContext extends Macroable {
|
|
|
440
452
|
this.response.setRedirectFactory(
|
|
441
453
|
() =>
|
|
442
454
|
new RedirectBuilder(this.response, {
|
|
443
|
-
requestUrl: this.request.url(),
|
|
455
|
+
requestUrl: this.request.url(true),
|
|
444
456
|
requestReferer: this.request.header('referer'),
|
|
457
|
+
requestAuthority: this.request.authority() ?? undefined,
|
|
445
458
|
routeUrlResolver: this.#routeUrlResolver,
|
|
446
459
|
}),
|
|
447
460
|
)
|