@c9up/ream 0.2.9 → 0.2.11
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 +20 -18
- package/dist/Application.d.ts +26 -6
- package/dist/Application.d.ts.map +1 -1
- package/dist/Application.js +40 -12
- package/dist/Application.js.map +1 -1
- package/dist/ErrorBoundary.d.ts.map +1 -1
- package/dist/ErrorBoundary.js +2 -1
- package/dist/ErrorBoundary.js.map +1 -1
- package/dist/Ignitor.d.ts +13 -0
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +98 -23
- package/dist/Ignitor.js.map +1 -1
- package/dist/bodyparser/MultipartFile.d.ts +8 -0
- package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
- package/dist/bodyparser/MultipartFile.js +46 -1
- package/dist/bodyparser/MultipartFile.js.map +1 -1
- package/dist/env/Env.d.ts.map +1 -1
- package/dist/env/Env.js +2 -1
- package/dist/env/Env.js.map +1 -1
- package/dist/env/loadEnvFiles.d.ts.map +1 -1
- package/dist/env/loadEnvFiles.js +5 -1
- package/dist/env/loadEnvFiles.js.map +1 -1
- package/dist/env/nodeEnv.d.ts +24 -0
- package/dist/env/nodeEnv.d.ts.map +1 -0
- package/dist/env/nodeEnv.js +40 -0
- package/dist/env/nodeEnv.js.map +1 -0
- package/dist/errors/ReamError.d.ts +6 -1
- package/dist/errors/ReamError.d.ts.map +1 -1
- package/dist/errors/ReamError.js +8 -2
- package/dist/errors/ReamError.js.map +1 -1
- 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.map +1 -1
- package/dist/http/Exception.js +2 -1
- package/dist/http/Exception.js.map +1 -1
- package/dist/http/Request.d.ts +18 -3
- package/dist/http/Request.d.ts.map +1 -1
- package/dist/http/Request.js +71 -8
- package/dist/http/Request.js.map +1 -1
- package/dist/http/Response.d.ts +11 -4
- package/dist/http/Response.d.ts.map +1 -1
- package/dist/http/Response.js +46 -17
- package/dist/http/Response.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/router/Router.d.ts.map +1 -1
- package/dist/router/Router.js +14 -5
- package/dist/router/Router.js.map +1 -1
- package/dist/rpc/RpcRouter.d.ts.map +1 -1
- package/dist/rpc/RpcRouter.js +2 -1
- package/dist/rpc/RpcRouter.js.map +1 -1
- package/dist/scheduler/ScheduleProvider.d.ts +14 -0
- package/dist/scheduler/ScheduleProvider.d.ts.map +1 -1
- package/dist/scheduler/ScheduleProvider.js +62 -14
- package/dist/scheduler/ScheduleProvider.js.map +1 -1
- package/dist/scheduler/config.d.ts +29 -0
- package/dist/scheduler/config.d.ts.map +1 -0
- package/dist/scheduler/config.js +14 -0
- package/dist/scheduler/config.js.map +1 -0
- package/dist/scheduler/index.d.ts +4 -0
- package/dist/scheduler/index.d.ts.map +1 -1
- package/dist/scheduler/index.js +3 -0
- package/dist/scheduler/index.js.map +1 -1
- package/dist/scheduler/locks/LockBackend.d.ts +13 -3
- package/dist/scheduler/locks/LockBackend.d.ts.map +1 -1
- package/dist/scheduler/locks/LockBackend.js +20 -4
- package/dist/scheduler/locks/LockBackend.js.map +1 -1
- package/dist/scheduler/locks/MemoryLockBackend.d.ts +5 -6
- package/dist/scheduler/locks/MemoryLockBackend.d.ts.map +1 -1
- package/dist/scheduler/locks/MemoryLockBackend.js +5 -13
- package/dist/scheduler/locks/MemoryLockBackend.js.map +1 -1
- package/dist/scheduler/locks/RedisLockBackend.d.ts +53 -0
- package/dist/scheduler/locks/RedisLockBackend.d.ts.map +1 -0
- package/dist/scheduler/locks/RedisLockBackend.js +83 -0
- package/dist/scheduler/locks/RedisLockBackend.js.map +1 -0
- package/dist/scheduler/locks/locks.d.ts +39 -0
- package/dist/scheduler/locks/locks.d.ts.map +1 -0
- package/dist/scheduler/locks/locks.js +42 -0
- package/dist/scheduler/locks/locks.js.map +1 -0
- package/dist/scheduler/locks/quasar.d.ts +17 -0
- package/dist/scheduler/locks/quasar.d.ts.map +1 -0
- package/dist/scheduler/locks/quasar.js +51 -0
- package/dist/scheduler/locks/quasar.js.map +1 -0
- package/dist/security/CookieSigner.d.ts +16 -13
- package/dist/security/CookieSigner.d.ts.map +1 -1
- package/dist/security/CookieSigner.js +33 -14
- package/dist/security/CookieSigner.js.map +1 -1
- package/dist/services/encryption.d.ts +12 -2
- package/dist/services/encryption.d.ts.map +1 -1
- package/dist/services/encryption.js +11 -1
- package/dist/services/encryption.js.map +1 -1
- package/dist/session/Session.d.ts +8 -7
- 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 +4 -1
- package/dist/session/SessionMiddleware.js.map +1 -1
- package/dist/testing/ApiResponse.d.ts.map +1 -1
- package/dist/testing/ApiResponse.js +1 -1
- package/dist/testing/ApiResponse.js.map +1 -1
- package/dist/testing/RequestBuilder.d.ts.map +1 -1
- package/dist/testing/RequestBuilder.js +1 -1
- package/dist/testing/RequestBuilder.js.map +1 -1
- package/dist/utils/Macroable.d.ts.map +1 -1
- package/dist/utils/Macroable.js +1 -0
- package/dist/utils/Macroable.js.map +1 -1
- package/events.win32-x64-msvc.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +5 -1
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/Application.ts +42 -15
- package/src/ErrorBoundary.ts +3 -1
- package/src/Ignitor.ts +109 -27
- package/src/bodyparser/MultipartFile.ts +48 -1
- package/src/env/Env.ts +2 -1
- package/src/env/loadEnvFiles.ts +5 -1
- package/src/env/nodeEnv.ts +38 -0
- package/src/errors/ReamError.ts +9 -2
- package/src/graphql/GraphQLEngine.ts +3 -2
- package/src/http/Exception.ts +2 -1
- package/src/http/Request.ts +73 -8
- package/src/http/Response.ts +58 -19
- package/src/index.ts +8 -1
- package/src/router/Router.ts +14 -5
- package/src/rpc/RpcRouter.ts +2 -1
- package/src/scheduler/ScheduleProvider.ts +65 -15
- package/src/scheduler/config.ts +34 -0
- package/src/scheduler/index.ts +8 -0
- package/src/scheduler/locks/LockBackend.ts +26 -3
- package/src/scheduler/locks/MemoryLockBackend.ts +5 -19
- package/src/scheduler/locks/RedisLockBackend.ts +114 -0
- package/src/scheduler/locks/locks.ts +49 -0
- package/src/scheduler/locks/quasar.ts +67 -0
- package/src/security/CookieSigner.ts +36 -14
- package/src/services/encryption.ts +12 -2
- package/src/session/Session.ts +8 -7
- package/src/session/SessionMiddleware.ts +4 -1
- package/src/testing/ApiResponse.ts +1 -2
- package/src/testing/RequestBuilder.ts +1 -2
- package/src/utils/Macroable.ts +1 -0
|
@@ -110,6 +110,8 @@ export class MultipartFile {
|
|
|
110
110
|
|
|
111
111
|
/** Magic-byte fingerprint — set by {@link detectType}; absent for content `file-type` can't detect (text: txt/csv/svg/json). */
|
|
112
112
|
#detected?: { ext: string; mime: string }
|
|
113
|
+
/** Whether the magic-byte pass has run, however it turned out. */
|
|
114
|
+
#detectionRan = false
|
|
113
115
|
|
|
114
116
|
constructor(options: {
|
|
115
117
|
fieldName: string
|
|
@@ -136,9 +138,22 @@ export class MultipartFile {
|
|
|
136
138
|
* extension in force — same fallback AdonisJS uses.
|
|
137
139
|
*/
|
|
138
140
|
async detectType(): Promise<void> {
|
|
139
|
-
const { fileTypeFromBuffer } = await import('file-type')
|
|
141
|
+
const { fileTypeFromBuffer, supportedExtensions } = await import('file-type')
|
|
142
|
+
detectableExtensions = supportedExtensions
|
|
140
143
|
const result = await fileTypeFromBuffer(this.content)
|
|
141
144
|
if (result) this.#detected = { ext: result.ext, mime: result.mime }
|
|
145
|
+
this.#detectionRan = true
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Where {@link mime} and {@link extname} came from.
|
|
150
|
+
*
|
|
151
|
+
* `'detected'` means the bytes said so. `'claimed'` means they did not, and
|
|
152
|
+
* what is reported is the filename and the header the client sent — which
|
|
153
|
+
* the client chose.
|
|
154
|
+
*/
|
|
155
|
+
get typeSource(): 'detected' | 'claimed' {
|
|
156
|
+
return this.#detected ? 'detected' : 'claimed'
|
|
142
157
|
}
|
|
143
158
|
|
|
144
159
|
/**
|
|
@@ -201,11 +216,35 @@ export class MultipartFile {
|
|
|
201
216
|
this.errors.push(
|
|
202
217
|
`Extension '${this.extname}' not allowed. Allowed: ${options.extnames.join(', ')}`,
|
|
203
218
|
)
|
|
219
|
+
} else if (this.#claimsATypeItCannotBe(options.extnames)) {
|
|
220
|
+
// Every allowed format carries a signature, and none was found — so the
|
|
221
|
+
// bytes are definitively not one of them, whatever the name says. A
|
|
222
|
+
// shell script uploaded as `avatar.png` with `Content-Type: image/png`
|
|
223
|
+
// otherwise passes an image allowlist, because both halves of what was
|
|
224
|
+
// checked came from the client.
|
|
225
|
+
this.errors.push(
|
|
226
|
+
`Extension '${this.extname}' is claimed by the upload, not found in its content. ` +
|
|
227
|
+
`Every allowed format (${options.extnames.join(', ')}) has a signature, and this file carries none.`,
|
|
228
|
+
)
|
|
204
229
|
}
|
|
205
230
|
}
|
|
206
231
|
return this.errors.length === 0
|
|
207
232
|
}
|
|
208
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Whether refusing this upload is warranted on the strength of the allowlist
|
|
236
|
+
* alone.
|
|
237
|
+
*
|
|
238
|
+
* Only when the magic-byte pass actually ran and found nothing, AND every
|
|
239
|
+
* allowed extension is one it would have recognised. For a list that allows
|
|
240
|
+
* `csv` or `txt`, finding nothing is the normal case and says nothing.
|
|
241
|
+
*/
|
|
242
|
+
#claimsATypeItCannotBe(extnames: string[]): boolean {
|
|
243
|
+
if (this.#detected || !this.#detectionRan) return false
|
|
244
|
+
if (!detectableExtensions) return false
|
|
245
|
+
return extnames.every((extension) => detectableExtensions?.has(extension))
|
|
246
|
+
}
|
|
247
|
+
|
|
209
248
|
get isValid(): boolean {
|
|
210
249
|
return this.errors.length === 0
|
|
211
250
|
}
|
|
@@ -363,3 +402,11 @@ export function hydrateMultipartPayload(payload: {
|
|
|
363
402
|
)
|
|
364
403
|
return { fields, files }
|
|
365
404
|
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* The extensions `file-type` can recognise, captured on the first detection.
|
|
408
|
+
*
|
|
409
|
+
* Module-level because it is a property of the library, not of a file, and
|
|
410
|
+
* because `validate()` is synchronous while reading it is not.
|
|
411
|
+
*/
|
|
412
|
+
let detectableExtensions: ReadonlySet<string> | undefined
|
package/src/env/Env.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
removeIdentifier,
|
|
6
6
|
} from './interpolate.js'
|
|
7
7
|
import { loadEnvFiles } from './loadEnvFiles.js'
|
|
8
|
+
import { normalizeNodeEnv } from './nodeEnv.js'
|
|
8
9
|
import { type SchemaNode, schema } from './schema.js'
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -87,7 +88,7 @@ export class Env<Values extends Record<string, unknown>> {
|
|
|
87
88
|
appRoot: URL,
|
|
88
89
|
schema: S,
|
|
89
90
|
): Promise<Env<EnvRecord<S>>> {
|
|
90
|
-
loadEnvFiles(appRoot, { skipEnvLocal: process.env.NODE_ENV === 'test' })
|
|
91
|
+
loadEnvFiles(appRoot, { skipEnvLocal: normalizeNodeEnv(process.env.NODE_ENV) === 'test' })
|
|
91
92
|
|
|
92
93
|
return new Env(Env.rules(schema).validate(process.env))
|
|
93
94
|
}
|
package/src/env/loadEnvFiles.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'
|
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
3
|
import { parseEnv } from 'node:util'
|
|
4
4
|
import { interpolate } from './interpolate.js'
|
|
5
|
+
import { normalizeNodeEnv } from './nodeEnv.js'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Load `.env` files into `process.env` — the shared primitive behind both the
|
|
@@ -17,7 +18,10 @@ import { interpolate } from './interpolate.js'
|
|
|
17
18
|
* developer's local overrides don't leak into tests).
|
|
18
19
|
*/
|
|
19
20
|
export function loadEnvFiles(appRoot: URL, options: { skipEnvLocal?: boolean } = {}): void {
|
|
20
|
-
|
|
21
|
+
// Normalised, so `NODE_ENV=prod` loads `.env.production` — the file the
|
|
22
|
+
// deployment actually wrote — instead of looking for `.env.prod`.
|
|
23
|
+
const raw = normalizeNodeEnv(process.env.NODE_ENV)
|
|
24
|
+
const nodeEnv = raw === 'unknown' ? undefined : raw
|
|
21
25
|
const files = [
|
|
22
26
|
nodeEnv ? `.env.${nodeEnv}.local` : null,
|
|
23
27
|
options.skipEnvLocal ? null : '.env.local',
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading `NODE_ENV`, with the aliases people actually set.
|
|
3
|
+
*
|
|
4
|
+
* `NODE_ENV=prod` is a normal thing to find in a Dockerfile or a platform's
|
|
5
|
+
* dashboard, and a bare `=== 'production'` reads it as "not production": the
|
|
6
|
+
* app then serves without Secure cookies, with development error pages, and
|
|
7
|
+
* loads the wrong `.env` file. Normalising once, here, is what stops that
|
|
8
|
+
* spelling from deciding how an application behaves.
|
|
9
|
+
*
|
|
10
|
+
* The tables mirror upstream exactly. Anything outside them is handed back
|
|
11
|
+
* lowercased rather than forced into a bucket — `staging` stays `staging`, so
|
|
12
|
+
* `.env.staging` still resolves.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const DEV_ENVS = ['dev', 'develop', 'development']
|
|
16
|
+
const PROD_ENVS = ['prod', 'production']
|
|
17
|
+
const TEST_ENVS = ['test', 'testing']
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The canonical name for whatever `NODE_ENV` holds.
|
|
21
|
+
*
|
|
22
|
+
* Returns `'unknown'` when it is unset — an absent environment is not the
|
|
23
|
+
* development one, and pretending otherwise turns a misconfigured deploy into
|
|
24
|
+
* a silently permissive one.
|
|
25
|
+
*/
|
|
26
|
+
export function normalizeNodeEnv(value: string | undefined): string {
|
|
27
|
+
if (!value || typeof value !== 'string') return 'unknown'
|
|
28
|
+
const env = value.toLowerCase()
|
|
29
|
+
if (DEV_ENVS.includes(env)) return 'development'
|
|
30
|
+
if (PROD_ENVS.includes(env)) return 'production'
|
|
31
|
+
if (TEST_ENVS.includes(env)) return 'test'
|
|
32
|
+
return env
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The current environment, normalised. */
|
|
36
|
+
export function currentNodeEnv(): string {
|
|
37
|
+
return normalizeNodeEnv(process.env.NODE_ENV)
|
|
38
|
+
}
|
package/src/errors/ReamError.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* @implements FR71, FR72, FR73, FR77
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { currentNodeEnv } from '../env/nodeEnv.js'
|
|
11
|
+
|
|
10
12
|
/**
|
|
11
13
|
* Is this a Ream error, and one of these codes?
|
|
12
14
|
*
|
|
@@ -39,7 +41,12 @@ export function isReamError(error: unknown, ...codes: string[]): boolean {
|
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
export class ReamError extends Error {
|
|
42
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Error code, `E_`-prefixed as every framework code is — an application
|
|
46
|
+
* matching on one should never have to remember which convention a
|
|
47
|
+
* particular package followed (e.g. "E_ATLAS_QUERY_ERROR",
|
|
48
|
+
* "E_CONTAINER_NOT_FOUND").
|
|
49
|
+
*/
|
|
43
50
|
readonly code: string
|
|
44
51
|
|
|
45
52
|
/** Additional context key-value pairs */
|
|
@@ -164,6 +171,6 @@ export class ReamError extends Error {
|
|
|
164
171
|
* Auto-format based on NODE_ENV.
|
|
165
172
|
*/
|
|
166
173
|
toFormattedString(): string {
|
|
167
|
-
return
|
|
174
|
+
return currentNodeEnv() === 'production' ? this.toProdString() : this.toDevString()
|
|
168
175
|
}
|
|
169
176
|
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import * as fs from 'node:fs'
|
|
25
|
+
import { currentNodeEnv } from '../env/nodeEnv.js'
|
|
25
26
|
import { loadNapi } from '../helpers/napi-loader.js'
|
|
26
27
|
import type { HttpContext } from '../http/HttpContext.js'
|
|
27
28
|
|
|
@@ -231,7 +232,7 @@ export class GraphQLEngine {
|
|
|
231
232
|
}
|
|
232
233
|
this.#schemaSource = fs.readFileSync(config.schemaPath, 'utf8')
|
|
233
234
|
this.path = config.path ?? '/graphql'
|
|
234
|
-
this.#playground = config.playground ??
|
|
235
|
+
this.#playground = config.playground ?? currentNodeEnv() !== 'production'
|
|
235
236
|
this.#maxQueryBytes = config.maxQueryBytes ?? 100_000
|
|
236
237
|
this.#maxDepth = config.maxDepth ?? 12
|
|
237
238
|
this.#maxComplexity = config.maxComplexity ?? 1000
|
|
@@ -288,7 +289,7 @@ export class GraphQLEngine {
|
|
|
288
289
|
ctx.response.status(200).json(result)
|
|
289
290
|
} catch (err) {
|
|
290
291
|
const message =
|
|
291
|
-
|
|
292
|
+
currentNodeEnv() !== 'production' && err instanceof Error
|
|
292
293
|
? err.message
|
|
293
294
|
: 'Internal server error'
|
|
294
295
|
ctx.response.status(500).json({ errors: [{ message }] })
|
package/src/http/Exception.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { format } from 'node:util'
|
|
10
|
+
import { currentNodeEnv } from '../env/nodeEnv.js'
|
|
10
11
|
import { Macroable } from '../utils/Macroable.js'
|
|
11
12
|
import type { HttpContext } from './HttpContext.js'
|
|
12
13
|
|
|
@@ -272,7 +273,7 @@ type LogLevel = 'error' | 'warn' | 'info'
|
|
|
272
273
|
export class ExceptionHandler extends Macroable {
|
|
273
274
|
protected debug: boolean
|
|
274
275
|
/** Render `statusPages` (browser HTML). Defaults to production only (AdonisJS). */
|
|
275
|
-
protected renderStatusPages: boolean =
|
|
276
|
+
protected renderStatusPages: boolean = currentNodeEnv() === 'production'
|
|
276
277
|
/** Status → HTML renderer, keys may be single codes or `'500..599'` ranges. */
|
|
277
278
|
protected statusPages: Record<string, StatusPageRenderer> = {}
|
|
278
279
|
/** Master reporting switch (AdonisJS `reportErrors`). */
|
package/src/http/Request.ts
CHANGED
|
@@ -383,7 +383,14 @@ export class Request extends Macroable {
|
|
|
383
383
|
cookie(name: string, defaultValue?: string): string | null
|
|
384
384
|
cookie(name: string, defaultValue?: string): string | null {
|
|
385
385
|
const raw = this.plainCookie<string>(name, undefined, { encoded: false })
|
|
386
|
-
|
|
386
|
+
// No signer means nothing here was ever signed. Handing the raw value back
|
|
387
|
+
// would present whatever the client wrote as a verified one — the reader
|
|
388
|
+
// cannot tell the difference, which is exactly the confusion signing
|
|
389
|
+
// exists to remove. `plainCookie()` is the way to read an unsigned value.
|
|
390
|
+
if (!this.#cookieSigner) return defaultValue ?? null
|
|
391
|
+
// The name is part of the signature, so a value moved to another cookie
|
|
392
|
+
// fails here rather than being honoured under its new name.
|
|
393
|
+
const value = raw === null ? null : this.#cookieSigner.unsign<string>(raw, name)
|
|
387
394
|
return value ?? defaultValue ?? null
|
|
388
395
|
}
|
|
389
396
|
|
|
@@ -675,20 +682,29 @@ export class Request extends Macroable {
|
|
|
675
682
|
}
|
|
676
683
|
|
|
677
684
|
/**
|
|
678
|
-
* A JSON
|
|
679
|
-
*
|
|
680
|
-
*
|
|
685
|
+
* A JSON view of the request (AdonisJS `serialize`).
|
|
686
|
+
*
|
|
687
|
+
* The whole request, as upstream reports it: body, cookies and every header
|
|
688
|
+
* verbatim. It is a debugging dump, and it carries credentials — an
|
|
689
|
+
* `authorization` header, a session cookie, a password field in the body.
|
|
690
|
+
*
|
|
691
|
+
* Do not put it in a log line. {@link serializeSafe} is the one built for
|
|
692
|
+
* that.
|
|
681
693
|
*/
|
|
682
694
|
serialize(): Record<string, unknown> {
|
|
683
695
|
return {
|
|
684
696
|
id: this.id(),
|
|
685
697
|
url: this.url(true),
|
|
698
|
+
query: this.parsedUrl().search?.slice(1) ?? '',
|
|
699
|
+
body: this.body(),
|
|
700
|
+
params: this.params(),
|
|
701
|
+
headers: this.headers(),
|
|
686
702
|
method: this.method(),
|
|
687
703
|
protocol: this.protocol(),
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
704
|
+
cookies: this.cookies(),
|
|
705
|
+
hostname: this.hostname(),
|
|
706
|
+
ip: this.ip(),
|
|
707
|
+
subdomains: this.subdomains(),
|
|
692
708
|
}
|
|
693
709
|
}
|
|
694
710
|
|
|
@@ -697,6 +713,28 @@ export class Request extends Macroable {
|
|
|
697
713
|
return this.serialize()
|
|
698
714
|
}
|
|
699
715
|
|
|
716
|
+
/**
|
|
717
|
+
* A view fit for a log line or an error report.
|
|
718
|
+
*
|
|
719
|
+
* Not upstream's — it has no such method, and its `serialize()` is a full
|
|
720
|
+
* dump. This one carries no body and no cookies, and the headers that hold a
|
|
721
|
+
* credential come back as `[redacted]` rather than as their value: the header
|
|
722
|
+
* is still LISTED, because knowing a request arrived with an `authorization`
|
|
723
|
+
* header is most of what a reader wants, and its contents are none of it.
|
|
724
|
+
*/
|
|
725
|
+
serializeSafe(): Record<string, unknown> {
|
|
726
|
+
return {
|
|
727
|
+
id: this.id(),
|
|
728
|
+
url: this.url(true),
|
|
729
|
+
method: this.method(),
|
|
730
|
+
protocol: this.protocol(),
|
|
731
|
+
host: this.host(),
|
|
732
|
+
headers: redactSensitiveHeaders(this.headers()),
|
|
733
|
+
qs: this.qs(),
|
|
734
|
+
params: this.params(),
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
700
738
|
// ─── Query string ─────────────────────────────────────────
|
|
701
739
|
|
|
702
740
|
/** Get parsed query string as an object. */
|
|
@@ -999,6 +1037,33 @@ function isPlainObject(value: unknown): value is Dict<unknown> {
|
|
|
999
1037
|
}
|
|
1000
1038
|
|
|
1001
1039
|
/** True for an IPv4 dotted-quad or a (bracketed) IPv6 literal — hosts with no subdomains. */
|
|
1040
|
+
/**
|
|
1041
|
+
* Headers whose VALUE is a credential. Listing them is useful; printing them
|
|
1042
|
+
* hands whoever reads the log a working key.
|
|
1043
|
+
*/
|
|
1044
|
+
const SENSITIVE_HEADERS = new Set([
|
|
1045
|
+
'authorization',
|
|
1046
|
+
'proxy-authorization',
|
|
1047
|
+
'cookie',
|
|
1048
|
+
'set-cookie',
|
|
1049
|
+
'x-api-key',
|
|
1050
|
+
'x-auth-token',
|
|
1051
|
+
'x-access-token',
|
|
1052
|
+
'x-csrf-token',
|
|
1053
|
+
'x-xsrf-token',
|
|
1054
|
+
'x-session-token',
|
|
1055
|
+
'x-amz-security-token',
|
|
1056
|
+
])
|
|
1057
|
+
|
|
1058
|
+
/** Replace each credential-bearing header's value with a marker, keeping the name. */
|
|
1059
|
+
function redactSensitiveHeaders(headers: Dict): Dict {
|
|
1060
|
+
const out: Dict = {}
|
|
1061
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
1062
|
+
out[name] = SENSITIVE_HEADERS.has(name.toLowerCase()) ? '[redacted]' : value
|
|
1063
|
+
}
|
|
1064
|
+
return out
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1002
1067
|
function isIpLiteral(host: string): boolean {
|
|
1003
1068
|
if (host.startsWith('[') || host.includes('::')) return true
|
|
1004
1069
|
return /^(\d{1,3}\.){3}\d{1,3}$/.test(host)
|
package/src/http/Response.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { readFile, stat } from 'node:fs/promises'
|
|
|
13
13
|
import { basename } from 'node:path'
|
|
14
14
|
import etag from 'etag'
|
|
15
15
|
import { contentType } from 'mime-types'
|
|
16
|
+
import { ReamError } from '../errors/ReamError.js'
|
|
16
17
|
import { durationToSeconds } from '../helpers/duration.js'
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -60,8 +61,6 @@ function assertNoCRLF(name: string, value: string): void {
|
|
|
60
61
|
*/
|
|
61
62
|
function contentDisposition(disposition: string, filename: string): string {
|
|
62
63
|
const quoted = filename.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
63
|
-
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching control
|
|
64
|
-
// characters is the point — they cannot travel in a header field.
|
|
65
64
|
const ascii = quoted.replace(/[^\x20-\x7e]/g, '_')
|
|
66
65
|
const base = `${disposition}; filename="${ascii}"`
|
|
67
66
|
if (ascii === quoted) return base
|
|
@@ -285,7 +284,7 @@ export class Response extends Macroable {
|
|
|
285
284
|
/** Send a JSON response. Sets content-type and stringifies. */
|
|
286
285
|
json(data: unknown): void {
|
|
287
286
|
this.#headers['content-type'] = 'application/json'
|
|
288
|
-
this.#
|
|
287
|
+
this.#setBody(safeStringify(data))
|
|
289
288
|
this.#finished = true
|
|
290
289
|
}
|
|
291
290
|
|
|
@@ -315,7 +314,7 @@ export class Response extends Macroable {
|
|
|
315
314
|
c === '\u2028' ? '\\u2028' : '\\u2029',
|
|
316
315
|
)
|
|
317
316
|
this.#headers['content-type'] = 'text/javascript; charset=utf-8'
|
|
318
|
-
this.#
|
|
317
|
+
this.#setBody(`/**/ typeof ${safeCallback} === 'function' && ${safeCallback}(${json});`)
|
|
319
318
|
this.#finished = true
|
|
320
319
|
}
|
|
321
320
|
|
|
@@ -330,7 +329,7 @@ export class Response extends Macroable {
|
|
|
330
329
|
? 'text/html; charset=utf-8'
|
|
331
330
|
: 'text/plain; charset=utf-8'
|
|
332
331
|
}
|
|
333
|
-
this.#
|
|
332
|
+
this.#setBody(data)
|
|
334
333
|
} else if (Buffer.isBuffer(data)) {
|
|
335
334
|
// Without this branch a Buffer would hit the `typeof === 'object'`
|
|
336
335
|
// path and be JSON-stringified into `{"type":"Buffer","data":[...]}`
|
|
@@ -339,12 +338,12 @@ export class Response extends Macroable {
|
|
|
339
338
|
return
|
|
340
339
|
} else if (typeof data === 'object' && data !== null) {
|
|
341
340
|
this.#headers['content-type'] = 'application/json'
|
|
342
|
-
this.#
|
|
341
|
+
this.#setBody(safeStringify(data))
|
|
343
342
|
} else if (data !== undefined && data !== null) {
|
|
344
343
|
if (!this.#headers['content-type']) {
|
|
345
344
|
this.#headers['content-type'] = 'text/plain'
|
|
346
345
|
}
|
|
347
|
-
this.#
|
|
346
|
+
this.#setBody(String(data))
|
|
348
347
|
}
|
|
349
348
|
this.#finished = true
|
|
350
349
|
}
|
|
@@ -383,12 +382,37 @@ export class Response extends Macroable {
|
|
|
383
382
|
* `@c9up/archive` instead and let the client fetch it from storage — the
|
|
384
383
|
* bytes then never pass through the server at all.
|
|
385
384
|
*/
|
|
385
|
+
/**
|
|
386
|
+
* Assign the body, refusing one too large to hold.
|
|
387
|
+
*
|
|
388
|
+
* Every textual path goes through here — `json()`, `send()`, `jsonp()`, a
|
|
389
|
+
* middleware rewriting the body. The ceiling used to live only in
|
|
390
|
+
* `sendBuffer()`, so `json(hugeObject)` and `send(hugeString)` walked past
|
|
391
|
+
* it and the process grew until it died, which is the failure the ceiling
|
|
392
|
+
* exists to name.
|
|
393
|
+
*
|
|
394
|
+
* Measured in UTF-8 BYTES, not characters: a string of accented or CJK text
|
|
395
|
+
* is one and a half to three times its length on the wire.
|
|
396
|
+
*/
|
|
397
|
+
#setBody(body: string): void {
|
|
398
|
+
this.#assertBodyFits(Buffer.byteLength(body, 'utf8'))
|
|
399
|
+
this.#body = body
|
|
400
|
+
}
|
|
401
|
+
|
|
386
402
|
#assertBodyFits(bytes: number): void {
|
|
387
403
|
if (bytes <= this.#maxBodyBytes) return
|
|
388
|
-
throw new
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
404
|
+
throw new ReamError(
|
|
405
|
+
'E_RESPONSE_TOO_LARGE',
|
|
406
|
+
`Response body is ${Math.round(bytes / 1_048_576)}MB, over the ${Math.round(this.#maxBodyBytes / 1_048_576)}MB ceiling.`,
|
|
407
|
+
{
|
|
408
|
+
context: {
|
|
409
|
+
bytes: String(bytes),
|
|
410
|
+
ceiling: String(this.#maxBodyBytes),
|
|
411
|
+
},
|
|
412
|
+
hint:
|
|
413
|
+
'The body is held whole in memory (and base64-encoded, ~2.3x) because responses are not streamed yet. ' +
|
|
414
|
+
'Raise `maxResponseBytes` in the kernel config, or serve large files with a signed URL from @c9up/archive.',
|
|
415
|
+
},
|
|
392
416
|
)
|
|
393
417
|
}
|
|
394
418
|
|
|
@@ -665,7 +689,11 @@ export class Response extends Macroable {
|
|
|
665
689
|
*/
|
|
666
690
|
redirect(): RedirectBuilder
|
|
667
691
|
redirect(path: string, forwardQueryString?: boolean, statusCode?: number): void
|
|
668
|
-
redirect(
|
|
692
|
+
redirect(
|
|
693
|
+
path?: string,
|
|
694
|
+
forwardQueryString = false,
|
|
695
|
+
statusCode = 302,
|
|
696
|
+
): RedirectBuilder | undefined {
|
|
669
697
|
if (!this.#redirectBuilderFactory) {
|
|
670
698
|
throw new Error(
|
|
671
699
|
'redirect() requires an HttpContext. Response was created outside a request handler.',
|
|
@@ -1063,14 +1091,25 @@ export class Response extends Macroable {
|
|
|
1063
1091
|
// ─── Cookies ──────────────────────────────────────────────
|
|
1064
1092
|
|
|
1065
1093
|
/**
|
|
1066
|
-
* Set a SIGNED response cookie (
|
|
1067
|
-
*
|
|
1068
|
-
*
|
|
1069
|
-
*
|
|
1094
|
+
* Set a SIGNED response cookie (the default). The value is HMAC-signed with
|
|
1095
|
+
* the app's `APP_KEY`, so tampering is detected on read; `request.cookie()`
|
|
1096
|
+
* verifies and unwraps it.
|
|
1097
|
+
*
|
|
1098
|
+
* Requires APP_KEY, and REFUSES rather than falling back to a plain cookie.
|
|
1099
|
+
* A silent fallback is the worst of both: the caller asked for integrity,
|
|
1100
|
+
* the value ships without it, and `request.cookie()` on the far side hands
|
|
1101
|
+
* back whatever the client wrote as though it had been verified.
|
|
1102
|
+
*
|
|
1103
|
+
* The cookie's NAME is signed along with its value, so a signed cookie
|
|
1104
|
+
* cannot be lifted onto another name and still verify.
|
|
1070
1105
|
*/
|
|
1071
1106
|
cookie(name: string, value: string, options?: CookieOptions): this {
|
|
1072
|
-
|
|
1073
|
-
|
|
1107
|
+
if (!this.#cookieSigner) {
|
|
1108
|
+
throw new Error(
|
|
1109
|
+
'cookie() signs the value and needs APP_KEY — set it so the encryption service is registered, or use plainCookie() when the value does not need signing.',
|
|
1110
|
+
)
|
|
1111
|
+
}
|
|
1112
|
+
return this.#writeCookie(name, this.#cookieSigner.sign(value, undefined, name), options)
|
|
1074
1113
|
}
|
|
1075
1114
|
|
|
1076
1115
|
/**
|
|
@@ -1262,7 +1301,7 @@ export class Response extends Macroable {
|
|
|
1262
1301
|
|
|
1263
1302
|
/** @internal Set body directly (used by redirect, exception handler). */
|
|
1264
1303
|
setBody(body: string): void {
|
|
1265
|
-
this.#body
|
|
1304
|
+
this.#setBody(body)
|
|
1266
1305
|
this.#finished = true
|
|
1267
1306
|
}
|
|
1268
1307
|
|
package/src/index.ts
CHANGED
|
@@ -347,12 +347,16 @@ export {
|
|
|
347
347
|
export type {
|
|
348
348
|
ErrorReporter,
|
|
349
349
|
LockBackend,
|
|
350
|
+
LockBackendFactory,
|
|
351
|
+
LockRedisClient,
|
|
352
|
+
LockRedisResolver,
|
|
350
353
|
RunTaskOutcome,
|
|
351
354
|
ScheduleEvent,
|
|
352
355
|
ScheduleEventSink,
|
|
353
356
|
ScheduleInvocation,
|
|
354
357
|
ScheduleMetadata,
|
|
355
358
|
ScheduleProviderOptions,
|
|
359
|
+
SchedulerConfig,
|
|
356
360
|
SchedulerOptions,
|
|
357
361
|
ScheduleTaskCompletedEvent,
|
|
358
362
|
ScheduleTaskFailedEvent,
|
|
@@ -362,8 +366,11 @@ export type {
|
|
|
362
366
|
TaskStats,
|
|
363
367
|
} from './scheduler/index.js'
|
|
364
368
|
export {
|
|
369
|
+
defineSchedulerConfig,
|
|
365
370
|
getScheduleMetadata,
|
|
371
|
+
locks,
|
|
366
372
|
MemoryLockBackend,
|
|
373
|
+
RedisLockBackend,
|
|
367
374
|
SCHEDULE_METADATA_KEY,
|
|
368
375
|
Schedule,
|
|
369
376
|
ScheduleProvider,
|
|
@@ -371,4 +378,4 @@ export {
|
|
|
371
378
|
StatsTracker,
|
|
372
379
|
} from './scheduler/index.js'
|
|
373
380
|
|
|
374
|
-
export const VERSION = '0.2.
|
|
381
|
+
export const VERSION = '0.2.11'
|
package/src/router/Router.ts
CHANGED
|
@@ -1602,12 +1602,21 @@ function matchPath(pattern: string, actual: string): MatchedParams | null {
|
|
|
1602
1602
|
return params
|
|
1603
1603
|
}
|
|
1604
1604
|
|
|
1605
|
-
/**
|
|
1605
|
+
/**
|
|
1606
|
+
* Match a domain pattern against the request's host. Supports wildcards like
|
|
1607
|
+
* `*.example.com`.
|
|
1608
|
+
*
|
|
1609
|
+
* Both sides are lowercased: a hostname is case-insensitive (RFC 4343), so a
|
|
1610
|
+
* client that sends `API.Example.COM` — which is legal, and what some proxies
|
|
1611
|
+
* and older clients do — must reach the same route as `api.example.com`.
|
|
1612
|
+
* Comparing them verbatim sends that request to a fallback, or to a 404.
|
|
1613
|
+
*/
|
|
1606
1614
|
function matchDomain(pattern: string, host: string): boolean {
|
|
1607
|
-
const actualHost = host.split(':')[0] // strip port
|
|
1608
|
-
|
|
1609
|
-
if (
|
|
1610
|
-
|
|
1615
|
+
const actualHost = host.split(':')[0].toLowerCase() // strip port
|
|
1616
|
+
const wanted = pattern.toLowerCase()
|
|
1617
|
+
if (wanted === actualHost) return true
|
|
1618
|
+
if (wanted.startsWith('*.')) {
|
|
1619
|
+
const suffix = wanted.slice(1) // ".example.com"
|
|
1611
1620
|
return actualHost.endsWith(suffix) && actualHost.length > suffix.length
|
|
1612
1621
|
}
|
|
1613
1622
|
return false
|
package/src/rpc/RpcRouter.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
parseRequest as parseRpcRequest,
|
|
19
19
|
buildError as rpcError,
|
|
20
20
|
} from '@c9up/comet'
|
|
21
|
+
import { currentNodeEnv } from '../env/nodeEnv.js'
|
|
21
22
|
import type { HttpContext } from '../http/HttpContext.js'
|
|
22
23
|
import type { MiddlewareRegistry, RuntimeValidator } from '../middleware/Pipeline.js'
|
|
23
24
|
import { composeMiddleware, runValidator } from '../middleware/Pipeline.js'
|
|
@@ -316,7 +317,7 @@ export class RpcRouter {
|
|
|
316
317
|
return rpcError(err.code, message, id, err.data)
|
|
317
318
|
}
|
|
318
319
|
// Otherwise don't leak internal error details to the caller.
|
|
319
|
-
const isDev =
|
|
320
|
+
const isDev = currentNodeEnv() !== 'production'
|
|
320
321
|
const message = isDev && err instanceof Error ? err.message : 'Internal error'
|
|
321
322
|
return rpcError(-32603, message, id)
|
|
322
323
|
}
|