fastify-auto-sqlapi 0.1.13 → 0.1.14

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 CHANGED
@@ -357,6 +357,8 @@ buildRelation(mainSchema, mainField, joinSchema, joinField, options?)
357
357
  // unique?: boolean; // default false.
358
358
  // // false → 1:N (child→main). Allowed in joinMustExist/joinMultiple/joinGroup.
359
359
  // // true → N:1 (parent→main). Allowed in joinLeft only.
360
+ // fields?: string[]; // allowlist of the target's fields reachable through this relation.
361
+ // // Read joins only. Must include joinField. Everything else is 400.
360
362
  // }
361
363
  ```
362
364
 
@@ -367,6 +369,27 @@ buildRelation(mainSchema, mainField, joinSchema, joinField, options?)
367
369
 
368
370
  **When to declare an explicit `alias`**: only when (a) you join the same table multiple times in the same `allowedReadJoins`/`allowedWriteJoins`, or (b) you want a friendlier name in the public API surface than the SQL table name (e.g. `'orders'` instead of `'customer_order'`). Otherwise omit it — the default is `joinSchema.tableName`.
369
371
 
372
+ **⚠️ A relation in `allowedReadJoins` is a read grant on the target table.** The join runs inside the query of the *host* table's route, so the target's `onRequests` never fire and its `operations` whitelist does not apply — a table with `operations: []` is still fully readable through any relation pointing at it. What does cross a join: `readExclude`, `tenantScope`, and the schema the relation was declared with.
373
+
374
+ To expose a table broadly but keep some of its columns narrow, give the relation a `fields` allowlist:
375
+
376
+ ```typescript
377
+ allowedReadJoins: [
378
+ buildRelation(SchemaAgent, 'userId', SchemaUser, 'id', { unique: true, fields: ['id', 'name'] }),
379
+ ],
380
+ ```
381
+
382
+ The relation is declared against a schema narrowed to that list, so every read surface validates against it — `selection`, `filters`, `conditions`, `orderBy`, aggregations, and the generated request/response schemas. A field outside the list is `400 Unknown field` everywhere, not just missing from the projection, and the default `'*'` selection is spelled out into the allowed columns rather than emitting a real `SELECT *`.
383
+
384
+ Rules, all enforced at declaration time:
385
+
386
+ - **Must include the join field** — it is what correlates the fetched rows with the main ones.
387
+ - **Not allowed on `allowedWriteJoins`** — the allowlist restricts reading only; write paths resolve `upsertMap` by schema identity and must write every column the caller sent. Declare two relations if the same pair is joined for reading and for writing.
388
+ - **Computed fields on the target resolve against the narrowed schema** — one that reads a field outside the list is a `400`, so a computed cannot be used to reach past the allowlist. One that stays inside keeps working.
389
+ - **Fail-closed** — a column added to the target later is not reachable through the relation until it is added to `fields`.
390
+
391
+ `fields` is not a replacement for `readExclude` (which hides a column from the owning table's own routes too). See [ADR 0010](./docs/adr/0010-joins-do-not-run-route-guards.md) and [ADR 0011](./docs/adr/0011-join-fields-allowlist.md).
392
+
370
393
  **Choosing `unique`**: if `joinField` is the PK (or part of the composite PK) of `joinSchema`, the relation is N:1 — you almost certainly want `unique: true` so the alias is usable in `joinLeft`.
371
394
 
372
395
  **Owned child tables (translations, `*_info` details): use a writeJoin, not a standalone table.** A table that only exists as a child of a parent — e.g. `product_info` with composite PK `(product_id, lang)` — should be an `allowedWriteJoins` on the parent, not its own `DbTables` entry. The engine auto-fills the FK (`product_id`); add it to `upsertMap` (conflict key = the composite PK) to upsert children passing only their own fields. Avoids redundant endpoints/validators.
@@ -431,6 +454,10 @@ export const TableCustomer = defineTable({
431
454
 
432
455
  `extraFilters` accepts either a `Type.Object({...})` or a plain `Record<string, TSchema>`. Extra filter fields appear in Swagger but are NOT auto-applied as `WHERE col = value` — they are handled exclusively by the `extendedCondition` callback. The `filters` parameter in the callback is fully typed with autocomplete for all schema fields + extra filter keys.
433
456
 
457
+ **Not available on `joinLeft`.** `extendedCondition` writes its own column references, which cannot be qualified with the `LEFT JOIN` alias the parent gets there, so `buildLeftJoinClauses` handles schema and computed fields only. The generated body schema does not advertise the parent's `extraFilters` under `joinLeft.<alias>.filters`, and sending one is a `400` rather than a silent no-op. Use `joinMustExist` on the same relation when you need an extra filter on the parent.
458
+
459
+ **Unknown filter keys are a `400`** (`Unknown filter field: <key>`), on the main table and on every join. A filter that does not match a schema field, an `extraFilters` key or a computed field is rejected instead of dropped: silently ignoring it would return *more* rows than the caller asked for. The check lives in the engine, so it applies to `sqlApi.search()` too, and it runs before any query — the outcome never depends on whether the main query matched rows.
460
+
434
461
  ---
435
462
 
436
463
  ## Computed Fields (extension system)
@@ -777,7 +804,7 @@ Tables without `tenantScope` are unaffected — no filtering regardless of `getT
777
804
  - **All fields Optional in response**: `RETURNING *` may return any subset. Response schemas use `Type.Partial`.
778
805
  - **Nullable columns use `Nullable(T)` (type-array form)**: the generator emits `Type.Optional(Nullable(T))` for nullable columns — `Nullable` (exported by the package) produces `{ type: ['integer', 'null'] }`. Do NOT replace it with `Type.Union([T, Type.Null()])`: under Fastify's default Ajv `coerceTypes` a union corrupts values through its branches (`null` → `0` with the Null branch last, `0`/`""` → `null` with it first), while the type array validates and serializes NULL correctly. A bare `Type.Optional(T)` is also wrong: NULL serializes as `0`/`""`. In `filters`, an explicit `null` on a schema field builds `WHERE col IS NULL`.
779
806
  - **`excludeFromCreation`**: **IMPORTANT** — auto-increment PKs (e.g. `id` serial/auto_increment) MUST be listed here, otherwise INSERT will try to send them and fail. The CLI auto-detects this and adds it by default. Also useful for `createdAt`/`updatedAt` columns managed by DB defaults or hooks. It strips **client-supplied** values only: the payload is sanitized before `beforeInsert` runs, so a value the hook assigns to an excluded field (e.g. a server-generated TEXT id) DOES reach the INSERT. Same for the engine's FK auto-fill on secondaries — an excluded FK column does not suppress it. It is an **ergonomics tool for creation, NOT a field-level security mechanism**: it does not apply to updates — every Schema field is updatable by default, by design. Field-level update rules (`isAdmin`, roles, owner/tenant columns, state fields) are product logic: enforce them in `beforeUpdate` (silent strip) or `validate` (loud 400), or move the privileged transition to a dedicated endpoint and keep it off the auto routes via `operations`.
780
- - **`readExclude`**: fields hidden from every read — not projected by search/get, omitted from read response schemas and from this table's default (`*`) join selection when it is a join target. Referencing one from `filters`, `conditions`, `orderBy`, `joinGroup` aggregations or an explicit join `selection` returns 400: hiding a field from output while allowing it to be filtered would leak its value by bisection. **Writes are unaffected** — insert/update/bulk still accept the field, so a column can be writable but never readable (password hash, access token). Primary keys cannot be excluded. Complementary to trimming the Schema, which removes the column from reads *and* writes.
807
+ - **`readExclude`**: fields hidden from every read — not projected by search/get, omitted from read response schemas and from this table's default (`*`) join selection when it is a join target. Referencing one from `filters`, `conditions`, `orderBy`, `joinGroup` aggregations or an explicit join `selection` returns 400: hiding a field from output while allowing it to be filtered would leak its value by bisection. **Writes are unaffected** — insert/update/bulk still accept the field, so a column can be writable but never readable (password hash, access token). Primary keys cannot be excluded. Complementary to trimming the Schema, which removes the column from reads *and* writes. It is **per table and static**: a column hidden here is hidden from every caller, including admins. To keep a column readable on the table's own routes but not through a relation, trim the Schema the *relation* is declared with instead — see the read-grant warning under [buildRelation](#buildrelation-signature).
781
808
  - **`upsertMap`**: when present for a schema, INSERT becomes upsert. PostgreSQL: `ON CONFLICT (...) DO UPDATE`. MySQL/MariaDB: `ON DUPLICATE KEY UPDATE`. Applies to both main and secondary tables.
782
809
  - **`schemaOverrides`**: override auto-generated schema fields with stricter TypeBox types (e.g. `{ email: Type.String({ format: 'email' }) }`). Overrides are merged into the body schema for insert, update, and bulk-upsert. The original Schema file is never modified. In updates, overridden fields are still wrapped in Optional (validates only when present). Overrides appear in Swagger.
783
810
  - **Validation receives camelCase**: `validate` receives the original camelCase record (as sent by the client) and secondaries. Field names match the schema definition, with full TypeScript inference (`main.startDate`, not `main.start_date`). It returns `ValidationError[]` — tuples of `[field, code]` or `[field, code, message]`. If any errors are returned, the request is rejected with 400 before hooks or SQL execute.
@@ -380,3 +380,78 @@ endpoints previously behaved correctly for you — they are now gone and you mus
380
380
  - **First PK column is truly unique on its own?** Then the composite declaration was
381
381
  redundant: declare `primary: 'id'` (single) and keep the second column as a plain field —
382
382
  every by-id operation comes back.
383
+
384
+ ---
385
+
386
+ # Breaking Change — unknown filter keys are rejected
387
+
388
+ `filters` keys that match nothing are now a `400`, on the main table and on every join family.
389
+ Previously they were dropped without a word.
390
+
391
+ ## Why this changed
392
+
393
+ The engine only ever visited filter keys matching a schema field, an `extraFilters` entry or a
394
+ computed field. Anything else — a typo, a renamed column, a field that exists on the table but
395
+ not on the trimmed Schema a relation was declared with — was never looked at, and the query ran
396
+ as if the filter had not been sent.
397
+
398
+ That fails in the dangerous direction. A rejected filter returns *more* rows than the caller
399
+ asked for, and nothing in the response says so; in an application where filters are also how
400
+ visibility is narrowed, a typo becomes a data exposure. It was also the odd one out: an unknown
401
+ field in `selection`, `conditions`, `orderBy` or a `joinGroup` aggregation has always been a
402
+ 400.
403
+
404
+ ## What changed
405
+
406
+ | Request | Before | After |
407
+ |---|---|---|
408
+ | `filters: { nosuchfield: 'x' }` | `200`, filter ignored | `400 Unknown filter field: nosuchfield` |
409
+ | `joinMultiple.<alias>.filters: { nosuchfield: 1 }` | `200`, filter ignored | `400 Unknown filter field: nosuchfield` |
410
+ | `joinLeft.<alias>.filters: { <extraFilter> }` | `200`, filter ignored | `400 Filter '<key>' is an extraFilter: …` |
411
+ | `filters: { knownField: undefined }` | ignored | ignored (unchanged) |
412
+ | `filters: { knownField: null }` | `IS NULL` | `IS NULL` (unchanged) |
413
+
414
+ Two related fixes ship with it:
415
+
416
+ - The check runs in the engine, not in the route schema, so `sqlApi.search()` behaves the same
417
+ as the HTTP routes. (A closed `additionalProperties: false` schema would not have worked:
418
+ Fastify runs Ajv with `removeAdditional: true`, so it would have *stripped* the key and
419
+ reproduced the same silent behaviour one layer up.)
420
+ - Join filters are validated **before any query runs**. They used to be validated inside the
421
+ side query, which is skipped entirely when the main result set is empty — so the same request
422
+ answered 400 or 200 depending on the data it matched.
423
+
424
+ ## Who is affected
425
+
426
+ Callers that send filter keys the target table does not declare. Over-sending is the common
427
+ case: a frontend that spreads a whole form state into `filters`, or reuses one filter object
428
+ across two tables.
429
+
430
+ ## Migration
431
+
432
+ Send only keys the table declares. `GET /agent/manifest` and the Swagger body schema both list
433
+ them per table (schema fields + `extraFilters` + computed fields).
434
+
435
+ For a form-state object, filter it before sending rather than relying on the server to ignore
436
+ the extras:
437
+
438
+ ```typescript
439
+ const ALLOWED = ['name', 'status', 'createdFrom'] as const;
440
+ const filters = Object.fromEntries(
441
+ Object.entries(formState).filter(([k, v]) => ALLOWED.includes(k) && v !== '')
442
+ );
443
+ ```
444
+
445
+ Passing `undefined` for an absent value is still safe and needs no filtering: only keys with a
446
+ defined value are validated.
447
+
448
+ ## joinLeft and extraFilters
449
+
450
+ `joinLeft.<alias>.filters` used to advertise the parent table's `extraFilters` in Swagger and
451
+ accept them at runtime, applying nothing: `buildLeftJoinClauses` builds its condition inline and
452
+ never runs the target's `extendedCondition`, whose column references cannot be qualified with
453
+ the `LEFT JOIN` alias the parent gets there. They are no longer advertised, and sending one
454
+ returns a `400` naming the reason.
455
+
456
+ Use `joinMustExist` on the same relation when you need an extra filter on the parent — that path
457
+ delegates to the target's own `filters()` and does run `extendedCondition`.
package/CHANGELOG.md CHANGED
@@ -9,8 +9,53 @@ Migration instructions for breaking changes live in **[BREAKING_CHANGES.md](./BR
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ### Changed
13
+
14
+ - **Unknown `filters` keys are now rejected with `400 Unknown filter field: <key>`** instead
15
+ of being dropped in silence — on the main table and on every join family. The engine only
16
+ ever visited keys matching a schema field, an `extraFilters` entry or a computed field, so a
17
+ mistyped filter name came back as an unfiltered (wider) result set with no error: the
18
+ dangerous direction to fail in, and inconsistent with `selection`, `conditions`, `orderBy`
19
+ and aggregations, which have always answered 400. The check lives in the engine, so it
20
+ covers `sqlApi.search()` as well as the HTTP routes. `undefined` values still mean "filter
21
+ not supplied" and are ignored; an explicit `null` still filters by `IS NULL`. See
22
+ [BREAKING_CHANGES.md](./BREAKING_CHANGES.md#breaking-change--unknown-filter-keys-are-rejected).
23
+ - **`joinLeft.<alias>.filters` no longer advertises the parent's `extraFilters`.** They were
24
+ present in the generated body schema and in Swagger, but `buildLeftJoinClauses` never runs
25
+ the target's `extendedCondition` (its column references cannot be qualified with the
26
+ `LEFT JOIN` alias), so the filter applied nothing. They are now rejected with a `400` naming
27
+ the reason and pointing at `joinMustExist` on the same relation.
28
+
29
+ ### Fixed
30
+
31
+ - **Join validation no longer depends on the main result set.** `joinMultiple` / `joinLeft` /
32
+ `joinGroup` skip their side query entirely when the main query matched no rows, so everything
33
+ validated inside it was skipped with it — the same request answered 400 or 200 depending on
34
+ the data. This covered filter keys and `readExclude` on join filters, an explicit `selection`
35
+ naming an unknown field, and `joinGroup` aggregation fields including `aggregations.by`. Join
36
+ filters are now validated up front, and the selection and aggregation lists are resolved
37
+ before the empty-result bail-out.
38
+
12
39
  ### Added
13
40
 
41
+ - **`fields` allowlist on `buildRelation`** — restricts which of the target table's fields are
42
+ reachable through one relation, without hiding them from the table's own routes the way
43
+ `readExclude` would. The relation is declared against a schema narrowed to the list, so
44
+ `selection`, `filters`, `conditions`, `orderBy`, aggregations and the generated
45
+ request/response schemas all reject anything outside it with `400 Unknown field`; the default
46
+ `'*'` selection is spelled out into the allowed columns instead of emitting a real `SELECT *`;
47
+ and computed fields on the target resolve against the narrowed schema, so one reading an
48
+ excluded column is a 400 rather than a way around the list. Declaration-time checks: the list
49
+ must include the join field, and is rejected on `allowedWriteJoins` (write paths resolve
50
+ `upsertMap` by schema identity and must write every column the caller sent). Fail-closed by
51
+ design — see [ADR 0011](./docs/adr/0011-join-fields-allowlist.md) for allowlist vs blocklist.
52
+ - **[ADR 0010](./docs/adr/0010-joins-do-not-run-route-guards.md)** — a declared join is a read
53
+ grant: `onRequests` and `operations` are route-level and do not follow a join, so
54
+ `allowedReadJoins` grants read access to the target table under the *host* table's
55
+ authorization. Documents what does cross a join (`readExclude`, `tenantScope`, the relation's
56
+ schema), the trimmed-schema + explicit-`selection` pattern for column-level narrowing, and
57
+ the rejected alternatives (`canBeJoined` hook, `selection` as a ceiling).
58
+
14
59
  - **`excludeTables` config option** — blacklist for `sqlapi-generate-schema`. Tables
15
60
  listed in `excludeTables` in `sqlapi.config.ts` (exact names or `*` globs, e.g.
16
61
  `knex_*`) are skipped during schema generation; their previously generated schema files
package/README.md CHANGED
@@ -266,6 +266,8 @@ const TableCustomer = defineTable({
266
266
  buildRelation(SchemaCustomer, 'id', SchemaOrder, 'customerId'), // alias = 'order'
267
267
  buildRelation(SchemaSession, 'userId', SchemaUser, 'id', { unique: true }), // alias = 'user', N:1
268
268
  buildRelation(SchemaSession, 'updatedBy', SchemaUser, 'id', { alias: 'updater', unique: true }),
269
+ // `fields` restricts what this relation exposes of the target — read joins only
270
+ buildRelation(SchemaAgent, 'userId', SchemaUser, 'id', { unique: true, fields: ['id', 'name'] }),
269
271
  ],
270
272
  allowedWriteJoins: [
271
273
  buildRelation(SchemaCustomer, 'id', SchemaOrder, 'customerId'), // alias = 'order'
@@ -423,6 +425,43 @@ are always available to your own code.
423
425
  **3. Multi-tenancy** — `getTenantId` + `tenantScope` filter every query by tenant (see
424
426
  [Multi-Tenant](#multi-tenant)).
425
427
 
428
+ ### A declared join is a read grant
429
+
430
+ ⚠️ **Neither of the first two layers follows a join.** `onRequests` and `operations` are
431
+ properties of the routes generated *for* a table; a join is resolved inside the query of the
432
+ **host** table's route, so the target's hooks never run and its `operations` whitelist does not
433
+ apply. A table with `operations: []` — no HTTP routes at all — is still fully readable through
434
+ any relation pointing at it.
435
+
436
+ So `allowedReadJoins` is a security decision: declaring a relation grants read access to the
437
+ target table under the **host** table's authorization.
438
+
439
+ What does follow a join: `readExclude`, `tenantScope`, and the schema the relation was declared
440
+ with. To expose a table broadly while keeping some columns narrow, give the relation a `fields`
441
+ allowlist:
442
+
443
+ ```typescript
444
+ allowedReadJoins: [
445
+ buildRelation(SchemaAgent, 'userId', SchemaUser, 'id', {
446
+ unique: true,
447
+ fields: ['id', 'name'], // everything else on `user` is unreachable here
448
+ }),
449
+ ],
450
+ ```
451
+
452
+ Every read surface validates against it — `selection`, `filters`, `conditions`, `orderBy`,
453
+ aggregations, and the generated request/response schemas — so `email` is `400 Unknown field`
454
+ everywhere, not just missing from the projection. It is fail-closed: a column added to `user`
455
+ later is not reachable through this relation until it is added to the list.
456
+
457
+ `fields` restricts **reading** only, and only through that relation. It is not a replacement for
458
+ `readExclude` (which hides a column from the table's own routes too) or for `onRequests` (which
459
+ decides who may call them).
460
+
461
+ Full rationale and the rejected alternatives in
462
+ [ADR 0010](./docs/adr/0010-joins-do-not-run-route-guards.md) and
463
+ [ADR 0011](./docs/adr/0011-join-fields-allowlist.md).
464
+
426
465
  ### Request limits
427
466
 
428
467
  Two hard caps bound how much work a single request can trigger (defense against accidental or
@@ -439,11 +478,14 @@ capped — they run your own trusted code.
439
478
 
440
479
  ### Write whitelist
441
480
 
442
- Insert/update/bulk-upsert bodies reject **unknown properties** (`additionalProperties: false`):
443
- only columns present in the generated Schema (as narrowed by `schemaOverrides` /
444
- `excludeFromCreation`) can be written. An unexpected field is a `400`, never a silently-written
445
- column — the Schema is the write whitelist. Trim the generated Schema (or use `excludeFromCreation`)
446
- to keep sensitive columns out of it.
481
+ Insert/update/bulk-upsert bodies are `additionalProperties: false`: only columns present in the
482
+ generated Schema (as narrowed by `schemaOverrides` / `excludeFromCreation`) can be written. The
483
+ Schema is the write whitelist. Trim the generated Schema (or use `excludeFromCreation`) to keep
484
+ sensitive columns out of it.
485
+
486
+ An unexpected field is **stripped, not rejected**: Fastify runs Ajv with `removeAdditional: true`,
487
+ so the request succeeds and the column is simply never written. Mass assignment is prevented
488
+ either way, but do not expect a `400` to tell a client its extra field was ignored.
447
489
 
448
490
  ### Field-level update rules are product logic — use the hooks
449
491
 
@@ -543,12 +585,12 @@ Search with filters, advanced conditions, pagination, ordering, joins, and aggre
543
585
  }
544
586
  ```
545
587
 
546
- - **`filters`** — equality-based, flat key/value. Supports schema fields + `extraFilters`.
588
+ - **`filters`** — equality-based, flat key/value. Supports schema fields, `extraFilters` and computed fields. A key that is none of those is a `400 Unknown filter field: <key>` — a mistyped filter must never come back as a wider result set. An explicit `null` filters by `IS NULL`; `undefined` means "not supplied" and is ignored.
547
589
  - **`conditions`** — array of `{ field, method, params }`. Methods: `isEqual`, `isNotEqual`, `isGreater`, `isGreaterOrEqual`, `isLess`, `isLessOrEqual`, `isLike`, `isILike`, `isIn`, `isNotIn`, `isBetween`, `isNotBetween`, `isNull`, `isNotNull`.
548
590
  - **`joinMustExist`** — EXISTS-based filter: "main rows where at least one related row matches". Accepts `{ filters, conditions }` (both optional). Aliases must come from `allowedReadJoins` declarations with `unique: false`.
549
591
  - **`joinMultiple`** — fetches related child rows in a side query. Accepts `{ filters, conditions, selection }`. Same `unique: false` aliases.
550
592
  - **`joinGroup`** — aggregations on the related table. Supports `sum`, `min`, `max`, `avg`, `count`, `distinctCount`, and optional `by` for GROUP BY (a schema field name or a computed-field name declared on the join table — e.g. for date bucketing declare a computed using `db.dateTrunc('month', qiCol('orderDate'))`). Accepts `{ filters, conditions }`. Same `unique: false` aliases.
551
- - **`joinLeft`** — embeds an N:1 parent. Real `LEFT JOIN` is added on demand (only when the request has `filters`/`conditions` on the parent or uses 2-parti `orderBy` on this alias). Aliases must be declared with `unique: true`. Accepts `{ filters, conditions, selection }`.
593
+ - **`joinLeft`** — embeds an N:1 parent. Real `LEFT JOIN` is added on demand (only when the request has `filters`/`conditions` on the parent or uses 2-parti `orderBy` on this alias). Aliases must be declared with `unique: true`. Accepts `{ filters, conditions, selection }`. Its `filters` accept schema and computed fields only: the parent's `extraFilters` need an alias-aware `extendedCondition`, so they are rejected with a `400` here instead of being silently ignored — use `joinMustExist` on the same relation when you need one.
552
594
 
553
595
  **Dot-notation in `orderBy` and `conditions`**:
554
596
 
@@ -1 +1 @@
1
- {"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../../../src/lib/agent/manifest.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAOvE,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,4DAA4D;IAC5D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,mDAAmD;IACnD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,2FAA2F;IAC3F,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,0FAA0F;IAC1F,IAAI,EAAE,KAAK,GAAG,KAAK,CAAC;CACrB;AAED,MAAM,WAAW,aAAa;IAC5B,2CAA2C;IAC3C,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC3B,UAAU,EAAE,cAAc,EAAE,CAAC;IAC7B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;IACtC,uFAAuF;IACvF,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,sFAAsF;IACtF,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAClC,qEAAqE;IACrE,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,SAAS,CAAC,EAAE,YAAY,EAAE,CAAC;IAC3B,oEAAoE;IACpE,UAAU,CAAC,EAAE,YAAY,EAAE,CAAC;IAC5B,gFAAgF;IAChF,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;CACvC;AAgCD,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,cAAc,EAAE,CAInE;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,QAAQ,GAAG,aAAa,CAuDpE;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,aAAa,GAAG,MAAM,CA2CrE"}
1
+ {"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../../../src/lib/agent/manifest.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAOvE,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,4DAA4D;IAC5D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,mDAAmD;IACnD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,2FAA2F;IAC3F,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,0FAA0F;IAC1F,IAAI,EAAE,KAAK,GAAG,KAAK,CAAC;CACrB;AAED,MAAM,WAAW,aAAa;IAC5B,2CAA2C;IAC3C,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC3B,UAAU,EAAE,cAAc,EAAE,CAAC;IAC7B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;IACtC,uFAAuF;IACvF,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,sFAAsF;IACtF,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAClC,qEAAqE;IACrE,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,SAAS,CAAC,EAAE,YAAY,EAAE,CAAC;IAC3B,oEAAoE;IACpE,UAAU,CAAC,EAAE,YAAY,EAAE,CAAC;IAC5B,gFAAgF;IAChF,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;CACvC;AAgCD,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,cAAc,EAAE,CAInE;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,QAAQ,GAAG,aAAa,CAuDpE;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,aAAa,GAAG,MAAM,CA6CrE"}
@@ -114,6 +114,8 @@ export function renderAgentManifestMd(manifest) {
114
114
  'serverGenerated fields: omit on insert (values from client are ignored).',
115
115
  'readJoins aliases: `1:N` → joinMustExist/joinMultiple/joinGroup; `N:1` → joinLeft.',
116
116
  'writeJoins aliases: keys for `secondaries`/`deletions` in writes.',
117
+ 'filters: unknown keys are rejected (400), never ignored. extraFilters do not apply ' +
118
+ 'under joinLeft — use joinMustExist on the same relation.',
117
119
  '',
118
120
  ];
119
121
  for (const t of Object.values(manifest.tables)) {
@@ -1 +1 @@
1
- {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/search.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EACV,QAAQ,EACR,YAAY,EACZ,YAAY,EAcb,MAAM,mBAAmB,CAAC;AA8lC3B,wBAAsB,YAAY,CAChC,QAAQ,EAAE,QAAQ,EAClB,MAAM,EAAE,YAAY,GACnB,OAAO,CAAC,YAAY,CAAC,CA4HvB"}
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/search.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EACV,QAAQ,EACR,YAAY,EACZ,YAAY,EAeb,MAAM,mBAAmB,CAAC;AAqoC3B,wBAAsB,YAAY,CAChC,QAAQ,EAAE,QAAQ,EAClB,MAAM,EAAE,YAAY,GACnB,OAAO,CAAC,YAAY,CAAC,CAqIvB"}
@@ -2,7 +2,7 @@ import { ConditionBuilder, Expression } from 'node-condition-builder';
2
2
  import { camelcaseObject } from '../../naming.js';
3
3
  import { QueryParams } from '../query-params.js';
4
4
  import { buildTenantCondition, buildTenantJoin } from '../../tenant.js';
5
- import { assertReadable, assertFiltersReadable, readableSelectColumns } from '../../read-access.js';
5
+ import { assertReadable, assertFiltersReadable, assertKnownFilterKeys, readableSelectColumns, explicitSelectColumns, } from '../../read-access.js';
6
6
  import { primaryAsString } from '../../../types.js';
7
7
  // ─── Schema field validation ────────────────────────────────
8
8
  function validateSchemaField(field, schema, tableConf) {
@@ -87,6 +87,28 @@ function subqueryAlias(alias, tableConf) {
87
87
  function findJoinByAlias(tableConf, alias) {
88
88
  return tableConf.allowedReadJoins?.find((j) => j.alias === alias);
89
89
  }
90
+ /**
91
+ * Validate the filter keys of every join reference in a request map.
92
+ *
93
+ * Runs up front, before any query: the executors skip their side-query entirely when the
94
+ * main result set is empty (`ids.length === 0`), so validating there alone would make the
95
+ * same request answer 400 or 200 depending on the data it happens to match.
96
+ *
97
+ * Unknown aliases are left to `requireJoin`, which reports them with its own message.
98
+ */
99
+ function assertJoinFilterKeys(dbTables, tableConf, refs, allowExtraFilters) {
100
+ if (!refs)
101
+ return;
102
+ for (const [alias, ref] of Object.entries(refs)) {
103
+ const joinDef = findJoinByAlias(tableConf, alias);
104
+ if (!joinDef)
105
+ continue;
106
+ const joinTableConf = dbTables[joinDef.joinSchema.tableName];
107
+ const filters = ref?.filters;
108
+ assertFiltersReadable(filters, joinTableConf);
109
+ assertKnownFilterKeys(filters, joinDef.joinSchema, joinTableConf, allowExtraFilters);
110
+ }
111
+ }
90
112
  function requireJoin(tableConf, alias, requireUnique) {
91
113
  const joinDef = findJoinByAlias(tableConf, alias);
92
114
  if (!joinDef) {
@@ -176,9 +198,15 @@ function mapRowsToCamelCase(rows, schema, computedFieldNames) {
176
198
  * Build the SELECT columns list for a join fetch: '*' or comma-separated quoted columns.
177
199
  * A default '*' selection narrows to the join table's readable columns when it declares
178
200
  * `readExclude`; an explicit selection naming an excluded field is rejected with 400.
201
+ *
202
+ * On a relation narrowed by a `fields` allowlist the '*' is always spelled out. Emitting a
203
+ * real `SELECT *` there would fetch the excluded columns and leave them to be dropped by
204
+ * the response serializer — which does not run for a caller of `sqlApi.search()`.
179
205
  */
180
- function buildSelectionColumns(selection, joinSchema, db, joinTableConf) {
206
+ function buildSelectionColumns(selection, joinSchema, db, joinTableConf, restricted = false) {
181
207
  if (selection === '*') {
208
+ if (restricted)
209
+ return explicitSelectColumns(joinTableConf, joinSchema, db);
182
210
  return readableSelectColumns(joinTableConf, joinSchema, db) ?? '*';
183
211
  }
184
212
  const table = db.qi(joinSchema.tableName);
@@ -331,7 +359,9 @@ function buildLeftJoinClauses(db, dbTables, tableConf, aliasesNeedingJoin, joinL
331
359
  // (so SQL references the LEFT JOIN'd table, not a bare table name).
332
360
  // Note: extraFilters declared on joinTableConf are not supported here for
333
361
  // joinLeft (they would require alias-aware handlers); only schema fields
334
- // and computed fields apply.
362
+ // and computed fields apply. `assertJoinFilterKeys` rejects them up front rather
363
+ // than letting them through as a silent no-op, and the generated body schema does
364
+ // not advertise them for unique:true relations.
335
365
  const cb = new ConditionBuilder('AND', db.cbDialect);
336
366
  const joinTableConf = dbTables[joinSchema.tableName];
337
367
  const computed = joinTableConf?.computedFields;
@@ -487,6 +517,11 @@ async function executeJoinMultiple(db, dbTables, tableConf, mainResults, joinMul
487
517
  const joinDef = requireJoin(tableConf, alias, false);
488
518
  const { joinSchema, joinField, mainField, selection: defaultSelection } = joinDef;
489
519
  const joinTableConf = dbTables[joinSchema.tableName];
520
+ // The selection is resolved before the empty-result bail-out: it is validated against
521
+ // the join schema, and that rejection must not depend on whether the main query
522
+ // matched rows.
523
+ const selection = ref?.selection ?? defaultSelection;
524
+ const columns = buildSelectionColumns(selection, joinSchema, db, joinTableConf, joinDef.fields !== undefined);
490
525
  const ids = collectIds(mainResults, mainField);
491
526
  if (ids.length === 0) {
492
527
  result[alias] = [];
@@ -499,8 +534,6 @@ async function executeJoinMultiple(db, dbTables, tableConf, mainResults, joinMul
499
534
  appendJoinTenantScope(db, joinTableConf, tenant, joinSchema.tableName, cb, tenantJoins);
500
535
  const where = cb.build(1, db.ph);
501
536
  const values = cb.getValues();
502
- const selection = ref?.selection ?? defaultSelection;
503
- const columns = buildSelectionColumns(selection, joinSchema, db, joinTableConf);
504
537
  const rows = await db.select({
505
538
  tableName: joinSchema.tableName,
506
539
  columns,
@@ -519,6 +552,10 @@ async function executeJoinLeft(db, dbTables, tableConf, mainResults, joinLeft, t
519
552
  const joinDef = requireJoin(tableConf, alias, true);
520
553
  const { joinSchema, joinField, mainField, selection: defaultSelection } = joinDef;
521
554
  const joinTableConf = dbTables[joinSchema.tableName];
555
+ // Resolved before the bail-out, for the same reason as joinMultiple: an invalid
556
+ // selection is a 400 whether or not the main query matched anything.
557
+ const selection = ref?.selection ?? defaultSelection;
558
+ const columns = buildSelectionColumns(selection, joinSchema, db, joinTableConf, joinDef.fields !== undefined);
522
559
  // For joinLeft (N:1), mainField on main is the FK pointing to joinField (PK) on parent.
523
560
  // We collect the FK values from the main results and look up parents by their PK.
524
561
  const ids = collectIds(mainResults, mainField);
@@ -533,8 +570,6 @@ async function executeJoinLeft(db, dbTables, tableConf, mainResults, joinLeft, t
533
570
  appendJoinTenantScope(db, joinTableConf, tenant, joinSchema.tableName, cb, tenantJoins);
534
571
  const where = cb.build(1, db.ph);
535
572
  const values = cb.getValues();
536
- const selection = ref?.selection ?? defaultSelection;
537
- const columns = buildSelectionColumns(selection, joinSchema, db, joinTableConf);
538
573
  const rows = await db.select({
539
574
  tableName: joinSchema.tableName,
540
575
  columns,
@@ -577,10 +612,6 @@ async function executeJoinGroup(db, dbTables, tableConf, mainResults, joinGroup,
577
612
  const { joinSchema, joinField, mainField } = joinDef;
578
613
  const joinTableConf = dbTables[joinSchema.tableName];
579
614
  const ids = collectIds(mainResults, mainField);
580
- if (ids.length === 0) {
581
- result[alias] = {};
582
- continue;
583
- }
584
615
  const { aggregations, filters: groupFilters, conditions: groupConditions } = groupReq;
585
616
  const selectParts = [];
586
617
  const groupByParts = [];
@@ -604,7 +635,9 @@ async function executeJoinGroup(db, dbTables, tableConf, mainResults, joinGroup,
604
635
  addAgg('sum', aggregations.sum);
605
636
  addAgg('avg', aggregations.avg);
606
637
  addAgg('count', aggregations.count);
607
- if (selectParts.length === 0) {
638
+ // Bail out only here: `by` and every aggregation field have now been validated against
639
+ // the join schema, so an unknown or unreachable field is a 400 regardless of the data.
640
+ if (selectParts.length === 0 || ids.length === 0) {
608
641
  result[alias] = {};
609
642
  continue;
610
643
  }
@@ -676,6 +709,8 @@ function dispatchConditionMethod(cb, method, colOrExpr, params) {
676
709
  }
677
710
  function buildJoinRefCondition(joinTableConf, joinSchema, ref, db, qualifier) {
678
711
  assertFiltersReadable(ref.filters, joinTableConf);
712
+ // Filter keys are checked by assertJoinFilterKeys, up front in searchEngine — this side
713
+ // query may never run, so it is not a place a request can be rejected from.
679
714
  // Bare table name for plain side-queries; the subquery alias when the condition lands
680
715
  // inside an aliased, correlated FROM (there the table name would resolve to the outer query).
681
716
  const colQualifier = qualifier ?? joinSchema.tableName;
@@ -690,7 +725,7 @@ function buildJoinRefCondition(joinTableConf, joinSchema, ref, db, qualifier) {
690
725
  continue;
691
726
  if (!computed[name])
692
727
  continue;
693
- const expr = evaluateComputedField(name, computed[name], joinTableConf.Schema, db, colQualifier, '', true);
728
+ const expr = evaluateComputedField(name, computed[name], joinSchema, db, colQualifier, '', true);
694
729
  cb.isEqual(expr, value);
695
730
  }
696
731
  }
@@ -702,7 +737,7 @@ function buildJoinRefCondition(joinTableConf, joinSchema, ref, db, qualifier) {
702
737
  // Computed fields resolve to an Expression carrying their own bound values, which
703
738
  // the ConditionBuilder places together with the compared value.
704
739
  const operand = computed?.[c.field]
705
- ? evaluateComputedField(c.field, computed[c.field], joinTableConf.Schema, db, colQualifier, '', true)
740
+ ? evaluateComputedField(c.field, computed[c.field], joinSchema, db, colQualifier, '', true)
706
741
  : `${db.qi(colQualifier)}.${db.qi(validateSchemaField(c.field, joinSchema, joinTableConf))}`;
707
742
  dispatchConditionMethod(cb, c.method, operand, c.params ?? []);
708
743
  }
@@ -809,8 +844,16 @@ export async function searchEngine(dbTables, params) {
809
844
  if (joinLeft)
810
845
  for (const a of Object.keys(joinLeft))
811
846
  requireJoin(tableConf, a, true);
847
+ // Join filter keys, validated before any query so the outcome never depends on whether
848
+ // the main query returned rows. joinLeft is the odd one: its condition is built inline
849
+ // and never runs the target's extendedCondition, so extraFilters are not accepted there.
850
+ assertJoinFilterKeys(dbTables, tableConf, joinMustExist, true);
851
+ assertJoinFilterKeys(dbTables, tableConf, joinMultiple, true);
852
+ assertJoinFilterKeys(dbTables, tableConf, joinGroup, true);
853
+ assertJoinFilterKeys(dbTables, tableConf, joinLeft, false);
812
854
  // Build main condition
813
855
  assertFiltersReadable(filters, tableConf);
856
+ assertKnownFilterKeys(filters, tableConf.Schema, tableConf);
814
857
  const condition = tableConf.filters(filters || {}, db.cbDialect);
815
858
  // Filters and conditions targeting computed fields go on the same ConditionBuilder:
816
859
  // a computed resolves to an Expression carrying its own values, and the builder is
@@ -17,6 +17,26 @@ export declare function assertReadable(tableConf: ITable | undefined, field: str
17
17
  * excluded pass through untouched (extraFilters and computed names included).
18
18
  */
19
19
  export declare function assertFiltersReadable(filters: Record<string, unknown> | undefined, tableConf: ITable | undefined): void;
20
+ /**
21
+ * Throws 400 when a filter map carries a key the engine would never visit.
22
+ *
23
+ * Filters are matched against the schema fields, the table's `extraFilters` and its computed
24
+ * fields; anything else used to be dropped without a word, so a mistyped key returned MORE
25
+ * rows than the caller asked for and nothing said so. That is the dangerous direction to fail
26
+ * in — filters are also how consumers narrow visibility — and it contradicted every
27
+ * neighbouring path, where an unknown field in `selection`, `conditions`, `orderBy` or an
28
+ * aggregation is already a 400.
29
+ *
30
+ * `schema` is the schema the relation was declared with, which for a join may be a trimmed
31
+ * copy: a field outside it is unknown here exactly as it is for `selection`.
32
+ *
33
+ * `allowExtraFilters` is false on the joinLeft path, whose condition is built inline
34
+ * (`buildLeftJoinClauses`) and never runs the table's `extendedCondition`.
35
+ *
36
+ * Call AFTER `assertFiltersReadable`, so a read-excluded field keeps its own message
37
+ * instead of being reported as unknown.
38
+ */
39
+ export declare function assertKnownFilterKeys(filters: Record<string, unknown> | undefined, schema: SchemaDefinition, tableConf: ITable | undefined, allowExtraFilters?: boolean): void;
20
40
  /** Schema field names that may be read, in schema declaration order. */
21
41
  export declare function readableFieldNames(tableConf: ITable | undefined, schema: SchemaDefinition): string[];
22
42
  /**
@@ -28,4 +48,9 @@ export declare function readableFieldNames(tableConf: ITable | undefined, schema
28
48
  * would be ambiguous.
29
49
  */
30
50
  export declare function readableSelectColumns(tableConf: ITable | undefined, schema: SchemaDefinition, db: QueryClient): string | undefined;
51
+ /**
52
+ * The same list, always spelled out. Used where `*` is not an acceptable fallback: a
53
+ * relation narrowed by a `fields` allowlist must project the allowlist, not the table.
54
+ */
55
+ export declare function explicitSelectColumns(tableConf: ITable | undefined, schema: SchemaDefinition, db: QueryClient): string;
31
56
  //# sourceMappingURL=read-access.d.ts.map
@@ -1 +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"}
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;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAC5C,MAAM,EAAE,gBAAgB,EACxB,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,iBAAiB,UAAO,GACvB,IAAI,CAkBN;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,CAGpB;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,MAAM,EAAE,gBAAgB,EACxB,EAAE,EAAE,WAAW,GACd,MAAM,CAKR"}
@@ -28,6 +28,46 @@ export function assertFiltersReadable(filters, tableConf) {
28
28
  assertReadable(tableConf, key);
29
29
  }
30
30
  }
31
+ /**
32
+ * Throws 400 when a filter map carries a key the engine would never visit.
33
+ *
34
+ * Filters are matched against the schema fields, the table's `extraFilters` and its computed
35
+ * fields; anything else used to be dropped without a word, so a mistyped key returned MORE
36
+ * rows than the caller asked for and nothing said so. That is the dangerous direction to fail
37
+ * in — filters are also how consumers narrow visibility — and it contradicted every
38
+ * neighbouring path, where an unknown field in `selection`, `conditions`, `orderBy` or an
39
+ * aggregation is already a 400.
40
+ *
41
+ * `schema` is the schema the relation was declared with, which for a join may be a trimmed
42
+ * copy: a field outside it is unknown here exactly as it is for `selection`.
43
+ *
44
+ * `allowExtraFilters` is false on the joinLeft path, whose condition is built inline
45
+ * (`buildLeftJoinClauses`) and never runs the table's `extendedCondition`.
46
+ *
47
+ * Call AFTER `assertFiltersReadable`, so a read-excluded field keeps its own message
48
+ * instead of being reported as unknown.
49
+ */
50
+ export function assertKnownFilterKeys(filters, schema, tableConf, allowExtraFilters = true) {
51
+ if (!filters)
52
+ return;
53
+ for (const [key, value] of Object.entries(filters)) {
54
+ // `undefined` means "filter not supplied" everywhere else in the engine; an absent
55
+ // filter cannot be a wrong one. `null` does filter (IS NULL) and is validated.
56
+ if (value === undefined)
57
+ continue;
58
+ if (key in schema.fields)
59
+ continue;
60
+ if (tableConf?.computedFields?.[key])
61
+ continue;
62
+ if (tableConf?.extraFilters && key in tableConf.extraFilters) {
63
+ if (allowExtraFilters)
64
+ continue;
65
+ throw httpError(400, `Filter '${key}' is an extraFilter: extraFilters are not applied on joinLeft ` +
66
+ `(only schema fields and computed fields are). Use joinMustExist on the same relation.`);
67
+ }
68
+ throw httpError(400, `Unknown filter field: ${key}`);
69
+ }
70
+ }
31
71
  /** Schema field names that may be read, in schema declaration order. */
32
72
  export function readableFieldNames(tableConf, schema) {
33
73
  const fields = Object.keys(schema.fields);
@@ -46,6 +86,13 @@ export function readableFieldNames(tableConf, schema) {
46
86
  export function readableSelectColumns(tableConf, schema, db) {
47
87
  if (!tableConf?.readExclude?.length)
48
88
  return undefined;
89
+ return explicitSelectColumns(tableConf, schema, db);
90
+ }
91
+ /**
92
+ * The same list, always spelled out. Used where `*` is not an acceptable fallback: a
93
+ * relation narrowed by a `fields` allowlist must project the allowlist, not the table.
94
+ */
95
+ export function explicitSelectColumns(tableConf, schema, db) {
49
96
  const table = db.qi(schema.tableName);
50
97
  return readableFieldNames(tableConf, schema)
51
98
  .map((f) => `${table}.${db.qi(schema.col(f))}`)
@@ -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;AAIrE,OAAO,KAAK,EAAE,QAAQ,EAA4B,MAAM,gBAAgB,CAAC;AAyBzE,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAuHlF;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,eAAe,GAAE,MAAmC,GAAG,OAAO,CAe9F;AAED,qFAAqF;AACrF,eAAO,MAAM,sBAAsB,kDAAqB,CAAC;AAEzD,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAsElF"}
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;AAIrE,OAAO,KAAK,EAAE,QAAQ,EAA4B,MAAM,gBAAgB,CAAC;AAsCzE,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CA+HlF;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,eAAe,GAAE,MAAmC,GAAG,OAAO,CAe9F;AAED,qFAAqF;AACrF,eAAO,MAAM,sBAAsB,kDAAqB,CAAC;AAEzD,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAsElF"}
@@ -12,6 +12,18 @@ function readableFields(schema, tableConf) {
12
12
  }
13
13
  return out;
14
14
  }
15
+ /**
16
+ * A `filters` map.
17
+ *
18
+ * Deliberately NOT `additionalProperties: false`, unlike the write bodies. Fastify runs Ajv
19
+ * with `removeAdditional: true`, so a closed schema would silently *strip* an unknown filter
20
+ * key before the handler ever sees it — reproducing the very bug it looks like it fixes, and
21
+ * leaving the engine's rejection unreachable over HTTP. Unknown keys are rejected by
22
+ * `assertKnownFilterKeys` in the engine instead, which also covers `sqlApi.search()`.
23
+ */
24
+ function filtersObject(fields) {
25
+ return Type.Partial(Type.Object(fields));
26
+ }
15
27
  const JoinGroupResultItem = Type.Object({
16
28
  sum: Type.Optional(Type.Record(Type.String(), Type.Any())),
17
29
  min: Type.Optional(Type.Record(Type.String(), Type.Any())),
@@ -54,7 +66,7 @@ export function SearchTableBodyPost(dbTables, tableName) {
54
66
  ...tableConf.extraFilters,
55
67
  ...computedTypes,
56
68
  };
57
- const filtersSchema = Type.Optional(Type.Partial(Type.Object(filterFields)));
69
+ const filtersSchema = Type.Optional(filtersObject(filterFields));
58
70
  // Per-alias entries split by unique flag:
59
71
  // - unique:false → joinMustExist / joinMultiple / joinGroup
60
72
  // - unique:true → joinLeft
@@ -82,23 +94,31 @@ export function SearchTableBodyPost(dbTables, tableName) {
82
94
  }
83
95
  }
84
96
  }
85
- const joinFilterFields = joinTableConf
86
- ? { ...readableFields(joinSchema, joinTableConf), ...joinTableConf.extraFilters, ...joinComputedTypes }
97
+ const joinSchemaFields = joinTableConf
98
+ ? { ...readableFields(joinSchema, joinTableConf), ...joinComputedTypes }
87
99
  : { ...joinSchema.fields };
100
+ // `extraFilters` are only honoured where the engine delegates to the join table's
101
+ // own `filters()` — buildJoinRefCondition, i.e. the unique:false paths. joinLeft
102
+ // builds its condition inline (buildLeftJoinClauses) and handles schema and computed
103
+ // fields only, so advertising extraFilters there would promise a silent no-op.
104
+ const joinFilterFields = { ...joinSchemaFields, ...(joinTableConf?.extraFilters ?? {}) };
88
105
  const joinRefShape = {
89
- filters: Type.Optional(Type.Partial(Type.Object(joinFilterFields))),
106
+ filters: Type.Optional(filtersObject(joinFilterFields)),
90
107
  conditions: Type.Optional(Type.Array(conditionItemSchema)),
91
108
  };
92
- const joinFetchShape = {
93
- ...joinRefShape,
94
- selection: Type.Optional(Type.String()),
95
- };
96
109
  if (unique) {
97
- joinLeftProps[alias] = Type.Object(joinFetchShape);
110
+ joinLeftProps[alias] = Type.Object({
111
+ filters: Type.Optional(filtersObject(joinSchemaFields)),
112
+ conditions: Type.Optional(Type.Array(conditionItemSchema)),
113
+ selection: Type.Optional(Type.String()),
114
+ });
98
115
  }
99
116
  else {
100
117
  joinMustExistProps[alias] = Type.Object(joinRefShape);
101
- joinMultipleProps[alias] = Type.Object(joinFetchShape);
118
+ joinMultipleProps[alias] = Type.Object({
119
+ ...joinRefShape,
120
+ selection: Type.Optional(Type.String()),
121
+ });
102
122
  joinGroupProps[alias] = Type.Object({
103
123
  aggregations: Type.Object({
104
124
  by: Type.Optional(Type.String()),
@@ -109,7 +129,7 @@ export function SearchTableBodyPost(dbTables, tableName) {
109
129
  avg: Type.Optional(Type.Array(Type.String())),
110
130
  count: Type.Optional(Type.Array(Type.String())),
111
131
  }),
112
- filters: Type.Optional(Type.Partial(Type.Object(joinFilterFields))),
132
+ filters: Type.Optional(filtersObject(joinFilterFields)),
113
133
  conditions: Type.Optional(Type.Array(conditionItemSchema)),
114
134
  });
115
135
  }
@@ -1,5 +1,5 @@
1
1
  import { ConditionBuilder, type ConditionValueOrUndefined } from 'node-condition-builder';
2
- import type { TSchema, TObject } from '@sinclair/typebox';
2
+ import { type TSchema, type TObject } from '@sinclair/typebox';
3
3
  import type { SchemaDefinition, JoinDefinition, TableFilterFn, ITable } from '../types.js';
4
4
  type ExtraProps<EF> = EF extends TObject<infer P> ? P : EF extends Record<string, TSchema> ? EF : Record<string, never>;
5
5
  export declare function exportTableInfo<F extends Record<string, TSchema>, EF extends TObject | Record<string, TSchema> = Record<never, TSchema>>(Schema: SchemaDefinition<F>, extraFilters?: EF, extendedCondition?: (condition: ConditionBuilder, filters: {
@@ -14,6 +14,21 @@ export interface BuildRelationOptions {
14
14
  alias?: string;
15
15
  selection?: string;
16
16
  unique?: boolean;
17
+ /**
18
+ * Allowlist of the target table's fields reachable through this relation.
19
+ *
20
+ * The relation is declared against a schema narrowed to these fields, so every read
21
+ * surface — `selection`, `filters`, `conditions`, `orderBy`, aggregations, and the
22
+ * generated request/response schemas — rejects anything outside the list with
23
+ * `400 Unknown field`. Use it to expose a table broadly while keeping some of its
24
+ * columns out of one relation, without hiding them from the table's own routes the way
25
+ * `readExclude` would.
26
+ *
27
+ * Fail-closed: a column added to the table later is not reachable through the relation
28
+ * until it is added here. Must include the join field, and is not allowed on a write
29
+ * join.
30
+ */
31
+ fields?: string[];
17
32
  }
18
33
  export declare function buildRelation<M extends Record<string, TSchema>, J extends Record<string, TSchema>>(mainSchema: SchemaDefinition<M>, mainField: string & keyof M | (string & keyof M)[], joinSchema: SchemaDefinition<J>, joinField: string & keyof J, options?: BuildRelationOptions): JoinDefinition;
19
34
  export declare function buildUpsertRule<F extends Record<string, TSchema>>(schema: SchemaDefinition<F>, columns: (string & keyof F)[]): [SchemaDefinition, string[]];
@@ -1 +1 @@
1
- {"version":3,"file":"table-helpers.d.ts","sourceRoot":"","sources":["../../src/lib/table-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,KAAK,yBAAyB,EAAiC,MAAM,wBAAwB,CAAC;AACzH,OAAO,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAC1D,OAAO,KAAK,EACV,gBAAgB,EAChB,cAAc,EACd,aAAa,EACb,MAAM,EACP,MAAM,aAAa,CAAC;AAarB,KAAK,UAAU,CAAC,EAAE,IAAI,EAAE,SAAS,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAExH,wBAAgB,eAAe,CAC7B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,EAAE,SAAS,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,EAErE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,YAAY,GAAE,EAAa,EAC3B,iBAAiB,CAAC,EAAE,CAClB,SAAS,EAAE,gBAAgB,EAC3B,OAAO,EAAE;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,MAAM,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,yBAAyB;CAAE,KAC3E,IAAI,GACR;IAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC;IAAC,OAAO,EAAE,aAAa,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAsChG;AAED,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3D,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAChB,MAAM,CAAC,CAAC,CAAC,CAMX;AAkED,MAAM,WAAW,oBAAoB;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,wBAAgB,aAAa,CAC3B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAEjC,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,EAClD,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,EAC3B,OAAO,CAAC,EAAE,oBAAoB,GAC7B,cAAc,CAShB;AAED,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/D,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,OAAO,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,GAC5B,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAE9B;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,KAAK,EAAE,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,EAAE,GACvC,GAAG,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAEjC"}
1
+ {"version":3,"file":"table-helpers.d.ts","sourceRoot":"","sources":["../../src/lib/table-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,KAAK,yBAAyB,EAAiC,MAAM,wBAAwB,CAAC;AACzH,OAAO,EAAQ,KAAK,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EACV,gBAAgB,EAChB,cAAc,EACd,aAAa,EACb,MAAM,EACP,MAAM,aAAa,CAAC;AAarB,KAAK,UAAU,CAAC,EAAE,IAAI,EAAE,SAAS,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAExH,wBAAgB,eAAe,CAC7B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,EAAE,SAAS,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,EAErE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,YAAY,GAAE,EAAa,EAC3B,iBAAiB,CAAC,EAAE,CAClB,SAAS,EAAE,gBAAgB,EAC3B,OAAO,EAAE;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,MAAM,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,yBAAyB;CAAE,KAC3E,IAAI,GACR;IAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC;IAAC,OAAO,EAAE,aAAa,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAsChG;AAED,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3D,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAChB,MAAM,CAAC,CAAC,CAAC,CAOX;AAqFD,MAAM,WAAW,oBAAoB;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AAgDD,wBAAgB,aAAa,CAC3B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAEjC,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,EAClD,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,EAC3B,OAAO,CAAC,EAAE,oBAAoB,GAC7B,cAAc,CAWhB;AAED,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/D,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,OAAO,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,GAC5B,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAE9B;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,KAAK,EAAE,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,EAAE,GACvC,GAAG,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAEjC"}
@@ -1,4 +1,5 @@
1
1
  import { ConditionBuilder } from 'node-condition-builder';
2
+ import { Type } from '@sinclair/typebox';
2
3
  import { getDialect } from './dialect.js';
3
4
  // Quote an identifier using the given ConditionBuilder dialect (or the global
4
5
  // default when not provided). Needed so that DB columns with uppercase letters
@@ -46,10 +47,27 @@ export function exportTableInfo(Schema, extraFilters = {}, extendedCondition) {
46
47
  export function defineTable(config) {
47
48
  validateAliasUniqueness(config.allowedReadJoins, 'allowedReadJoins');
48
49
  validateAliasUniqueness(config.allowedWriteJoins, 'allowedWriteJoins');
50
+ validateWriteJoinsUnrestricted(config.allowedWriteJoins);
49
51
  validateComputedFields(config);
50
52
  validateReadExclude(config);
51
53
  return config;
52
54
  }
55
+ /**
56
+ * `fields` narrows a relation for reading; it has no meaning on a write join and would be
57
+ * actively harmful there. Write paths resolve the secondary's upsert rule by looking the
58
+ * schema object up in `upsertMap` **by identity**, and a narrowed relation carries a copy —
59
+ * the lookup would miss and the upsert would silently degrade to a plain insert. The
60
+ * narrowed schema would also drop columns the caller legitimately sends.
61
+ */
62
+ function validateWriteJoinsUnrestricted(joins) {
63
+ for (const join of joins ?? []) {
64
+ if (join.fields) {
65
+ throw new Error(`defineTable: relation '${join.alias}' in allowedWriteJoins declares 'fields'. ` +
66
+ `The fields allowlist restricts reading only — remove it, and use a separate ` +
67
+ `buildRelation for the read side if that relation is also in allowedReadJoins.`);
68
+ }
69
+ }
70
+ }
53
71
  function validateReadExclude(config) {
54
72
  const excluded = config.readExclude;
55
73
  if (!excluded?.length)
@@ -97,14 +115,50 @@ function validateAliasUniqueness(joins, label) {
97
115
  seen.add(j.alias);
98
116
  }
99
117
  }
118
+ /**
119
+ * Copy of `schema` exposing only `fields`, in schema declaration order.
120
+ *
121
+ * `tableName`, `col` and `colMap` are carried over untouched: the engine resolves the
122
+ * target's configuration through `dbTables[joinSchema.tableName]`, so a narrowed relation
123
+ * must still answer to its real table name, and column mapping must keep working for the
124
+ * fields that survive.
125
+ */
126
+ function narrowSchemaToFields(schema, fields, joinField) {
127
+ const declared = Object.keys(schema.fields);
128
+ for (const field of fields) {
129
+ if (!declared.includes(field)) {
130
+ throw new Error(`buildRelation: 'fields' entry '${field}' is not a field of schema ` +
131
+ `'${schema.tableName}'. Available: ${declared.join(', ')}.`);
132
+ }
133
+ }
134
+ // The join field is what ties the fetched rows back to the main result set. Dropping it
135
+ // from the projection would hand the caller rows it cannot match to anything.
136
+ if (!fields.includes(joinField)) {
137
+ throw new Error(`buildRelation: 'fields' must include the join field '${joinField}' on ` +
138
+ `'${schema.tableName}' — it is what correlates the joined rows with the main ones.`);
139
+ }
140
+ const picked = {};
141
+ for (const field of declared) {
142
+ if (fields.includes(field))
143
+ picked[field] = schema.fields[field];
144
+ }
145
+ return {
146
+ ...schema,
147
+ fields: picked,
148
+ validation: Type.Object(picked),
149
+ partialValidation: Type.Partial(Type.Object(picked)),
150
+ };
151
+ }
100
152
  export function buildRelation(mainSchema, mainField, joinSchema, joinField, options) {
153
+ const fields = options?.fields;
101
154
  return {
102
- joinSchema,
155
+ joinSchema: fields ? narrowSchemaToFields(joinSchema, fields, joinField) : joinSchema,
103
156
  joinField,
104
157
  mainField,
105
158
  alias: options?.alias ?? joinSchema.tableName,
106
159
  selection: options?.selection ?? '*',
107
160
  unique: options?.unique ?? false,
161
+ ...(fields ? { fields: [...fields] } : {}),
108
162
  };
109
163
  }
110
164
  export function buildUpsertRule(schema, columns) {
@@ -8,6 +8,15 @@ export interface JoinDefinition {
8
8
  alias: string;
9
9
  selection: string;
10
10
  unique: boolean;
11
+ /**
12
+ * Allowlist of the target table's fields reachable through this relation, when
13
+ * `buildRelation` was given one. `joinSchema` is already narrowed to it — this is kept
14
+ * so the engine can tell a restricted relation from a full one, which is what stops the
15
+ * default `'*'` selection from falling back to a real `SELECT *`.
16
+ *
17
+ * Read joins only: a write join declaring it is rejected by `defineTable`.
18
+ */
19
+ fields?: string[];
11
20
  }
12
21
  export interface AggregationRequest {
13
22
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"join.d.ts","sourceRoot":"","sources":["../../src/types/join.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEvD,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,gBAAgB,CAAC;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,kBAAkB;IACjC;;;OAGG;IACH,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,gBAAgB;IAC/B,YAAY,EAAE,kBAAkB,CAAC;IACjC,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,UAAU,CAAC,EAAE,eAAe,EAAE,CAAC;CAChC;AAED;;;;;GAKG;AACH,MAAM,WAAW,aAAa;IAC5B,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,UAAU,CAAC,EAAE,eAAe,EAAE,CAAC;CAChC;AAED,kEAAkE;AAClE,MAAM,WAAW,gBAAiB,SAAQ,aAAa;IACrD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB"}
1
+ {"version":3,"file":"join.d.ts","sourceRoot":"","sources":["../../src/types/join.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEvD,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,gBAAgB,CAAC;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,OAAO,CAAC;IAChB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,MAAM,WAAW,kBAAkB;IACjC;;;OAGG;IACH,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,gBAAgB;IAC/B,YAAY,EAAE,kBAAkB,CAAC;IACjC,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,UAAU,CAAC,EAAE,eAAe,EAAE,CAAC;CAChC;AAED;;;;;GAKG;AACH,MAAM,WAAW,aAAa;IAC5B,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,UAAU,CAAC,EAAE,eAAe,EAAE,CAAC;CAChC;AAED,kEAAkE;AAClE,MAAM,WAAW,gBAAiB,SAAQ,aAAa;IACrD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB"}
@@ -0,0 +1,100 @@
1
+ # 0010. A declared join is a read grant — joins do not run the target table's route guards
2
+
3
+ - **Status**: accepted
4
+ - **Date**: 2026-07-30
5
+
6
+ ## Context
7
+
8
+ `onRequests` and `operations` are configured per table, which reads as "this table's
9
+ protection". They are not. Both are properties of the *routes generated for* a table:
10
+ `mergeOnRequests` composes the global and per-table hooks into `fastify.route({ onRequest })`,
11
+ and `operations` decides which routes get registered at all.
12
+
13
+ A join is resolved inside the query of the **host** table's route. `searchEngine` validates a
14
+ join alias against the host's `allowedReadJoins` and nothing else — it never looks up the
15
+ target's `operations`, and no Fastify hook fires mid-query. So:
16
+
17
+ ```typescript
18
+ // agent: readable by every employee
19
+ allowedReadJoins: [buildRelation(SchemaAgent, 'userId', SchemaUser, 'id', { unique: true })],
20
+
21
+ // user: admins only... except through the join above
22
+ const TableUser = defineTable({
23
+ ...exportTableInfo(SchemaUser),
24
+ onRequests: [requireAdmin],
25
+ operations: [], // no HTTP routes at all
26
+ });
27
+ ```
28
+
29
+ `POST /search/agent` with `joinLeft: { user: { selection: 'id,email' } }` returns the emails.
30
+ `requireAdmin` never ran, and `operations: []` did not matter: the caller was authorized as a
31
+ reader of `agent`, and the join is part of what `agent` exposes.
32
+
33
+ Reviewers reach this and read it as a hole to plug. It is a boundary, and the boundary needs
34
+ to be stated rather than discovered.
35
+
36
+ ## Decision
37
+
38
+ **Declaring a relation in `allowedReadJoins` grants read access to the target table, under
39
+ the authorization of the host table.** The target's own route guards do not apply and are not
40
+ made to apply.
41
+
42
+ Route-level controls (`onRequests`, `operations`) do not follow joins. Configuration-level
43
+ controls do, and are the ones to use on a join target:
44
+
45
+ | Control | Follows a join | Granularity |
46
+ |---|---|---|
47
+ | `onRequests` | no | per route |
48
+ | `operations` | no | per route |
49
+ | `readExclude` | yes | per table, static |
50
+ | the relation's `Schema` + `selection` | yes | per relation, static |
51
+ | `tenantScope` + `getTenantId` | yes | per request |
52
+
53
+ To expose a table broadly while keeping some of its columns narrow, declare the relation
54
+ against a **trimmed copy of the Schema** and give it an explicit `selection`:
55
+
56
+ ```typescript
57
+ const SchemaUserPublic = { ...SchemaUser, fields: { id: SchemaUser.fields.id, name: SchemaUser.fields.name } };
58
+
59
+ buildRelation(SchemaAgent, 'userId', SchemaUserPublic, 'id', { unique: true, selection: 'id, name' })
60
+ ```
61
+
62
+ Every read surface validates against the schema the relation was declared with — `selection`,
63
+ `filters`, `conditions`, `orderBy`, aggregations — so a field outside it is `400 Unknown
64
+ field`, not a hidden column. The explicit `selection` is not redundant: a relation left at the
65
+ default `'*'` emits a literal `SELECT *` when the target declares no `readExclude`, and the
66
+ extra columns are dropped only by the response serializer — they are still present on the
67
+ programmatic `sqlApi.search()` path.
68
+
69
+ ## Alternatives considered
70
+
71
+ - **Run the target's `onRequests` when it is joined** — rejected: a Fastify request hook may
72
+ reply and terminate; there is no coherent meaning for that halfway through building one
73
+ table's query. It would also fire N times per request with no request lifecycle to attach to.
74
+ - **`canBeJoined?: (request) => Promise<boolean>` on `ITable`** — rejected: wrong granularity.
75
+ The real cases are "join yes, this column no", which a boolean cannot express, and the
76
+ refusal has no good answer (a `400` leaks the relation's existence; omitting the join
77
+ silently is indistinguishable from "no related rows"). It also adds a per-join await and
78
+ cannot be enforced for programmatic callers.
79
+ - **Make the relation's `selection` a ceiling instead of a default** — rejected: breaking, and
80
+ it does not solve the case it looks like it solves. A ceiling is still static, and it caps
81
+ only the projection: the excluded field stays filterable through `conditions`, which is the
82
+ bisection leak `readExclude` exists to prevent. Trimming the schema closes every surface at
83
+ once.
84
+ - **Refusing to register a table that is a join target of a less-protected table** — rejected:
85
+ the plugin has no way to compare two `onRequests` arrays for strictness, and ADR 0002 says
86
+ auth is the consumer's to model.
87
+
88
+ ## Consequences
89
+
90
+ - `allowedReadJoins` must be read as a security decision, not just an ergonomics one. Adding a
91
+ relation widens the reachable data set for everyone authorized on the host table.
92
+ - Column-level protection that depends on the caller's role has no mechanism today.
93
+ `readExclude` is per table and static; the trimmed schema is per relation and static. The
94
+ known gap is a request-resolved `readExclude`, which would work without new plumbing at the
95
+ join sites — every path already resolves it through `dbTables[joinSchema.tableName]` — but
96
+ requires the generated response and filter schemas to stay the widest set and the runtime to
97
+ narrow. Not decided here.
98
+ - `tenantScope` remains the one request-derived protection that crosses joins, and is the
99
+ precedent for the shape any future one should take: table configuration plus a plugin-level
100
+ resolver, not a route hook.
@@ -0,0 +1,95 @@
1
+ # 0011. Per-relation field restriction is an allowlist, not a blocklist
2
+
3
+ - **Status**: accepted
4
+ - **Date**: 2026-07-31
5
+
6
+ ## Context
7
+
8
+ [ADR 0010](./0010-joins-do-not-run-route-guards.md) establishes that a declared join is a read
9
+ grant, and that the only thing narrowing what a relation exposes is the schema it was declared
10
+ with. The pattern it documented was to hand-write a trimmed copy:
11
+
12
+ ```typescript
13
+ const SchemaUserPublic = { ...SchemaUser, fields: { id: SchemaUser.fields.id, name: SchemaUser.fields.name } };
14
+ ```
15
+
16
+ That works, but the copy is derived state with nothing keeping it in sync. Schemas are
17
+ regenerated from the database as disposable mirrors (ADR 0007), so the copy silently drifts:
18
+ adding a column to `user` leaves `SchemaUserPublic` correct, dropping or renaming one leaves it
19
+ referencing a field that no longer exists, and neither shows up until a request fails.
20
+
21
+ The restriction belongs in the relation, where someone reading the table configuration is
22
+ already looking. The open question was its polarity.
23
+
24
+ ## Decision
25
+
26
+ `buildRelation` takes `fields: string[]` — an **allowlist** of the target's fields reachable
27
+ through that relation. The relation is declared against a schema narrowed to that list, so every
28
+ existing check (`field in schema.fields`) enforces it without a parallel code path: `selection`,
29
+ `filters`, `conditions`, `orderBy`, aggregations, `aggregations.by`, and the generated request
30
+ and response schemas.
31
+
32
+ ```typescript
33
+ buildRelation(SchemaAgent, 'userId', SchemaUser, 'id', { unique: true, fields: ['id', 'name'] })
34
+ ```
35
+
36
+ Allowlist, not a `excludeFields` blocklist, because the failure modes are not symmetric. A
37
+ column added to the table later is not reachable through the relation until someone adds it —
38
+ the new column is invisible by default, and the cost of forgetting is a `400`. Under a blocklist
39
+ the same event silently widens every relation that did not anticipate it, and the cost of
40
+ forgetting is disclosure. On a join — a grant handed out under *another* table's authorization —
41
+ fail-closed is the only defensible default. It also reads the same way as `selection`, which is
42
+ already an allowlist.
43
+
44
+ Three rules follow from making it real rather than cosmetic:
45
+
46
+ - **It must include the join field.** That column is what correlates the fetched rows with the
47
+ main ones; omitting it hands the caller rows it cannot match to anything. Rejected at
48
+ declaration, like `readExclude` refusing to hide a primary key.
49
+ - **It is rejected on `allowedWriteJoins`.** Write paths resolve a secondary's upsert rule by
50
+ looking its schema up in `upsertMap` **by identity**; a narrowed relation carries a copy, so
51
+ the lookup would miss and the upsert would quietly degrade to a plain insert. The narrowed
52
+ schema would also drop columns the caller legitimately sent.
53
+ - **Computed fields on the target resolve against the narrowed schema.** A computed field reads
54
+ whatever columns it likes; resolving it against the full table schema would leave a documented
55
+ way around the allowlist (`salaryBand` returning what `salary` says). One that stays inside the
56
+ list keeps working; one that reaches outside is a `400`.
57
+
58
+ The default `selection: '*'` is spelled out into the allowed columns on a restricted relation.
59
+ It used to emit a real `SELECT *`, with the extra columns removed only by the response
60
+ serializer — which does not run for a caller of `sqlApi.search()`. An allowlist that leaks on its
61
+ default is not an allowlist.
62
+
63
+ ## Alternatives considered
64
+
65
+ - **`excludeFields: string[]` (blocklist)** — rejected on the fail-open argument above. It is
66
+ shorter to write when hiding one column out of twenty, and it would have read like
67
+ `readExclude`; neither outweighs a new column defaulting to exposed.
68
+ - **`lockSelection: true`, making the declared `selection` a ceiling** — rejected: it caps the
69
+ projection only. The excluded column stays reachable from `filters`, `conditions` and
70
+ `orderBy`, which is the bisection leak `readExclude` exists to prevent — a guard that hides a
71
+ value from the output while leaving it interrogable is worse than none, because it reads as
72
+ protection.
73
+ - **Making the relation's `selection` a ceiling instead of adding a field** — rejected in
74
+ ADR 0010: breaking, and still static.
75
+ - **Threading the allowlist through every validation site instead of narrowing the schema** —
76
+ rejected: it would leave each new read surface to remember the check. Narrowing the schema is
77
+ closed by construction, which is the property that made the hand-written trimmed copy work in
78
+ the first place.
79
+ - **Keeping the hand-written trimmed copy as the documented pattern** — rejected: it duplicates
80
+ regenerated state. It still works, and is the way out if a relation needs a shape `fields`
81
+ cannot express.
82
+
83
+ ## Consequences
84
+
85
+ - `fields` restricts **reading** only. It is not a substitute for `readExclude`, which hides a
86
+ column from the owning table's own routes too, nor for `onRequests`, which is what decides who
87
+ may call them.
88
+ - It is static, like everything else that crosses a join. Role-dependent column visibility —
89
+ admins seeing `email`, nobody else — still has no mechanism; the candidate remains a
90
+ request-resolved `readExclude`, unaffected by this ADR.
91
+ - A relation and its write counterpart can no longer be the same `buildRelation` call when the
92
+ read side is restricted. Declaring two is the intended answer, not a workaround.
93
+ - The generated agent manifest still describes join targets by table name only, so an LLM client
94
+ reading it may ask for a field the relation does not expose and get a `400`. Listing a
95
+ restricted relation's fields in the manifest is a follow-up.
@@ -24,6 +24,8 @@ not by editing history.
24
24
  | [0007](./0007-explicit-table-generation.md) | Table generation is explicit — `--all` is a migration helper, not the default |
25
25
  | [0008](./0008-agent-client-interface.md) | Agent client interface: static grammar + runtime manifest |
26
26
  | [0009](./0009-schema-exclusion-config-blacklist.md) | Schema exclusion is a config blacklist — excluded schemas are removed as orphans |
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
+ | [0011](./0011-join-fields-allowlist.md) | Per-relation field restriction is an allowlist, not a blocklist |
27
29
 
28
30
  ## When to write one
29
31
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fastify-auto-sqlapi",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "Fastify plugin for automatic CRUD API generation from PostgreSQL, MySQL and MariaDB tables",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",