fastify-auto-sqlapi 0.2.0 → 0.2.2
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.md +50 -0
- package/AGENTS_BACKEND.md +312 -9
- package/AGENTS_FRONTEND.md +1 -1
- package/BREAKING_CHANGES.md +132 -0
- package/CHANGELOG.md +170 -0
- package/README.md +255 -877
- package/dist/bin/generate-schema.js +49 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/agent/manifest.d.ts +2 -0
- package/dist/lib/agent/manifest.d.ts.map +1 -1
- package/dist/lib/agent/manifest.js +4 -0
- package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
- package/dist/lib/cli/mysql-introspect.js +15 -5
- package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
- package/dist/lib/cli/pg-introspect.js +53 -14
- package/dist/lib/cli/schema-codegen.d.ts +2 -1
- package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
- package/dist/lib/cli/schema-codegen.js +17 -4
- package/dist/lib/cli/tables-codegen.d.ts +4 -0
- package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
- package/dist/lib/cli/tables-codegen.js +125 -27
- 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 +6 -5
- package/dist/lib/engine/read-hooks.d.ts +19 -0
- package/dist/lib/engine/read-hooks.d.ts.map +1 -0
- package/dist/lib/engine/read-hooks.js +20 -0
- package/dist/lib/engine/rest/get.d.ts.map +1 -1
- package/dist/lib/engine/rest/get.js +10 -6
- package/dist/lib/engine/rest/insert.js +1 -1
- package/dist/lib/engine/rest/update.d.ts.map +1 -1
- package/dist/lib/engine/rest/update.js +15 -11
- package/dist/lib/engine/search/join-families.d.ts +11 -2
- package/dist/lib/engine/search/join-families.d.ts.map +1 -1
- package/dist/lib/engine/search/join-families.js +11 -13
- package/dist/lib/engine/search/search.d.ts.map +1 -1
- package/dist/lib/engine/search/search.js +14 -7
- package/dist/lib/engine/validate.d.ts +3 -3
- package/dist/lib/engine/validate.d.ts.map +1 -1
- package/dist/lib/engine/write-helpers.d.ts +7 -3
- package/dist/lib/engine/write-helpers.d.ts.map +1 -1
- package/dist/lib/engine/write-helpers.js +29 -30
- 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/schema/bulk-upsert.d.ts.map +1 -1
- package/dist/lib/schema/bulk-upsert.js +2 -1
- package/dist/lib/schema/get.d.ts +14 -0
- package/dist/lib/schema/get.d.ts.map +1 -0
- package/dist/lib/schema/get.js +18 -0
- package/dist/lib/schema/helpers.d.ts +21 -3
- package/dist/lib/schema/helpers.d.ts.map +1 -1
- package/dist/lib/schema/helpers.js +37 -5
- package/dist/lib/schema/insert.d.ts.map +1 -1
- package/dist/lib/schema/insert.js +7 -3
- package/dist/lib/schema/search.d.ts.map +1 -1
- package/dist/lib/schema/search.js +3 -11
- package/dist/lib/schema/update.d.ts.map +1 -1
- package/dist/lib/schema/update.js +7 -3
- package/dist/lib/sql-api.d.ts +9 -9
- package/dist/lib/sql-api.d.ts.map +1 -1
- package/dist/lib/sql-api.js +2 -1
- package/dist/lib/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +169 -0
- package/dist/lib/tenant.d.ts +43 -8
- package/dist/lib/tenant.d.ts.map +1 -1
- package/dist/lib/tenant.js +169 -54
- package/dist/lib/write-access.d.ts +36 -0
- package/dist/lib/write-access.d.ts.map +1 -0
- package/dist/lib/write-access.js +58 -0
- package/dist/routes/auto/get.routes.d.ts.map +1 -1
- package/dist/routes/auto/get.routes.js +3 -2
- package/dist/routes/auto/plugin.d.ts.map +1 -1
- package/dist/routes/auto/plugin.js +8 -2
- package/dist/routes/auto/route-helpers.d.ts.map +1 -1
- package/dist/routes/auto/route-helpers.js +13 -4
- package/dist/routes/setup-swagger.d.ts.map +1 -0
- package/dist/{lib → routes}/sql-api-decorator.d.ts +1 -1
- package/dist/routes/sql-api-decorator.d.ts.map +1 -0
- package/dist/{lib → routes}/sql-api-decorator.js +4 -4
- package/dist/types/cli.d.ts +27 -1
- package/dist/types/cli.d.ts.map +1 -1
- package/dist/types/dml.d.ts +6 -4
- package/dist/types/dml.d.ts.map +1 -1
- package/dist/types/plugin.d.ts +22 -3
- package/dist/types/plugin.d.ts.map +1 -1
- package/dist/types/request.d.ts +24 -0
- package/dist/types/request.d.ts.map +1 -0
- package/dist/types/request.js +1 -0
- package/dist/types/schema.d.ts +15 -0
- package/dist/types/schema.d.ts.map +1 -1
- package/dist/types/search.d.ts +3 -0
- package/dist/types/search.d.ts.map +1 -1
- package/dist/types/table.d.ts +84 -11
- package/dist/types/table.d.ts.map +1 -1
- package/dist/types/tenant.d.ts +26 -2
- package/dist/types/tenant.d.ts.map +1 -1
- package/dist/types/validation.d.ts +3 -3
- package/dist/types/validation.d.ts.map +1 -1
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -0
- package/docs/GLOSSARY.md +3 -1
- package/docs/adr/0004-updates-always-open.md +2 -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/0014-what-follows-a-write-join.md +84 -0
- package/docs/adr/0015-non-writable-columns.md +86 -0
- package/docs/adr/0016-a-view-is-a-table-config.md +92 -0
- package/docs/adr/0017-the-schema-is-the-contract.md +85 -0
- package/docs/adr/0018-one-seam-to-the-http-framework.md +68 -0
- package/docs/adr/README.md +6 -0
- package/package.json +1 -1
- package/dist/lib/setup-swagger.d.ts.map +0 -1
- package/dist/lib/sql-api-decorator.d.ts.map +0 -1
- /package/dist/{lib → routes}/setup-swagger.d.ts +0 -0
- /package/dist/{lib → routes}/setup-swagger.js +0 -0
package/dist/lib/tenant.d.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ApiRequest } from '../types/request.js';
|
|
2
2
|
import type { QueryClient } from './db.js';
|
|
3
3
|
import { ConditionBuilder, type ConditionValue, type ConditionValueOrUndefined } from 'node-condition-builder';
|
|
4
|
-
import type { ITable, SqlApiPluginOptions, TenantId, TenantScope, TenantScopeIndirect, TenantContext } from '../types.js';
|
|
5
|
-
export declare function resolveTenant(options: SqlApiPluginOptions, tableConf: ITable, request:
|
|
4
|
+
import type { ITable, SqlApiPluginOptions, TenantId, TenantScope, TenantScopeDirect, TenantScopeIndirect, TenantScopeAnyOf, TenantContext } from '../types.js';
|
|
5
|
+
export declare function resolveTenant(options: SqlApiPluginOptions, tableConf: ITable, request: ApiRequest): Promise<TenantContext | undefined>;
|
|
6
|
+
/**
|
|
7
|
+
* The read predicate for a scope, as a fragment every caller appends to its own WHERE.
|
|
8
|
+
*
|
|
9
|
+
* An `anyOf` scope returns an OR-mode builder — the row is visible when any listed column holds
|
|
10
|
+
* a tenant id — and every caller appends it, so the group keeps its own parentheses. A NULL
|
|
11
|
+
* column simply fails to match, which is the wanted behaviour: the other party still decides.
|
|
12
|
+
*/
|
|
6
13
|
export declare function buildTenantCondition(db: QueryClient, scope: TenantScope, tenantIds: TenantId[], ownTable: string): ConditionBuilder;
|
|
7
14
|
/**
|
|
8
15
|
* Re-target a tenant context onto a related table: the caller's tenant ids paired with that
|
|
@@ -21,21 +28,35 @@ export declare function tenantForTable(tenant: TenantContext | undefined, tableC
|
|
|
21
28
|
*
|
|
22
29
|
* `tableRef` is the name the scoped rows go by in that statement — the table name in a DELETE,
|
|
23
30
|
* the join alias in an ON clause, the subquery alias inside a correlated SELECT. Returns
|
|
24
|
-
* undefined without a tenant context (admin).
|
|
31
|
+
* undefined without a tenant context (admin) or when the table carries no scope.
|
|
25
32
|
*/
|
|
26
33
|
export declare function buildTenantRowGuard(db: QueryClient, tenant: TenantContext | undefined, tableRef: string): ConditionBuilder | undefined;
|
|
27
34
|
export declare function buildTenantJoin(db: QueryClient, scope: TenantScopeIndirect, mainTableName: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* The insert-side rule for an `anyOf` scope: the payload must anchor the row itself.
|
|
37
|
+
*
|
|
38
|
+
* Nothing is auto-injected — with several owner columns the plugin cannot know which party the
|
|
39
|
+
* caller is meant to be, and guessing would either invent a relationship or refuse a legitimate
|
|
40
|
+
* one. So the record must name at least one listed column, and at least one of the named ones
|
|
41
|
+
* must hold a tenant id. The *other* parties are deliberately left alone: a swap request points
|
|
42
|
+
* at a colleague by definition, and requiring every listed column to match would make the shape
|
|
43
|
+
* unusable.
|
|
44
|
+
*
|
|
45
|
+
* @testonly Exported only so unit tests can exercise it directly.
|
|
46
|
+
*/
|
|
47
|
+
export declare function assertTenantAnchor(record: Record<string, unknown>, scope: TenantScopeAnyOf, tenantIds: TenantId[]): void;
|
|
28
48
|
/** Writes the tenant value into a record according to its scope.
|
|
29
49
|
* @testonly Exported only so unit tests can exercise it directly.
|
|
30
50
|
*/
|
|
31
|
-
export declare function injectTenantValue(record: Record<string, unknown>, scope:
|
|
51
|
+
export declare function injectTenantValue(record: Record<string, unknown>, scope: TenantScopeDirect | TenantScopeIndirect, tenantIds: TenantId[]): void;
|
|
32
52
|
/** Checks that an indirect-scope FK really belongs to the tenant.
|
|
33
53
|
* @testonly Exported only so unit tests can exercise it directly.
|
|
34
54
|
*/
|
|
35
55
|
export declare function validateTenantFK(db: QueryClient, scope: TenantScopeIndirect, tenantIds: TenantId[], fkValues: unknown[]): Promise<void>;
|
|
36
56
|
/**
|
|
37
57
|
* Build WHERE clause for DELETE with tenant filtering.
|
|
38
|
-
* Handles both direct (simple AND) and indirect (subquery via JOIN) scopes
|
|
58
|
+
* Handles both direct (simple AND) and indirect (subquery via JOIN) scopes; a tenant whose
|
|
59
|
+
* addressed table declares no scope yields the plain primary-key clause.
|
|
39
60
|
*/
|
|
40
61
|
export declare function buildTenantDeleteWhere(db: QueryClient, tableName: string, pkCol: string, pkValue: ConditionValueOrUndefined | ConditionValue[], tenant: TenantContext): {
|
|
41
62
|
where: string;
|
|
@@ -64,8 +85,9 @@ export declare function assertTenantOwnsConflicts(db: QueryClient, tenant: Tenan
|
|
|
64
85
|
*/
|
|
65
86
|
export declare function enforceTenantFKOnUpdate(db: QueryClient, tenant: TenantContext | undefined, updateFields: Record<string, unknown>): Promise<void>;
|
|
66
87
|
/**
|
|
67
|
-
* Mutates `fields` in-place: removes the tenant
|
|
68
|
-
*
|
|
88
|
+
* Mutates `fields` in-place: removes the tenant columns from a SET payload (scopes whose columns
|
|
89
|
+
* live on the table itself). An `anyOf` scope drops *every* listed column — neither party can be
|
|
90
|
+
* reassigned, the same spirit as "a tenant cannot move its own row". No-op for indirect scopes.
|
|
69
91
|
*/
|
|
70
92
|
export declare function stripTenantColumn(fields: Record<string, unknown>, scope: TenantScope): void;
|
|
71
93
|
/**
|
|
@@ -75,6 +97,19 @@ export declare function stripTenantColumn(fields: Record<string, unknown>, scope
|
|
|
75
97
|
* Throws 400 (ambiguous tenant) or 403 (mismatch) on violation. No-op when `tenant` is undefined.
|
|
76
98
|
*/
|
|
77
99
|
export declare function enforceTenantOnWrites(db: QueryClient, tenant: TenantContext | undefined, records: Record<string, unknown>[]): Promise<void>;
|
|
100
|
+
/**
|
|
101
|
+
* Columns an UPSERT must leave alone when it lands on an existing row.
|
|
102
|
+
*
|
|
103
|
+
* `stripTenantColumn` does this for a plain UPDATE, but an upsert's payload doubles as the
|
|
104
|
+
* INSERT branch, where the owner columns are exactly what anchors a new row — they cannot simply
|
|
105
|
+
* be dropped. So they are excluded from the `DO UPDATE SET` instead: state which party you are
|
|
106
|
+
* to create the row, and that statement is ignored when the row already exists.
|
|
107
|
+
*
|
|
108
|
+
* Only `anyOf` scopes need this. A direct scope injects the caller's own tenant, so writing it
|
|
109
|
+
* back onto a row the conflict guard already proved theirs is a no-op; an indirect one keeps its
|
|
110
|
+
* owner on another table entirely.
|
|
111
|
+
*/
|
|
112
|
+
export declare function tenantImmutableColumns(tenant: TenantContext | undefined): string[];
|
|
78
113
|
/**
|
|
79
114
|
* For update: builds the optional `extraCondition` ConditionBuilder for direct tenants.
|
|
80
115
|
* Returns undefined for indirect or no-tenant scenarios — those are handled separately
|
package/dist/lib/tenant.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tenant.d.ts","sourceRoot":"","sources":["../../src/lib/tenant.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,
|
|
1
|
+
{"version":3,"file":"tenant.d.ts","sourceRoot":"","sources":["../../src/lib/tenant.ts"],"names":[],"mappings":"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"}
|
package/dist/lib/tenant.js
CHANGED
|
@@ -3,31 +3,72 @@ import { httpError } from './errors.js';
|
|
|
3
3
|
function isIndirect(scope) {
|
|
4
4
|
return 'through' in scope;
|
|
5
5
|
}
|
|
6
|
+
function isAnyOf(scope) {
|
|
7
|
+
return 'anyOf' in scope;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The tenant columns living on the scoped table itself, in declaration order: one for a direct
|
|
11
|
+
* scope, several for an `anyOf` one. Never called for an indirect scope, whose column belongs to
|
|
12
|
+
* the through table.
|
|
13
|
+
*/
|
|
14
|
+
function ownColumns(scope) {
|
|
15
|
+
return isAnyOf(scope) ? scope.anyOf : [scope.column];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Whether this request could touch a scoped row at all: the addressed table is scoped, or one of
|
|
19
|
+
* the tables it can reach through a declared relation is.
|
|
20
|
+
*
|
|
21
|
+
* Relations are what makes the second case necessary — a relation is a read grant on its target
|
|
22
|
+
* and a write join a write grant, so a deliberately public host must still carry its neighbours'
|
|
23
|
+
* scopes into the joins and secondaries it drives. One level is the whole reach: joins resolve
|
|
24
|
+
* from the addressed table's own `allowedReadJoins`/`allowedWriteJoins` and never chain further.
|
|
25
|
+
*
|
|
26
|
+
* When nothing scoped is in reach, `getTenantId` is not called at all — a request that cannot
|
|
27
|
+
* read or write a scoped row should not pay for resolving a tenant, and consumer code that
|
|
28
|
+
* assumes an authenticated request should not run on routes that never needed one.
|
|
29
|
+
*/
|
|
30
|
+
function scopeInReach(tableConf, dbTables) {
|
|
31
|
+
if (tableConf.tenantScope)
|
|
32
|
+
return true;
|
|
33
|
+
if (!dbTables)
|
|
34
|
+
return false;
|
|
35
|
+
const relations = [...(tableConf.allowedReadJoins ?? []), ...(tableConf.allowedWriteJoins ?? [])];
|
|
36
|
+
return relations.some((join) => dbTables[join.joinSchema.tableName]?.tenantScope !== undefined);
|
|
37
|
+
}
|
|
6
38
|
export async function resolveTenant(options, tableConf, request) {
|
|
7
39
|
if (!options.getTenantId)
|
|
8
40
|
return undefined;
|
|
9
|
-
if (!tableConf.
|
|
41
|
+
if (!scopeInReach(tableConf, options.DbTables))
|
|
10
42
|
return undefined;
|
|
11
43
|
const raw = await options.getTenantId(request);
|
|
12
44
|
if (raw == null)
|
|
13
45
|
return undefined; // admin
|
|
14
46
|
const ids = Array.isArray(raw) ? raw : [raw];
|
|
47
|
+
// `scope` stays undefined for an unscoped host: the ids still travel, so the tables it reaches
|
|
48
|
+
// enforce their own scopes, while the host itself keeps no filter.
|
|
15
49
|
return { ids, scope: tableConf.tenantScope };
|
|
16
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* The read predicate for a scope, as a fragment every caller appends to its own WHERE.
|
|
53
|
+
*
|
|
54
|
+
* An `anyOf` scope returns an OR-mode builder — the row is visible when any listed column holds
|
|
55
|
+
* a tenant id — and every caller appends it, so the group keeps its own parentheses. A NULL
|
|
56
|
+
* column simply fails to match, which is the wanted behaviour: the other party still decides.
|
|
57
|
+
*/
|
|
17
58
|
export function buildTenantCondition(db, scope, tenantIds, ownTable) {
|
|
18
|
-
const col = scope.column;
|
|
19
|
-
let qualifier;
|
|
20
59
|
if (isIndirect(scope)) {
|
|
21
60
|
const throughTable = scope.through.schema.tableName;
|
|
22
|
-
|
|
61
|
+
const cb = new ConditionBuilder('AND', db.cbDialect);
|
|
62
|
+
cb.isIn(`${db.qi(throughTable)}.${db.qi(scope.column)}`, tenantIds);
|
|
63
|
+
return cb;
|
|
23
64
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
65
|
+
// Qualify with the table the scope columns live on: the statement may carry
|
|
66
|
+
// joins, and a bare column shared with a joined table would be ambiguous.
|
|
67
|
+
const cols = ownColumns(scope);
|
|
68
|
+
const cb = new ConditionBuilder(cols.length > 1 ? 'OR' : 'AND', db.cbDialect);
|
|
69
|
+
for (const col of cols) {
|
|
70
|
+
cb.isIn(`${db.qi(ownTable)}.${db.qi(col)}`, tenantIds);
|
|
28
71
|
}
|
|
29
|
-
const cb = new ConditionBuilder('AND', db.cbDialect);
|
|
30
|
-
cb.isIn(qualifier, tenantIds);
|
|
31
72
|
return cb;
|
|
32
73
|
}
|
|
33
74
|
/**
|
|
@@ -52,17 +93,18 @@ export function tenantForTable(tenant, tableConf) {
|
|
|
52
93
|
*
|
|
53
94
|
* `tableRef` is the name the scoped rows go by in that statement — the table name in a DELETE,
|
|
54
95
|
* the join alias in an ON clause, the subquery alias inside a correlated SELECT. Returns
|
|
55
|
-
* undefined without a tenant context (admin).
|
|
96
|
+
* undefined without a tenant context (admin) or when the table carries no scope.
|
|
56
97
|
*/
|
|
57
98
|
export function buildTenantRowGuard(db, tenant, tableRef) {
|
|
58
|
-
|
|
99
|
+
const scope = tenant?.scope;
|
|
100
|
+
if (!tenant || !scope)
|
|
59
101
|
return undefined;
|
|
60
102
|
const cb = new ConditionBuilder('AND', db.cbDialect);
|
|
61
|
-
if (!isIndirect(
|
|
62
|
-
cb.append(buildTenantCondition(db,
|
|
103
|
+
if (!isIndirect(scope)) {
|
|
104
|
+
cb.append(buildTenantCondition(db, scope, tenant.ids, tableRef));
|
|
63
105
|
return cb;
|
|
64
106
|
}
|
|
65
|
-
const { through, column } =
|
|
107
|
+
const { through, column } = scope;
|
|
66
108
|
const throughRef = db.qi(through.schema.tableName);
|
|
67
109
|
const markers = tenant.ids.map(() => '?').join(', ');
|
|
68
110
|
cb.raw(`EXISTS (SELECT 1 FROM ${throughRef} WHERE ` +
|
|
@@ -76,6 +118,27 @@ export function buildTenantJoin(db, scope, mainTableName) {
|
|
|
76
118
|
const foreignField = scope.through.foreignField;
|
|
77
119
|
return `INNER JOIN ${db.qi(throughTable)} ON ${db.qi(mainTableName)}.${db.qi(localField)} = ${db.qi(throughTable)}.${db.qi(foreignField)}`;
|
|
78
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* The insert-side rule for an `anyOf` scope: the payload must anchor the row itself.
|
|
123
|
+
*
|
|
124
|
+
* Nothing is auto-injected — with several owner columns the plugin cannot know which party the
|
|
125
|
+
* caller is meant to be, and guessing would either invent a relationship or refuse a legitimate
|
|
126
|
+
* one. So the record must name at least one listed column, and at least one of the named ones
|
|
127
|
+
* must hold a tenant id. The *other* parties are deliberately left alone: a swap request points
|
|
128
|
+
* at a colleague by definition, and requiring every listed column to match would make the shape
|
|
129
|
+
* unusable.
|
|
130
|
+
*
|
|
131
|
+
* @testonly Exported only so unit tests can exercise it directly.
|
|
132
|
+
*/
|
|
133
|
+
export function assertTenantAnchor(record, scope, tenantIds) {
|
|
134
|
+
const present = scope.anyOf.filter((col) => record[col] != null);
|
|
135
|
+
if (!present.length) {
|
|
136
|
+
throw httpError(400, `Missing tenant anchor: one of ${scope.anyOf.join(', ')} must be set`);
|
|
137
|
+
}
|
|
138
|
+
if (!present.some((col) => tenantIds.includes(record[col]))) {
|
|
139
|
+
throw httpError(403, 'Access denied: tenant value does not match');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
79
142
|
/** Writes the tenant value into a record according to its scope.
|
|
80
143
|
* @testonly Exported only so unit tests can exercise it directly.
|
|
81
144
|
*/
|
|
@@ -121,16 +184,18 @@ export async function validateTenantFK(db, scope, tenantIds, fkValues) {
|
|
|
121
184
|
}
|
|
122
185
|
/**
|
|
123
186
|
* Build WHERE clause for DELETE with tenant filtering.
|
|
124
|
-
* Handles both direct (simple AND) and indirect (subquery via JOIN) scopes
|
|
187
|
+
* Handles both direct (simple AND) and indirect (subquery via JOIN) scopes; a tenant whose
|
|
188
|
+
* addressed table declares no scope yields the plain primary-key clause.
|
|
125
189
|
*/
|
|
126
190
|
export function buildTenantDeleteWhere(db, tableName, pkCol, pkValue, tenant) {
|
|
127
|
-
|
|
191
|
+
const scope = tenant.scope;
|
|
192
|
+
if (scope && isIndirect(scope)) {
|
|
128
193
|
const innerCb = new ConditionBuilder('AND', db.cbDialect);
|
|
129
194
|
innerCb.isIn(`${db.qi(tableName)}.${db.qi(pkCol)}`, (Array.isArray(pkValue) ? pkValue : [pkValue]));
|
|
130
|
-
innerCb.append(buildTenantCondition(db,
|
|
195
|
+
innerCb.append(buildTenantCondition(db, scope, tenant.ids, tableName));
|
|
131
196
|
const innerWhere = innerCb.build(1, db.ph);
|
|
132
197
|
const values = innerCb.getValues();
|
|
133
|
-
const joinSql = buildTenantJoin(db,
|
|
198
|
+
const joinSql = buildTenantJoin(db, scope, tableName);
|
|
134
199
|
const where = `${db.qi(pkCol)} IN (SELECT ${db.qi(tableName)}.${db.qi(pkCol)} FROM ${db.qi(tableName)} ${joinSql} WHERE ${innerWhere})`;
|
|
135
200
|
return { where, values };
|
|
136
201
|
}
|
|
@@ -141,7 +206,8 @@ export function buildTenantDeleteWhere(db, tableName, pkCol, pkValue, tenant) {
|
|
|
141
206
|
else {
|
|
142
207
|
cb.isEqual(db.qi(pkCol), pkValue);
|
|
143
208
|
}
|
|
144
|
-
|
|
209
|
+
if (scope)
|
|
210
|
+
cb.append(buildTenantCondition(db, scope, tenant.ids, tableName));
|
|
145
211
|
const where = cb.build(1, db.ph);
|
|
146
212
|
const values = cb.getValues();
|
|
147
213
|
return { where, values };
|
|
@@ -158,7 +224,8 @@ export function buildTenantDeleteWhere(db, tableName, pkCol, pkValue, tenant) {
|
|
|
158
224
|
* any conflict column are skipped (they can only INSERT, never hit a conflict).
|
|
159
225
|
*/
|
|
160
226
|
export async function assertTenantOwnsConflicts(db, tenant, tableName, conflictCols, records) {
|
|
161
|
-
|
|
227
|
+
const scope = tenant?.scope;
|
|
228
|
+
if (!tenant || !scope || !conflictCols.length)
|
|
162
229
|
return;
|
|
163
230
|
// Collect distinct, fully-specified conflict-key tuples.
|
|
164
231
|
const tuples = [];
|
|
@@ -199,19 +266,35 @@ export async function assertTenantOwnsConflicts(db, tenant, tableName, conflictC
|
|
|
199
266
|
}
|
|
200
267
|
// Tenant-mismatch clause: rows whose tenant is NOT one the caller owns.
|
|
201
268
|
let joinSql = '';
|
|
202
|
-
let
|
|
203
|
-
if (isIndirect(
|
|
204
|
-
joinSql = ` ${buildTenantJoin(db,
|
|
205
|
-
|
|
269
|
+
let tenantCols;
|
|
270
|
+
if (isIndirect(scope)) {
|
|
271
|
+
joinSql = ` ${buildTenantJoin(db, scope, tableName)}`;
|
|
272
|
+
tenantCols = [`${db.qi(scope.through.schema.tableName)}.${db.qi(scope.column)}`];
|
|
206
273
|
}
|
|
207
274
|
else {
|
|
208
|
-
|
|
275
|
+
tenantCols = ownColumns(scope).map((c) => `${qTable}.${db.qi(c)}`);
|
|
276
|
+
}
|
|
277
|
+
const inClause = (col) => {
|
|
278
|
+
const phs = tenant.ids.map((id) => {
|
|
279
|
+
values.push(id);
|
|
280
|
+
return db.ph(values.length);
|
|
281
|
+
});
|
|
282
|
+
return `${col} IN (${phs.join(', ')})`;
|
|
283
|
+
};
|
|
284
|
+
let mismatchSql;
|
|
285
|
+
if (tenantCols.length === 1) {
|
|
286
|
+
mismatchSql = `${tenantCols[0]} NOT IN (${tenant.ids.map((id) => {
|
|
287
|
+
values.push(id);
|
|
288
|
+
return db.ph(values.length);
|
|
289
|
+
}).join(', ')})`;
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
// A NULL party makes `IN` return NULL, and `NOT NULL` is not TRUE — a plain negation would
|
|
293
|
+
// let a row with one party NULL and the other owned by a stranger pass the probe unflagged.
|
|
294
|
+
// COALESCE turns "unknown" into "not visible to me", which is what the read side already
|
|
295
|
+
// does implicitly by never matching a NULL.
|
|
296
|
+
mismatchSql = `NOT COALESCE(${tenantCols.map(inClause).join(' OR ')}, FALSE)`;
|
|
209
297
|
}
|
|
210
|
-
const tenantPhs = tenant.ids.map((id) => {
|
|
211
|
-
values.push(id);
|
|
212
|
-
return db.ph(values.length);
|
|
213
|
-
});
|
|
214
|
-
const mismatchSql = `${tenantCol} NOT IN (${tenantPhs.join(', ')})`;
|
|
215
298
|
const sql = `SELECT 1 FROM ${qTable}${joinSql} WHERE ${matchSql} AND ${mismatchSql} LIMIT 1`;
|
|
216
299
|
const r = await db.query(sql, values);
|
|
217
300
|
if (r.rows.length > 0) {
|
|
@@ -228,24 +311,27 @@ export async function assertTenantOwnsConflicts(db, tenant, tableName, conflictC
|
|
|
228
311
|
* `updateFields` keys are DB column names (snake_case), matching `through.localField`.
|
|
229
312
|
*/
|
|
230
313
|
export async function enforceTenantFKOnUpdate(db, tenant, updateFields) {
|
|
231
|
-
if (!tenant || !isIndirect(tenant.scope))
|
|
314
|
+
if (!tenant?.scope || !isIndirect(tenant.scope))
|
|
232
315
|
return;
|
|
233
|
-
const
|
|
316
|
+
const scope = tenant.scope;
|
|
317
|
+
const localCol = scope.through.localField;
|
|
234
318
|
if (!(localCol in updateFields))
|
|
235
319
|
return;
|
|
236
320
|
const newValue = updateFields[localCol];
|
|
237
321
|
if (newValue == null)
|
|
238
322
|
return;
|
|
239
|
-
await validateTenantFK(db,
|
|
323
|
+
await validateTenantFK(db, scope, tenant.ids, [newValue]);
|
|
240
324
|
}
|
|
241
325
|
/**
|
|
242
|
-
* Mutates `fields` in-place: removes the tenant
|
|
243
|
-
*
|
|
326
|
+
* Mutates `fields` in-place: removes the tenant columns from a SET payload (scopes whose columns
|
|
327
|
+
* live on the table itself). An `anyOf` scope drops *every* listed column — neither party can be
|
|
328
|
+
* reassigned, the same spirit as "a tenant cannot move its own row". No-op for indirect scopes.
|
|
244
329
|
*/
|
|
245
330
|
export function stripTenantColumn(fields, scope) {
|
|
246
331
|
if (isIndirect(scope))
|
|
247
332
|
return;
|
|
248
|
-
|
|
333
|
+
for (const col of ownColumns(scope))
|
|
334
|
+
delete fields[col];
|
|
249
335
|
}
|
|
250
336
|
/**
|
|
251
337
|
* For insert/bulk-upsert: enforce tenant on records that are about to be written.
|
|
@@ -254,15 +340,38 @@ export function stripTenantColumn(fields, scope) {
|
|
|
254
340
|
* Throws 400 (ambiguous tenant) or 403 (mismatch) on violation. No-op when `tenant` is undefined.
|
|
255
341
|
*/
|
|
256
342
|
export async function enforceTenantOnWrites(db, tenant, records) {
|
|
257
|
-
|
|
343
|
+
const scope = tenant?.scope;
|
|
344
|
+
if (!tenant || !scope)
|
|
258
345
|
return;
|
|
259
|
-
if (isIndirect(
|
|
260
|
-
const fkCol =
|
|
261
|
-
await validateTenantFK(db,
|
|
346
|
+
if (isIndirect(scope)) {
|
|
347
|
+
const fkCol = scope.through.localField;
|
|
348
|
+
await validateTenantFK(db, scope, tenant.ids, records.map((r) => r[fkCol]));
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (isAnyOf(scope)) {
|
|
352
|
+
for (const r of records)
|
|
353
|
+
assertTenantAnchor(r, scope, tenant.ids);
|
|
262
354
|
return;
|
|
263
355
|
}
|
|
264
356
|
for (const r of records)
|
|
265
|
-
injectTenantValue(r,
|
|
357
|
+
injectTenantValue(r, scope, tenant.ids);
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Columns an UPSERT must leave alone when it lands on an existing row.
|
|
361
|
+
*
|
|
362
|
+
* `stripTenantColumn` does this for a plain UPDATE, but an upsert's payload doubles as the
|
|
363
|
+
* INSERT branch, where the owner columns are exactly what anchors a new row — they cannot simply
|
|
364
|
+
* be dropped. So they are excluded from the `DO UPDATE SET` instead: state which party you are
|
|
365
|
+
* to create the row, and that statement is ignored when the row already exists.
|
|
366
|
+
*
|
|
367
|
+
* Only `anyOf` scopes need this. A direct scope injects the caller's own tenant, so writing it
|
|
368
|
+
* back onto a row the conflict guard already proved theirs is a no-op; an indirect one keeps its
|
|
369
|
+
* owner on another table entirely.
|
|
370
|
+
*/
|
|
371
|
+
export function tenantImmutableColumns(tenant) {
|
|
372
|
+
if (!tenant?.scope || !isAnyOf(tenant.scope))
|
|
373
|
+
return [];
|
|
374
|
+
return tenant.scope.anyOf;
|
|
266
375
|
}
|
|
267
376
|
/**
|
|
268
377
|
* For update: builds the optional `extraCondition` ConditionBuilder for direct tenants.
|
|
@@ -270,13 +379,17 @@ export async function enforceTenantOnWrites(db, tenant, records) {
|
|
|
270
379
|
* (indirect uses pre-check via `assertTenantOwnership`).
|
|
271
380
|
*/
|
|
272
381
|
export function buildTenantUpdateExtra(db, tenant) {
|
|
273
|
-
|
|
382
|
+
const scope = tenant?.scope;
|
|
383
|
+
if (!scope || isIndirect(scope))
|
|
274
384
|
return undefined;
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
385
|
+
const cols = ownColumns(scope);
|
|
386
|
+
const cb = new ConditionBuilder(cols.length > 1 ? 'OR' : 'AND', db.cbDialect);
|
|
387
|
+
for (const col of cols) {
|
|
388
|
+
if (tenant.ids.length === 1)
|
|
389
|
+
cb.isEqual(col, tenant.ids[0]);
|
|
390
|
+
else
|
|
391
|
+
cb.isIn(col, tenant.ids);
|
|
392
|
+
}
|
|
280
393
|
return cb;
|
|
281
394
|
}
|
|
282
395
|
/**
|
|
@@ -285,14 +398,15 @@ export function buildTenantUpdateExtra(db, tenant) {
|
|
|
285
398
|
* No-op for direct or no-tenant — those are enforced via `extraCondition` in the UPDATE itself.
|
|
286
399
|
*/
|
|
287
400
|
export async function assertTenantOwnership(db, tenant, tableName, pkCol, pkValue) {
|
|
288
|
-
if (!tenant || !isIndirect(tenant.scope))
|
|
401
|
+
if (!tenant?.scope || !isIndirect(tenant.scope))
|
|
289
402
|
return;
|
|
403
|
+
const scope = tenant.scope;
|
|
290
404
|
const cb = new ConditionBuilder('AND', db.cbDialect);
|
|
291
405
|
const cols = Array.isArray(pkCol) ? pkCol : [pkCol];
|
|
292
406
|
const vals = Array.isArray(pkValue) ? pkValue : [pkValue];
|
|
293
407
|
cols.forEach((c, i) => cb.isEqual(`${db.qi(tableName)}.${db.qi(c)}`, vals[i]));
|
|
294
|
-
cb.append(buildTenantCondition(db,
|
|
295
|
-
const sql = `SELECT 1 FROM ${db.qi(tableName)} ${buildTenantJoin(db,
|
|
408
|
+
cb.append(buildTenantCondition(db, scope, tenant.ids, tableName));
|
|
409
|
+
const sql = `SELECT 1 FROM ${db.qi(tableName)} ${buildTenantJoin(db, scope, tableName)} WHERE ${cb.build(1, db.ph)} LIMIT 1`;
|
|
296
410
|
const r = await db.query(sql, cb.getValues());
|
|
297
411
|
if (r.rows.length === 0) {
|
|
298
412
|
throw httpError(404, 'Record not found');
|
|
@@ -305,13 +419,14 @@ export async function assertTenantOwnership(db, tenant, tableName, pkCol, pkValu
|
|
|
305
419
|
* `beforeBulkDelete` hook, so user code never runs against rows the caller cannot access.
|
|
306
420
|
*/
|
|
307
421
|
export async function assertTenantOwnsAll(db, tenant, tableName, pkCol, ids) {
|
|
308
|
-
|
|
422
|
+
const scope = tenant?.scope;
|
|
423
|
+
if (!tenant || !scope || !ids.length)
|
|
309
424
|
return;
|
|
310
425
|
const qualifiedPk = `${db.qi(tableName)}.${db.qi(pkCol)}`;
|
|
311
426
|
const cb = new ConditionBuilder('AND', db.cbDialect);
|
|
312
427
|
cb.isIn(qualifiedPk, ids);
|
|
313
|
-
cb.append(buildTenantCondition(db,
|
|
314
|
-
const join = isIndirect(
|
|
428
|
+
cb.append(buildTenantCondition(db, scope, tenant.ids, tableName));
|
|
429
|
+
const join = isIndirect(scope) ? ` ${buildTenantJoin(db, scope, tableName)}` : '';
|
|
315
430
|
const sql = `SELECT DISTINCT ${qualifiedPk} AS pk FROM ${db.qi(tableName)}${join} WHERE ${cb.build(1, db.ph)}`;
|
|
316
431
|
const r = await db.query(sql, cb.getValues());
|
|
317
432
|
const uniqueRequested = new Set(ids.map((v) => String(v))).size;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { TSchema } from '@sinclair/typebox';
|
|
2
|
+
import type { ITable, SchemaDefinition } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Write visibility — the counterpart of `read-access.ts`.
|
|
5
|
+
*
|
|
6
|
+
* A field is excluded from writes for one of two reasons:
|
|
7
|
+
*
|
|
8
|
+
* - the database computes it (`GENERATED ALWAYS AS (<expr>)`), which the generated Schema
|
|
9
|
+
* publishes in `generatedFields`. Naming such a column in an INSERT or an UPDATE is an
|
|
10
|
+
* error on both engines, whoever put the value there, so this exclusion is a fact about
|
|
11
|
+
* the column rather than a decision about the caller;
|
|
12
|
+
* - the table config lists it in `writeExclude`, for a column the deployment keeps read-only
|
|
13
|
+
* for reasons of its own.
|
|
14
|
+
*
|
|
15
|
+
* Excluded fields are removed from the write body schemas — so an HTTP caller gets a 400 from
|
|
16
|
+
* `additionalProperties: false` rather than a driver error — and dropped again in the engines,
|
|
17
|
+
* which `sqlApi.*` reaches without passing through those schemas.
|
|
18
|
+
*
|
|
19
|
+
* This is not `excludeFromCreation`, which whitelists CLIENT input on insert only and lets a
|
|
20
|
+
* hook assign the field afterwards (ADR 0005); nor is it the place for per-caller field rules,
|
|
21
|
+
* which stay in `beforeUpdate` / `validate` (ADR 0011).
|
|
22
|
+
*/
|
|
23
|
+
export declare function writeExcludedFields(tableConf: ITable | undefined, schema: SchemaDefinition | undefined): Set<string>;
|
|
24
|
+
/**
|
|
25
|
+
* A field map minus everything that may not be written. Used to build the write body schemas,
|
|
26
|
+
* where the removal is what turns a rejected column into a 400 instead of a 500.
|
|
27
|
+
*/
|
|
28
|
+
export declare function writableFields(fields: Record<string, TSchema>, tableConf: ITable | undefined, schema: SchemaDefinition | undefined): Record<string, TSchema>;
|
|
29
|
+
/**
|
|
30
|
+
* Drop the non-writable fields from a camelCase record, in place.
|
|
31
|
+
*
|
|
32
|
+
* Call this AFTER the write hooks, unlike `excludeFromCreation`: a hook cannot be allowed to
|
|
33
|
+
* put back a value the database will refuse, so the last word has to belong to the exclusion.
|
|
34
|
+
*/
|
|
35
|
+
export declare function removeWriteExcluded(record: Record<string, unknown>, tableConf: ITable | undefined, schema: SchemaDefinition | undefined): void;
|
|
36
|
+
//# sourceMappingURL=write-access.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"write-access.d.ts","sourceRoot":"","sources":["../../src/lib/write-access.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,KAAK,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAE5D;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,MAAM,EAAE,gBAAgB,GAAG,SAAS,GACnC,GAAG,CAAC,MAAM,CAAC,CAIb;AAUD;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,MAAM,EAAE,gBAAgB,GAAG,SAAS,GACnC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAQzB;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,MAAM,EAAE,gBAAgB,GAAG,SAAS,GACnC,IAAI,CAKN"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write visibility — the counterpart of `read-access.ts`.
|
|
3
|
+
*
|
|
4
|
+
* A field is excluded from writes for one of two reasons:
|
|
5
|
+
*
|
|
6
|
+
* - the database computes it (`GENERATED ALWAYS AS (<expr>)`), which the generated Schema
|
|
7
|
+
* publishes in `generatedFields`. Naming such a column in an INSERT or an UPDATE is an
|
|
8
|
+
* error on both engines, whoever put the value there, so this exclusion is a fact about
|
|
9
|
+
* the column rather than a decision about the caller;
|
|
10
|
+
* - the table config lists it in `writeExclude`, for a column the deployment keeps read-only
|
|
11
|
+
* for reasons of its own.
|
|
12
|
+
*
|
|
13
|
+
* Excluded fields are removed from the write body schemas — so an HTTP caller gets a 400 from
|
|
14
|
+
* `additionalProperties: false` rather than a driver error — and dropped again in the engines,
|
|
15
|
+
* which `sqlApi.*` reaches without passing through those schemas.
|
|
16
|
+
*
|
|
17
|
+
* This is not `excludeFromCreation`, which whitelists CLIENT input on insert only and lets a
|
|
18
|
+
* hook assign the field afterwards (ADR 0005); nor is it the place for per-caller field rules,
|
|
19
|
+
* which stay in `beforeUpdate` / `validate` (ADR 0011).
|
|
20
|
+
*/
|
|
21
|
+
export function writeExcludedFields(tableConf, schema) {
|
|
22
|
+
const excluded = new Set(schema?.generatedFields ?? []);
|
|
23
|
+
for (const field of tableConf?.writeExclude ?? [])
|
|
24
|
+
excluded.add(field);
|
|
25
|
+
return excluded;
|
|
26
|
+
}
|
|
27
|
+
/** True when the table has nothing to exclude — lets callers keep their existing object. */
|
|
28
|
+
function hasWriteExclusions(tableConf, schema) {
|
|
29
|
+
return (schema?.generatedFields?.length ?? 0) > 0 || (tableConf?.writeExclude?.length ?? 0) > 0;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A field map minus everything that may not be written. Used to build the write body schemas,
|
|
33
|
+
* where the removal is what turns a rejected column into a 400 instead of a 500.
|
|
34
|
+
*/
|
|
35
|
+
export function writableFields(fields, tableConf, schema) {
|
|
36
|
+
if (!hasWriteExclusions(tableConf, schema))
|
|
37
|
+
return fields;
|
|
38
|
+
const excluded = writeExcludedFields(tableConf, schema);
|
|
39
|
+
const result = {};
|
|
40
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
41
|
+
if (!excluded.has(name))
|
|
42
|
+
result[name] = value;
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Drop the non-writable fields from a camelCase record, in place.
|
|
48
|
+
*
|
|
49
|
+
* Call this AFTER the write hooks, unlike `excludeFromCreation`: a hook cannot be allowed to
|
|
50
|
+
* put back a value the database will refuse, so the last word has to belong to the exclusion.
|
|
51
|
+
*/
|
|
52
|
+
export function removeWriteExcluded(record, tableConf, schema) {
|
|
53
|
+
if (!hasWriteExclusions(tableConf, schema))
|
|
54
|
+
return;
|
|
55
|
+
for (const field of writeExcludedFields(tableConf, schema)) {
|
|
56
|
+
delete record[field];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"get.routes.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/get.routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"get.routes.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/get.routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAI/C,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAE1D,wBAA8B,SAAS,CACrC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAkBf"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Type } from '@sinclair/typebox';
|
|
2
|
+
import { GetTableResponse } from '../../lib/schema/get.js';
|
|
2
3
|
import { registerForAllTables } from './route-helpers.js';
|
|
3
4
|
export default async function getRoutes(fastify, options) {
|
|
4
5
|
await registerForAllTables(fastify, options, {
|
|
@@ -7,9 +8,9 @@ export default async function getRoutes(fastify, options) {
|
|
|
7
8
|
method: 'GET',
|
|
8
9
|
url: (tc) => `/rest/${tc.Schema.tableName}/:id`,
|
|
9
10
|
successStatus: 200,
|
|
10
|
-
schemas: (
|
|
11
|
+
schemas: (db, table) => ({
|
|
11
12
|
params: Type.Object({ id: Type.String() }),
|
|
12
|
-
response:
|
|
13
|
+
response: GetTableResponse(db, table),
|
|
13
14
|
}),
|
|
14
15
|
summary: 'Get',
|
|
15
16
|
description: (name) => `Get a record from ${name} by primary key`,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAgB,MAAM,SAAS,CAAC;AAc7D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;kCAuD/C,eAAe,WACf,mBAAmB,KAC3B,OAAO,CAAC,IAAI,CAAC;AAHhB,
|
|
1
|
+
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAgB,MAAM,SAAS,CAAC;AAc7D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;kCAuD/C,eAAe,WACf,mBAAmB,KAC3B,OAAO,CAAC,IAAI,CAAC;AAHhB,wBAsEoC"}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import fp from 'fastify-plugin';
|
|
2
2
|
import { ConditionBuilder } from 'node-condition-builder';
|
|
3
3
|
import { getDialect } from '../../lib/dialect.js';
|
|
4
|
-
import { ensureSqlApiDecorator } from '
|
|
5
|
-
import { setupSwagger } from '
|
|
4
|
+
import { ensureSqlApiDecorator } from '../sql-api-decorator.js';
|
|
5
|
+
import { setupSwagger } from '../setup-swagger.js';
|
|
6
6
|
import searchRoutes from './search.routes.js';
|
|
7
7
|
import getRoutes from './get.routes.js';
|
|
8
8
|
import insertRoutes from './insert.routes.js';
|
|
@@ -82,10 +82,16 @@ export default fp(async function fastifyAutoSqlApi(fastify, options) {
|
|
|
82
82
|
});
|
|
83
83
|
}
|
|
84
84
|
const statusCode = error.statusCode || 500;
|
|
85
|
+
// A sanitized error carries the request id so the client can point at the log line
|
|
86
|
+
// holding the real cause, and — under `exposeDebugInfo` — the description of that
|
|
87
|
+
// cause (ADR 0013). Both are additive: the fields above never change shape.
|
|
88
|
+
const sanitized = error;
|
|
85
89
|
reply.status(statusCode).send({
|
|
86
90
|
statusCode,
|
|
87
91
|
error: error.name || 'Error',
|
|
88
92
|
message: error.message,
|
|
93
|
+
...(sanitized.requestId ? { requestId: sanitized.requestId } : {}),
|
|
94
|
+
...(sanitized.debugInfo ? { debugInfo: sanitized.debugInfo } : {}),
|
|
89
95
|
});
|
|
90
96
|
});
|
|
91
97
|
if (options.swagger) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"route-helpers.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/route-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"route-helpers.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/route-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAI7E,OAAO,KAAK,EAAE,MAAM,EAAE,mBAAmB,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAQ5F,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAQlG;AAED,UAAU,WAAW;IAEnB,IAAI,CAAC,EAAE,GAAG,CAAC;IAEX,MAAM,CAAC,EAAE,GAAG,CAAC;IAEb,WAAW,CAAC,EAAE,GAAG,CAAC;IAElB,QAAQ,CAAC,EAAE,GAAG,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC5B,iGAAiG;IACjG,SAAS,EAAE,cAAc,CAAC;IAC1B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,MAAM,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;IAC1C,gFAAgF;IAChF,GAAG,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,MAAM,CAAC;IACnC,aAAa,EAAE,GAAG,GAAG,GAAG,CAAC;IACzB,wIAAwI;IACxI,OAAO,EAAE,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,KAAK,WAAW,CAAC;IACnF,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,KAAK,MAAM,CAAC;IACpF;2EACuE;IACvE,MAAM,EAAE,CACN,OAAO,EAAE,eAAe,EACxB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,cAAc,EACvB,KAAK,EAAE,YAAY,KAChB,OAAO,CAAC,OAAO,CAAC,CAAC;CACvB;AA6DD,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,EAC5B,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,IAAI,CAAC,CA6Bf"}
|