velocious 1.0.468 → 1.0.470
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 +6 -4
- package/build/configuration-types.js +9 -1
- package/build/configuration.js +6 -1
- package/build/environment-handlers/node/cli/commands/generate/frontend-models.js +139 -4
- package/build/error-reporting/request-details.js +308 -0
- package/build/frontend-model-controller.js +3 -1
- package/build/src/configuration-types.d.ts +23 -1
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +9 -2
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +7 -2
- package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts +43 -0
- package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts.map +1 -1
- package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.js +127 -5
- package/build/src/error-reporting/request-details.d.ts +7 -0
- package/build/src/error-reporting/request-details.d.ts.map +1 -0
- package/build/src/error-reporting/request-details.js +278 -0
- package/build/src/frontend-model-controller.d.ts.map +1 -1
- package/build/src/frontend-model-controller.js +4 -2
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/configuration-types.js +9 -1
- package/src/configuration.js +6 -1
- package/src/environment-handlers/node/cli/commands/generate/frontend-models.js +139 -4
- package/src/error-reporting/request-details.js +308 -0
- package/src/frontend-model-controller.js +3 -1
package/README.md
CHANGED
|
@@ -378,7 +378,7 @@ export default new Configuration({
|
|
|
378
378
|
`frontendModels` entries must be `FrontendModelBaseResource` subclasses. Built-in CRUD/find/index/serialize behavior lives in the base class, and app resources override only the pieces they actually need.
|
|
379
379
|
Resource-level index customization should prefer `indexQuery()` or the pagination/search/sort hooks over replacing `records()`, so built-in pluck and aggregate count support can keep using the same query. See [`docs/frontend-model-resources.md`](docs/frontend-model-resources.md) for the resource extension points.
|
|
380
380
|
|
|
381
|
-
Custom class- and instance-level commands are declared via `collectionCommands` / `memberCommands`. Each entry is a plain camelCase method name, or a `{name, args?, returnType?}` object that types the command's arguments and response — e.g. `memberCommands: ["suspend", {name: "refresh", args: [{name: "age", type: "number"}], returnType: "string"}]`. See [`docs/frontend-model-resources.md#custom-commands`](docs/frontend-model-resources.md#custom-commands).
|
|
381
|
+
Custom class- and instance-level commands are declared via `collectionCommands` / `memberCommands`. Each entry is a plain camelCase method name, or a `{name, args?, returnType?}` object that types the command's arguments and response — e.g. `memberCommands: ["suspend", {name: "refresh", args: [{name: "age", type: "number"}], returnType: "string"}]`. A command whose args are a single object literal with only optional fields generates an omittable parameter (`record.command()` works without passing `{}`); any required field keeps the argument mandatory. See [`docs/frontend-model-resources.md#custom-commands`](docs/frontend-model-resources.md#custom-commands).
|
|
382
382
|
|
|
383
383
|
Resources expose the full CRUD ability set (`create`, `destroy`, `read`, `update`) by default. To restrict the API surface — for example to a read-only resource — declare an explicit subset:
|
|
384
384
|
|
|
@@ -591,16 +591,18 @@ const configuration = new Configuration({
|
|
|
591
591
|
|
|
592
592
|
This opt-in is ignored in `production`; production frontend-model responses never include internal exception details.
|
|
593
593
|
|
|
594
|
-
Backends can append client-safe metadata to frontend-model error responses with `configuration.addClientErrorPayloadReporter(...)`. Reporters receive the caught `error`, the current `request`, and a small `context` object, and should only return fields that are safe for clients to see. Frontend-model endpoint failures include `context.frontendModelEndpoint`, `action`, `commandType`, `model`, `requestId`, and `expectedError`. This is useful for attaching an error-reporting URL while keeping the normal production error message generic:
|
|
594
|
+
Backends can append client-safe metadata to frontend-model error responses with `configuration.addClientErrorPayloadReporter(...)`. Reporters receive the caught `error`, the current `request`, a safe `requestDetails` snapshot, and a small `context` object, and should only return fields that are safe for clients to see. Frontend-model endpoint failures include `context.frontendModelEndpoint`, `action`, `commandType`, `model`, `requestId`, and `expectedError`. This is useful for attaching an error-reporting URL while keeping the normal production error message generic:
|
|
595
595
|
|
|
596
596
|
```js
|
|
597
|
-
configuration.addClientErrorPayloadReporter(async ({error,
|
|
598
|
-
const report = await reportErrorToService({error,
|
|
597
|
+
configuration.addClientErrorPayloadReporter(async ({error, requestDetails, context}) => {
|
|
598
|
+
const report = await reportErrorToService({error, requestDetails, context})
|
|
599
599
|
|
|
600
600
|
return {bugReportUrl: report.url}
|
|
601
601
|
})
|
|
602
602
|
```
|
|
603
603
|
|
|
604
|
+
`requestDetails` includes `httpMethod`, `path`, and a parsed `body` snapshot when available. The body snapshot redacts common secret keys, truncates large strings and arrays, summarizes uploaded files and buffers without bytes, and compacts oversized frontend-model batches while preserving `requestId`, `model`, `commandType` / `customPath`, and payload shape.
|
|
605
|
+
|
|
604
606
|
For sqlite web databases, Velocious defaults to `https://sql.js.org/dist/<file>` for `sql.js` wasm loading. You can override wasm resolution per database config with `locateFile`:
|
|
605
607
|
|
|
606
608
|
```js
|
|
@@ -212,6 +212,13 @@
|
|
|
212
212
|
* @typedef {Record<string, import("./frontend-models/query.js").FrontendModelTransportValue>} ClientErrorPayloadReporterPayload
|
|
213
213
|
*/
|
|
214
214
|
|
|
215
|
+
/**
|
|
216
|
+
* @typedef {object} ErrorRequestDetails
|
|
217
|
+
* @property {?} [body] - Sanitized parsed request body, when available.
|
|
218
|
+
* @property {string} httpMethod - Request HTTP method.
|
|
219
|
+
* @property {string} path - Request path.
|
|
220
|
+
*/
|
|
221
|
+
|
|
215
222
|
/**
|
|
216
223
|
* @typedef {object} ClientErrorPayloadContext
|
|
217
224
|
* @property {string} controller - Controller class name.
|
|
@@ -227,7 +234,8 @@
|
|
|
227
234
|
* @typedef {function({
|
|
228
235
|
* context: ClientErrorPayloadContext,
|
|
229
236
|
* error: Error,
|
|
230
|
-
* request: import("./http-server/client/request.js").default | import("./http-server/client/websocket-request.js").default | undefined
|
|
237
|
+
* request: import("./http-server/client/request.js").default | import("./http-server/client/websocket-request.js").default | undefined,
|
|
238
|
+
* requestDetails: ErrorRequestDetails | null
|
|
231
239
|
* }): Promise<ClientErrorPayloadReporterPayload | void> | ClientErrorPayloadReporterPayload | void} ClientErrorPayloadReporterType
|
|
232
240
|
*/
|
|
233
241
|
|
package/build/configuration.js
CHANGED
|
@@ -18,6 +18,7 @@ import Ability from "./authorization/ability.js"
|
|
|
18
18
|
import EventEmitter from "./utils/event-emitter.js"
|
|
19
19
|
import VelociousWebsocketChannelSubscribers from "./http-server/websocket-channel-subscribers.js"
|
|
20
20
|
import {CurrentConfigurationNotSetError, currentConfiguration, setCurrentConfiguration} from "./current-configuration.js"
|
|
21
|
+
import {requestDetails} from "./error-reporting/request-details.js"
|
|
21
22
|
import {frontendModelResourceClassFromDefinition, frontendModelResourceConfigurationFromDefinition, frontendModelResourcesForBackendProject} from "./frontend-models/resource-definition.js"
|
|
22
23
|
import PluginRoutes from "./routes/plugin-routes.js"
|
|
23
24
|
import restArgsError from "./utils/rest-args-error.js"
|
|
@@ -2411,9 +2412,13 @@ export default class VelociousConfiguration {
|
|
|
2411
2412
|
async clientErrorPayloadForError(args) {
|
|
2412
2413
|
/** @type {import("./configuration-types.js").ClientErrorPayloadReporterPayload} */
|
|
2413
2414
|
const payload = {}
|
|
2415
|
+
const details = requestDetails(args.request)
|
|
2414
2416
|
|
|
2415
2417
|
for (const reporter of this._clientErrorPayloadReporters) {
|
|
2416
|
-
const reporterPayload = await reporter(
|
|
2418
|
+
const reporterPayload = await reporter({
|
|
2419
|
+
...args,
|
|
2420
|
+
requestDetails: details
|
|
2421
|
+
})
|
|
2417
2422
|
|
|
2418
2423
|
if (reporterPayload && typeof reporterPayload === "object") {
|
|
2419
2424
|
Object.assign(payload, reporterPayload)
|
|
@@ -1474,10 +1474,15 @@ export default class DbGenerateFrontendModels extends BaseCommand {
|
|
|
1474
1474
|
|
|
1475
1475
|
if (metadata.args.length > 0) {
|
|
1476
1476
|
const parameterNames = metadata.args.map((arg) => arg.name)
|
|
1477
|
+
// A single args object whose every field is optional accepts `{}`, so default
|
|
1478
|
+
// the parameter and mark it optional — callers can then omit it entirely
|
|
1479
|
+
// (`record.command()` instead of `record.command({})`). Required-field args keep
|
|
1480
|
+
// the mandatory parameter (a `{}` default wouldn't satisfy their type).
|
|
1481
|
+
const defaultsToEmptyObject = metadata.args.length === 1 && this.argTypeAcceptsEmptyObject(metadata.args[0].type)
|
|
1477
1482
|
|
|
1478
1483
|
return {
|
|
1479
|
-
paramDocs: metadata.args.map((arg) => ` * @param {${arg.type}} ${arg.name} - Command argument.\n`).join(""),
|
|
1480
|
-
parameters: parameterNames.join(", "),
|
|
1484
|
+
paramDocs: metadata.args.map((arg) => ` * @param {${arg.type}} ${defaultsToEmptyObject ? `[${arg.name}]` : arg.name} - Command argument.\n`).join(""),
|
|
1485
|
+
parameters: defaultsToEmptyObject ? `${parameterNames[0]} = {}` : parameterNames.join(", "),
|
|
1481
1486
|
payloadArguments: `[${parameterNames.join(", ")}]`,
|
|
1482
1487
|
returnType
|
|
1483
1488
|
}
|
|
@@ -1491,6 +1496,124 @@ export default class DbGenerateFrontendModels extends BaseCommand {
|
|
|
1491
1496
|
}
|
|
1492
1497
|
}
|
|
1493
1498
|
|
|
1499
|
+
/**
|
|
1500
|
+
* Whether a single command-args JSDoc type is known to accept an empty object `{}`:
|
|
1501
|
+
* a single balanced object literal whose top-level members are all optional (`name?:`)
|
|
1502
|
+
* or index signatures (`[k: ...]:`). Anything else returns false so the parameter stays
|
|
1503
|
+
* required — including a required member, a non-object-literal (a positional `number`,
|
|
1504
|
+
* a `Record<...>` / `Partial<...>` whose key/wrapper may still require data), and any
|
|
1505
|
+
* intersection/union (e.g. `{a?: x} & {b: string}`), where `{}` is not assignable.
|
|
1506
|
+
* @param {string} type - The arg's JSDoc type string.
|
|
1507
|
+
* @returns {boolean} - Whether the generated parameter can default to `{}`.
|
|
1508
|
+
*/
|
|
1509
|
+
argTypeAcceptsEmptyObject(type) {
|
|
1510
|
+
const trimmedType = type.trim()
|
|
1511
|
+
|
|
1512
|
+
// Must be a single balanced object literal: starts with `{`, ends with `}`, and the
|
|
1513
|
+
// opening brace closes only at the final character. This rejects intersections/unions
|
|
1514
|
+
// like `{a?: x} & {b: string}` that merely happen to start `{` and end `}`.
|
|
1515
|
+
if (!(trimmedType.startsWith("{") && trimmedType.endsWith("}"))) return false
|
|
1516
|
+
if (!this.isSingleBalancedObjectLiteral(trimmedType)) return false
|
|
1517
|
+
|
|
1518
|
+
const inner = trimmedType.slice(1, -1)
|
|
1519
|
+
|
|
1520
|
+
for (const member of this.splitTopLevelTypeMembers(inner)) {
|
|
1521
|
+
const colonIndex = this.topLevelColonIndex(member)
|
|
1522
|
+
|
|
1523
|
+
// No top-level colon: a call/construct/mapped signature or malformed member —
|
|
1524
|
+
// can't confirm it's optional, so treat the type as not empty-defaultable.
|
|
1525
|
+
if (colonIndex < 0) return false
|
|
1526
|
+
|
|
1527
|
+
const key = member.slice(0, colonIndex).trim()
|
|
1528
|
+
|
|
1529
|
+
// Index signatures (`[k: string]`) don't require a value; optional props end in `?`.
|
|
1530
|
+
// Anything else is a required property, so `{}` would not satisfy the type.
|
|
1531
|
+
if (!key.startsWith("[") && !key.endsWith("?")) return false
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
return true
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
/**
|
|
1538
|
+
* Splits the inner body of an object-literal type into its top-level members,
|
|
1539
|
+
* respecting nested `{}` / `[]` / `<>` / `()` so field types like `string[] | null`
|
|
1540
|
+
* or `{a: b}` aren't split mid-type. Members are separated by `,` or `;`.
|
|
1541
|
+
* @param {string} inner - Object-literal body (without the outer braces).
|
|
1542
|
+
* @returns {string[]} - Trimmed non-empty top-level members.
|
|
1543
|
+
*/
|
|
1544
|
+
splitTopLevelTypeMembers(inner) {
|
|
1545
|
+
const members = []
|
|
1546
|
+
let depth = 0
|
|
1547
|
+
let start = 0
|
|
1548
|
+
|
|
1549
|
+
for (let index = 0; index < inner.length; index += 1) {
|
|
1550
|
+
const character = inner[index]
|
|
1551
|
+
|
|
1552
|
+
if (character === "{" || character === "[" || character === "<" || character === "(") {
|
|
1553
|
+
depth += 1
|
|
1554
|
+
} else if (character === "}" || character === "]" || character === ">" || character === ")") {
|
|
1555
|
+
depth -= 1
|
|
1556
|
+
} else if ((character === "," || character === ";") && depth === 0) {
|
|
1557
|
+
members.push(inner.slice(start, index))
|
|
1558
|
+
start = index + 1
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
members.push(inner.slice(start))
|
|
1563
|
+
|
|
1564
|
+
return members.map((member) => member.trim()).filter((member) => member.length > 0)
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
/**
|
|
1568
|
+
* Index of the first top-level `:` in an object-literal member, ignoring colons
|
|
1569
|
+
* nested inside `{}` / `[]` / `<>` / `()` (e.g. an index signature `[k: string]`).
|
|
1570
|
+
* @param {string} member - A single object-literal member.
|
|
1571
|
+
* @returns {number} - The colon index, or -1 when none is found at the top level.
|
|
1572
|
+
*/
|
|
1573
|
+
topLevelColonIndex(member) {
|
|
1574
|
+
let depth = 0
|
|
1575
|
+
|
|
1576
|
+
for (let index = 0; index < member.length; index += 1) {
|
|
1577
|
+
const character = member[index]
|
|
1578
|
+
|
|
1579
|
+
if (character === "{" || character === "[" || character === "<" || character === "(") {
|
|
1580
|
+
depth += 1
|
|
1581
|
+
} else if (character === "}" || character === "]" || character === ">" || character === ")") {
|
|
1582
|
+
depth -= 1
|
|
1583
|
+
} else if (character === ":" && depth === 0) {
|
|
1584
|
+
return index
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
return -1
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
/**
|
|
1592
|
+
* Whether the type is a single balanced object literal — its leading `{` closes only
|
|
1593
|
+
* at the final character. Rejects top-level intersections/unions like `{a?: x} & {b: y}`
|
|
1594
|
+
* or `{a?: x} | string` whose brace depth returns to 0 before the end.
|
|
1595
|
+
* @param {string} type - A trimmed type string that starts with `{` and ends with `}`.
|
|
1596
|
+
* @returns {boolean} - Whether the braces wrap the whole type.
|
|
1597
|
+
*/
|
|
1598
|
+
isSingleBalancedObjectLiteral(type) {
|
|
1599
|
+
let depth = 0
|
|
1600
|
+
|
|
1601
|
+
for (let index = 0; index < type.length; index += 1) {
|
|
1602
|
+
const character = type[index]
|
|
1603
|
+
|
|
1604
|
+
if (character === "{" || character === "[" || character === "<" || character === "(") {
|
|
1605
|
+
depth += 1
|
|
1606
|
+
} else if (character === "}" || character === "]" || character === ">" || character === ")") {
|
|
1607
|
+
depth -= 1
|
|
1608
|
+
|
|
1609
|
+
// The opening brace balanced before the end, so something follows the literal.
|
|
1610
|
+
if (depth === 0 && index < type.length - 1) return false
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
return depth === 0
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1494
1617
|
/**
|
|
1495
1618
|
* Enriches custom-command metadata by deriving a command's typed args and return
|
|
1496
1619
|
* type from the backend resource method's `@param`/`@returns` JSDoc when they are
|
|
@@ -1852,7 +1975,7 @@ export default class DbGenerateFrontendModels extends BaseCommand {
|
|
|
1852
1975
|
throw new Error(`Could not parse JSDoc return type from: ${jsDocText}`)
|
|
1853
1976
|
}
|
|
1854
1977
|
|
|
1855
|
-
const returnType = jsDocText.slice(typeOpenIndex + 1, typeCloseIndex)
|
|
1978
|
+
const returnType = this.normalizeJsDocType(jsDocText.slice(typeOpenIndex + 1, typeCloseIndex))
|
|
1856
1979
|
|
|
1857
1980
|
if (returnType.length < 1) {
|
|
1858
1981
|
throw new Error(`Expected non-empty JSDoc return type in: ${jsDocText}`)
|
|
@@ -1861,6 +1984,18 @@ export default class DbGenerateFrontendModels extends BaseCommand {
|
|
|
1861
1984
|
return returnType
|
|
1862
1985
|
}
|
|
1863
1986
|
|
|
1987
|
+
/**
|
|
1988
|
+
* Collapses a JSDoc type spanning multiple comment lines into a single line so it can
|
|
1989
|
+
* be emitted into an inline type-assertion cast. A multiline backend return type keeps
|
|
1990
|
+
* its leading continuation asterisks in the captured substring, which are invalid inside
|
|
1991
|
+
* an inline cast and make TypeScript read the asserted type as `undefined`.
|
|
1992
|
+
* @param {string} jsDocType - Raw captured JSDoc type, possibly multiline.
|
|
1993
|
+
* @returns {string} - Single-line JSDoc type.
|
|
1994
|
+
*/
|
|
1995
|
+
normalizeJsDocType(jsDocType) {
|
|
1996
|
+
return jsDocType.replace(/\s*\n\s*\*?[ \t]*/g, " ").trim()
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1864
1999
|
/**
|
|
1865
2000
|
* Runs js doc parameters.
|
|
1866
2001
|
* @param {string} jsDocText - JSDoc text inside comment markers.
|
|
@@ -1879,7 +2014,7 @@ export default class DbGenerateFrontendModels extends BaseCommand {
|
|
|
1879
2014
|
throw new Error(`Could not parse JSDoc parameter type from: ${jsDocText}`)
|
|
1880
2015
|
}
|
|
1881
2016
|
|
|
1882
|
-
const type = jsDocText.slice(typeOpenIndex + 1, typeCloseIndex)
|
|
2017
|
+
const type = this.normalizeJsDocType(jsDocText.slice(typeOpenIndex + 1, typeCloseIndex))
|
|
1883
2018
|
|
|
1884
2019
|
if (type.length < 1) {
|
|
1885
2020
|
throw new Error(`Expected non-empty JSDoc parameter type in: ${jsDocText}`)
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import UploadedFile from "../http-server/client/uploaded-file/uploaded-file.js"
|
|
4
|
+
|
|
5
|
+
const REQUEST_DETAILS_BODY_MAX_SERIALIZED_LENGTH = 12000
|
|
6
|
+
const REQUEST_DETAILS_STRING_MAX_LENGTH = 1000
|
|
7
|
+
const REQUEST_DETAILS_ARRAY_MAX_ITEMS = 50
|
|
8
|
+
const REDACTED_REQUEST_DETAILS_VALUE = "[redacted]"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Extracts request metadata without retaining the request object.
|
|
12
|
+
* @param {import("../http-server/client/request.js").default | import("../http-server/client/websocket-request.js").default | undefined} request - Request object, when present.
|
|
13
|
+
* @returns {import("../configuration-types.js").ErrorRequestDetails | null} - Request metadata.
|
|
14
|
+
*/
|
|
15
|
+
export function requestDetails(request) {
|
|
16
|
+
if (!request) return null
|
|
17
|
+
|
|
18
|
+
let details
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
details = {
|
|
22
|
+
httpMethod: request.httpMethod(),
|
|
23
|
+
path: request.path()
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const body = requestBodySnapshot(request)
|
|
30
|
+
|
|
31
|
+
if (body !== undefined) {
|
|
32
|
+
return {...details, body}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return details
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Snapshots parsed request params for error reports.
|
|
40
|
+
* @param {import("../http-server/client/request.js").default | import("../http-server/client/websocket-request.js").default} request - Request object.
|
|
41
|
+
* @returns {? | undefined} - Sanitized body snapshot.
|
|
42
|
+
*/
|
|
43
|
+
function requestBodySnapshot(request) {
|
|
44
|
+
if (typeof request.params !== "function") return undefined
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
return requestDetailsBodySnapshot(request.params())
|
|
48
|
+
} catch (error) {
|
|
49
|
+
return {
|
|
50
|
+
__unavailable: true,
|
|
51
|
+
error: error instanceof Error ? error.message : String(error)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Builds a bounded, redacted body snapshot for error reports.
|
|
58
|
+
* @param {?} body - Parsed request body.
|
|
59
|
+
* @returns {?} - Sanitized body snapshot.
|
|
60
|
+
*/
|
|
61
|
+
function requestDetailsBodySnapshot(body) {
|
|
62
|
+
const originalLength = serializedLength(body)
|
|
63
|
+
const sanitized = sanitizeRequestDetailsValue(body)
|
|
64
|
+
const sanitizedLength = serializedLength(sanitized)
|
|
65
|
+
|
|
66
|
+
if (originalLength <= REQUEST_DETAILS_BODY_MAX_SERIALIZED_LENGTH && sanitizedLength <= REQUEST_DETAILS_BODY_MAX_SERIALIZED_LENGTH) {
|
|
67
|
+
return sanitized
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const compactFrontendModelBody = compactFrontendModelRequestBody(sanitized, originalLength)
|
|
71
|
+
|
|
72
|
+
if (serializedLength(compactFrontendModelBody) <= REQUEST_DETAILS_BODY_MAX_SERIALIZED_LENGTH) {
|
|
73
|
+
return compactFrontendModelBody
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return compactRequestDetailsBody(sanitized, originalLength)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Sanitizes a value recursively for request details.
|
|
81
|
+
* @param {?} value - Value to sanitize.
|
|
82
|
+
* @param {string} [key] - Current object key.
|
|
83
|
+
* @param {WeakSet<object>} [seen] - Seen object references.
|
|
84
|
+
* @returns {?} - Sanitized value.
|
|
85
|
+
*/
|
|
86
|
+
function sanitizeRequestDetailsValue(value, key = "", seen = new WeakSet()) {
|
|
87
|
+
if (sensitiveRequestDetailsKey(key)) return REDACTED_REQUEST_DETAILS_VALUE
|
|
88
|
+
if (primitiveRequestDetailsValue(value)) return value
|
|
89
|
+
if (bufferValue(value)) return bufferRequestDetailsSummary(value)
|
|
90
|
+
if (value instanceof UploadedFile) return uploadedFileRequestDetailsSummary(value)
|
|
91
|
+
if (typeof value === "string") return sanitizeRequestDetailsString(value)
|
|
92
|
+
if (Array.isArray(value)) return sanitizeRequestDetailsArray(value, seen)
|
|
93
|
+
if (typeof value === "object") return sanitizeRequestDetailsObject(value, seen)
|
|
94
|
+
|
|
95
|
+
return String(value)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Checks whether a value can be kept as-is.
|
|
100
|
+
* @param {?} value - Value.
|
|
101
|
+
* @returns {boolean} - Whether the value is primitive and safe.
|
|
102
|
+
*/
|
|
103
|
+
function primitiveRequestDetailsValue(value) {
|
|
104
|
+
return value == null || typeof value === "boolean" || typeof value === "number"
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Sanitizes a string value.
|
|
109
|
+
* @param {string} value - String value.
|
|
110
|
+
* @returns {string} - Sanitized string.
|
|
111
|
+
*/
|
|
112
|
+
function sanitizeRequestDetailsString(value) {
|
|
113
|
+
if (value.length <= REQUEST_DETAILS_STRING_MAX_LENGTH) return value
|
|
114
|
+
|
|
115
|
+
return `${value.slice(0, REQUEST_DETAILS_STRING_MAX_LENGTH)}... [truncated ${value.length - REQUEST_DETAILS_STRING_MAX_LENGTH} chars]`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Sanitizes an array value.
|
|
120
|
+
* @param {Array<?>} value - Array value.
|
|
121
|
+
* @param {WeakSet<object>} seen - Seen object references.
|
|
122
|
+
* @returns {Array<?>} - Sanitized array.
|
|
123
|
+
*/
|
|
124
|
+
function sanitizeRequestDetailsArray(value, seen) {
|
|
125
|
+
const entries = value
|
|
126
|
+
.slice(0, REQUEST_DETAILS_ARRAY_MAX_ITEMS)
|
|
127
|
+
.map((entry) => sanitizeRequestDetailsValue(entry, "", seen))
|
|
128
|
+
|
|
129
|
+
if (value.length > REQUEST_DETAILS_ARRAY_MAX_ITEMS) {
|
|
130
|
+
entries.push({__omittedItems: value.length - REQUEST_DETAILS_ARRAY_MAX_ITEMS})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return entries
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Sanitizes a plain object-like value.
|
|
138
|
+
* @param {object} value - Object value.
|
|
139
|
+
* @param {WeakSet<object>} seen - Seen object references.
|
|
140
|
+
* @returns {Record<string, ?> | string} - Sanitized object or circular marker.
|
|
141
|
+
*/
|
|
142
|
+
function sanitizeRequestDetailsObject(value, seen) {
|
|
143
|
+
if (seen.has(value)) return "[circular]"
|
|
144
|
+
|
|
145
|
+
seen.add(value)
|
|
146
|
+
|
|
147
|
+
/** @type {Record<string, ?>} */
|
|
148
|
+
const sanitized = {}
|
|
149
|
+
|
|
150
|
+
for (const [entryKey, entryValue] of Object.entries(value)) {
|
|
151
|
+
sanitized[entryKey] = sanitizeRequestDetailsValue(entryValue, entryKey, seen)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
seen.delete(value)
|
|
155
|
+
|
|
156
|
+
return sanitized
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Checks whether a value is a Node.js Buffer.
|
|
161
|
+
* @param {?} value - Value.
|
|
162
|
+
* @returns {value is Buffer} - Whether value is a Buffer.
|
|
163
|
+
*/
|
|
164
|
+
function bufferValue(value) {
|
|
165
|
+
return typeof Buffer !== "undefined" && typeof Buffer.isBuffer === "function" && Buffer.isBuffer(value)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Summarizes raw binary data without serializing bytes.
|
|
170
|
+
* @param {Buffer} buffer - Uploaded or request buffer.
|
|
171
|
+
* @returns {Record<string, ?>} - Request-safe buffer summary.
|
|
172
|
+
*/
|
|
173
|
+
function bufferRequestDetailsSummary(buffer) {
|
|
174
|
+
return {
|
|
175
|
+
__redacted: true,
|
|
176
|
+
__type: "Buffer",
|
|
177
|
+
byteLength: buffer.byteLength
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Summarizes uploaded files without serializing file bytes or temp paths.
|
|
183
|
+
* @param {UploadedFile} uploadedFile - Uploaded file object.
|
|
184
|
+
* @returns {Record<string, ?>} - Request-safe upload summary.
|
|
185
|
+
*/
|
|
186
|
+
function uploadedFileRequestDetailsSummary(uploadedFile) {
|
|
187
|
+
return {
|
|
188
|
+
__redacted: true,
|
|
189
|
+
__type: "UploadedFile",
|
|
190
|
+
contentType: uploadedFile.contentType() ?? null,
|
|
191
|
+
fieldName: uploadedFile.fieldName(),
|
|
192
|
+
filename: uploadedFile.filename(),
|
|
193
|
+
size: uploadedFile.size()
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Checks whether a key should be redacted from request details.
|
|
199
|
+
* @param {string} key - Object key.
|
|
200
|
+
* @returns {boolean} - Whether the value is sensitive.
|
|
201
|
+
*/
|
|
202
|
+
function sensitiveRequestDetailsKey(key) {
|
|
203
|
+
return /authorization|contentBase64|password|secret|sessionToken|token/i.test(key)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Compacts a frontend-model request envelope while preserving debugging shape.
|
|
208
|
+
* @param {?} value - Sanitized body value.
|
|
209
|
+
* @param {number} originalSerializedLength - Byte count before redaction or compaction.
|
|
210
|
+
* @returns {?} - Compacted body value.
|
|
211
|
+
*/
|
|
212
|
+
function compactFrontendModelRequestBody(value, originalSerializedLength) {
|
|
213
|
+
if (!value || typeof value !== "object" || !Array.isArray(/** @type {Record<string, ?>} */ (value).requests)) {
|
|
214
|
+
return compactRequestDetailsBody(value, originalSerializedLength)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
__truncated: true,
|
|
219
|
+
originalSerializedLength,
|
|
220
|
+
requests: /** @type {Array<?>} */ (/** @type {Record<string, ?>} */ (value).requests).map((request) => {
|
|
221
|
+
return compactFrontendModelRequest(request)
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Compacts one frontend-model request entry.
|
|
228
|
+
* @param {?} value - Candidate request entry from the transport envelope.
|
|
229
|
+
* @returns {?} - Compacted request.
|
|
230
|
+
*/
|
|
231
|
+
function compactFrontendModelRequest(value) {
|
|
232
|
+
if (!value || typeof value !== "object") return value
|
|
233
|
+
|
|
234
|
+
const request = /** @type {Record<string, ?>} */ (value)
|
|
235
|
+
/** @type {Record<string, ?>} */
|
|
236
|
+
const compact = {}
|
|
237
|
+
|
|
238
|
+
for (const key of ["requestId", "model", "commandType", "customPath"]) {
|
|
239
|
+
if (request[key] !== undefined) compact[key] = request[key]
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (request.payload !== undefined) {
|
|
243
|
+
compact.payload = compactFrontendModelPayload(request.payload)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return compact
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Compacts a frontend-model payload.
|
|
251
|
+
* @param {?} value - Candidate payload from a request entry.
|
|
252
|
+
* @returns {?} - Compacted payload.
|
|
253
|
+
*/
|
|
254
|
+
function compactFrontendModelPayload(value) {
|
|
255
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return value
|
|
256
|
+
|
|
257
|
+
const payload = /** @type {Record<string, ?>} */ (value)
|
|
258
|
+
/** @type {Record<string, ?>} */
|
|
259
|
+
const compact = {}
|
|
260
|
+
|
|
261
|
+
for (const [key, entryValue] of Object.entries(payload)) {
|
|
262
|
+
compact[key] = compactFrontendModelPayloadEntry({key, value: entryValue})
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return compact
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Compacts one frontend-model payload entry.
|
|
270
|
+
* @param {{key: string, value: ?}} args - Payload entry.
|
|
271
|
+
* @returns {?} - Compacted value.
|
|
272
|
+
*/
|
|
273
|
+
function compactFrontendModelPayloadEntry({key, value}) {
|
|
274
|
+
if (key !== "attributes" || !value || typeof value !== "object" || Array.isArray(value)) {
|
|
275
|
+
return value
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return {__keys: Object.keys(/** @type {Record<string, ?>} */ (value))}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Builds a last-resort compact body summary.
|
|
283
|
+
* @param {?} value - Sanitized body.
|
|
284
|
+
* @param {number} originalSerializedLength - Byte count before redaction or compaction.
|
|
285
|
+
* @returns {Record<string, ?>} - Compact summary.
|
|
286
|
+
*/
|
|
287
|
+
function compactRequestDetailsBody(value, originalSerializedLength) {
|
|
288
|
+
return {
|
|
289
|
+
__truncated: true,
|
|
290
|
+
originalSerializedLength,
|
|
291
|
+
topLevelKeys: value && typeof value === "object" && !Array.isArray(value)
|
|
292
|
+
? Object.keys(/** @type {Record<string, ?>} */ (value))
|
|
293
|
+
: []
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Measures JSON serialized length.
|
|
299
|
+
* @param {?} value - Value to measure.
|
|
300
|
+
* @returns {number} - Serialized length, or Infinity when not serializable.
|
|
301
|
+
*/
|
|
302
|
+
function serializedLength(value) {
|
|
303
|
+
try {
|
|
304
|
+
return JSON.stringify(value).length
|
|
305
|
+
} catch {
|
|
306
|
+
return Infinity
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -8,6 +8,7 @@ import {frontendModelResourcesWithBuiltInsForBackendProject} from "./frontend-mo
|
|
|
8
8
|
import {frontendModelResourceClassFromDefinition, frontendModelResourceConfigurationFromDefinition, frontendModelResourcePath, frontendModelResourcesForBackendProject} from "./frontend-models/resource-definition.js"
|
|
9
9
|
import {FrontendModelQueryError, normalizeGroup as normalizeQueryGroup, normalizeJoins as normalizeQueryJoins, normalizePluck as normalizeQueryPluck, normalizePreload as normalizeQueryPreload, normalizeSearchOperator as normalizeQuerySearchOperator, normalizeSort as normalizeQuerySort} from "./frontend-models/query.js"
|
|
10
10
|
import {assignSafeProperty, deserializeFrontendModelTransportValue, isBackendModelInstance, serializeFrontendModelTransportValue} from "./frontend-models/transport-serialization.js"
|
|
11
|
+
import {requestDetails} from "./error-reporting/request-details.js"
|
|
11
12
|
import RoutesResolver from "./routes/resolver.js"
|
|
12
13
|
import {ValidationError} from "./database/record/index.js"
|
|
13
14
|
import { normalizeDateStringForWrite } from "./database/datetime-storage.js"
|
|
@@ -3183,7 +3184,8 @@ export default class FrontendModelController extends Controller {
|
|
|
3183
3184
|
const errorPayload = {
|
|
3184
3185
|
context: errorContext,
|
|
3185
3186
|
error: error instanceof Error ? error : new Error(String(error)),
|
|
3186
|
-
request: this.getRequest()
|
|
3187
|
+
request: this.getRequest(),
|
|
3188
|
+
requestDetails: requestDetails(this.getRequest())
|
|
3187
3189
|
}
|
|
3188
3190
|
|
|
3189
3191
|
this.getConfiguration().getErrorEvents().emit("framework-error", errorPayload)
|
|
@@ -186,6 +186,12 @@
|
|
|
186
186
|
/**
|
|
187
187
|
* @typedef {Record<string, import("./frontend-models/query.js").FrontendModelTransportValue>} ClientErrorPayloadReporterPayload
|
|
188
188
|
*/
|
|
189
|
+
/**
|
|
190
|
+
* @typedef {object} ErrorRequestDetails
|
|
191
|
+
* @property {?} [body] - Sanitized parsed request body, when available.
|
|
192
|
+
* @property {string} httpMethod - Request HTTP method.
|
|
193
|
+
* @property {string} path - Request path.
|
|
194
|
+
*/
|
|
189
195
|
/**
|
|
190
196
|
* @typedef {object} ClientErrorPayloadContext
|
|
191
197
|
* @property {string} controller - Controller class name.
|
|
@@ -200,7 +206,8 @@
|
|
|
200
206
|
* @typedef {function({
|
|
201
207
|
* context: ClientErrorPayloadContext,
|
|
202
208
|
* error: Error,
|
|
203
|
-
* request: import("./http-server/client/request.js").default | import("./http-server/client/websocket-request.js").default | undefined
|
|
209
|
+
* request: import("./http-server/client/request.js").default | import("./http-server/client/websocket-request.js").default | undefined,
|
|
210
|
+
* requestDetails: ErrorRequestDetails | null
|
|
204
211
|
* }): Promise<ClientErrorPayloadReporterPayload | void> | ClientErrorPayloadReporterPayload | void} ClientErrorPayloadReporterType
|
|
205
212
|
*/
|
|
206
213
|
/**
|
|
@@ -878,6 +885,20 @@ export type ScheduledBackgroundJobConfiguration = {
|
|
|
878
885
|
};
|
|
879
886
|
export type VelociousParams = Record<string, string>;
|
|
880
887
|
export type ClientErrorPayloadReporterPayload = Record<string, import("./frontend-models/query.js").FrontendModelTransportValue>;
|
|
888
|
+
export type ErrorRequestDetails = {
|
|
889
|
+
/**
|
|
890
|
+
* - Sanitized parsed request body, when available.
|
|
891
|
+
*/
|
|
892
|
+
body?: unknown;
|
|
893
|
+
/**
|
|
894
|
+
* - Request HTTP method.
|
|
895
|
+
*/
|
|
896
|
+
httpMethod: string;
|
|
897
|
+
/**
|
|
898
|
+
* - Request path.
|
|
899
|
+
*/
|
|
900
|
+
path: string;
|
|
901
|
+
};
|
|
881
902
|
export type ClientErrorPayloadContext = {
|
|
882
903
|
/**
|
|
883
904
|
* - Controller class name.
|
|
@@ -912,6 +933,7 @@ export type ClientErrorPayloadReporterType = (arg0: {
|
|
|
912
933
|
context: ClientErrorPayloadContext;
|
|
913
934
|
error: Error;
|
|
914
935
|
request: import("./http-server/client/request.js").default | import("./http-server/client/websocket-request.js").default | undefined;
|
|
936
|
+
requestDetails: ErrorRequestDetails | null;
|
|
915
937
|
}) => Promise<ClientErrorPayloadReporterPayload | void> | ClientErrorPayloadReporterPayload | void;
|
|
916
938
|
export type VelociousLooseObject = Record<string, unknown> & {
|
|
917
939
|
configuration?: import("./configuration.js").default;
|