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 +28 -1
- package/BREAKING_CHANGES.md +75 -0
- package/CHANGELOG.md +45 -0
- package/README.md +49 -7
- package/dist/lib/agent/manifest.d.ts.map +1 -1
- package/dist/lib/agent/manifest.js +2 -0
- package/dist/lib/engine/search/search.d.ts.map +1 -1
- package/dist/lib/engine/search/search.js +57 -14
- package/dist/lib/read-access.d.ts +25 -0
- package/dist/lib/read-access.d.ts.map +1 -1
- package/dist/lib/read-access.js +47 -0
- package/dist/lib/schema/search.d.ts.map +1 -1
- package/dist/lib/schema/search.js +31 -11
- package/dist/lib/table-helpers.d.ts +16 -1
- package/dist/lib/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +55 -1
- package/dist/types/join.d.ts +9 -0
- package/dist/types/join.d.ts.map +1 -1
- package/docs/adr/0010-joins-do-not-run-route-guards.md +100 -0
- package/docs/adr/0011-join-fields-allowlist.md +95 -0
- package/docs/adr/README.md +2 -0
- package/package.json +1 -1
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.
|
package/BREAKING_CHANGES.md
CHANGED
|
@@ -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
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
|
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,
|
|
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":"
|
|
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
|
-
|
|
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],
|
|
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],
|
|
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,
|
|
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"}
|
package/dist/lib/read-access.js
CHANGED
|
@@ -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;
|
|
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(
|
|
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
|
|
86
|
-
? { ...readableFields(joinSchema, joinTableConf), ...
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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,
|
|
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) {
|
package/dist/types/join.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/types/join.d.ts.map
CHANGED
|
@@ -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;
|
|
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.
|
package/docs/adr/README.md
CHANGED
|
@@ -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