fastify-auto-sqlapi 0.2.0 → 0.2.1
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/AGENTS_BACKEND.md +48 -3
- package/BREAKING_CHANGES.md +81 -0
- package/CHANGELOG.md +57 -0
- package/README.md +109 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/lib/db.d.ts +6 -1
- package/dist/lib/db.d.ts.map +1 -1
- package/dist/lib/db.js +8 -2
- package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
- package/dist/lib/engine/bulk/bulk-upsert.js +4 -3
- package/dist/lib/engine/rest/get.d.ts.map +1 -1
- package/dist/lib/engine/rest/get.js +5 -4
- package/dist/lib/engine/rest/update.js +1 -1
- package/dist/lib/engine/search/search.d.ts.map +1 -1
- package/dist/lib/engine/search/search.js +5 -4
- package/dist/lib/engine/write-helpers.d.ts.map +1 -1
- package/dist/lib/engine/write-helpers.js +2 -2
- package/dist/lib/errors.d.ts +24 -0
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +59 -0
- package/dist/lib/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +48 -0
- package/dist/lib/tenant.d.ts +41 -6
- package/dist/lib/tenant.d.ts.map +1 -1
- package/dist/lib/tenant.js +169 -54
- package/dist/routes/auto/plugin.d.ts.map +1 -1
- package/dist/routes/auto/plugin.js +6 -0
- package/dist/routes/auto/route-helpers.d.ts.map +1 -1
- package/dist/routes/auto/route-helpers.js +12 -3
- package/dist/types/plugin.d.ts +19 -0
- package/dist/types/plugin.d.ts.map +1 -1
- package/dist/types/tenant.d.ts +26 -2
- package/dist/types/tenant.d.ts.map +1 -1
- package/docs/GLOSSARY.md +3 -1
- package/docs/adr/0006-raw-db-errors.md +2 -1
- package/docs/adr/0010-joins-do-not-run-route-guards.md +4 -1
- package/docs/adr/0013-sanitized-db-errors.md +114 -0
- package/docs/adr/README.md +1 -0
- package/package.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ensureSqlApiDecorator } from '../../lib/sql-api-decorator.js';
|
|
2
|
+
import { sanitizeRouteError } from '../../lib/errors.js';
|
|
2
3
|
import { isCompositePrimary } from '../../types.js';
|
|
3
4
|
function mergeOnRequests(options, tableConf) {
|
|
4
5
|
return [...(options.onRequests || []), ...(tableConf.onRequests || [])];
|
|
@@ -75,9 +76,17 @@ export async function registerForAllTables(fastify, options, spec) {
|
|
|
75
76
|
schema: buildRouteSchema(tableName, tableConf, spec, schemas),
|
|
76
77
|
onRequest: mergeOnRequests(options, tableConf),
|
|
77
78
|
handler: async (request, reply) => {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
reply.
|
|
79
|
+
try {
|
|
80
|
+
const result = await spec.handle(fastify, tableName, tableConf, request, reply);
|
|
81
|
+
if (!reply.sent)
|
|
82
|
+
reply.status(spec.successStatus).send(result);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
const safe = sanitizeRouteError(err, request.id, options.exposeDebugInfo);
|
|
86
|
+
if (safe !== err)
|
|
87
|
+
request.log.error({ err }, 'sqlapi: unhandled error');
|
|
88
|
+
throw safe;
|
|
89
|
+
}
|
|
81
90
|
},
|
|
82
91
|
});
|
|
83
92
|
}
|
package/dist/types/plugin.d.ts
CHANGED
|
@@ -35,6 +35,25 @@ export interface SqlApiPluginOptions {
|
|
|
35
35
|
* routes run behind the same global `onRequests` hooks as the data routes.
|
|
36
36
|
*/
|
|
37
37
|
agentManifest?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Attach a `debugInfo` payload — the driver message, its `code`/`constraint`/`detail`,
|
|
40
|
+
* the stack — to the `500` an error the plugin did not raise itself produces (a
|
|
41
|
+
* constraint violation, a hook throwing without a `statusCode`).
|
|
42
|
+
*
|
|
43
|
+
* **Development only**: it hands whoever triggers an error a description of the schema,
|
|
44
|
+
* and the auto routes are open by default (ADR 0002). Off unless set. It is additive —
|
|
45
|
+
* `statusCode`, `error`, `message` and `requestId` keep the shape they have in production,
|
|
46
|
+
* so client-side error handling does not fork between environments.
|
|
47
|
+
*
|
|
48
|
+
* Without it the response carries no database detail, and the original error is still
|
|
49
|
+
* reachable: logged through `request.log.error` under the same `reqId` the body reports as
|
|
50
|
+
* `requestId`, and attached as the `cause` of the error a consumer `setErrorHandler`
|
|
51
|
+
* receives — which is where policy such as unique-violation → `409` belongs (ADR 0006).
|
|
52
|
+
*
|
|
53
|
+
* Nothing derives this: not `debug` (whoever registers the plugin decides that one, and
|
|
54
|
+
* may wire it to an environment variable), not `NODE_ENV`. See ADR 0013.
|
|
55
|
+
*/
|
|
56
|
+
exposeDebugInfo?: boolean;
|
|
38
57
|
}
|
|
39
58
|
/** Default row cap for search (page size and no-paginator LIMIT). Override via `maxItemsPerPage`. */
|
|
40
59
|
export declare const DEFAULT_MAX_ITEMS_PER_PAGE = 1000;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../src/types/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAC5D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAC3C,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAE5C,MAAM,WAAW,cAAc;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,QAAQ,CAAC;IACnB,UAAU,CAAC,EAAE,CAAC,CAAC,OAAO,EAAE,cAAc,EAAE,KAAK,EAAE,YAAY,KAAK,OAAO,CAAC,IAAI,GAAG,YAAY,CAAC,CAAC,EAAE,CAAC;IAChG,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,GAAG,cAAc,CAAC;IACnC,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,cAAc,KAAK,QAAQ,GAAG,QAAQ,EAAE,GAAG,IAAI,GAAG,SAAS,GAC/E,OAAO,CAAC,QAAQ,GAAG,QAAQ,EAAE,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;IACtD,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../src/types/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAC5D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAC3C,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAE5C,MAAM,WAAW,cAAc;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,QAAQ,CAAC;IACnB,UAAU,CAAC,EAAE,CAAC,CAAC,OAAO,EAAE,cAAc,EAAE,KAAK,EAAE,YAAY,KAAK,OAAO,CAAC,IAAI,GAAG,YAAY,CAAC,CAAC,EAAE,CAAC;IAChG,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,GAAG,cAAc,CAAC;IACnC,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,cAAc,KAAK,QAAQ,GAAG,QAAQ,EAAE,GAAG,IAAI,GAAG,SAAS,GAC/E,OAAO,CAAC,QAAQ,GAAG,QAAQ,EAAE,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;IACtD,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;;;;;;;;;;;;;;;OAiBG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AAED,qGAAqG;AACrG,eAAO,MAAM,0BAA0B,OAAO,CAAC;AAE/C,8EAA8E;AAC9E,eAAO,MAAM,sBAAsB,OAAO,CAAC"}
|
package/dist/types/tenant.d.ts
CHANGED
|
@@ -11,9 +11,33 @@ export interface TenantScopeIndirect {
|
|
|
11
11
|
foreignField: string;
|
|
12
12
|
};
|
|
13
13
|
}
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* A row owned by several parties, visible to any of them: a message (`sender_id` /
|
|
16
|
+
* `recipient_id`), a transfer (`from_account_id` / `to_account_id`), a shift swap. Reads test
|
|
17
|
+
* every listed column with an OR, so a row matches when *any* of them holds a tenant id.
|
|
18
|
+
*
|
|
19
|
+
* Writes cannot auto-inject a tenant the way a single-column scope does — the plugin has no way
|
|
20
|
+
* to know which party the caller is meant to be — so the payload must anchor the row itself:
|
|
21
|
+
* see `assertTenantAnchor`. Columns are DB column names, like `TenantScopeDirect.column`.
|
|
22
|
+
*
|
|
23
|
+
* Not combinable with `column`/`through`: an entry reached through a parent FK would need its
|
|
24
|
+
* own join per entry, and is rejected at `defineTable` time rather than half-supported.
|
|
25
|
+
*/
|
|
26
|
+
export interface TenantScopeAnyOf {
|
|
27
|
+
anyOf: string[];
|
|
28
|
+
}
|
|
29
|
+
export type TenantScope = TenantScopeDirect | TenantScopeIndirect | TenantScopeAnyOf;
|
|
30
|
+
/**
|
|
31
|
+
* Who the caller is, for this request — plus how the table currently being addressed is scoped.
|
|
32
|
+
*
|
|
33
|
+
* `ids` is request-level: it is resolved once from `getTenantId` and stays valid for every table
|
|
34
|
+
* the request touches. `scope` is table-level and therefore **optional**: the addressed table may
|
|
35
|
+
* declare no scope of its own while a table it reaches through a relation does. Absent `scope`
|
|
36
|
+
* means "no filtering on this table", never "no tenant" — `tenantForTable` re-targets the same
|
|
37
|
+
* ids onto a related table's own scope.
|
|
38
|
+
*/
|
|
15
39
|
export interface TenantContext {
|
|
16
40
|
ids: TenantId[];
|
|
17
|
-
scope
|
|
41
|
+
scope?: TenantScope;
|
|
18
42
|
}
|
|
19
43
|
//# sourceMappingURL=tenant.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tenant.d.ts","sourceRoot":"","sources":["../../src/types/tenant.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAEpD,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,MAAM,CAAC;AAEvC,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE;QACP,MAAM,EAAE,gBAAgB,CAAC;QACzB,UAAU,EAAE,MAAM,CAAC;QACnB,YAAY,EAAE,MAAM,CAAC;KACtB,CAAC;CACH;AAED,MAAM,MAAM,WAAW,GAAG,iBAAiB,GAAG,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"tenant.d.ts","sourceRoot":"","sources":["../../src/types/tenant.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAEpD,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,MAAM,CAAC;AAEvC,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE;QACP,MAAM,EAAE,gBAAgB,CAAC;QACzB,UAAU,EAAE,MAAM,CAAC;QACnB,YAAY,EAAE,MAAM,CAAC;KACtB,CAAC;CACH;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,MAAM,WAAW,GAAG,iBAAiB,GAAG,mBAAmB,GAAG,gBAAgB,CAAC;AAErF;;;;;;;;GAQG;AACH,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,QAAQ,EAAE,CAAC;IAChB,KAAK,CAAC,EAAE,WAAW,CAAC;CACrB"}
|
package/docs/GLOSSARY.md
CHANGED
|
@@ -19,7 +19,9 @@ existing concept makes search fail and the concept get built twice.
|
|
|
19
19
|
| Extra filter | Non-column search filter declared per table. (not: custom filter) |
|
|
20
20
|
| Extended condition | Consumer-supplied SQL fragment merged into a search WHERE. |
|
|
21
21
|
| Dotted notation | `orderBy`/condition key counted in parts: 1-part `field`, 2-part `alias.field`, 3-part `alias.fn.field`. (not: parti) |
|
|
22
|
-
| Tenant scope | Row-level isolation: `direct` (own column)
|
|
22
|
+
| Tenant scope | Row-level isolation: `direct` (own column), `indirect` (via FK) or `anyOf`. |
|
|
23
|
+
| `anyOf` scope | Tenant scope over several owner columns: the row is visible to any of them. |
|
|
24
|
+
| Anchor | The owner column an `anyOf` write names to tie a new row to the caller. |
|
|
23
25
|
| Engine | Dialect-agnostic function performing one operation. (not: service, handler) |
|
|
24
26
|
| Route | Fastify plugin exposing an engine over HTTP. (not: controller) |
|
|
25
27
|
| Dialect | Per-DB SQL differences (`postgres` \| `mysql`). |
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# 0006. Raw DB errors surface as 500 — no SQLSTATE→HTTP mapping
|
|
2
2
|
|
|
3
|
-
- **Status**: accepted
|
|
3
|
+
- **Status**: accepted on the mapping; message exposure narrowed by
|
|
4
|
+
[0013](./0013-sanitized-db-errors.md)
|
|
4
5
|
- **Date**: 2026-07-23 (confirmed by the maintainer in June 2026)
|
|
5
6
|
|
|
6
7
|
## Context
|
|
@@ -97,4 +97,7 @@ programmatic `sqlApi.search()` path.
|
|
|
97
97
|
narrow. Not decided here.
|
|
98
98
|
- `tenantScope` remains the one request-derived protection that crosses joins, and is the
|
|
99
99
|
precedent for the shape any future one should take: table configuration plus a plugin-level
|
|
100
|
-
resolver, not a route hook.
|
|
100
|
+
resolver, not a route hook. It crosses joins **unconditionally** only since the tenant stopped
|
|
101
|
+
being resolved from the addressed table alone; before that, a host declaring no scope of its
|
|
102
|
+
own carried none into its join targets, which made the row above true only when the host
|
|
103
|
+
happened to be scoped too (see BREAKING_CHANGES.md).
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# 0013. Database error messages are sanitized on the wire by default
|
|
2
|
+
|
|
3
|
+
- **Status**: accepted — narrows [0006](./0006-raw-db-errors.md)
|
|
4
|
+
- **Date**: 2026-08-05
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
[ADR 0006](./0006-raw-db-errors.md) rejected two distinct proposals in one decision: mapping
|
|
9
|
+
SQLSTATE codes to HTTP semantics, and sanitizing driver messages. The first rejection holds
|
|
10
|
+
— the mapping is dialect-specific and encodes product decisions the plugin should not make.
|
|
11
|
+
The second rested on "hiding details is an environment policy, and Fastify already gives
|
|
12
|
+
consumers the hook for it", and three facts undermine it:
|
|
13
|
+
|
|
14
|
+
- The plugin installs its own `setErrorHandler` in the routes' child scope
|
|
15
|
+
(`src/routes/auto/plugin.ts`), and it sends `error.message` verbatim for any status code.
|
|
16
|
+
A consumer handler registered on the parent scope never sees these errors, so the escape
|
|
17
|
+
route 0006 points at does not exist for the main-plugin path.
|
|
18
|
+
- The posture is inconsistent with the rest of the codebase. `maxItemsPerPage`,
|
|
19
|
+
`maxBulkItems`, prototype-key filtering and the join field allowlist are all guards the
|
|
20
|
+
plugin applies by default where the cost is zero. A constraint name describing the schema
|
|
21
|
+
to whoever triggered a duplicate is the same class of free hardening.
|
|
22
|
+
- [ADR 0002](./0002-open-by-default.md) amplifies the exposure: the scenario the plugin
|
|
23
|
+
optimizes for — internal scaffolding wired up in five minutes — is exactly the one where
|
|
24
|
+
nobody wrote an error handler.
|
|
25
|
+
|
|
26
|
+
The failure mode is silent. A consumer who never thinks about it ships the leak and gets no
|
|
27
|
+
signal.
|
|
28
|
+
|
|
29
|
+
## Decision
|
|
30
|
+
|
|
31
|
+
Errors escaping an auto-generated route handler are replaced with a bare
|
|
32
|
+
`500 Internal Server Error` carrying no database detail. Errors the plugin raises itself are
|
|
33
|
+
deliberate `4xx` with messages written for clients (`httpError`, `validationErrors`) and pass
|
|
34
|
+
through untouched — the rule is `statusCode` in `[400, 500)`, nothing else.
|
|
35
|
+
|
|
36
|
+
No status code is remapped: a `500` stays a `500`. ADR 0006 stands on that point.
|
|
37
|
+
|
|
38
|
+
Nothing is lost. The original error is logged through `request.log.error` and attached as the
|
|
39
|
+
`cause` of the error the consumer's `setErrorHandler` receives, so unique-violation → `409`
|
|
40
|
+
remains implementable, and remains the consumer's decision. The sanitized body carries
|
|
41
|
+
`requestId` — the same value Fastify binds as `reqId` on the request logger — so the client
|
|
42
|
+
can name the log line holding the real error instead of describing a `500` that happened
|
|
43
|
+
"around then".
|
|
44
|
+
|
|
45
|
+
Developing against the API is a real need the log does not serve — a client building on it,
|
|
46
|
+
an agent especially, may have no way to read the server's log, and a round trip per failure
|
|
47
|
+
is expensive. `exposeDebugInfo: true` answers it by **adding** a `debugInfo` payload (driver
|
|
48
|
+
message, `code`, `constraint`/`detail`, stack) beside the fields above, which keep the shape
|
|
49
|
+
they have in production. Client-side error handling therefore does not fork between
|
|
50
|
+
environments — the difference is extra data, not different data.
|
|
51
|
+
|
|
52
|
+
`exposeDebugInfo` derives from nothing. Not `debug`, not `NODE_ENV`, not the log level. The
|
|
53
|
+
deciding argument is that **`debug` belongs to the consumer**: whoever registers the plugin
|
|
54
|
+
decides how it is wired — a constant in one deployment, an environment variable flipped
|
|
55
|
+
during an incident in another — so the library cannot assume its lifecycle, and an option
|
|
56
|
+
whose value the library does not control must not decide what leaves the server. Beyond that,
|
|
57
|
+
`debug` writes to the log, an access-controlled internal channel; the response body is
|
|
58
|
+
external, and under ADR 0002 often unauthenticated. And the posture wanted during a
|
|
59
|
+
production incident — log everything, tell clients nothing — stops being expressible the
|
|
60
|
+
moment one switch drives both.
|
|
61
|
+
|
|
62
|
+
The guard sits in the handler built by `registerForAllTables`
|
|
63
|
+
(`src/routes/auto/route-helpers.ts`) — the single choke point every auto route passes
|
|
64
|
+
through, including route plugins registered standalone without the main plugin. The
|
|
65
|
+
`debugInfo` payload is copied from the driver error by allowlist, never spread: an error
|
|
66
|
+
object that starts carrying the connection config must not reach a client through it.
|
|
67
|
+
|
|
68
|
+
## Alternatives considered
|
|
69
|
+
|
|
70
|
+
- **`fastify.setErrorHandler` in the plugin** — rejected: the plugin is registered through
|
|
71
|
+
`fp()`, so a handler installed at that level would hijack error rendering for the
|
|
72
|
+
consumer's entire application, not just the auto routes.
|
|
73
|
+
- **Sanitizing inside the existing child-scope handler in `plugin.ts`** — rejected: route
|
|
74
|
+
plugins registered standalone (`searchRoutes` alone, per AGENTS_BACKEND.md "Register only
|
|
75
|
+
specific routes") never reach that handler, and would keep leaking.
|
|
76
|
+
- **Leaving it to the consumer, as 0006 decided** — rejected for the three reasons in
|
|
77
|
+
Context; the deciding one is that the plugin's own child-scope handler makes the
|
|
78
|
+
prescribed consumer remedy unreachable.
|
|
79
|
+
- **Deriving the behaviour from `debug`** — rejected, for the reasons in Decision: the
|
|
80
|
+
consumer owns that option and may wire it to an environment variable, it currently governs
|
|
81
|
+
an internal channel only, and coupling makes "verbose log, silent response" unexpressible.
|
|
82
|
+
An override (`debug: true` + an explicit opt-out) would keep the combination reachable but
|
|
83
|
+
leaves the default pointing the dangerous way, which is the failure mode this ADR exists to
|
|
84
|
+
close.
|
|
85
|
+
- **Keying it on `NODE_ENV`** — rejected. `NODE_ENV !== 'production'` is fail-open: the
|
|
86
|
+
variable is unset often enough in real deployments (a bare `node dist/server.js`, many
|
|
87
|
+
container images, systemd units) that the leak would come back precisely where nothing was
|
|
88
|
+
configured. `NODE_ENV === 'development'` would at least fail closed, but a security-relevant
|
|
89
|
+
response decided by the shell instead of by the registration call is action at a distance.
|
|
90
|
+
- **Replacing `message` with the driver text instead of adding `debugInfo`** — rejected: it
|
|
91
|
+
makes one field mean different things in different environments, so client error handling
|
|
92
|
+
has to be written twice. The additive payload costs nothing and carries more, structured.
|
|
93
|
+
- **Echoing only the SQLSTATE code** (`23505`, no names) — rejected: a third middle mode is
|
|
94
|
+
harder to reason about than a boolean, and reading meaning into SQLSTATE is the direction
|
|
95
|
+
0006 closed.
|
|
96
|
+
- **Sending the detail only to localhost callers** — rejected: it makes the response depend
|
|
97
|
+
on network topology, which reverse proxies routinely flatten.
|
|
98
|
+
|
|
99
|
+
## Consequences
|
|
100
|
+
|
|
101
|
+
- Constraint violations reach clients as `{"statusCode":500,"error":"Internal Server
|
|
102
|
+
Error","message":"Internal Server Error","requestId":"…"}`. Diagnosing one requires the
|
|
103
|
+
server log — which is where the error was already going, under that same id.
|
|
104
|
+
- Behavioural breaking change for consumers who parsed driver messages out of `500` bodies.
|
|
105
|
+
They read `debugInfo` instead, and only where the deployment opted in.
|
|
106
|
+
- Development needs one more line (`exposeDebugInfo: true`) than it did. That is the price of
|
|
107
|
+
the option meaning exactly one thing: what a client sees is readable from the registration
|
|
108
|
+
call alone, without knowing how `debug` or the environment happen to be wired.
|
|
109
|
+
- A deployment that leaves `exposeDebugInfo` on by mistake is back to disclosing schema
|
|
110
|
+
detail. The name is the mitigation: `expose` is the word that makes a reader stop, which
|
|
111
|
+
`debug` is not.
|
|
112
|
+
- Errors thrown by consumer `onRequests` hooks are outside the handler and unaffected: that
|
|
113
|
+
is consumer code running before the plugin's SQL surface, and its messages are the
|
|
114
|
+
consumer's to write.
|
package/docs/adr/README.md
CHANGED
|
@@ -27,6 +27,7 @@ not by editing history.
|
|
|
27
27
|
| [0010](./0010-joins-do-not-run-route-guards.md) | A declared join is a read grant — joins do not run the target table's route guards |
|
|
28
28
|
| [0011](./0011-join-fields-allowlist.md) | Per-relation field restriction is an allowlist, not a blocklist |
|
|
29
29
|
| [0012](./0012-anti-duplication-tooling.md) | Repository rules are enforced by executable gates, not by documentation |
|
|
30
|
+
| [0013](./0013-sanitized-db-errors.md) | Database error messages are sanitized on the wire by default (narrows 0006) |
|
|
30
31
|
|
|
31
32
|
## When to write one
|
|
32
33
|
|