@stonyx/orm 0.3.2-alpha.76 → 0.3.2-alpha.77
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 +241 -127
- package/dist/orm-request.d.ts +13 -3
- package/dist/orm-request.js +161 -150
- package/dist/types/orm-types.d.ts +30 -4
- package/package.json +1 -1
- package/src/orm-request.ts +164 -152
- 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,59 +961,117 @@ 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
|
-
|
|
933
|
-
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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** — the result
|
|
971
|
+
is shaped exactly like a genuinely empty relationship, `links` intact, no
|
|
972
|
+
`errors` member, same status. A denied `belongsTo` target answers **`200` with
|
|
973
|
+
`data: null`**, byte-identical to a target that is genuinely absent, for the
|
|
974
|
+
same reason. Nothing on either family errors and no status changes — the
|
|
975
|
+
status on these routes belongs to the **parent**, and `data` carries the
|
|
976
|
+
answer about the related record. The `/relationships/` family built its `{type, id}` by
|
|
977
|
+
hand rather than through `toJSON()`, which is why the linkage filter shipped in
|
|
978
|
+
[#234](https://github.com/abofs/stonyx-orm/issues/234) did not reach it.
|
|
979
|
+
|
|
980
|
+
Before this, both families served a record hidden on every one of its own
|
|
981
|
+
surfaces, in full, from another model's route, at **zero query parameters**.
|
|
982
|
+
The severe case is a model **claimed by no access class**: `getAccess()`
|
|
983
|
+
returns `undefined`, no route is mounted for it at all, and it was still
|
|
984
|
+
readable as a related resource — a collection the consumer deliberately never
|
|
985
|
+
exposed.
|
|
986
|
+
|
|
987
|
+
**The `belongsTo` shape is not an existence oracle, and it was measured
|
|
988
|
+
rather than reasoned about.** An earlier revision of this fix answered `404`
|
|
989
|
+
for a denied target, which made it distinguishable from a target that is
|
|
990
|
+
genuinely absent. Unauthenticated, zero query parameters, one request each, on
|
|
991
|
+
`tag` — a model with **no route mounted at all**:
|
|
992
|
+
|
|
993
|
+
```
|
|
994
|
+
GET /traits/1/tag [target absent] -> 200 application/json 68 bytes
|
|
995
|
+
GET /traits/2/tag [target denied] -> 404 text/plain 9 bytes
|
|
996
|
+
```
|
|
997
|
+
|
|
998
|
+
`GET /traits/1` and `GET /traits/2` report `relationships.tag = {"data":null}`
|
|
999
|
+
byte-identical modulo the id, because
|
|
1000
|
+
[#234](https://github.com/abofs/stonyx-orm/issues/234) closed that oracle
|
|
1001
|
+
deliberately — so this route was the one remaining way to ask which of those
|
|
1002
|
+
two nulls was a denial. Under `data: null` both requests answer `200`, same
|
|
1003
|
+
content-type, same content-length, same bytes modulo the parent id the caller
|
|
1004
|
+
put in the URL. It discloses nothing further: `links` on these routes are
|
|
1005
|
+
derived entirely from the parent and the relationship name, there is no `meta`
|
|
1006
|
+
and there are no counts. This also brings the two families back into line with
|
|
1007
|
+
the module-wide rule under [Filter functions](#filter-functions) — *every
|
|
1008
|
+
status on a record route must be identical for "filtered out" and "does not
|
|
1009
|
+
exist"* — which the `404` spelling was an exception to.
|
|
1010
|
+
|
|
1011
|
+
**Per-record denies for a related resource are not expressible.** A predicate resolved for a
|
|
1012
|
+
related resource on these routes receives `recordId: null` and a `request`
|
|
1013
|
+
whose `params` name a record of a **different model**. So the inputs it has
|
|
1014
|
+
are the model name, the operation and the request — and **a rule that needs to
|
|
1015
|
+
know *which* related record it is being asked about cannot be written**.
|
|
1016
|
+
Model-level denies (`return false` for a model) work. Request-level denies (a
|
|
1017
|
+
rule reading a header, a tenant, the method) work. The per-record **filter**
|
|
1018
|
+
shape works too — `access()` may return a function, and that function receives
|
|
1019
|
+
the whole record, id included. What does not work is branching on the record's
|
|
1020
|
+
identity *before* returning, because `access()` is not told it.
|
|
1021
|
+
|
|
1022
|
+
This is not an oversight and it is not closed here. The verdict is resolved
|
|
1023
|
+
**once per type**, cached, before any record has been examined — a `hasMany`
|
|
1024
|
+
related-resource route returns many records of one type, so seeding `recordId`
|
|
1025
|
+
from a record would let the first one decide the context for all of them. The
|
|
1026
|
+
rule the framework holds to is: **`recordId` may name a record only where the
|
|
1027
|
+
route addresses exactly one record of the model being asked about.** That is
|
|
1028
|
+
true for `GET /owners/{id}`, false for linkage, and false for a `hasMany`
|
|
1029
|
+
related-resource route.
|
|
1030
|
+
|
|
1031
|
+
- **`?include=` records are still not filtered — the relationship routes now
|
|
1032
|
+
are.** *Re-specified by [#232](https://github.com/abofs/stonyx-orm/issues/232);
|
|
1033
|
+
the sentence this replaces said all three surfaces were unfiltered, and two of
|
|
1034
|
+
them no longer are.* `GET /animals/1/owner` and
|
|
1035
|
+
`GET /animals/1/relationships/owner` resolve the related model's own access
|
|
1036
|
+
class (see the bullet above). **`?include=owner` still does not**: it
|
|
1037
|
+
serializes the related record without resolving that class, so a filter on
|
|
1038
|
+
`/owners` does not hide an owner reached through `?include=` on `/animals`.
|
|
1039
|
+
There are **two** open questions here and they are owned separately —
|
|
1040
|
+
[#233](https://github.com/abofs/stonyx-orm/issues/233), the remaining child of
|
|
1041
|
+
[#196](https://github.com/abofs/stonyx-orm/issues/196), owns whether a
|
|
1042
|
+
resource enters `included` **at all** (membership), and
|
|
1043
|
+
[#235](https://github.com/abofs/stonyx-orm/issues/235) owns the
|
|
1044
|
+
`relationships.*.data` emitted **inside** a record that is already there
|
|
1045
|
+
(linkage). Neither closes the other, and following only #233 will not lead you
|
|
1046
|
+
to the second. Membership — whether the related resource is served at all — is
|
|
1047
|
+
a different question from which ids a document may *name*, immediately
|
|
1048
|
+
below.
|
|
1049
|
+
- **Relationship linkage is filtered on the four request-bound read surfaces,
|
|
1050
|
+
and only there.** A document's `relationships.*.data` used to publish the id
|
|
1051
|
+
of every related record unconditionally, so a record hidden on every one of
|
|
1052
|
+
its own surfaces was still named inside another model's document — with no
|
|
1053
|
+
`include=`, no relationship route and no query string
|
|
939
1054
|
([#234](https://github.com/abofs/stonyx-orm/issues/234)). The ORM now resolves
|
|
940
|
-
the **related** model's own access class on `GET /:models`, `GET /:models/:id
|
|
941
|
-
both `GET /:models/:id/{relationship}` shapes,
|
|
942
|
-
`
|
|
943
|
-
|
|
944
|
-
([#235](https://github.com/abofs/stonyx-orm/issues/235)), and asks it
|
|
945
|
-
`{ model: <related>, operation: 'read' }`. **`operation` is `'read'` even on a
|
|
946
|
-
write route, and that is correct rather than an oversight** — the question
|
|
947
|
-
asked of the *related* model is "may this caller **read** this id", not "may
|
|
948
|
-
they update it". An access class that grants `['create']` but not `['read']`
|
|
949
|
-
on the related model therefore denies that linkage on its own `POST`
|
|
950
|
-
response; that is the fail-closed direction. Do **not** wire these handlers to
|
|
951
|
-
`methodAccessMap[request.method]`: it would ask a different question on a
|
|
952
|
-
write route than on a read route, which is the two-vocabularies failure
|
|
953
|
-
`createLinkageFilter` exists to prevent. An unresolvable class
|
|
954
|
-
(`getAccess()` → `undefined`) and a predicate that throws both **deny**.
|
|
955
|
-
|
|
956
|
-
**What the two write surfaces cost before #235, measured rather than
|
|
957
|
-
described:** one HTTP verb defeated the filter on the same record. On
|
|
958
|
-
`dev @ 8dda5d6`, seconds apart, with no query string and no relationship
|
|
959
|
-
route, `GET /animals/1` returned `owner.data: null` while `PATCH /animals/1`
|
|
960
|
-
returned **200 naming angela**. Any caller who could read a record could also
|
|
961
|
-
write it and be handed the id the read withheld. That consequence is kept here
|
|
962
|
-
after the fix, and stated as a measurement, because **naming the two handlers
|
|
963
|
-
is not a substitute for it** — a reader who is told only that `POST` and
|
|
964
|
-
`PATCH` are now covered cannot tell what was wrong, and a reviewer cannot tell
|
|
965
|
-
whether the fix addressed it. A
|
|
1055
|
+
the **related** model's own access class on `GET /:models`, `GET /:models/:id`
|
|
1056
|
+
and both `GET /:models/:id/{relationship}` shapes, and asks it
|
|
1057
|
+
`{ model: <related>, operation: 'read' }`. An unresolvable class
|
|
1058
|
+
(`getAccess()` → `undefined`) and a predicate that throws both **deny**. A
|
|
966
1059
|
filtered-out relationship is **indistinguishable from a genuinely empty one** —
|
|
967
1060
|
an emptied `hasMany` is `data: []` and an emptied `belongsTo` is `data: null`,
|
|
968
1061
|
both **keeping their `links`**, which are built from the serialized record's
|
|
969
|
-
own id and never from the related one.
|
|
970
|
-
no `links` to keep: neither handler passes a `baseUrl`, so a filtered and a
|
|
971
|
-
genuinely-empty relationship are both a bare `{ "data": … }` there. That is
|
|
972
|
-
pre-existing and deliberate — adding `baseUrl` to the write handlers would be
|
|
973
|
-
an unrelated change to their response shape. Nothing errors and no status changes,
|
|
1062
|
+
own id and never from the related one. Nothing errors and no status changes,
|
|
974
1063
|
because throwing here would be an existence oracle *and* would throw out of
|
|
975
1064
|
the enclosing `JSON.stringify`.
|
|
976
1065
|
|
|
1066
|
+
**[#232](https://github.com/abofs/stonyx-orm/issues/232) holds to the same
|
|
1067
|
+
spelling on the routes where that linkage is the *primary* data.** A denied
|
|
1068
|
+
member is dropped from the `hasMany` array and a denied `belongsTo` target is
|
|
1069
|
+
`data: null`, at `200`, `links` intact — so the claim above is true of both
|
|
1070
|
+
`GET /:models/:id/{relationship}` shapes as *routes* and not only as linkage
|
|
1071
|
+
emitted inside somebody else's document. An earlier revision of #232 answered
|
|
1072
|
+
`404` on the `belongsTo` shape and did contradict this paragraph; that is
|
|
1073
|
+
measured and closed in the #232 bullet above.
|
|
1074
|
+
|
|
977
1075
|
**That resolves the right class; it does not guarantee a model-correct
|
|
978
1076
|
answer, and the failure direction is not the safe one.** Only a predicate that
|
|
979
1077
|
*reads* `context.model` can answer about the model it was asked about — see
|
|
@@ -994,45 +1092,56 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
994
1092
|
*permitted* related record, which is recorded in the release notes as a
|
|
995
1093
|
breaking change.
|
|
996
1094
|
|
|
997
|
-
**Not yet covered
|
|
998
|
-
withhold
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
- **`
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
#234. The route builds its `{type, id}` objects by hand and never calls
|
|
1008
|
-
`toJSON`, so the `linkage` **option** never reaches it; whatever that route
|
|
1009
|
-
filters, it filters itself. Measured **on `dev @ 8dda5d6`**, the commit
|
|
1010
|
-
#235 branched from: `GET /animals/1/relationships/owner` answered
|
|
1011
|
-
`{"type":"owner","id":"angela"}` while `GET /owners/angela` was `404`. That
|
|
1012
|
-
measurement is pinned to a commit on purpose, so that it does not quietly
|
|
1013
|
-
become a false claim about `dev`. **PR
|
|
1014
|
-
[#247](https://github.com/abofs/stonyx-orm/pull/247) is in flight against
|
|
1015
|
-
this entry**; if it has landed, this route is covered and the bullet #247
|
|
1016
|
-
adds above supersedes this one.
|
|
1017
|
-
- **Whether a related resource appears in `included` at all.**
|
|
1095
|
+
**Not yet covered.** The first two still publish ids the surfaces above
|
|
1096
|
+
withhold; the third is struck through because it **is** covered now, and is
|
|
1097
|
+
kept for the reason given under it:
|
|
1098
|
+
|
|
1099
|
+
- **`included`** — [#235](https://github.com/abofs/stonyx-orm/issues/235). A
|
|
1100
|
+
permitted record sideloaded by `?include=` emits its **own**
|
|
1101
|
+
`relationships.*.data` unfiltered, so `GET /animals/1?include=owner,owner.pets`
|
|
1102
|
+
returns `owner.data: null` on the primary document and then names angela in
|
|
1103
|
+
`included`, along with eight permitted animals that each name
|
|
1104
|
+
`{"type":"owner","id":"angela"}`. Separately,
|
|
1018
1105
|
[#233](https://github.com/abofs/stonyx-orm/issues/233) owns whether a
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
`
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1106
|
+
resource appears in `included` **at all** — that is membership, a different
|
|
1107
|
+
question, and following it will not lead you to this residual.
|
|
1108
|
+
- **The `POST`/`PATCH` response documents** —
|
|
1109
|
+
[#235](https://github.com/abofs/stonyx-orm/issues/235). `createHandler` and
|
|
1110
|
+
`updateHandler` destructure the request rather than binding it, so wiring
|
|
1111
|
+
them needs a signature change rather than an argument. Until then **one HTTP
|
|
1112
|
+
verb defeats the filter on the same record**: measured, `GET /animals/1`
|
|
1113
|
+
returns `owner.data: null` and `PATCH /animals/1` returns **200 naming
|
|
1114
|
+
angela**, seconds apart, with no query string and no relationship route. Any
|
|
1115
|
+
caller who can read a record can also write it and be handed the id the read
|
|
1116
|
+
withheld.
|
|
1117
|
+
- **A computed property can republish a hidden related record's id into
|
|
1118
|
+
`attributes`** — [#245](https://github.com/abofs/stonyx-orm/issues/245).
|
|
1119
|
+
`Record.toJSON()` runs consumer computed properties and writes their return
|
|
1120
|
+
values straight into `attributes` with no verdict applied. A computed
|
|
1121
|
+
property is ordinary consumer code with `this` bound to the record, so it
|
|
1122
|
+
can read any resolved relationship and emit the id this list's surfaces
|
|
1123
|
+
withheld — through a channel that filters `relationships.*.data` and never
|
|
1124
|
+
sees it. The shipped sample's `animal.tag` getter does exactly that. **This
|
|
1125
|
+
is reachable on `dev` once this pull request merges**, because
|
|
1126
|
+
[#240](https://github.com/abofs/stonyx-orm/issues/240) fixture 1 puts a
|
|
1127
|
+
hidden child (animal 18) under a **permitted** parent for the first time.
|
|
1128
|
+
- **The absence of `attributes.<fk>` on a `POST` response proves a hidden
|
|
1129
|
+
record exists** — [#246](https://github.com/abofs/stonyx-orm/issues/246).
|
|
1130
|
+
`createHandler` copies each supplied relationship's raw id into
|
|
1131
|
+
`attributes`; when the related record resolves, the value is consumed and
|
|
1132
|
+
does not appear, and when it does not resolve, it survives. The oracle runs
|
|
1133
|
+
in the negative space, so nothing this list's surfaces withhold is
|
|
1134
|
+
*published* — the **absence** is the signal. Pre-existing, and it does not
|
|
1135
|
+
compose with the two relationship families above: they emit no `attributes`
|
|
1136
|
+
for a related record at all.
|
|
1137
|
+
- ~~**`GET /:models/:id/relationships/{relationship}`**~~ — **covered as of
|
|
1138
|
+
[#232](https://github.com/abofs/stonyx-orm/issues/232)**, together with
|
|
1139
|
+
`GET /:models/:id/{relationship}`. Left in place rather than deleted because
|
|
1140
|
+
this list is what a reader consults to find out what is *not* covered, and a
|
|
1141
|
+
silently vanishing entry reads as an entry that was never there. Its
|
|
1142
|
+
*primary data* is linkage, so filtering it was a **membership** decision —
|
|
1143
|
+
which is why it belonged to #232 and not to
|
|
1144
|
+
[#234](https://github.com/abofs/stonyx-orm/issues/234).
|
|
1036
1145
|
- **A bare `toJSON()` still emits unfiltered linkage, and that is deliberate.**
|
|
1037
1146
|
`Record.toJSON()` **applies** a verdict; it never **resolves** one. It has no
|
|
1038
1147
|
request, and the documented `access()` contract permits a predicate to read
|
|
@@ -1126,29 +1235,11 @@ read all of these.
|
|
|
1126
1235
|
|
|
1127
1236
|
#### `Record.toJSON()` does not filter relationship linkage unless you pass a verdict
|
|
1128
1237
|
|
|
1129
|
-
**The framework
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
**response documents**, and every record inside an `?include=` **`included`**
|
|
1135
|
-
array ([#234](https://github.com/abofs/stonyx-orm/issues/234) for the four
|
|
1136
|
-
reads, [#235](https://github.com/abofs/stonyx-orm/issues/235) for the two
|
|
1137
|
-
writes and `included`). Each resolves a linkage verdict and passes it to
|
|
1138
|
-
`toJSON()` for you.
|
|
1139
|
-
|
|
1140
|
-
**`GET /:models/:id/relationships/{relationship}` is not on that list, and its
|
|
1141
|
-
state is not this section's to report.** It builds its `{ type, id }` objects by
|
|
1142
|
-
hand instead of calling `toJSON()`, so the `linkage` **option** never reaches it
|
|
1143
|
-
— whatever that route filters, it filters itself. And because its linkage *is*
|
|
1144
|
-
its primary data, filtering it is a **membership** decision rather than a
|
|
1145
|
-
linkage one. Membership on both relationship route families is owned by
|
|
1146
|
-
[#232](https://github.com/abofs/stonyx-orm/issues/232) (PR
|
|
1147
|
-
[#247](https://github.com/abofs/stonyx-orm/pull/247), in flight as this is
|
|
1148
|
-
written); read that issue for its state rather than inferring it here, because
|
|
1149
|
-
this section describes only what `toJSON()` filters.
|
|
1150
|
-
|
|
1151
|
-
Any other path to a document — `JSON.stringify(record)`, `res.json(record)`,
|
|
1238
|
+
**The framework owns this on four surfaces. You own it everywhere else.**
|
|
1239
|
+
|
|
1240
|
+
`GET /:models`, `GET /:models/:id` and both `GET /:models/:id/{relationship}`
|
|
1241
|
+
shapes resolve a linkage verdict and pass it to `toJSON()` for you. Any other
|
|
1242
|
+
path to a document — `JSON.stringify(record)`, `res.json(record)`,
|
|
1152
1243
|
`console.log(record)`, a custom route, a queue payload, a websocket frame —
|
|
1153
1244
|
calls `toJSON()` with no verdict, and **the no-verdict document names every
|
|
1154
1245
|
related id, including records hidden on every one of their own surfaces**
|
|
@@ -1161,6 +1252,31 @@ default, the default is the unfiltered document, and a filtered relationship is
|
|
|
1161
1252
|
byte-identical to a genuinely empty one — so nothing on the wire distinguishes
|
|
1162
1253
|
"filtered" from "forgotten".
|
|
1163
1254
|
|
|
1255
|
+
**And `linkage` cannot reach a document you build by hand.** It is an *option to
|
|
1256
|
+
`toJSON()`*, so it filters only what goes through `toJSON()`. The ORM's own
|
|
1257
|
+
`GET /:models/:id/relationships/{relationship}` route is the worked example: its
|
|
1258
|
+
primary data *is* linkage, it assembles `{ type, id }` directly rather than
|
|
1259
|
+
serializing a record, and it therefore resolves and applies the verdict itself
|
|
1260
|
+
([#232](https://github.com/abofs/stonyx-orm/issues/232)). If you assemble
|
|
1261
|
+
linkage the same way anywhere — a custom relationship route, a projection, a
|
|
1262
|
+
hand-built document — **passing `linkage` to `toJSON()` does nothing for it and
|
|
1263
|
+
nothing warns**. Build the filter and consult it before you emit an id:
|
|
1264
|
+
|
|
1265
|
+
```js
|
|
1266
|
+
const linkage = createLinkageFilter(request);
|
|
1267
|
+
|
|
1268
|
+
if (related && linkage(related.__model.__name, related)) {
|
|
1269
|
+
data = { type: related.__model.__name, id: related.id };
|
|
1270
|
+
} else {
|
|
1271
|
+
data = null; // withheld and genuinely-empty must be the SAME answer
|
|
1272
|
+
}
|
|
1273
|
+
```
|
|
1274
|
+
|
|
1275
|
+
The `else` branch is the part that is easy to get wrong. Answering `404`, `403`
|
|
1276
|
+
or an `errors` member for the withheld case makes the route an **existence
|
|
1277
|
+
oracle** — see [Filter functions](#filter-functions) for the rule and for the
|
|
1278
|
+
measurement that closed it on this route.
|
|
1279
|
+
|
|
1164
1280
|
Do this:
|
|
1165
1281
|
|
|
1166
1282
|
```js
|
|
@@ -1448,15 +1564,13 @@ GET /animals/1
|
|
|
1448
1564
|
#### Limitations
|
|
1449
1565
|
|
|
1450
1566
|
- Only available on GET endpoints (not POST/PATCH)
|
|
1451
|
-
- **`included`
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
404 on its own routes is still served as an `included` resource, attributes
|
|
1459
|
-
and all. See [Consumer Contracts](#consumer-contracts).
|
|
1567
|
+
- **`included` records are not access-filtered, on either question.** Whether a
|
|
1568
|
+
resource appears in `included` at all is
|
|
1569
|
+
[#233](https://github.com/abofs/stonyx-orm/issues/233); a record that *is*
|
|
1570
|
+
permitted still emits its **own** `relationships.*.data` unfiltered, so
|
|
1571
|
+
`?include=` republishes ids the primary document withholds
|
|
1572
|
+
([#235](https://github.com/abofs/stonyx-orm/issues/235)). See
|
|
1573
|
+
[Consumer Contracts](#consumer-contracts).
|
|
1460
1574
|
|
|
1461
1575
|
## Lifecycle Hooks
|
|
1462
1576
|
|
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
|