@stonyx/orm 0.3.2-beta.159 → 0.3.2-beta.160
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/README.md +296 -24
- package/dist/orm-request.d.ts +13 -3
- package/dist/orm-request.js +131 -14
- package/dist/types/orm-types.d.ts +30 -4
- package/package.json +1 -1
- package/src/orm-request.ts +132 -14
- package/src/types/orm-types.ts +30 -4
package/README.md
CHANGED
|
@@ -439,11 +439,22 @@ export default class GlobalAccess {
|
|
|
439
439
|
// does not remove a rule loudly, it turns a deny into an ALLOW, silently.
|
|
440
440
|
if (recordId === 'archived') return false;
|
|
441
441
|
|
|
442
|
-
// Returning a function plugs it in as a per-record filter
|
|
443
|
-
//
|
|
442
|
+
// Returning a function plugs it in as a per-record filter. It is enforced
|
|
443
|
+
// on every surface addressed to one of these records —
|
|
444
444
|
// /owners, /owners/:id, /owners/:id/pets, /owners/:id/relationships/pets
|
|
445
|
-
//
|
|
446
|
-
//
|
|
445
|
+
// — AND, since #232, on every surface that reaches one of these records
|
|
446
|
+
// as the RELATED resource of another model:
|
|
447
|
+
// /animals/:id/owner, /animals/:id/relationships/owner
|
|
448
|
+
// Both readings are the same rule: an owner this predicate rejects is
|
|
449
|
+
// withheld wherever she is reachable, not only on /owners.
|
|
450
|
+
//
|
|
451
|
+
// NOTHING HERE IS AN EXISTENCE ORACLE, AND THE SPELLING DIFFERS BY WHOSE
|
|
452
|
+
// RECORD IS BEING REJECTED. A rejected ADDRESSED record is 404 — the same
|
|
453
|
+
// status as a record that does not exist. A rejected RELATED record is
|
|
454
|
+
// `data: null` at 200 — byte-identical to a relationship that is
|
|
455
|
+
// genuinely empty, because on those routes 404 is already the answer for
|
|
456
|
+
// a PARENT that does not exist. In both cases "rejected" and "not there"
|
|
457
|
+
// are the same answer, which is the property that matters.
|
|
447
458
|
return record => record.id !== 'angela' && record.id !== 'restricted';
|
|
448
459
|
}
|
|
449
460
|
|
|
@@ -452,7 +463,11 @@ export default class GlobalAccess {
|
|
|
452
463
|
// inert. Deliberately NO `?? record.owner` fallback: accepting the raw
|
|
453
464
|
// shape as well as the resolved one would absorb a resolution regression
|
|
454
465
|
// silently, which is exactly what blinded this fixture before.
|
|
455
|
-
|
|
466
|
+
// `record.id !== 18` hides one animal whose OWNER is permitted. It is the
|
|
467
|
+
// fixture that makes the `hasMany` half of the relationship-route rules
|
|
468
|
+
// observable: gina is served, animal 18 is not, and every surface that
|
|
469
|
+
// names gina's pets has to drop it.
|
|
470
|
+
if (model === 'animal') return record => record.owner?.id !== 'restricted' && record.id !== 18;
|
|
456
471
|
|
|
457
472
|
// Allows full access to all calls that don't match any of the above conditions
|
|
458
473
|
return ['read', 'create', 'update', 'delete'];
|
|
@@ -665,9 +680,11 @@ A `throw` inside `access()` is a **denial**, not a 500.
|
|
|
665
680
|
A function return value is a **per-record predicate**, and it is enforced on
|
|
666
681
|
every endpoint that is addressed to a record — not only on the collection.
|
|
667
682
|
|
|
668
|
-
It is evaluated against the record the route is *addressed to
|
|
669
|
-
|
|
670
|
-
|
|
683
|
+
It is evaluated against the record the route is *addressed to*. On the two
|
|
684
|
+
relationship route families it is **also** evaluated against the **related**
|
|
685
|
+
record, by that record's *own* model's predicate — see the two `{relationship}`
|
|
686
|
+
rows below. It is not a guarantee that a hidden record cannot be reached or
|
|
687
|
+
modified: a write to a *different* collection can still re-parent one. See
|
|
671
688
|
[Known limitations](#known-limitations) and
|
|
672
689
|
[#207](https://github.com/abofs/stonyx-orm/issues/207).
|
|
673
690
|
|
|
@@ -675,19 +692,42 @@ a write to a *different* collection can still re-parent one. See
|
|
|
675
692
|
|---|---|
|
|
676
693
|
| `GET /:models` | omitted from the collection |
|
|
677
694
|
| `GET /:models/:id` | `404` |
|
|
678
|
-
| `GET /:models/:id/{relationship}` | `404
|
|
679
|
-
| `GET /:models/:id/relationships/{relationship}` |
|
|
695
|
+
| `GET /:models/:id/{relationship}` | the **addressed** record → `404`. The **related** record → `200` with `data: null` for a `belongsTo`, or dropped from the array for a `hasMany` |
|
|
696
|
+
| `GET /:models/:id/relationships/{relationship}` | same, on the linkage objects |
|
|
680
697
|
| `PATCH /:models/:id` | `404`, no attribute is applied |
|
|
681
698
|
| `DELETE /:models/:id` | `404`, the record is not removed and no SQL `DELETE` is issued |
|
|
682
699
|
| `POST /:models` | `403`, and a record **this request inserted** is rolled back — see [Known limitations](#known-limitations) for why the rollback is conditional |
|
|
683
700
|
|
|
701
|
+
**A withheld related record is not an error, and that is the same rule.** The
|
|
702
|
+
addressed record is filtered with `404` and the related one with `data: null`
|
|
703
|
+
because in both cases the answer must be **identical to the answer for a record
|
|
704
|
+
that does not exist**. A `belongsTo` whose target is genuinely absent already
|
|
705
|
+
answers `200 {"data": null}`; a `hasMany` with no members already answers `200`
|
|
706
|
+
with an empty array. Withholding therefore has to be spelled the same way, or
|
|
707
|
+
the route becomes an existence oracle for a record on a collection the caller
|
|
708
|
+
may have no access to at all. Measured before this was closed — unauthenticated,
|
|
709
|
+
zero query parameters, one request each:
|
|
710
|
+
|
|
711
|
+
```
|
|
712
|
+
GET /traits/1/tag [target absent] -> 200 application/json 68 bytes
|
|
713
|
+
GET /traits/2/tag [target denied] -> 404 text/plain 9 bytes
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
`tag` is a model with **no route mounted at all**, so those two requests were the
|
|
717
|
+
only way to ask about it — and they answered differently. Both now answer
|
|
718
|
+
`200 {"data": null}`.
|
|
719
|
+
|
|
684
720
|
**Denied record-level requests return 404, not 403.** This is deliberate and it
|
|
685
721
|
is the property most easily "improved" away. 403 would confirm that the record
|
|
686
722
|
exists to a caller who is not allowed to know that, which turns the filter into
|
|
687
723
|
an existence oracle: `404` means "no such record", `403` means "there is one and
|
|
688
724
|
it is not yours". Every status on a record route must therefore be identical for
|
|
689
725
|
"filtered out" and "does not exist" — including `DELETE`, which is why deleting
|
|
690
|
-
a record that never existed also returns 404 rather than 204
|
|
726
|
+
a record that never existed also returns 404 rather than 204, and including the
|
|
727
|
+
**related** record on the two relationship families, which is why a denied
|
|
728
|
+
`belongsTo` target is `200 {"data": null}` rather than `404`: on that route
|
|
729
|
+
`404` is the answer for a parent that does not exist, so it is `data: null`, and
|
|
730
|
+
not the status, that carries "no target you may see".
|
|
691
731
|
|
|
692
732
|
`POST` is the one exception and returns **403**, because 404 on a mounted
|
|
693
733
|
collection route is indistinguishable from "model not mounted" — a genuinely
|
|
@@ -921,15 +961,109 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
921
961
|
operation and which record the request addresses. The five variants above are
|
|
922
962
|
the five ways that has been observed to fail open so far. Tracked as
|
|
923
963
|
[#202](https://github.com/abofs/stonyx-orm/issues/202).
|
|
924
|
-
- **
|
|
925
|
-
|
|
926
|
-
`GET /
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
`
|
|
931
|
-
|
|
932
|
-
|
|
964
|
+
- **The two relationship route families now resolve the *related* model's own
|
|
965
|
+
access class — `GET /:models/:id/{relationship}` and
|
|
966
|
+
`GET /:models/:id/relationships/{relationship}`**
|
|
967
|
+
([#232](https://github.com/abofs/stonyx-orm/issues/232)). This is
|
|
968
|
+
**membership**: the related resource is the route's *primary* data, so the
|
|
969
|
+
filter decides whether it is served at all, not merely which ids a document
|
|
970
|
+
may name. A denied `hasMany` member is **dropped from the array** and nothing
|
|
971
|
+
in the relationship marks the drop: `links` intact, no `errors` member, same
|
|
972
|
+
status, and an array of survivors shaped exactly like one from a parent that
|
|
973
|
+
only ever had those members. A denied `belongsTo` target answers **`200` with
|
|
974
|
+
`data: null`**, byte-identical to a target that is genuinely absent, for the
|
|
975
|
+
same reason.
|
|
976
|
+
|
|
977
|
+
**That is a claim about the relationship, not about the document, and the gap
|
|
978
|
+
is measurable in this repo's own fixture.** `owner` declares a computed
|
|
979
|
+
`totalPets` returning `this.pets.length`, which reads the **store** and is
|
|
980
|
+
never filtered. Measured on this branch, unauthenticated, at zero query
|
|
981
|
+
parameters: `GET /owners/gina` answers `attributes.totalPets: 5` while its
|
|
982
|
+
`relationships.pets.data` names **four** ids, and both relationship routes
|
|
983
|
+
serve the same four. The relationship discloses nothing; the document it
|
|
984
|
+
arrives in discloses that exactly one child was withheld. Do not read
|
|
985
|
+
"indistinguishable" as a property of the response — it is a property of the
|
|
986
|
+
relationship member alone. The other channels in the same class are
|
|
987
|
+
[#245](https://github.com/abofs/stonyx-orm/issues/245) (computed attributes,
|
|
988
|
+
which is the one measured above),
|
|
989
|
+
[#233](https://github.com/abofs/stonyx-orm/issues/233) (`included`
|
|
990
|
+
membership) and [#246](https://github.com/abofs/stonyx-orm/issues/246) (the
|
|
991
|
+
absence of `attributes.<fk>` on a `POST` response), all open. **Audit your
|
|
992
|
+
computed properties before you treat a dropped member as unobservable.** Nothing on either family errors and no status changes — the
|
|
993
|
+
status on these routes belongs to the **parent**, and `data` carries the
|
|
994
|
+
answer about the related record. The `/relationships/` family built its `{type, id}` by
|
|
995
|
+
hand rather than through `toJSON()`, which is why the linkage filter shipped in
|
|
996
|
+
[#234](https://github.com/abofs/stonyx-orm/issues/234) did not reach it.
|
|
997
|
+
|
|
998
|
+
Before this, both families served a record hidden on every one of its own
|
|
999
|
+
surfaces, in full, from another model's route, at **zero query parameters**.
|
|
1000
|
+
The severe case is a model **claimed by no access class**: `getAccess()`
|
|
1001
|
+
returns `undefined`, no route is mounted for it at all, and it was still
|
|
1002
|
+
readable as a related resource — a collection the consumer deliberately never
|
|
1003
|
+
exposed.
|
|
1004
|
+
|
|
1005
|
+
**The `belongsTo` shape is not an existence oracle, and it was measured
|
|
1006
|
+
rather than reasoned about.** An earlier revision of this fix answered `404`
|
|
1007
|
+
for a denied target, which made it distinguishable from a target that is
|
|
1008
|
+
genuinely absent. Unauthenticated, zero query parameters, one request each, on
|
|
1009
|
+
`tag` — a model with **no route mounted at all**:
|
|
1010
|
+
|
|
1011
|
+
```
|
|
1012
|
+
GET /traits/1/tag [target absent] -> 200 application/json 68 bytes
|
|
1013
|
+
GET /traits/2/tag [target denied] -> 404 text/plain 9 bytes
|
|
1014
|
+
```
|
|
1015
|
+
|
|
1016
|
+
`GET /traits/1` and `GET /traits/2` report `relationships.tag = {"data":null}`
|
|
1017
|
+
byte-identical modulo the id, because
|
|
1018
|
+
[#234](https://github.com/abofs/stonyx-orm/issues/234) closed that oracle
|
|
1019
|
+
deliberately — so this route was the one remaining way to ask which of those
|
|
1020
|
+
two nulls was a denial. Under `data: null` both requests answer `200`, same
|
|
1021
|
+
content-type, same content-length, same bytes modulo the parent id the caller
|
|
1022
|
+
put in the URL. It discloses nothing further: `links` on these routes are
|
|
1023
|
+
derived entirely from the parent and the relationship name, there is no `meta`
|
|
1024
|
+
and there are no counts. This also brings the two families back into line with
|
|
1025
|
+
the module-wide rule under [Filter functions](#filter-functions) — *every
|
|
1026
|
+
status on a record route must be identical for "filtered out" and "does not
|
|
1027
|
+
exist"* — which the `404` spelling was an exception to.
|
|
1028
|
+
|
|
1029
|
+
**Per-record denies for a related resource are not expressible.** A predicate resolved for a
|
|
1030
|
+
related resource on these routes receives `recordId: null` and a `request`
|
|
1031
|
+
whose `params` name a record of a **different model**. So the inputs it has
|
|
1032
|
+
are the model name, the operation and the request — and **a rule that needs to
|
|
1033
|
+
know *which* related record it is being asked about cannot be written**.
|
|
1034
|
+
Model-level denies (`return false` for a model) work. Request-level denies (a
|
|
1035
|
+
rule reading a header, a tenant, the method) work. The per-record **filter**
|
|
1036
|
+
shape works too — `access()` may return a function, and that function receives
|
|
1037
|
+
the whole record, id included. What does not work is branching on the record's
|
|
1038
|
+
identity *before* returning, because `access()` is not told it.
|
|
1039
|
+
|
|
1040
|
+
This is not an oversight and it is not closed here. The verdict is resolved
|
|
1041
|
+
**once per type**, cached, before any record has been examined — a `hasMany`
|
|
1042
|
+
related-resource route returns many records of one type, so seeding `recordId`
|
|
1043
|
+
from a record would let the first one decide the context for all of them. The
|
|
1044
|
+
rule the framework holds to is: **`recordId` may name a record only where the
|
|
1045
|
+
route addresses exactly one record of the model being asked about.** That is
|
|
1046
|
+
true for `GET /owners/{id}`, false for linkage, and false for a `hasMany`
|
|
1047
|
+
related-resource route.
|
|
1048
|
+
|
|
1049
|
+
- **`?include=` records are still not filtered — the relationship routes now
|
|
1050
|
+
are.** *Re-specified by [#232](https://github.com/abofs/stonyx-orm/issues/232);
|
|
1051
|
+
the sentence this replaces said all three surfaces were unfiltered, and two of
|
|
1052
|
+
them no longer are.* `GET /animals/1/owner` and
|
|
1053
|
+
`GET /animals/1/relationships/owner` resolve the related model's own access
|
|
1054
|
+
class (see the bullet above). **`?include=owner` still does not**: it
|
|
1055
|
+
serializes the related record without resolving that class, so a filter on
|
|
1056
|
+
`/owners` does not hide an owner reached through `?include=` on `/animals`.
|
|
1057
|
+
There are **two** open questions here and they are owned separately —
|
|
1058
|
+
[#233](https://github.com/abofs/stonyx-orm/issues/233), the remaining child of
|
|
1059
|
+
[#196](https://github.com/abofs/stonyx-orm/issues/196), owns whether a
|
|
1060
|
+
resource enters `included` **at all** (membership), and
|
|
1061
|
+
[#235](https://github.com/abofs/stonyx-orm/issues/235) owns the
|
|
1062
|
+
`relationships.*.data` emitted **inside** a record that is already there
|
|
1063
|
+
(linkage). Neither closes the other, and following only #233 will not lead you
|
|
1064
|
+
to the second. Membership — whether the related resource is served at all — is
|
|
1065
|
+
a different question from which ids a document may *name*, immediately
|
|
1066
|
+
below.
|
|
933
1067
|
- **Relationship linkage is filtered on every request-bound surface that
|
|
934
1068
|
serializes a record — the reads, the two writes, and `included`.** A
|
|
935
1069
|
document's `relationships.*.data` used to publish the id of every related
|
|
@@ -974,6 +1108,15 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
974
1108
|
because throwing here would be an existence oracle *and* would throw out of
|
|
975
1109
|
the enclosing `JSON.stringify`.
|
|
976
1110
|
|
|
1111
|
+
**[#232](https://github.com/abofs/stonyx-orm/issues/232) holds to the same
|
|
1112
|
+
spelling on the routes where that linkage is the *primary* data.** A denied
|
|
1113
|
+
member is dropped from the `hasMany` array and a denied `belongsTo` target is
|
|
1114
|
+
`data: null`, at `200`, `links` intact — so the claim above is true of both
|
|
1115
|
+
`GET /:models/:id/{relationship}` shapes as *routes* and not only as linkage
|
|
1116
|
+
emitted inside somebody else's document. An earlier revision of #232 answered
|
|
1117
|
+
`404` on the `belongsTo` shape and did contradict this paragraph; that is
|
|
1118
|
+
measured and closed in the #232 bullet above.
|
|
1119
|
+
|
|
977
1120
|
**That resolves the right class; it does not guarantee a model-correct
|
|
978
1121
|
answer, and the failure direction is not the safe one.** Only a predicate that
|
|
979
1122
|
*reads* `context.model` can answer about the model it was asked about — see
|
|
@@ -1038,6 +1181,15 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
1038
1181
|
measurement is where #245 starts, and it holds whichever way the decision
|
|
1039
1182
|
lands. Until it lands, if your access rules hide a record, audit your
|
|
1040
1183
|
computed properties for its identifiers.
|
|
1184
|
+
- **The absence of `attributes.<fk>` on a `POST` response proves a hidden
|
|
1185
|
+
record exists** — [#246](https://github.com/abofs/stonyx-orm/issues/246).
|
|
1186
|
+
`createHandler` copies each supplied relationship's raw id into
|
|
1187
|
+
`attributes`; when the related record resolves, the value is consumed and
|
|
1188
|
+
does not appear, and when it does not resolve, it survives. The oracle runs
|
|
1189
|
+
in the negative space, so nothing this list's surfaces withhold is
|
|
1190
|
+
*published* — the **absence** is the signal. Pre-existing, and it does not
|
|
1191
|
+
compose with the two relationship families above: they emit no `attributes`
|
|
1192
|
+
for a related record at all.
|
|
1041
1193
|
- **A bare `toJSON()` still emits unfiltered linkage, and that is deliberate.**
|
|
1042
1194
|
`Record.toJSON()` **applies** a verdict; it never **resolves** one. It has no
|
|
1043
1195
|
request, and the documented `access()` contract permits a predicate to read
|
|
@@ -1166,6 +1318,57 @@ default, the default is the unfiltered document, and a filtered relationship is
|
|
|
1166
1318
|
byte-identical to a genuinely empty one — so nothing on the wire distinguishes
|
|
1167
1319
|
"filtered" from "forgotten".
|
|
1168
1320
|
|
|
1321
|
+
**And `linkage` cannot reach a document you build by hand.** It is an *option to
|
|
1322
|
+
`toJSON()`*, so it filters only what goes through `toJSON()`. The ORM's own
|
|
1323
|
+
`GET /:models/:id/relationships/{relationship}` route is the worked example: its
|
|
1324
|
+
primary data *is* linkage, it assembles `{ type, id }` directly rather than
|
|
1325
|
+
serializing a record, and it therefore resolves and applies the verdict itself
|
|
1326
|
+
([#232](https://github.com/abofs/stonyx-orm/issues/232)). If you assemble
|
|
1327
|
+
linkage the same way anywhere — a custom relationship route, a projection, a
|
|
1328
|
+
hand-built document — **passing `linkage` to `toJSON()` does nothing for it and
|
|
1329
|
+
nothing warns**. Build the filter and consult it before you emit an id:
|
|
1330
|
+
|
|
1331
|
+
```js
|
|
1332
|
+
const linkage = createLinkageFilter(request);
|
|
1333
|
+
|
|
1334
|
+
if (related && linkage(related.__model.__name, related)) {
|
|
1335
|
+
data = { type: related.__model.__name, id: related.id };
|
|
1336
|
+
} else {
|
|
1337
|
+
data = null; // withheld and genuinely-empty must be the SAME answer
|
|
1338
|
+
}
|
|
1339
|
+
```
|
|
1340
|
+
|
|
1341
|
+
The `else` branch is the part that is easy to get wrong. Answering `404`, `403`
|
|
1342
|
+
or an `errors` member for the withheld case makes the route an **existence
|
|
1343
|
+
oracle** — see [Filter functions](#filter-functions) for the rule and for the
|
|
1344
|
+
measurement that closed it on this route.
|
|
1345
|
+
|
|
1346
|
+
**A per-record deny for a *related* resource cannot be expressed, and nothing
|
|
1347
|
+
tells you so at the point you would write it.** `createLinkageFilter` resolves
|
|
1348
|
+
the related model's access class by **type**: `context.recordId` is `null`, and
|
|
1349
|
+
`request.params` names a record of a **different model** — the one the route is
|
|
1350
|
+
addressed to. So `access()` is handed the model, the operation and the request,
|
|
1351
|
+
and **a rule that has to know *which* related record it is being asked about
|
|
1352
|
+
cannot be written**. Model-level denies (`return false` for a model) work.
|
|
1353
|
+
Request-level denies (a header, a tenant, the method) work. The per-record
|
|
1354
|
+
**filter** shape works too — `access()` may return a function, and that function
|
|
1355
|
+
receives the whole record, id included. What does not work is branching on the
|
|
1356
|
+
record's identity *before* returning, because `access()` is not told it.
|
|
1357
|
+
|
|
1358
|
+
This is a fixed property of the mechanism rather than a defect awaiting a fix.
|
|
1359
|
+
The verdict is resolved **once per type** and cached before any record has been
|
|
1360
|
+
examined, so seeding `recordId` from a record would let the first member of a
|
|
1361
|
+
`hasMany` decide the context for all of them. The rule the framework holds to is
|
|
1362
|
+
**`recordId` may name a record only where the route addresses exactly one record
|
|
1363
|
+
of the model being asked about** — true for `GET /owners/{id}`, false for
|
|
1364
|
+
linkage, and false for a `hasMany` related-resource route. The consumer-facing
|
|
1365
|
+
consequence is the part to check: a predicate that branches on `recordId` sees
|
|
1366
|
+
`null` here and takes whichever branch `null` takes, with no warning, and if
|
|
1367
|
+
that branch grants then it **grants**. Express the rule as a returned filter
|
|
1368
|
+
function instead. Stated again, with the same label, under
|
|
1369
|
+
[Known limitations](#known-limitations)
|
|
1370
|
+
([#232](https://github.com/abofs/stonyx-orm/issues/232)).
|
|
1371
|
+
|
|
1169
1372
|
Do this:
|
|
1170
1373
|
|
|
1171
1374
|
```js
|
|
@@ -1283,10 +1486,31 @@ they are recorded here.
|
|
|
1283
1486
|
return 404. Only affects function-style `access` users, for whom the old
|
|
1284
1487
|
behaviour was the bypass.
|
|
1285
1488
|
|
|
1286
|
-
"Seven surfaces" means the seven endpoints of **the filtered model
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
is
|
|
1489
|
+
"Seven surfaces" means the seven endpoints of **the filtered model** —
|
|
1490
|
+
`GET /:models`, `GET /:models/:id`, `GET /:models/:id/{relationship}`,
|
|
1491
|
+
`GET /:models/:id/relationships/{relationship}`, `POST /:models`,
|
|
1492
|
+
`PATCH /:models/:id` and `DELETE /:models/:id`. That count is still seven and
|
|
1493
|
+
is still the model's own endpoints, but **it is no longer the whole
|
|
1494
|
+
population**: the boundary moved outward rather than the number changing, and
|
|
1495
|
+
this sentence used to be read as saying a filtered model's predicate is
|
|
1496
|
+
consulted nowhere else. It now is, in two further places, both from
|
|
1497
|
+
*another* model's routes —
|
|
1498
|
+
|
|
1499
|
+
- on **both relationship route families**, where the related record is the
|
|
1500
|
+
primary data and the filtered model's own class decides whether it is
|
|
1501
|
+
served at all (breaking change 9 below,
|
|
1502
|
+
[#232](https://github.com/abofs/stonyx-orm/issues/232)); and
|
|
1503
|
+
- on the `relationships.*.data` **linkage** of every request-bound surface
|
|
1504
|
+
that serializes a record through `toJSON()`
|
|
1505
|
+
([#234](https://github.com/abofs/stonyx-orm/issues/234),
|
|
1506
|
+
[#235](https://github.com/abofs/stonyx-orm/issues/235)), which decides
|
|
1507
|
+
which ids another model's document may name.
|
|
1508
|
+
|
|
1509
|
+
A **write** to another collection can still reach one of its records through
|
|
1510
|
+
a relationship — [#207](https://github.com/abofs/stonyx-orm/issues/207),
|
|
1511
|
+
which is **not** closed here. That is the half of the old sentence that
|
|
1512
|
+
survives, and it is a read/write asymmetry now rather than a blanket
|
|
1513
|
+
statement about cross-model reach.
|
|
1290
1514
|
5. **A predicate that throws is treated as a denial** rather than propagating to
|
|
1291
1515
|
Express's default 500 handler. So is an `access()` that throws.
|
|
1292
1516
|
6. **`access()` returning a bare string is one permission, not full access.**
|
|
@@ -1361,6 +1585,54 @@ they are recorded here.
|
|
|
1361
1585
|
`stonyx/log`. Previously this case threw out of the handler and express
|
|
1362
1586
|
answered `500` with a stack trace.
|
|
1363
1587
|
|
|
1588
|
+
9. **Both relationship route families now resolve the *related* model's own
|
|
1589
|
+
access class, so a related record that is hidden on its own routes is no
|
|
1590
|
+
longer served through another model's.**
|
|
1591
|
+
[#232](https://github.com/abofs/stonyx-orm/issues/232). Affects
|
|
1592
|
+
function-style `access` users with relationships between filtered models. The
|
|
1593
|
+
old behaviour was a bypass at **zero query parameters** and with no
|
|
1594
|
+
`include=`: measured on `dev @ 8dda5d6`, `GET /animals/1/owner` returned
|
|
1595
|
+
angela's full document and `GET /animals/1/relationships/owner` returned
|
|
1596
|
+
`{"type":"owner","id":"angela"}`, while `GET /owners/angela` answered `404`.
|
|
1597
|
+
The severe case is a model claimed by **no** access class — `getAccess()`
|
|
1598
|
+
returns `undefined`, no route is mounted for it at all, and it was still
|
|
1599
|
+
readable as a related resource.
|
|
1600
|
+
|
|
1601
|
+
**The shapes, on both families.** A denied `hasMany` member is **dropped from
|
|
1602
|
+
the array**: `200`, `links` intact, no `errors` member. A denied `belongsTo`
|
|
1603
|
+
target answers **`200` with `data: null`**, byte-identical to a target that
|
|
1604
|
+
genuinely does not exist — same status, same bytes modulo the parent id the
|
|
1605
|
+
caller put in the URL. `404` on these routes is now reserved for the
|
|
1606
|
+
**parent**.
|
|
1607
|
+
|
|
1608
|
+
**`data: null` and not `404`, deliberately.** The 404 spelling was an
|
|
1609
|
+
existence oracle and was measured as one on this branch: unauthenticated, no
|
|
1610
|
+
query string, one request each, against `tag` — the model with no route
|
|
1611
|
+
mounted at all — `GET /traits/1/tag` (absent) answered `200`
|
|
1612
|
+
`application/json` at 68 bytes while `GET /traits/2/tag` (denied) answered
|
|
1613
|
+
`404` `text/plain` at 9 bytes, and the document surface reported both as
|
|
1614
|
+
`{"data":null}`. It also brings these two routes into line with this module's
|
|
1615
|
+
rule that every status on a record route is identical for filtered-out and
|
|
1616
|
+
does-not-exist (see [Filter functions](#filter-functions)), which the `404`
|
|
1617
|
+
spelling was the one exception to.
|
|
1618
|
+
|
|
1619
|
+
**What to check before you upgrade.** If a consumer reaches a related record
|
|
1620
|
+
through `GET /:models/:id/{relationship}` that it cannot reach on that
|
|
1621
|
+
record's own collection route, it was relying on the bypass and will now get
|
|
1622
|
+
`data: null` or a shorter array. And the related model's class is resolved
|
|
1623
|
+
through the same `Orm.instance.getAccess` path as the linkage filter, so it
|
|
1624
|
+
inherits the same arity limit: a **single-argument** predicate answers about
|
|
1625
|
+
the collection the request was *addressed to*, not the one it was asked
|
|
1626
|
+
about, and that is the direction that **grants**. See
|
|
1627
|
+
[Known limitations](#known-limitations) and
|
|
1628
|
+
[#221](https://github.com/abofs/stonyx-orm/issues/221).
|
|
1629
|
+
|
|
1630
|
+
**Not closed here:** whether a related resource appears in `included` at all
|
|
1631
|
+
([#233](https://github.com/abofs/stonyx-orm/issues/233)), and the
|
|
1632
|
+
re-parenting write ([#207](https://github.com/abofs/stonyx-orm/issues/207)).
|
|
1633
|
+
A **per-record** deny for a related resource is not expressible on these
|
|
1634
|
+
routes at all — see [Consumer Contracts](#consumer-contracts).
|
|
1635
|
+
|
|
1364
1636
|
### Include Parameter (Sideloading Relationships)
|
|
1365
1637
|
|
|
1366
1638
|
The ORM supports JSON API-compliant relationship sideloading via the `include` query parameter. This reduces the need for multiple API requests by embedding related records in a single response.
|
package/dist/orm-request.d.ts
CHANGED
|
@@ -65,9 +65,19 @@
|
|
|
65
65
|
* warning below sanctions. This repo's own fixture has such a rule: its
|
|
66
66
|
* `/archived` deny cannot be expressed from the context alone, and a predicate
|
|
67
67
|
* migrated to context-only would silently drop it, turning a deny into an
|
|
68
|
-
* allow.
|
|
69
|
-
*
|
|
70
|
-
*
|
|
68
|
+
* allow.
|
|
69
|
+
*
|
|
70
|
+
* THE RELATED-RESOURCE HALF OF THAT SENTENCE IS NOW OUT OF DATE AND IS
|
|
71
|
+
* CORRECTED HERE RATHER THAN DELETED. Both relationship route families resolve
|
|
72
|
+
* the RELATED model's own access class and ask it
|
|
73
|
+
* `{ model: <related>, operation: 'read', recordId: null }`
|
|
74
|
+
* (abofs/stonyx-orm#232), so those surfaces no longer serve another model's
|
|
75
|
+
* records under `model: 'owner'` unexamined. What the context still gives no
|
|
76
|
+
* signal of is WHICH related record is being asked about -- `recordId` is
|
|
77
|
+
* `null` there and `request.params` names a record of a different model. See
|
|
78
|
+
* `AccessContext.recordId` in ./types/orm-types.ts for the full statement of
|
|
79
|
+
* that limit. `?include=` is still unfiltered and is abofs/stonyx-orm#233 /
|
|
80
|
+
* #235.
|
|
71
81
|
*
|
|
72
82
|
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237, AND KEPT FOR THE
|
|
73
83
|
* CONSTRAINT IT STATES RATHER THAN AS A DESCRIPTION OF THE CODE. The context
|
package/dist/orm-request.js
CHANGED
|
@@ -65,9 +65,19 @@
|
|
|
65
65
|
* warning below sanctions. This repo's own fixture has such a rule: its
|
|
66
66
|
* `/archived` deny cannot be expressed from the context alone, and a predicate
|
|
67
67
|
* migrated to context-only would silently drop it, turning a deny into an
|
|
68
|
-
* allow.
|
|
69
|
-
*
|
|
70
|
-
*
|
|
68
|
+
* allow.
|
|
69
|
+
*
|
|
70
|
+
* THE RELATED-RESOURCE HALF OF THAT SENTENCE IS NOW OUT OF DATE AND IS
|
|
71
|
+
* CORRECTED HERE RATHER THAN DELETED. Both relationship route families resolve
|
|
72
|
+
* the RELATED model's own access class and ask it
|
|
73
|
+
* `{ model: <related>, operation: 'read', recordId: null }`
|
|
74
|
+
* (abofs/stonyx-orm#232), so those surfaces no longer serve another model's
|
|
75
|
+
* records under `model: 'owner'` unexamined. What the context still gives no
|
|
76
|
+
* signal of is WHICH related record is being asked about -- `recordId` is
|
|
77
|
+
* `null` there and `request.params` names a record of a different model. See
|
|
78
|
+
* `AccessContext.recordId` in ./types/orm-types.ts for the full statement of
|
|
79
|
+
* that limit. `?include=` is still unfiltered and is abofs/stonyx-orm#233 /
|
|
80
|
+
* #235.
|
|
71
81
|
*
|
|
72
82
|
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237, AND KEPT FOR THE
|
|
73
83
|
* CONSTRAINT IT STATES RATHER THAN AS A DESCRIPTION OF THE CODE. The context
|
|
@@ -1314,21 +1324,102 @@ export default class OrmRequest extends Request {
|
|
|
1314
1324
|
return 404;
|
|
1315
1325
|
const relatedData = record.__relationships[relationshipName];
|
|
1316
1326
|
const baseUrl = getBaseUrl(request);
|
|
1317
|
-
//
|
|
1318
|
-
//
|
|
1319
|
-
//
|
|
1320
|
-
//
|
|
1321
|
-
// related record is
|
|
1327
|
+
// ONE FILTER, TWO JOBS, AND abofs/stonyx-orm#232 IS THE SECOND ONE.
|
|
1328
|
+
//
|
|
1329
|
+
// As LINKAGE (#234) it decides which ids the emitted documents may NAME
|
|
1330
|
+
// in their own `relationships.*.data`. As MEMBERSHIP (this issue) it
|
|
1331
|
+
// decides whether the related record is served here AT ALL -- the
|
|
1332
|
+
// related resource is PRIMARY data on this route, so there is no
|
|
1333
|
+
// linkage-consistency question to answer separately.
|
|
1334
|
+
//
|
|
1335
|
+
// Until #232 this route filtered only the PARENT, so a record its own
|
|
1336
|
+
// model's predicate hides was served in full from another model's
|
|
1337
|
+
// route, at ZERO query parameters. Measured on dev @ 8dda5d6:
|
|
1338
|
+
//
|
|
1339
|
+
// GET /owners/angela -> 404
|
|
1340
|
+
// GET /animals/1/owner -> 200, owner:angela, full attributes
|
|
1341
|
+
// GET /traits/2/tag -> 200, a model NO access class
|
|
1342
|
+
// claims, on a collection that has
|
|
1343
|
+
// no mounted route at all
|
|
1344
|
+
//
|
|
1345
|
+
// ARGUMENT ONE IS THE LIVE REQUEST, NOT A DERIVED ONE. A fabricated
|
|
1346
|
+
// request addressing the RELATED resource was the original design and
|
|
1347
|
+
// it is dropped: #241 removed the shipped fixture's read of argument
|
|
1348
|
+
// one, so a fabricated value changes nothing it could observe.
|
|
1349
|
+
// `createLinkageFilter` is also a published public export
|
|
1350
|
+
// (src/index.ts) whose resolution granularity is per TYPE; supplying a
|
|
1351
|
+
// per-RECORD request would mean widening it, which takes a consumer
|
|
1352
|
+
// `access()` from ~2 calls to ~7 on a plain `GET /animals`. That is a
|
|
1353
|
+
// separate, consumer-visible story.
|
|
1354
|
+
//
|
|
1355
|
+
// GUARDED BY OWN-PROPERTY IDENTITY, NOT BY THE #234 AC13 PIN. That pin
|
|
1356
|
+
// (test/unit/linkage-verdict-test.ts, `strictEqual(seen[0].request,
|
|
1357
|
+
// READ_REQUEST)`) calls `createLinkageFilter` DIRECTLY, so it pins the
|
|
1358
|
+
// function's pass-through and constrains no call site -- an earlier
|
|
1359
|
+
// revision of this comment cited it for this decision and was wrong.
|
|
1360
|
+
// `Object.create(request)` here measured 1015 / 0 with nothing red.
|
|
1361
|
+
// test/integration/orm-test.ts, `#232 AC9`, now asserts that the object
|
|
1362
|
+
// the predicate is handed OWNS `params` (`Object.hasOwn`) and has
|
|
1363
|
+
// nothing request-shaped behind it on the prototype chain. A derived
|
|
1364
|
+
// request inherits `params` -- so it satisfies every value assertion
|
|
1365
|
+
// there -- and reds on those two. Measured: with the derived request in
|
|
1366
|
+
// place, 1014 / 1, and that one is this guard.
|
|
1367
|
+
//
|
|
1368
|
+
// THE RESIDUAL THAT FOLLOWS FROM THAT IS DISCLOSED, NOT PAPERED OVER.
|
|
1369
|
+
// `recordId` is `null` here and the request names a record of a
|
|
1370
|
+
// DIFFERENT model, so a consumer predicate can express a model-level or
|
|
1371
|
+
// a request-level deny for a related resource, but NOT a per-record
|
|
1372
|
+
// one. README.md and docs/usage-patterns.md say so; a ledger assertion
|
|
1373
|
+
// in test/unit/relationship-route-access-test.ts keeps them saying it.
|
|
1322
1374
|
const linkage = createLinkageFilter(request);
|
|
1375
|
+
// FAIL CLOSED ON A RECORD WHOSE TYPE CANNOT BE NAMED. `isLinkable` is
|
|
1376
|
+
// keyed on the model name; without one there is no predicate to ask,
|
|
1377
|
+
// and an unidentifiable input must never be the permissive path.
|
|
1378
|
+
const isLinkable = (r) => {
|
|
1379
|
+
const type = r.__model?.__name;
|
|
1380
|
+
return typeof type === 'string' && type !== '' && linkage(type, r);
|
|
1381
|
+
};
|
|
1323
1382
|
let data;
|
|
1324
1383
|
if (info.isArray) {
|
|
1325
|
-
// hasMany - return array
|
|
1384
|
+
// hasMany - return array, MINUS the members this caller may not see.
|
|
1385
|
+
// Dropped, never errored: the result is byte-identical to a genuinely
|
|
1386
|
+
// empty relationship, so this route is not an existence oracle.
|
|
1326
1387
|
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
1327
|
-
data = related.map(r => r.toJSON?.({ baseUrl, linkage }));
|
|
1388
|
+
data = related.filter(isLinkable).map(r => r.toJSON?.({ baseUrl, linkage }));
|
|
1328
1389
|
}
|
|
1329
1390
|
else {
|
|
1330
|
-
// belongsTo - return single or null
|
|
1331
|
-
|
|
1391
|
+
// belongsTo - return single or null. A DENIED target is `data: null`,
|
|
1392
|
+
// BYTE-IDENTICAL to a relationship that is genuinely empty, for the
|
|
1393
|
+
// same reason the hasMany branch above drops rather than errors: this
|
|
1394
|
+
// route must not be an existence oracle for the RELATED record.
|
|
1395
|
+
//
|
|
1396
|
+
// THE OTHER SPELLING WAS 404 AND IT WAS MEASURED AS A DISCLOSURE.
|
|
1397
|
+
// Unauthenticated, zero query parameters, one request each, on `tag`
|
|
1398
|
+
// -- the model with no route mounted at all, which is exactly what
|
|
1399
|
+
// #240 AC5 exists to protect:
|
|
1400
|
+
//
|
|
1401
|
+
// GET /traits/1/tag [ABSENT] -> 200 application/json len 68
|
|
1402
|
+
// GET /traits/2/tag [DENIED] -> 404 text/plain len 9
|
|
1403
|
+
//
|
|
1404
|
+
// and `GET /traits/1` and `GET /traits/2` both report
|
|
1405
|
+
// `relationships.tag = {"data":null}` byte-identical modulo the id,
|
|
1406
|
+
// because #234 closed THAT oracle deliberately. A 404 here would let
|
|
1407
|
+
// a caller ask which of those two nulls was a denial. Under
|
|
1408
|
+
// `data: null` the pair closes completely: 200/200, same
|
|
1409
|
+
// content-type, same content-length, bodies identical modulo the
|
|
1410
|
+
// parent id the caller put in the URL. It opens nothing -- `links`
|
|
1411
|
+
// are entirely parent-derived, there is no `meta` and no counts.
|
|
1412
|
+
//
|
|
1413
|
+
// This is also what README.md's module-wide rule already demanded:
|
|
1414
|
+
// every status on a record route must be identical for filtered-out
|
|
1415
|
+
// and does-not-exist. The route now CONFORMS to that rule rather than
|
|
1416
|
+
// carving an exception out of it.
|
|
1417
|
+
if (!isOrmRecord(relatedData))
|
|
1418
|
+
data = null;
|
|
1419
|
+
else if (!isLinkable(relatedData))
|
|
1420
|
+
data = null;
|
|
1421
|
+
else
|
|
1422
|
+
data = relatedData.toJSON?.({ baseUrl, linkage });
|
|
1332
1423
|
}
|
|
1333
1424
|
return {
|
|
1334
1425
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}/${dasherizedName}` },
|
|
@@ -1392,17 +1483,43 @@ export default class OrmRequest extends Request {
|
|
|
1392
1483
|
return 404;
|
|
1393
1484
|
const relatedData = record.__relationships[relationshipName];
|
|
1394
1485
|
const baseUrl = getBaseUrl(request);
|
|
1486
|
+
// THE ONE READ SURFACE THAT DOES NOT GO THROUGH `toJSON()`. It builds
|
|
1487
|
+
// `{ type, id }` BY HAND, which is why #234's linkage filter never
|
|
1488
|
+
// reached it and why this half belongs to abofs/stonyx-orm#232 rather
|
|
1489
|
+
// than to #234: on this route the linkage IS the primary data of an
|
|
1490
|
+
// opt-in request, so filtering it changes the route's MEMBERSHIP
|
|
1491
|
+
// semantics, not the ids named inside somebody else's document.
|
|
1492
|
+
//
|
|
1493
|
+
// DELIBERATELY NOT STATED AS A COUNT. README.md's Consumer Contracts
|
|
1494
|
+
// section enumerates the surfaces on which the framework resolves a
|
|
1495
|
+
// verdict and hands it to `toJSON()`, and that enumeration GROWS --
|
|
1496
|
+
// abofs/stonyx-orm#235 adds the two write handlers and the `included`
|
|
1497
|
+
// records. This route is not on that list under any count, because it
|
|
1498
|
+
// never calls `toJSON()`: whatever it filters, it filters here. A
|
|
1499
|
+
// number written into this comment would be false the next time that
|
|
1500
|
+
// list changes, and the README already carries the enumeration.
|
|
1501
|
+
//
|
|
1502
|
+
// Same filter, same argument-one decision, same residual as
|
|
1503
|
+
// `/:id/{relationship}` above -- read the block there.
|
|
1504
|
+
const linkage = createLinkageFilter(request);
|
|
1505
|
+
const isLinkable = (r) => {
|
|
1506
|
+
const type = r.__model?.__name;
|
|
1507
|
+
return typeof type === 'string' && type !== '' && linkage(type, r);
|
|
1508
|
+
};
|
|
1395
1509
|
let data;
|
|
1396
1510
|
if (info.isArray) {
|
|
1397
1511
|
// hasMany - return array of linkage objects
|
|
1398
1512
|
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
1399
1513
|
data = related
|
|
1400
1514
|
.filter((r) => Boolean(r.__model))
|
|
1515
|
+
.filter(isLinkable)
|
|
1401
1516
|
.map(r => ({ type: r.__model.__name, id: r.id }));
|
|
1402
1517
|
}
|
|
1403
1518
|
else {
|
|
1404
|
-
// belongsTo - return single linkage or null
|
|
1405
|
-
|
|
1519
|
+
// belongsTo - return single linkage or null. A DENIED target is
|
|
1520
|
+
// `data: null`, indistinguishable from a genuinely empty one -- see
|
|
1521
|
+
// the measured oracle in the `/:id/{relationship}` block above.
|
|
1522
|
+
if (isOrmRecord(relatedData) && relatedData.__model && isLinkable(relatedData)) {
|
|
1406
1523
|
data = { type: relatedData.__model.__name, id: relatedData.id };
|
|
1407
1524
|
}
|
|
1408
1525
|
else {
|
|
@@ -340,10 +340,36 @@ export interface AccessContext {
|
|
|
340
340
|
* repaired here. A predicate must not read `undefined` here as "collection",
|
|
341
341
|
* and nothing in this contract makes it safe to read the two keys as one key.
|
|
342
342
|
*
|
|
343
|
-
* IT NAMES WHICH RECORD, NOT WHICH SURFACE
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
343
|
+
* IT NAMES WHICH RECORD OF THE MODEL BEING ASKED ABOUT, NOT WHICH SURFACE,
|
|
344
|
+
* AND THE ANSWER DEPENDS ON WHICH MODEL IS BEING ASKED ABOUT.
|
|
345
|
+
*
|
|
346
|
+
* For the ask about the ROUTE'S OWN model, all three of `GET /owners/gina`,
|
|
347
|
+
* `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` carry
|
|
348
|
+
* `recordId: 'gina'` -- `auth()` reads it off `request.params`.
|
|
349
|
+
*
|
|
350
|
+
* FOR THE ASK ABOUT A RELATED MODEL, IT IS `null`, AND THAT IS A LIMIT ON
|
|
351
|
+
* WHAT A PREDICATE CAN EXPRESS (abofs/stonyx-orm#232). The two relationship
|
|
352
|
+
* route families resolve the RELATED model's own predicate -- `animal` on
|
|
353
|
+
* `GET /owners/gina/pets`, `owner` on `GET /animals/4/owner` -- and that ask
|
|
354
|
+
* carries `recordId: null` while `request.params` names a record of a
|
|
355
|
+
* DIFFERENT model. So a predicate answering about a related model gets the
|
|
356
|
+
* model name, the operation and the request, and CANNOT branch on which
|
|
357
|
+
* related record it is being asked about.
|
|
358
|
+
*
|
|
359
|
+
* The rule, so it is not re-derived wrong: `recordId` may name a record only
|
|
360
|
+
* where the route addresses exactly one record OF THE MODEL BEING ASKED
|
|
361
|
+
* ABOUT. A `hasMany` related-resource route returns many records of one type
|
|
362
|
+
* and the verdict is resolved ONCE PER TYPE, before any record is examined --
|
|
363
|
+
* seeding it from a record would let the first one decide for all of them.
|
|
364
|
+
*
|
|
365
|
+
* What still works, and what does not, is pinned as behaviour by `#232 AC10`
|
|
366
|
+
* in test/integration/orm-test.ts and stated for consumers in README.md:
|
|
367
|
+
* model-level denies work, request-level denies work, and the per-record
|
|
368
|
+
* FILTER shape works because `access()` may return a function and that
|
|
369
|
+
* function receives the whole record. Branching on identity BEFORE returning
|
|
370
|
+
* does not.
|
|
371
|
+
*
|
|
372
|
+
* `?include=` is a separate surface and is abofs/stonyx-orm#233 / #235.
|
|
347
373
|
*/
|
|
348
374
|
recordId: string | number | null;
|
|
349
375
|
}
|
package/package.json
CHANGED
package/src/orm-request.ts
CHANGED
|
@@ -65,9 +65,19 @@
|
|
|
65
65
|
* warning below sanctions. This repo's own fixture has such a rule: its
|
|
66
66
|
* `/archived` deny cannot be expressed from the context alone, and a predicate
|
|
67
67
|
* migrated to context-only would silently drop it, turning a deny into an
|
|
68
|
-
* allow.
|
|
69
|
-
*
|
|
70
|
-
*
|
|
68
|
+
* allow.
|
|
69
|
+
*
|
|
70
|
+
* THE RELATED-RESOURCE HALF OF THAT SENTENCE IS NOW OUT OF DATE AND IS
|
|
71
|
+
* CORRECTED HERE RATHER THAN DELETED. Both relationship route families resolve
|
|
72
|
+
* the RELATED model's own access class and ask it
|
|
73
|
+
* `{ model: <related>, operation: 'read', recordId: null }`
|
|
74
|
+
* (abofs/stonyx-orm#232), so those surfaces no longer serve another model's
|
|
75
|
+
* records under `model: 'owner'` unexamined. What the context still gives no
|
|
76
|
+
* signal of is WHICH related record is being asked about -- `recordId` is
|
|
77
|
+
* `null` there and `request.params` names a record of a different model. See
|
|
78
|
+
* `AccessContext.recordId` in ./types/orm-types.ts for the full statement of
|
|
79
|
+
* that limit. `?include=` is still unfiltered and is abofs/stonyx-orm#233 /
|
|
80
|
+
* #235.
|
|
71
81
|
*
|
|
72
82
|
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237, AND KEPT FOR THE
|
|
73
83
|
* CONSTRAINT IT STATES RATHER THAN AS A DESCRIPTION OF THE CODE. The context
|
|
@@ -1433,21 +1443,101 @@ export default class OrmRequest extends Request {
|
|
|
1433
1443
|
const relatedData = record.__relationships[relationshipName];
|
|
1434
1444
|
const baseUrl = getBaseUrl(request);
|
|
1435
1445
|
|
|
1436
|
-
//
|
|
1437
|
-
//
|
|
1438
|
-
//
|
|
1439
|
-
//
|
|
1440
|
-
// related record is
|
|
1446
|
+
// ONE FILTER, TWO JOBS, AND abofs/stonyx-orm#232 IS THE SECOND ONE.
|
|
1447
|
+
//
|
|
1448
|
+
// As LINKAGE (#234) it decides which ids the emitted documents may NAME
|
|
1449
|
+
// in their own `relationships.*.data`. As MEMBERSHIP (this issue) it
|
|
1450
|
+
// decides whether the related record is served here AT ALL -- the
|
|
1451
|
+
// related resource is PRIMARY data on this route, so there is no
|
|
1452
|
+
// linkage-consistency question to answer separately.
|
|
1453
|
+
//
|
|
1454
|
+
// Until #232 this route filtered only the PARENT, so a record its own
|
|
1455
|
+
// model's predicate hides was served in full from another model's
|
|
1456
|
+
// route, at ZERO query parameters. Measured on dev @ 8dda5d6:
|
|
1457
|
+
//
|
|
1458
|
+
// GET /owners/angela -> 404
|
|
1459
|
+
// GET /animals/1/owner -> 200, owner:angela, full attributes
|
|
1460
|
+
// GET /traits/2/tag -> 200, a model NO access class
|
|
1461
|
+
// claims, on a collection that has
|
|
1462
|
+
// no mounted route at all
|
|
1463
|
+
//
|
|
1464
|
+
// ARGUMENT ONE IS THE LIVE REQUEST, NOT A DERIVED ONE. A fabricated
|
|
1465
|
+
// request addressing the RELATED resource was the original design and
|
|
1466
|
+
// it is dropped: #241 removed the shipped fixture's read of argument
|
|
1467
|
+
// one, so a fabricated value changes nothing it could observe.
|
|
1468
|
+
// `createLinkageFilter` is also a published public export
|
|
1469
|
+
// (src/index.ts) whose resolution granularity is per TYPE; supplying a
|
|
1470
|
+
// per-RECORD request would mean widening it, which takes a consumer
|
|
1471
|
+
// `access()` from ~2 calls to ~7 on a plain `GET /animals`. That is a
|
|
1472
|
+
// separate, consumer-visible story.
|
|
1473
|
+
//
|
|
1474
|
+
// GUARDED BY OWN-PROPERTY IDENTITY, NOT BY THE #234 AC13 PIN. That pin
|
|
1475
|
+
// (test/unit/linkage-verdict-test.ts, `strictEqual(seen[0].request,
|
|
1476
|
+
// READ_REQUEST)`) calls `createLinkageFilter` DIRECTLY, so it pins the
|
|
1477
|
+
// function's pass-through and constrains no call site -- an earlier
|
|
1478
|
+
// revision of this comment cited it for this decision and was wrong.
|
|
1479
|
+
// `Object.create(request)` here measured 1015 / 0 with nothing red.
|
|
1480
|
+
// test/integration/orm-test.ts, `#232 AC9`, now asserts that the object
|
|
1481
|
+
// the predicate is handed OWNS `params` (`Object.hasOwn`) and has
|
|
1482
|
+
// nothing request-shaped behind it on the prototype chain. A derived
|
|
1483
|
+
// request inherits `params` -- so it satisfies every value assertion
|
|
1484
|
+
// there -- and reds on those two. Measured: with the derived request in
|
|
1485
|
+
// place, 1014 / 1, and that one is this guard.
|
|
1486
|
+
//
|
|
1487
|
+
// THE RESIDUAL THAT FOLLOWS FROM THAT IS DISCLOSED, NOT PAPERED OVER.
|
|
1488
|
+
// `recordId` is `null` here and the request names a record of a
|
|
1489
|
+
// DIFFERENT model, so a consumer predicate can express a model-level or
|
|
1490
|
+
// a request-level deny for a related resource, but NOT a per-record
|
|
1491
|
+
// one. README.md and docs/usage-patterns.md say so; a ledger assertion
|
|
1492
|
+
// in test/unit/relationship-route-access-test.ts keeps them saying it.
|
|
1441
1493
|
const linkage = createLinkageFilter(request);
|
|
1442
1494
|
|
|
1495
|
+
// FAIL CLOSED ON A RECORD WHOSE TYPE CANNOT BE NAMED. `isLinkable` is
|
|
1496
|
+
// keyed on the model name; without one there is no predicate to ask,
|
|
1497
|
+
// and an unidentifiable input must never be the permissive path.
|
|
1498
|
+
const isLinkable = (r: OrmRecord) => {
|
|
1499
|
+
const type = (r as { __model?: { __name?: string } }).__model?.__name;
|
|
1500
|
+
|
|
1501
|
+
return typeof type === 'string' && type !== '' && linkage(type, r);
|
|
1502
|
+
};
|
|
1503
|
+
|
|
1443
1504
|
let data: unknown;
|
|
1444
1505
|
if (info.isArray) {
|
|
1445
|
-
// hasMany - return array
|
|
1506
|
+
// hasMany - return array, MINUS the members this caller may not see.
|
|
1507
|
+
// Dropped, never errored: the result is byte-identical to a genuinely
|
|
1508
|
+
// empty relationship, so this route is not an existence oracle.
|
|
1446
1509
|
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
1447
|
-
data = related.map(r => r.toJSON?.({ baseUrl, linkage }));
|
|
1510
|
+
data = related.filter(isLinkable).map(r => r.toJSON?.({ baseUrl, linkage }));
|
|
1448
1511
|
} else {
|
|
1449
|
-
// belongsTo - return single or null
|
|
1450
|
-
|
|
1512
|
+
// belongsTo - return single or null. A DENIED target is `data: null`,
|
|
1513
|
+
// BYTE-IDENTICAL to a relationship that is genuinely empty, for the
|
|
1514
|
+
// same reason the hasMany branch above drops rather than errors: this
|
|
1515
|
+
// route must not be an existence oracle for the RELATED record.
|
|
1516
|
+
//
|
|
1517
|
+
// THE OTHER SPELLING WAS 404 AND IT WAS MEASURED AS A DISCLOSURE.
|
|
1518
|
+
// Unauthenticated, zero query parameters, one request each, on `tag`
|
|
1519
|
+
// -- the model with no route mounted at all, which is exactly what
|
|
1520
|
+
// #240 AC5 exists to protect:
|
|
1521
|
+
//
|
|
1522
|
+
// GET /traits/1/tag [ABSENT] -> 200 application/json len 68
|
|
1523
|
+
// GET /traits/2/tag [DENIED] -> 404 text/plain len 9
|
|
1524
|
+
//
|
|
1525
|
+
// and `GET /traits/1` and `GET /traits/2` both report
|
|
1526
|
+
// `relationships.tag = {"data":null}` byte-identical modulo the id,
|
|
1527
|
+
// because #234 closed THAT oracle deliberately. A 404 here would let
|
|
1528
|
+
// a caller ask which of those two nulls was a denial. Under
|
|
1529
|
+
// `data: null` the pair closes completely: 200/200, same
|
|
1530
|
+
// content-type, same content-length, bodies identical modulo the
|
|
1531
|
+
// parent id the caller put in the URL. It opens nothing -- `links`
|
|
1532
|
+
// are entirely parent-derived, there is no `meta` and no counts.
|
|
1533
|
+
//
|
|
1534
|
+
// This is also what README.md's module-wide rule already demanded:
|
|
1535
|
+
// every status on a record route must be identical for filtered-out
|
|
1536
|
+
// and does-not-exist. The route now CONFORMS to that rule rather than
|
|
1537
|
+
// carving an exception out of it.
|
|
1538
|
+
if (!isOrmRecord(relatedData)) data = null;
|
|
1539
|
+
else if (!isLinkable(relatedData)) data = null;
|
|
1540
|
+
else data = relatedData.toJSON?.({ baseUrl, linkage });
|
|
1451
1541
|
}
|
|
1452
1542
|
|
|
1453
1543
|
return {
|
|
@@ -1513,16 +1603,44 @@ export default class OrmRequest extends Request {
|
|
|
1513
1603
|
const relatedData = record.__relationships[relationshipName];
|
|
1514
1604
|
const baseUrl = getBaseUrl(request);
|
|
1515
1605
|
|
|
1606
|
+
// THE ONE READ SURFACE THAT DOES NOT GO THROUGH `toJSON()`. It builds
|
|
1607
|
+
// `{ type, id }` BY HAND, which is why #234's linkage filter never
|
|
1608
|
+
// reached it and why this half belongs to abofs/stonyx-orm#232 rather
|
|
1609
|
+
// than to #234: on this route the linkage IS the primary data of an
|
|
1610
|
+
// opt-in request, so filtering it changes the route's MEMBERSHIP
|
|
1611
|
+
// semantics, not the ids named inside somebody else's document.
|
|
1612
|
+
//
|
|
1613
|
+
// DELIBERATELY NOT STATED AS A COUNT. README.md's Consumer Contracts
|
|
1614
|
+
// section enumerates the surfaces on which the framework resolves a
|
|
1615
|
+
// verdict and hands it to `toJSON()`, and that enumeration GROWS --
|
|
1616
|
+
// abofs/stonyx-orm#235 adds the two write handlers and the `included`
|
|
1617
|
+
// records. This route is not on that list under any count, because it
|
|
1618
|
+
// never calls `toJSON()`: whatever it filters, it filters here. A
|
|
1619
|
+
// number written into this comment would be false the next time that
|
|
1620
|
+
// list changes, and the README already carries the enumeration.
|
|
1621
|
+
//
|
|
1622
|
+
// Same filter, same argument-one decision, same residual as
|
|
1623
|
+
// `/:id/{relationship}` above -- read the block there.
|
|
1624
|
+
const linkage = createLinkageFilter(request);
|
|
1625
|
+
const isLinkable = (r: OrmRecord) => {
|
|
1626
|
+
const type = (r as { __model?: { __name?: string } }).__model?.__name;
|
|
1627
|
+
|
|
1628
|
+
return typeof type === 'string' && type !== '' && linkage(type, r);
|
|
1629
|
+
};
|
|
1630
|
+
|
|
1516
1631
|
let data: unknown;
|
|
1517
1632
|
if (info.isArray) {
|
|
1518
1633
|
// hasMany - return array of linkage objects
|
|
1519
1634
|
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
1520
1635
|
data = related
|
|
1521
1636
|
.filter((r): r is OrmRecord & { __model: { __name: string } } => Boolean(r.__model))
|
|
1637
|
+
.filter(isLinkable)
|
|
1522
1638
|
.map(r => ({ type: r.__model.__name, id: r.id }));
|
|
1523
1639
|
} else {
|
|
1524
|
-
// belongsTo - return single linkage or null
|
|
1525
|
-
|
|
1640
|
+
// belongsTo - return single linkage or null. A DENIED target is
|
|
1641
|
+
// `data: null`, indistinguishable from a genuinely empty one -- see
|
|
1642
|
+
// the measured oracle in the `/:id/{relationship}` block above.
|
|
1643
|
+
if (isOrmRecord(relatedData) && relatedData.__model && isLinkable(relatedData)) {
|
|
1526
1644
|
data = { type: relatedData.__model.__name, id: relatedData.id };
|
|
1527
1645
|
} else {
|
|
1528
1646
|
data = null;
|
package/src/types/orm-types.ts
CHANGED
|
@@ -350,10 +350,36 @@ export interface AccessContext {
|
|
|
350
350
|
* repaired here. A predicate must not read `undefined` here as "collection",
|
|
351
351
|
* and nothing in this contract makes it safe to read the two keys as one key.
|
|
352
352
|
*
|
|
353
|
-
* IT NAMES WHICH RECORD, NOT WHICH SURFACE
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
353
|
+
* IT NAMES WHICH RECORD OF THE MODEL BEING ASKED ABOUT, NOT WHICH SURFACE,
|
|
354
|
+
* AND THE ANSWER DEPENDS ON WHICH MODEL IS BEING ASKED ABOUT.
|
|
355
|
+
*
|
|
356
|
+
* For the ask about the ROUTE'S OWN model, all three of `GET /owners/gina`,
|
|
357
|
+
* `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` carry
|
|
358
|
+
* `recordId: 'gina'` -- `auth()` reads it off `request.params`.
|
|
359
|
+
*
|
|
360
|
+
* FOR THE ASK ABOUT A RELATED MODEL, IT IS `null`, AND THAT IS A LIMIT ON
|
|
361
|
+
* WHAT A PREDICATE CAN EXPRESS (abofs/stonyx-orm#232). The two relationship
|
|
362
|
+
* route families resolve the RELATED model's own predicate -- `animal` on
|
|
363
|
+
* `GET /owners/gina/pets`, `owner` on `GET /animals/4/owner` -- and that ask
|
|
364
|
+
* carries `recordId: null` while `request.params` names a record of a
|
|
365
|
+
* DIFFERENT model. So a predicate answering about a related model gets the
|
|
366
|
+
* model name, the operation and the request, and CANNOT branch on which
|
|
367
|
+
* related record it is being asked about.
|
|
368
|
+
*
|
|
369
|
+
* The rule, so it is not re-derived wrong: `recordId` may name a record only
|
|
370
|
+
* where the route addresses exactly one record OF THE MODEL BEING ASKED
|
|
371
|
+
* ABOUT. A `hasMany` related-resource route returns many records of one type
|
|
372
|
+
* and the verdict is resolved ONCE PER TYPE, before any record is examined --
|
|
373
|
+
* seeding it from a record would let the first one decide for all of them.
|
|
374
|
+
*
|
|
375
|
+
* What still works, and what does not, is pinned as behaviour by `#232 AC10`
|
|
376
|
+
* in test/integration/orm-test.ts and stated for consumers in README.md:
|
|
377
|
+
* model-level denies work, request-level denies work, and the per-record
|
|
378
|
+
* FILTER shape works because `access()` may return a function and that
|
|
379
|
+
* function receives the whole record. Branching on identity BEFORE returning
|
|
380
|
+
* does not.
|
|
381
|
+
*
|
|
382
|
+
* `?include=` is a separate surface and is abofs/stonyx-orm#233 / #235.
|
|
357
383
|
*/
|
|
358
384
|
recordId: string | number | null;
|
|
359
385
|
}
|