fastify-auto-sqlapi 0.1.3 → 0.1.5

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.
Files changed (64) hide show
  1. package/AGENTS_BACKEND.md +14 -2
  2. package/AGENTS_FRONTEND.md +7 -5
  3. package/README.md +57 -2
  4. package/dist/bin/generate-tables.js +19 -5
  5. package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
  6. package/dist/lib/cli/tables-codegen.js +6 -4
  7. package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
  8. package/dist/lib/engine/bulk/bulk-upsert.js +3 -1
  9. package/dist/lib/engine/rest/get.d.ts.map +1 -1
  10. package/dist/lib/engine/rest/get.js +2 -0
  11. package/dist/lib/engine/rest/insert.d.ts.map +1 -1
  12. package/dist/lib/engine/rest/insert.js +3 -1
  13. package/dist/lib/engine/rest/update.d.ts.map +1 -1
  14. package/dist/lib/engine/rest/update.js +5 -2
  15. package/dist/lib/engine/search/search.d.ts.map +1 -1
  16. package/dist/lib/engine/search/search.js +107 -29
  17. package/dist/lib/read-access.d.ts +31 -0
  18. package/dist/lib/read-access.d.ts.map +1 -0
  19. package/dist/lib/read-access.js +53 -0
  20. package/dist/lib/schema/bulk-delete.d.ts +1 -1
  21. package/dist/lib/schema/bulk-delete.d.ts.map +1 -1
  22. package/dist/lib/schema/bulk-delete.js +2 -2
  23. package/dist/lib/schema/bulk-upsert.d.ts +1 -1
  24. package/dist/lib/schema/bulk-upsert.d.ts.map +1 -1
  25. package/dist/lib/schema/bulk-upsert.js +5 -3
  26. package/dist/lib/schema/helpers.d.ts +2 -0
  27. package/dist/lib/schema/helpers.d.ts.map +1 -1
  28. package/dist/lib/schema/helpers.js +3 -2
  29. package/dist/lib/schema/insert.d.ts.map +1 -1
  30. package/dist/lib/schema/insert.js +4 -1
  31. package/dist/lib/schema/search.d.ts.map +1 -1
  32. package/dist/lib/schema/search.js +17 -5
  33. package/dist/lib/schema/update.d.ts.map +1 -1
  34. package/dist/lib/schema/update.js +4 -1
  35. package/dist/lib/sql-api-decorator.d.ts +18 -0
  36. package/dist/lib/sql-api-decorator.d.ts.map +1 -0
  37. package/dist/lib/sql-api-decorator.js +40 -0
  38. package/dist/lib/sql-api.d.ts +2 -0
  39. package/dist/lib/sql-api.d.ts.map +1 -1
  40. package/dist/lib/sql-api.js +1 -0
  41. package/dist/lib/table-helpers.d.ts.map +1 -1
  42. package/dist/lib/table-helpers.js +18 -0
  43. package/dist/lib/tenant.d.ts +22 -0
  44. package/dist/lib/tenant.d.ts.map +1 -1
  45. package/dist/lib/tenant.js +0 -0
  46. package/dist/routes/auto/bulk-delete.routes.d.ts.map +1 -1
  47. package/dist/routes/auto/bulk-delete.routes.js +2 -1
  48. package/dist/routes/auto/bulk-upsert.routes.d.ts.map +1 -1
  49. package/dist/routes/auto/bulk-upsert.routes.js +2 -1
  50. package/dist/routes/auto/plugin.d.ts +0 -6
  51. package/dist/routes/auto/plugin.d.ts.map +1 -1
  52. package/dist/routes/auto/plugin.js +2 -28
  53. package/dist/routes/auto/route-helpers.d.ts.map +1 -1
  54. package/dist/routes/auto/route-helpers.js +4 -0
  55. package/dist/routes/auto/search.routes.d.ts.map +1 -1
  56. package/dist/routes/auto/search.routes.js +8 -0
  57. package/dist/types/plugin.d.ts +16 -0
  58. package/dist/types/plugin.d.ts.map +1 -1
  59. package/dist/types/plugin.js +4 -1
  60. package/dist/types/search.d.ts +7 -0
  61. package/dist/types/search.d.ts.map +1 -1
  62. package/dist/types/table.d.ts +13 -0
  63. package/dist/types/table.d.ts.map +1 -1
  64. package/package.json +2 -2
@@ -1,14 +1,16 @@
1
1
  import { ConditionBuilder } from 'node-condition-builder';
2
2
  import { camelcaseObject } from '../../naming.js';
3
3
  import { buildTenantCondition, buildTenantJoin } from '../../tenant.js';
4
+ import { assertReadable, assertFiltersReadable, readableSelectColumns } from '../../read-access.js';
4
5
  import { primaryAsString } from '../../../types.js';
5
6
  // ─── Schema field validation ────────────────────────────────
6
- function validateSchemaField(field, schema) {
7
+ function validateSchemaField(field, schema, tableConf) {
7
8
  if (!(field in schema.fields)) {
8
9
  const err = new Error(`Unknown field: ${field}`);
9
10
  err.statusCode = 400;
10
11
  throw err;
11
12
  }
13
+ assertReadable(tableConf, field);
12
14
  return schema.col(field);
13
15
  }
14
16
  function err400(msg) {
@@ -37,6 +39,7 @@ function buildComputedContext(db, schema, alias) {
37
39
  }
38
40
  function resolveFieldRef(field, schema, tableConf, db, alias) {
39
41
  if (field in schema.fields) {
42
+ assertReadable(tableConf, field);
40
43
  const col = db.qi(schema.col(field));
41
44
  const expr = alias ? `${db.qi(alias)}.${col}` : col;
42
45
  return { expr, values: [], computed: false };
@@ -121,11 +124,19 @@ function mapRowsToCamelCase(rows, schema, computedFieldNames) {
121
124
  return camel;
122
125
  });
123
126
  }
124
- /** Build the SELECT columns list for a join fetch: '*' or comma-separated quoted columns. */
125
- function buildSelectionColumns(selection, joinSchema, db) {
126
- if (selection === '*')
127
- return '*';
128
- return selection.split(',').map((c) => db.qi(joinSchema.col(c.trim()))).join(', ');
127
+ /**
128
+ * Build the SELECT columns list for a join fetch: '*' or comma-separated quoted columns.
129
+ * A default '*' selection narrows to the join table's readable columns when it declares
130
+ * `readExclude`; an explicit selection naming an excluded field is rejected with 400.
131
+ */
132
+ function buildSelectionColumns(selection, joinSchema, db, joinTableConf) {
133
+ if (selection === '*') {
134
+ return readableSelectColumns(joinTableConf, joinSchema, db) ?? '*';
135
+ }
136
+ return selection
137
+ .split(',')
138
+ .map((c) => db.qi(validateSchemaField(c.trim(), joinSchema, joinTableConf)))
139
+ .join(', ');
129
140
  }
130
141
  const AGG_FN = {
131
142
  sum: { sql: 'SUM', distinct: false },
@@ -171,6 +182,28 @@ function buildAggOrderExpr(db, dbTables, tableConf, alias, fn, field, joinGroup,
171
182
  const expr = `COALESCE((SELECT ${aggExpr(aggFn, qualifiedCol)} FROM ${refs.joinTable} WHERE ${refs.joinTable}.${refs.fkCol} = ${refs.mainTable}.${refs.mainCol}${filterWhere}), 0)`;
172
183
  return { expr, values: filterVals };
173
184
  }
185
+ /**
186
+ * Pre-scan `orderBy` for the joinLeft aliases referenced in 2-parti notation (`<alias>.<field>`).
187
+ * Needed BEFORE building the LEFT JOIN clauses (which must know their aliases) while the actual
188
+ * orderBy SQL — with its parameter placeholders — is only baked later, once the LEFT JOIN value
189
+ * count is known. Validates each alias against `allowedReadJoins` (throws 400), mirroring
190
+ * `validateOrderBy`; 3-parti aggregation entries are ignored (they use joinGroup, not a LEFT JOIN).
191
+ */
192
+ function collectOrderByLeftAliases(orderBy, tableConf) {
193
+ const aliases = new Set();
194
+ for (const part of orderBy.split(',')) {
195
+ const trimmed = part.trim();
196
+ if (/^(\w+)\.(\w+)\.(\w+)(?:\s+(ASC|DESC))?$/i.test(trimmed))
197
+ continue; // 3-parti aggregation
198
+ const m = trimmed.match(/^(\w+)\.(\w+)(?:\s+(ASC|DESC))?$/i);
199
+ if (m) {
200
+ const alias = m[1];
201
+ requireJoin(tableConf, alias, true);
202
+ aliases.add(alias);
203
+ }
204
+ }
205
+ return aliases;
206
+ }
174
207
  function validateOrderBy(orderBy, tableConf, db, dbTables, joinGroup, startIdx) {
175
208
  const parts = orderBy.split(',');
176
209
  const outParts = [];
@@ -197,7 +230,7 @@ function validateOrderBy(orderBy, tableConf, db, dbTables, joinGroup, startIdx)
197
230
  if (dotted2) {
198
231
  const [, alias, field, dir] = dotted2;
199
232
  const joinDef = requireJoin(tableConf, alias, true);
200
- const col = validateSchemaField(field, joinDef.joinSchema);
233
+ const col = validateSchemaField(field, joinDef.joinSchema, dbTables[joinDef.joinSchema.tableName]);
201
234
  // Reference the LEFT JOIN'd table via its alias (SQL identifier).
202
235
  outParts.push(`${db.qi(alias)}.${db.qi(col)} ${(dir || 'ASC').toUpperCase()}`);
203
236
  leftJoinAliases.add(alias);
@@ -237,6 +270,7 @@ function buildLeftJoinClauses(db, dbTables, tableConf, aliasesNeedingJoin, joinL
237
270
  const cb = new ConditionBuilder('AND', db.cbDialect);
238
271
  const joinTableConf = dbTables[joinSchema.tableName];
239
272
  const computed = joinTableConf?.computedFields;
273
+ assertFiltersReadable(ref.filters, joinTableConf);
240
274
  if (ref.filters) {
241
275
  for (const [field, value] of Object.entries(ref.filters)) {
242
276
  if (value === null || value === undefined)
@@ -257,6 +291,7 @@ function buildLeftJoinClauses(db, dbTables, tableConf, aliasesNeedingJoin, joinL
257
291
  err400(`Invalid condition method: ${c.method}`);
258
292
  }
259
293
  if (c.field in joinSchema.fields) {
294
+ assertReadable(dbTables[joinSchema.tableName], c.field);
260
295
  const col = `${aliasIdent}.${db.qi(joinSchema.col(c.field))}`;
261
296
  dispatchConditionMethod(cb, c.method, col, c.params ?? []);
262
297
  }
@@ -281,17 +316,52 @@ function buildLeftJoinClauses(db, dbTables, tableConf, aliasesNeedingJoin, joinL
281
316
  return { joinClauses, whereExtras, values };
282
317
  }
283
318
  // ─── Main query execution ───────────────────────────────────
284
- async function executeMainQuery(db, tableConf, where, values, orderBy, paginator, extraJoins = [], selectComputed) {
319
+ /**
320
+ * Converts a configured order (`defaultOrder` or the primary-key fallback) to SQL.
321
+ * Unlike the request `orderBy` (strictly validated by `validateOrderBy`), this is
322
+ * lenient for backward compatibility: tokens matching a camelCase schema field are
323
+ * mapped to their quoted DB column, computed fields (without bound values) expand
324
+ * to their expression, and anything else passes through unchanged as raw SQL.
325
+ */
326
+ function convertConfiguredOrder(order, tableConf, db) {
327
+ return order
328
+ .split(',')
329
+ .map((part) => {
330
+ const trimmed = part.trim();
331
+ const match = trimmed.match(/^(\w+)(?:\s+(ASC|DESC))?$/i);
332
+ if (match) {
333
+ const [, field, dir] = match;
334
+ const suffix = dir ? ` ${dir.toUpperCase()}` : '';
335
+ if (field in tableConf.Schema.fields) {
336
+ return `${db.qi(tableConf.Schema.col(field))}${suffix}`;
337
+ }
338
+ const computed = tableConf.computedFields?.[field];
339
+ if (computed) {
340
+ const ev = evaluateComputedField(computed, tableConf.Schema, db, undefined, `Computed field '${field}' with bound values cannot be used in defaultOrder`);
341
+ return `${ev.expr}${suffix}`;
342
+ }
343
+ }
344
+ return trimmed;
345
+ })
346
+ .join(', ');
347
+ }
348
+ async function executeMainQuery(db, tableConf, where, values, orderBy, paginator, extraJoins = [], selectComputed, maxRows) {
285
349
  const tableName = tableConf.Schema.tableName;
286
- const order = orderBy || tableConf.defaultOrder || primaryAsString(tableConf.primary);
350
+ // Request orderBy arrives already validated and mapped by validateOrderBy;
351
+ // configured fallbacks (defaultOrder / primary key) are converted leniently here.
352
+ const order = orderBy || convertConfiguredOrder(tableConf.defaultOrder || primaryAsString(tableConf.primary), tableConf, db);
353
+ // With a paginator, the page size governs the LIMIT. Without one, apply `maxRows` (if set) so an
354
+ // unbounded search cannot dump the whole table; a plain integer is a safe, non-injectable LIMIT.
287
355
  const limit = paginator
288
356
  ? `${paginator.itemsPerPage} OFFSET ${(paginator.page - 1) * paginator.itemsPerPage}`
289
- : null;
357
+ : (maxRows != null ? String(maxRows) : null);
358
+ // Base projection: '*' unless the table hides fields via readExclude.
359
+ const readableColumns = readableSelectColumns(tableConf, tableConf.Schema, db);
290
360
  // Optional computed projections — bound values, if any, are NOT yet supported
291
361
  // here (would require placeholder-aware composition with WHERE values).
292
- let columns;
362
+ let columns = readableColumns;
293
363
  if (selectComputed?.length) {
294
- const projections = ['*'];
364
+ const projections = [readableColumns ?? '*'];
295
365
  for (const name of selectComputed) {
296
366
  const fn = tableConf.computedFields?.[name];
297
367
  if (!fn)
@@ -367,7 +437,7 @@ async function executeJoinMultiple(db, dbTables, tableConf, mainResults, joinMul
367
437
  ({ where, values } = appendComputedConditions(where, values, computedClauses, db));
368
438
  }
369
439
  const selection = ref?.selection ?? defaultSelection;
370
- const columns = buildSelectionColumns(selection, joinSchema, db);
440
+ const columns = buildSelectionColumns(selection, joinSchema, db, joinTableConf);
371
441
  const rows = await db.select({
372
442
  tableName: joinSchema.tableName,
373
443
  columns,
@@ -401,7 +471,7 @@ async function executeJoinLeft(db, dbTables, tableConf, mainResults, joinLeft, t
401
471
  const where = cb.build(1, db.ph);
402
472
  const values = cb.getValues();
403
473
  const selection = ref?.selection ?? defaultSelection;
404
- const columns = buildSelectionColumns(selection, joinSchema, db);
474
+ const columns = buildSelectionColumns(selection, joinSchema, db, joinTableConf);
405
475
  const rows = await db.select({
406
476
  tableName: joinSchema.tableName,
407
477
  columns,
@@ -458,7 +528,7 @@ async function executeJoinGroup(db, dbTables, tableConf, mainResults, joinGroup,
458
528
  return;
459
529
  const fn = AGG_FN[kind];
460
530
  for (const f of fields) {
461
- const col = validateSchemaField(f, joinSchema);
531
+ const col = validateSchemaField(f, joinSchema, joinTableConf);
462
532
  selectParts.push(`${aggExpr(fn, db.qi(col))} as "${kind}_${f}"`);
463
533
  }
464
534
  };
@@ -544,6 +614,7 @@ function dispatchConditionMethod(cb, method, colOrExpr, params) {
544
614
  }
545
615
  }
546
616
  function buildJoinRefCondition(joinTableConf, joinSchema, ref, db) {
617
+ assertFiltersReadable(ref.filters, joinTableConf);
547
618
  const cb = (ref.filters && joinTableConf)
548
619
  ? joinTableConf.filters(ref.filters, db.cbDialect)
549
620
  : new ConditionBuilder('AND', db.cbDialect);
@@ -557,7 +628,7 @@ function buildJoinRefCondition(joinTableConf, joinSchema, ref, db) {
557
628
  // for an LHS expression.
558
629
  if (joinTableConf?.computedFields?.[c.field])
559
630
  continue;
560
- const col = db.qi(validateSchemaField(c.field, joinSchema));
631
+ const col = db.qi(validateSchemaField(c.field, joinSchema, joinTableConf));
561
632
  dispatchConditionMethod(cb, c.method, col, c.params ?? []);
562
633
  }
563
634
  }
@@ -754,6 +825,7 @@ export async function searchEngine(dbTables, params) {
754
825
  for (const a of Object.keys(joinLeft))
755
826
  requireJoin(tableConf, a, true);
756
827
  // Build main condition
828
+ assertFiltersReadable(filters, tableConf);
757
829
  const condition = tableConf.filters(filters || {}, db.cbDialect);
758
830
  // Filters that target computed fields (declared on tableConf.computedFields)
759
831
  // are applied via the side-channel pattern further down.
@@ -794,27 +866,23 @@ export async function searchEngine(dbTables, params) {
794
866
  ({ where, values } = appendAggConditions(where, values, conditions, db, dbTables, tableConf, joinGroup));
795
867
  }
796
868
  }
797
- // orderBy parsing — collects 2-parti aliases that need LEFT JOIN on the main query
798
- let safeOrderBy;
799
- let orderByValues = [];
800
- let orderByLeftAliases = new Set();
801
- if (orderBy) {
802
- const obResult = validateOrderBy(orderBy, tableConf, db, dbTables, joinGroup, values.length + 1);
803
- safeOrderBy = obResult.sql;
804
- orderByValues = obResult.values;
805
- orderByLeftAliases = obResult.leftJoinAliases;
806
- }
807
869
  // Determine which joinLeft aliases need a LEFT JOIN on the main query:
808
870
  // - any alias used in 2-parti orderBy
809
871
  // - any alias in joinLeft body that has filters or conditions on the parent
810
- const aliasesNeedingLeftJoin = new Set(orderByLeftAliases);
872
+ const aliasesNeedingLeftJoin = new Set();
873
+ if (orderBy) {
874
+ for (const a of collectOrderByLeftAliases(orderBy, tableConf))
875
+ aliasesNeedingLeftJoin.add(a);
876
+ }
811
877
  if (joinLeft) {
812
878
  for (const [alias, ref] of Object.entries(joinLeft)) {
813
879
  if (ref?.filters || ref?.conditions?.length)
814
880
  aliasesNeedingLeftJoin.add(alias);
815
881
  }
816
882
  }
817
- // Build LEFT JOIN clauses + extra WHERE for filtered parents
883
+ // Build LEFT JOIN clauses + extra WHERE for filtered parents. Their placeholders start right
884
+ // after the WHERE values; the same clause SQL is reused by the pagination COUNT/compute queries,
885
+ // whose value array is [...WHERE, ...leftJoin] — so this base MUST stay `values.length + 1`.
818
886
  const extraJoinClauses = [...tenantJoins];
819
887
  let leftJoinValues = [];
820
888
  if (aliasesNeedingLeftJoin.size > 0) {
@@ -824,11 +892,21 @@ export async function searchEngine(dbTables, params) {
824
892
  for (const w of lj.whereExtras)
825
893
  where += ` AND ${w}`;
826
894
  }
895
+ // orderBy is parsed AFTER the LEFT JOIN values so aggregation-orderBy placeholders start past
896
+ // them (main query binds [...WHERE, ...leftJoin, ...orderBy]). Parsing it earlier with base
897
+ // `values.length + 1` misbinds when BOTH an aggregation orderBy and a filtered joinLeft exist.
898
+ let safeOrderBy;
899
+ let orderByValues = [];
900
+ if (orderBy) {
901
+ const obResult = validateOrderBy(orderBy, tableConf, db, dbTables, joinGroup, values.length + leftJoinValues.length + 1);
902
+ safeOrderBy = obResult.sql;
903
+ orderByValues = obResult.values;
904
+ }
827
905
  // Bind WHERE values + LEFT JOIN values + orderBy aggregation values for main query.
828
906
  // Pagination COUNT/compute include WHERE + LEFT JOIN values (no orderBy).
829
907
  const whereAndJoinValues = [...values, ...leftJoinValues];
830
908
  const mainValues = [...whereAndJoinValues, ...orderByValues];
831
- const main = await executeMainQuery(db, tableConf, where, mainValues, safeOrderBy, paginator, extraJoinClauses, params.selectComputed);
909
+ const main = await executeMainQuery(db, tableConf, where, mainValues, safeOrderBy, paginator, extraJoinClauses, params.selectComputed, params.maxRows);
832
910
  let pagination;
833
911
  if (paginator) {
834
912
  pagination = await buildPagination(db, tableConf, where, whereAndJoinValues, paginator, extraJoinClauses, computeMin, computeMax, computeSum, computeAvg);
@@ -0,0 +1,31 @@
1
+ import type { QueryClient } from './db.js';
2
+ import type { ITable, SchemaDefinition } from '../types.js';
3
+ /**
4
+ * Read visibility (`ITable.readExclude`).
5
+ *
6
+ * Excluded fields are never projected by any read query and cannot be referenced
7
+ * from filters, conditions, orderBy, aggregations or join selections: allowing a
8
+ * field to be filtered while hiding it from the output would still leak its value
9
+ * by bisection. Write paths (insert/update/upsert) are deliberately unaffected —
10
+ * a field can be writable but never readable (e.g. a password hash).
11
+ */
12
+ export declare function isReadExcluded(tableConf: ITable | undefined, field: string): boolean;
13
+ /** Throws 400 when `field` is read-excluded on `tableConf`. */
14
+ export declare function assertReadable(tableConf: ITable | undefined, field: string): void;
15
+ /**
16
+ * Throws 400 when a filter map targets a read-excluded field. Keys that are not
17
+ * excluded pass through untouched (extraFilters and computed names included).
18
+ */
19
+ export declare function assertFiltersReadable(filters: Record<string, unknown> | undefined, tableConf: ITable | undefined): void;
20
+ /** Schema field names that may be read, in schema declaration order. */
21
+ export declare function readableFieldNames(tableConf: ITable | undefined, schema: SchemaDefinition): string[];
22
+ /**
23
+ * SELECT column list for a table with read exclusions, or `undefined` when the
24
+ * table has none (callers keep their existing `*` projection).
25
+ *
26
+ * Columns are qualified with the table name: read queries may carry a tenant
27
+ * `through` INNER JOIN, where a bare column name shared with the through table
28
+ * would be ambiguous.
29
+ */
30
+ export declare function readableSelectColumns(tableConf: ITable | undefined, schema: SchemaDefinition, db: QueryClient): string | undefined;
31
+ //# sourceMappingURL=read-access.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"read-access.d.ts","sourceRoot":"","sources":["../../src/lib/read-access.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAC3C,OAAO,KAAK,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAG5D;;;;;;;;GAQG;AAEH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAEpF;AAED,+DAA+D;AAC/D,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAIjF;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAC5C,SAAS,EAAE,MAAM,GAAG,SAAS,GAC5B,IAAI,CAKN;AAED,wEAAwE;AACxE,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,MAAM,EAAE,gBAAgB,GACvB,MAAM,EAAE,CAIV;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,MAAM,EAAE,gBAAgB,EACxB,EAAE,EAAE,WAAW,GACd,MAAM,GAAG,SAAS,CAMpB"}
@@ -0,0 +1,53 @@
1
+ import { httpError } from './errors.js';
2
+ /**
3
+ * Read visibility (`ITable.readExclude`).
4
+ *
5
+ * Excluded fields are never projected by any read query and cannot be referenced
6
+ * from filters, conditions, orderBy, aggregations or join selections: allowing a
7
+ * field to be filtered while hiding it from the output would still leak its value
8
+ * by bisection. Write paths (insert/update/upsert) are deliberately unaffected —
9
+ * a field can be writable but never readable (e.g. a password hash).
10
+ */
11
+ export function isReadExcluded(tableConf, field) {
12
+ return tableConf?.readExclude?.includes(field) ?? false;
13
+ }
14
+ /** Throws 400 when `field` is read-excluded on `tableConf`. */
15
+ export function assertReadable(tableConf, field) {
16
+ if (isReadExcluded(tableConf, field)) {
17
+ throw httpError(400, `Field is not readable: ${field}`);
18
+ }
19
+ }
20
+ /**
21
+ * Throws 400 when a filter map targets a read-excluded field. Keys that are not
22
+ * excluded pass through untouched (extraFilters and computed names included).
23
+ */
24
+ export function assertFiltersReadable(filters, tableConf) {
25
+ if (!filters || !tableConf?.readExclude?.length)
26
+ return;
27
+ for (const key of Object.keys(filters)) {
28
+ assertReadable(tableConf, key);
29
+ }
30
+ }
31
+ /** Schema field names that may be read, in schema declaration order. */
32
+ export function readableFieldNames(tableConf, schema) {
33
+ const fields = Object.keys(schema.fields);
34
+ if (!tableConf?.readExclude?.length)
35
+ return fields;
36
+ return fields.filter((f) => !isReadExcluded(tableConf, f));
37
+ }
38
+ /**
39
+ * SELECT column list for a table with read exclusions, or `undefined` when the
40
+ * table has none (callers keep their existing `*` projection).
41
+ *
42
+ * Columns are qualified with the table name: read queries may carry a tenant
43
+ * `through` INNER JOIN, where a bare column name shared with the through table
44
+ * would be ambiguous.
45
+ */
46
+ export function readableSelectColumns(tableConf, schema, db) {
47
+ if (!tableConf?.readExclude?.length)
48
+ return undefined;
49
+ const table = db.qi(schema.tableName);
50
+ return readableFieldNames(tableConf, schema)
51
+ .map((f) => `${table}.${db.qi(schema.col(f))}`)
52
+ .join(', ');
53
+ }
@@ -1,5 +1,5 @@
1
1
  import type { DbTables } from '../../types.js';
2
- export declare function BulkDeleteTableBody(dbTables: DbTables, tableName: string): import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
2
+ export declare function BulkDeleteTableBody(dbTables: DbTables, tableName: string, maxItems?: number): import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
3
3
  [x: string]: any;
4
4
  }>>;
5
5
  export declare function BulkDeleteTableResponse(dbTables: DbTables, tableName: string): import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
@@ -1 +1 @@
1
- {"version":3,"file":"bulk-delete.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/bulk-delete.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE/C,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM;;IAMxE;AAED,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM;;;;IAU5E"}
1
+ {"version":3,"file":"bulk-delete.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/bulk-delete.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE/C,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM;;IAS3F;AAED,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM;;;;IAU5E"}
@@ -1,10 +1,10 @@
1
1
  import { Type } from '@sinclair/typebox';
2
2
  import { primaryAsString } from '../../types.js';
3
- export function BulkDeleteTableBody(dbTables, tableName) {
3
+ export function BulkDeleteTableBody(dbTables, tableName, maxItems) {
4
4
  const tableConf = dbTables[tableName];
5
5
  const pkField = primaryAsString(tableConf.primary);
6
6
  const pkType = tableConf.Schema.fields[pkField];
7
- return Type.Array(Type.Object({ [pkField]: pkType }));
7
+ return Type.Array(Type.Object({ [pkField]: pkType }, { additionalProperties: false }), maxItems != null ? { maxItems } : {});
8
8
  }
9
9
  export function BulkDeleteTableResponse(dbTables, tableName) {
10
10
  const tableConf = dbTables[tableName];
@@ -1,5 +1,5 @@
1
1
  import { type TSchema } from '@sinclair/typebox';
2
2
  import type { DbTables } from '../../types.js';
3
- export declare function BulkUpsertTableBody(dbTables: DbTables, tableName: string): import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<Record<string, TSchema>>>;
3
+ export declare function BulkUpsertTableBody(dbTables: DbTables, tableName: string, maxItems?: number): import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<Record<string, TSchema>>>;
4
4
  export declare function BulkUpsertTableResponse(dbTables: DbTables, tableName: string): import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<Record<string, TSchema>>>;
5
5
  //# sourceMappingURL=bulk-upsert.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"bulk-upsert.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/bulk-upsert.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAEvD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAS/C,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,oGAgBxE;AAED,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,oGAa5E"}
1
+ {"version":3,"file":"bulk-upsert.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/bulk-upsert.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAEvD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAS/C,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,oGAoB3F;AAED,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,oGAa5E"}
@@ -1,18 +1,20 @@
1
1
  import { Type } from '@sinclair/typebox';
2
2
  import { primaryAsString } from '../../types.js';
3
3
  import { pkSchema, applySchemaOverrides, attachWriteJoinSections, writeJoinBodyFields, writeJoinResponseFields, } from './helpers.js';
4
- export function BulkUpsertTableBody(dbTables, tableName) {
4
+ export function BulkUpsertTableBody(dbTables, tableName, maxItems) {
5
5
  const tableConf = dbTables[tableName];
6
6
  const schema = tableConf.Schema;
7
- const mainSchema = Type.Partial(Type.Object(applySchemaOverrides({ ...schema.fields }, tableConf)));
7
+ // additionalProperties:false makes the schema the real write whitelist (no mass assignment).
8
+ const mainSchema = Type.Partial(Type.Object(applySchemaOverrides({ ...schema.fields }, tableConf), { additionalProperties: false }));
8
9
  const itemProperties = {
9
10
  main: mainSchema,
10
11
  };
11
12
  attachWriteJoinSections(itemProperties, tableConf, dbTables, {
12
13
  withDeletions: true,
13
14
  secondaryFields: writeJoinBodyFields,
15
+ strictItems: true,
14
16
  });
15
- return Type.Array(Type.Object(itemProperties));
17
+ return Type.Array(Type.Object(itemProperties), maxItems != null ? { maxItems } : {});
16
18
  }
17
19
  export function BulkUpsertTableResponse(dbTables, tableName) {
18
20
  const tableConf = dbTables[tableName];
@@ -24,6 +24,7 @@ type JoinFieldsBuilder = (join: JoinDefinition, secondaryConf: ITable | undefine
24
24
  */
25
25
  export declare function buildJoinAliasMap(tableConf: ITable, dbTables: DbTables, build: JoinFieldsBuilder, options?: {
26
26
  itemsPartial?: boolean;
27
+ strictItems?: boolean;
27
28
  }): Record<string, TSchema>;
28
29
  /**
29
30
  * Attach `secondaries` (and optionally `deletions`) sections to the given target object,
@@ -37,6 +38,7 @@ export declare function buildJoinAliasMap(tableConf: ITable, dbTables: DbTables,
37
38
  export declare function attachWriteJoinSections(target: Record<string, TSchema>, tableConf: ITable, dbTables: DbTables, options: {
38
39
  withDeletions: boolean;
39
40
  secondaryFields: JoinFieldsBuilder;
41
+ strictItems?: boolean;
40
42
  }): void;
41
43
  /** JoinFieldsBuilder for body schemas: includes all join fields with FK + excluded made Optional. */
42
44
  export declare const writeJoinBodyFields: JoinFieldsBuilder;
@@ -1 +1 @@
1
- {"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,KAAK,EAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAGzF;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAQ3H;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,UAAU,EAAE,gBAAgB,EAC5B,SAAS,EAAE,MAAM,EACjB,kBAAkB,EAAE,MAAM,GAAG,SAAS,GACrC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAgBzB;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,GAChB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CASzB;AAED,KAAK,iBAAiB,GAAG,CACvB,IAAI,EAAE,cAAc,EACpB,aAAa,EAAE,MAAM,GAAG,SAAS,KAC9B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAE7B;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,QAAQ,EAClB,KAAK,EAAE,iBAAiB,EACxB,OAAO,GAAE;IAAE,YAAY,CAAC,EAAE,OAAO,CAAA;CAAO,GACvC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CASzB;AAED;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE;IAAE,aAAa,EAAE,OAAO,CAAC;IAAC,eAAe,EAAE,iBAAiB,CAAA;CAAE,GACtE,IAAI,CAmBN;AAED,qGAAqG;AACrG,eAAO,MAAM,mBAAmB,EAAE,iBACmB,CAAC;AAEtD,uDAAuD;AACvD,eAAO,MAAM,uBAAuB,EAAE,iBACG,CAAC"}
1
+ {"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,KAAK,EAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAGzF;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAQ3H;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,UAAU,EAAE,gBAAgB,EAC5B,SAAS,EAAE,MAAM,EACjB,kBAAkB,EAAE,MAAM,GAAG,SAAS,GACrC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAgBzB;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,GAChB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CASzB;AAED,KAAK,iBAAiB,GAAG,CACvB,IAAI,EAAE,cAAc,EACpB,aAAa,EAAE,MAAM,GAAG,SAAS,KAC9B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAE7B;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,QAAQ,EAClB,KAAK,EAAE,iBAAiB,EACxB,OAAO,GAAE;IAAE,YAAY,CAAC,EAAE,OAAO,CAAC;IAAC,WAAW,CAAC,EAAE,OAAO,CAAA;CAAO,GAC9D,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAUzB;AAED;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE;IAAE,aAAa,EAAE,OAAO,CAAC;IAAC,eAAe,EAAE,iBAAiB,CAAC;IAAC,WAAW,CAAC,EAAE,OAAO,CAAA;CAAE,GAC7F,IAAI,CAmBN;AAED,qGAAqG;AACrG,eAAO,MAAM,mBAAmB,EAAE,iBACmB,CAAC;AAEtD,uDAAuD;AACvD,eAAO,MAAM,uBAAuB,EAAE,iBACG,CAAC"}
@@ -56,9 +56,10 @@ export function buildJoinAliasMap(tableConf, dbTables, build, options = {}) {
56
56
  const out = {};
57
57
  if (!tableConf.allowedWriteJoins?.length)
58
58
  return out;
59
+ const objOpts = options.strictItems ? { additionalProperties: false } : {};
59
60
  for (const j of tableConf.allowedWriteJoins) {
60
61
  const sc = findSecondaryTableConf(dbTables, j.joinSchema.tableName);
61
- const item = Type.Object(build(j, sc));
62
+ const item = Type.Object(build(j, sc), objOpts);
62
63
  out[j.alias] = Type.Array(options.itemsPartial ? Type.Partial(item) : item);
63
64
  }
64
65
  return out;
@@ -75,7 +76,7 @@ export function buildJoinAliasMap(tableConf, dbTables, build, options = {}) {
75
76
  export function attachWriteJoinSections(target, tableConf, dbTables, options) {
76
77
  if (!tableConf.allowedWriteJoins?.length)
77
78
  return;
78
- target.secondaries = Type.Optional(Type.Partial(Type.Object(buildJoinAliasMap(tableConf, dbTables, options.secondaryFields))));
79
+ target.secondaries = Type.Optional(Type.Partial(Type.Object(buildJoinAliasMap(tableConf, dbTables, options.secondaryFields, { strictItems: options.strictItems }))));
79
80
  if (options.withDeletions) {
80
81
  target.deletions = Type.Optional(Type.Partial(Type.Object(buildJoinAliasMap(tableConf, dbTables, ({ joinSchema }) => joinSchema.fields, { itemsPartial: true }))));
81
82
  }
@@ -1 +1 @@
1
- {"version":3,"file":"insert.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/insert.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAgB,MAAM,mBAAmB,CAAC;AAErE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAS/C,wBAAgB,eAAe,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAsB9E;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAalF"}
1
+ {"version":3,"file":"insert.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/insert.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAgB,MAAM,mBAAmB,CAAC;AAErE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAS/C,wBAAgB,eAAe,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAyB9E;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAalF"}
@@ -12,11 +12,14 @@ export function InsertTableBody(dbTables, tableName) {
12
12
  }
13
13
  }
14
14
  const bodyProperties = {
15
- main: Type.Object(mainFields),
15
+ // additionalProperties:false makes the schema the real write whitelist — unknown keys are
16
+ // rejected (400) instead of silently reaching the INSERT as columns (mass assignment).
17
+ main: Type.Object(mainFields, { additionalProperties: false }),
16
18
  };
17
19
  attachWriteJoinSections(bodyProperties, tableConf, dbTables, {
18
20
  withDeletions: false,
19
21
  secondaryFields: writeJoinBodyFields,
22
+ strictItems: true,
20
23
  });
21
24
  return Type.Object(bodyProperties);
22
25
  }
@@ -1 +1 @@
1
- {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/search.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAgB,MAAM,mBAAmB,CAAC;AAErE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAY/C,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAuHlF;AAED,eAAO,MAAM,sBAAsB;;;;;;;;EASjC,CAAC;AAEH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAmElF"}
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/search.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAgB,MAAM,mBAAmB,CAAC;AAGrE,OAAO,KAAK,EAAE,QAAQ,EAA4B,MAAM,gBAAgB,CAAC;AAyBzE,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAuHlF;AAED,eAAO,MAAM,sBAAsB;;;;;;;;EASjC,CAAC;AAEH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAsElF"}
@@ -1,5 +1,16 @@
1
1
  import { Type } from '@sinclair/typebox';
2
2
  import { ALLOWED_METHODS } from '../condition-methods.js';
3
+ import { readableFieldNames } from '../read-access.js';
4
+ /** Schema fields minus the ones hidden by `readExclude`. */
5
+ function readableFields(schema, tableConf) {
6
+ if (!tableConf?.readExclude?.length)
7
+ return { ...schema.fields };
8
+ const out = {};
9
+ for (const field of readableFieldNames(tableConf, schema)) {
10
+ out[field] = schema.fields[field];
11
+ }
12
+ return out;
13
+ }
3
14
  const JoinGroupResultItem = Type.Object({
4
15
  sum: Type.Optional(Type.Record(Type.String(), Type.Any())),
5
16
  min: Type.Optional(Type.Record(Type.String(), Type.Any())),
@@ -38,7 +49,7 @@ export function SearchTableBodyPost(dbTables, tableName) {
38
49
  }
39
50
  }
40
51
  const filterFields = {
41
- ...schema.fields,
52
+ ...readableFields(schema, tableConf),
42
53
  ...tableConf.extraFilters,
43
54
  ...computedTypes,
44
55
  };
@@ -71,7 +82,7 @@ export function SearchTableBodyPost(dbTables, tableName) {
71
82
  }
72
83
  }
73
84
  const joinFilterFields = joinTableConf
74
- ? { ...joinSchema.fields, ...joinTableConf.extraFilters, ...joinComputedTypes }
85
+ ? { ...readableFields(joinSchema, joinTableConf), ...joinTableConf.extraFilters, ...joinComputedTypes }
75
86
  : { ...joinSchema.fields };
76
87
  const joinRefShape = {
77
88
  filters: Type.Optional(Type.Partial(Type.Object(joinFilterFields))),
@@ -150,16 +161,17 @@ export function SearchTableResponse(dbTables, tableName) {
150
161
  }
151
162
  }
152
163
  }
164
+ const mainReadable = readableFields(tableConf.Schema, tableConf);
153
165
  const mainItem = Object.keys(computedTypes).length > 0
154
- ? Type.Partial(Type.Object({ ...tableConf.Schema.fields, ...computedTypes }))
155
- : Type.Partial(Type.Object(tableConf.Schema.fields));
166
+ ? Type.Partial(Type.Object({ ...mainReadable, ...computedTypes }))
167
+ : Type.Partial(Type.Object(mainReadable));
156
168
  const joinMultipleProps = {};
157
169
  const joinLeftProps = {};
158
170
  const joinGroupProps = {};
159
171
  if (tableConf.allowedReadJoins) {
160
172
  for (const joinDef of tableConf.allowedReadJoins) {
161
173
  const { joinSchema, alias, unique } = joinDef;
162
- const itemArray = Type.Array(Type.Partial(Type.Object(joinSchema.fields)));
174
+ const itemArray = Type.Array(Type.Partial(Type.Object(readableFields(joinSchema, dbTables[joinSchema.tableName]))));
163
175
  if (unique) {
164
176
  joinLeftProps[alias] = itemArray;
165
177
  }
@@ -1 +1 @@
1
- {"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/update.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAgB,MAAM,mBAAmB,CAAC;AAErE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAS/C,wBAAgB,eAAe,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAqB9E;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAalF"}
1
+ {"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/update.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAgB,MAAM,mBAAmB,CAAC;AAErE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAS/C,wBAAgB,eAAe,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAwB9E;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAalF"}
@@ -11,11 +11,14 @@ export function UpdateTableBody(dbTables, tableName) {
11
11
  mainFields[key] = key === primaryAsString(tableConf.primary) ? value : Type.Optional(value);
12
12
  }
13
13
  const bodyProperties = {
14
- main: Type.Object(mainFields),
14
+ // additionalProperties:false makes the schema the real write whitelist — unknown keys are
15
+ // rejected (400) instead of silently reaching the UPDATE as columns (mass assignment).
16
+ main: Type.Object(mainFields, { additionalProperties: false }),
15
17
  };
16
18
  attachWriteJoinSections(bodyProperties, tableConf, dbTables, {
17
19
  withDeletions: true,
18
20
  secondaryFields: writeJoinBodyFields,
21
+ strictItems: true,
19
22
  });
20
23
  return Type.Object(bodyProperties);
21
24
  }
@@ -0,0 +1,18 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { type SqlApi } from './sql-api.js';
3
+ import type { SqlApiPluginOptions } from '../types.js';
4
+ declare module 'fastify' {
5
+ interface FastifyInstance {
6
+ sqlApi: SqlApi;
7
+ }
8
+ }
9
+ /**
10
+ * Decorates `fastify.sqlApi` (lazy getter) on the given instance unless the
11
+ * decorator is already present in scope. The main plugin calls this at the
12
+ * fp level (parent scope); granular route plugins call it through
13
+ * `registerForAllTables` so they also work standalone, each within its own
14
+ * encapsulation context. The underlying pool (`fastify.pg` / `fastify.mysql`)
15
+ * is shared either way.
16
+ */
17
+ export declare function ensureSqlApiDecorator(fastify: FastifyInstance, options: SqlApiPluginOptions): void;
18
+ //# sourceMappingURL=sql-api-decorator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sql-api-decorator.d.ts","sourceRoot":"","sources":["../../src/lib/sql-api-decorator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAI/C,OAAO,EAAgB,KAAK,MAAM,EAAE,MAAM,cAAc,CAAC;AACzD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAEvD,OAAO,QAAQ,SAAS,CAAC;IACvB,UAAU,eAAe;QACvB,MAAM,EAAE,MAAM,CAAC;KAChB;CACF;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,GAC3B,IAAI,CA2BN"}