fastify-auto-sqlapi 0.1.12 → 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.
@@ -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.
@@ -23,6 +23,9 @@ not by editing history.
23
23
  | [0006](./0006-raw-db-errors.md) | Raw DB errors surface as 500 — no SQLSTATE→HTTP mapping |
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
+ | [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 |
26
29
 
27
30
  ## When to write one
28
31
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fastify-auto-sqlapi",
3
- "version": "0.1.12",
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",