@stonyx/orm 0.3.2-alpha.75 → 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 +140 -25
- package/dist/orm-request.d.ts +13 -3
- package/dist/orm-request.js +77 -34
- package/dist/types/orm-types.d.ts +30 -4
- package/package.json +1 -1
- package/src/orm-request.ts +77 -34
- 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
|
|
|
@@ -669,9 +680,11 @@ A `throw` inside `access()` is a **denial**, not a 500.
|
|
|
669
680
|
A function return value is a **per-record predicate**, and it is enforced on
|
|
670
681
|
every endpoint that is addressed to a record — not only on the collection.
|
|
671
682
|
|
|
672
|
-
It is evaluated against the record the route is *addressed to
|
|
673
|
-
|
|
674
|
-
|
|
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
|
|
675
688
|
[Known limitations](#known-limitations) and
|
|
676
689
|
[#207](https://github.com/abofs/stonyx-orm/issues/207).
|
|
677
690
|
|
|
@@ -679,19 +692,42 @@ a write to a *different* collection can still re-parent one. See
|
|
|
679
692
|
|---|---|
|
|
680
693
|
| `GET /:models` | omitted from the collection |
|
|
681
694
|
| `GET /:models/:id` | `404` |
|
|
682
|
-
| `GET /:models/:id/{relationship}` | `404
|
|
683
|
-
| `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 |
|
|
684
697
|
| `PATCH /:models/:id` | `404`, no attribute is applied |
|
|
685
698
|
| `DELETE /:models/:id` | `404`, the record is not removed and no SQL `DELETE` is issued |
|
|
686
699
|
| `POST /:models` | `403`, and a record **this request inserted** is rolled back — see [Known limitations](#known-limitations) for why the rollback is conditional |
|
|
687
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
|
+
|
|
688
720
|
**Denied record-level requests return 404, not 403.** This is deliberate and it
|
|
689
721
|
is the property most easily "improved" away. 403 would confirm that the record
|
|
690
722
|
exists to a caller who is not allowed to know that, which turns the filter into
|
|
691
723
|
an existence oracle: `404` means "no such record", `403` means "there is one and
|
|
692
724
|
it is not yours". Every status on a record route must therefore be identical for
|
|
693
725
|
"filtered out" and "does not exist" — including `DELETE`, which is why deleting
|
|
694
|
-
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".
|
|
695
731
|
|
|
696
732
|
`POST` is the one exception and returns **403**, because 404 on a mounted
|
|
697
733
|
collection route is indistinguishable from "model not mounted" — a genuinely
|
|
@@ -933,9 +969,11 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
933
969
|
filter decides whether it is served at all, not merely which ids a document
|
|
934
970
|
may name. A denied `hasMany` member is **dropped from the array** — the result
|
|
935
971
|
is shaped exactly like a genuinely empty relationship, `links` intact, no
|
|
936
|
-
`errors` member, same status. A denied `belongsTo` target answers
|
|
937
|
-
|
|
938
|
-
|
|
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
|
|
939
977
|
hand rather than through `toJSON()`, which is why the linkage filter shipped in
|
|
940
978
|
[#234](https://github.com/abofs/stonyx-orm/issues/234) did not reach it.
|
|
941
979
|
|
|
@@ -946,12 +984,29 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
946
984
|
readable as a related resource — a collection the consumer deliberately never
|
|
947
985
|
exposed.
|
|
948
986
|
|
|
949
|
-
**
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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.
|
|
955
1010
|
|
|
956
1011
|
**Per-record denies for a related resource are not expressible.** A predicate resolved for a
|
|
957
1012
|
related resource on these routes receives `recordId: null` and a `request`
|
|
@@ -981,10 +1036,15 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
981
1036
|
class (see the bullet above). **`?include=owner` still does not**: it
|
|
982
1037
|
serializes the related record without resolving that class, so a filter on
|
|
983
1038
|
`/owners` does not hide an owner reached through `?include=` on `/animals`.
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
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
|
|
988
1048
|
below.
|
|
989
1049
|
- **Relationship linkage is filtered on the four request-bound read surfaces,
|
|
990
1050
|
and only there.** A document's `relationships.*.data` used to publish the id
|
|
@@ -1003,6 +1063,15 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
1003
1063
|
because throwing here would be an existence oracle *and* would throw out of
|
|
1004
1064
|
the enclosing `JSON.stringify`.
|
|
1005
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
|
+
|
|
1006
1075
|
**That resolves the right class; it does not guarantee a model-correct
|
|
1007
1076
|
answer, and the failure direction is not the safe one.** Only a predicate that
|
|
1008
1077
|
*reads* `context.model` can answer about the model it was asked about — see
|
|
@@ -1023,8 +1092,9 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
1023
1092
|
*permitted* related record, which is recorded in the release notes as a
|
|
1024
1093
|
breaking change.
|
|
1025
1094
|
|
|
1026
|
-
**Not yet covered
|
|
1027
|
-
withhold
|
|
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:
|
|
1028
1098
|
|
|
1029
1099
|
- **`included`** — [#235](https://github.com/abofs/stonyx-orm/issues/235). A
|
|
1030
1100
|
permitted record sideloaded by `?include=` emits its **own**
|
|
@@ -1044,6 +1114,26 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
1044
1114
|
angela**, seconds apart, with no query string and no relationship route. Any
|
|
1045
1115
|
caller who can read a record can also write it and be handed the id the read
|
|
1046
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.
|
|
1047
1137
|
- ~~**`GET /:models/:id/relationships/{relationship}`**~~ — **covered as of
|
|
1048
1138
|
[#232](https://github.com/abofs/stonyx-orm/issues/232)**, together with
|
|
1049
1139
|
`GET /:models/:id/{relationship}`. Left in place rather than deleted because
|
|
@@ -1162,6 +1252,31 @@ default, the default is the unfiltered document, and a filtered relationship is
|
|
|
1162
1252
|
byte-identical to a genuinely empty one — so nothing on the wire distinguishes
|
|
1163
1253
|
"filtered" from "forgotten".
|
|
1164
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
|
+
|
|
1165
1280
|
Do this:
|
|
1166
1281
|
|
|
1167
1282
|
```js
|
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
|
|
@@ -1266,13 +1276,25 @@ export default class OrmRequest extends Request {
|
|
|
1266
1276
|
// ARGUMENT ONE IS THE LIVE REQUEST, NOT A DERIVED ONE. A fabricated
|
|
1267
1277
|
// request addressing the RELATED resource was the original design and
|
|
1268
1278
|
// it is dropped: #241 removed the shipped fixture's read of argument
|
|
1269
|
-
// one, so a fabricated value changes nothing it could observe
|
|
1270
|
-
//
|
|
1271
|
-
//
|
|
1272
|
-
//
|
|
1273
|
-
//
|
|
1274
|
-
//
|
|
1275
|
-
//
|
|
1279
|
+
// one, so a fabricated value changes nothing it could observe.
|
|
1280
|
+
// `createLinkageFilter` is also a published public export
|
|
1281
|
+
// (src/index.ts) whose resolution granularity is per TYPE; supplying a
|
|
1282
|
+
// per-RECORD request would mean widening it, which takes a consumer
|
|
1283
|
+
// `access()` from ~2 calls to ~7 on a plain `GET /animals`. That is a
|
|
1284
|
+
// separate, consumer-visible story.
|
|
1285
|
+
//
|
|
1286
|
+
// GUARDED BY OWN-PROPERTY IDENTITY, NOT BY THE #234 AC13 PIN. That pin
|
|
1287
|
+
// (test/unit/linkage-verdict-test.ts, `strictEqual(seen[0].request,
|
|
1288
|
+
// READ_REQUEST)`) calls `createLinkageFilter` DIRECTLY, so it pins the
|
|
1289
|
+
// function's pass-through and constrains no call site -- an earlier
|
|
1290
|
+
// revision of this comment cited it for this decision and was wrong.
|
|
1291
|
+
// `Object.create(request)` here measured 1015 / 0 with nothing red.
|
|
1292
|
+
// test/integration/orm-test.ts, `#232 AC9`, now asserts that the object
|
|
1293
|
+
// the predicate is handed OWNS `params` (`Object.hasOwn`) and has
|
|
1294
|
+
// nothing request-shaped behind it on the prototype chain. A derived
|
|
1295
|
+
// request inherits `params` -- so it satisfies every value assertion
|
|
1296
|
+
// there -- and reds on those two. Measured: with the derived request in
|
|
1297
|
+
// place, 1014 / 1, and that one is this guard.
|
|
1276
1298
|
//
|
|
1277
1299
|
// THE RESIDUAL THAT FOLLOWS FROM THAT IS DISCLOSED, NOT PAPERED OVER.
|
|
1278
1300
|
// `recordId` is `null` here and the request names a record of a
|
|
@@ -1297,23 +1319,36 @@ export default class OrmRequest extends Request {
|
|
|
1297
1319
|
data = related.filter(isLinkable).map(r => r.toJSON?.({ baseUrl, linkage }));
|
|
1298
1320
|
}
|
|
1299
1321
|
else {
|
|
1300
|
-
// belongsTo - return single or null. A DENIED target is
|
|
1301
|
-
//
|
|
1302
|
-
//
|
|
1303
|
-
//
|
|
1322
|
+
// belongsTo - return single or null. A DENIED target is `data: null`,
|
|
1323
|
+
// BYTE-IDENTICAL to a relationship that is genuinely empty, for the
|
|
1324
|
+
// same reason the hasMany branch above drops rather than errors: this
|
|
1325
|
+
// route must not be an existence oracle for the RELATED record.
|
|
1326
|
+
//
|
|
1327
|
+
// THE OTHER SPELLING WAS 404 AND IT WAS MEASURED AS A DISCLOSURE.
|
|
1328
|
+
// Unauthenticated, zero query parameters, one request each, on `tag`
|
|
1329
|
+
// -- the model with no route mounted at all, which is exactly what
|
|
1330
|
+
// #240 AC5 exists to protect:
|
|
1331
|
+
//
|
|
1332
|
+
// GET /traits/1/tag [ABSENT] -> 200 application/json len 68
|
|
1333
|
+
// GET /traits/2/tag [DENIED] -> 404 text/plain len 9
|
|
1304
1334
|
//
|
|
1305
|
-
//
|
|
1306
|
-
//
|
|
1307
|
-
//
|
|
1308
|
-
//
|
|
1309
|
-
//
|
|
1310
|
-
//
|
|
1311
|
-
//
|
|
1312
|
-
//
|
|
1335
|
+
// and `GET /traits/1` and `GET /traits/2` both report
|
|
1336
|
+
// `relationships.tag = {"data":null}` byte-identical modulo the id,
|
|
1337
|
+
// because #234 closed THAT oracle deliberately. A 404 here would let
|
|
1338
|
+
// a caller ask which of those two nulls was a denial. Under
|
|
1339
|
+
// `data: null` the pair closes completely: 200/200, same
|
|
1340
|
+
// content-type, same content-length, bodies identical modulo the
|
|
1341
|
+
// parent id the caller put in the URL. It opens nothing -- `links`
|
|
1342
|
+
// are entirely parent-derived, there is no `meta` and no counts.
|
|
1343
|
+
//
|
|
1344
|
+
// This is also what README.md's module-wide rule already demanded:
|
|
1345
|
+
// every status on a record route must be identical for filtered-out
|
|
1346
|
+
// and does-not-exist. The route now CONFORMS to that rule rather than
|
|
1347
|
+
// carving an exception out of it.
|
|
1313
1348
|
if (!isOrmRecord(relatedData))
|
|
1314
1349
|
data = null;
|
|
1315
1350
|
else if (!isLinkable(relatedData))
|
|
1316
|
-
|
|
1351
|
+
data = null;
|
|
1317
1352
|
else
|
|
1318
1353
|
data = relatedData.toJSON?.({ baseUrl, linkage });
|
|
1319
1354
|
}
|
|
@@ -1331,13 +1366,21 @@ export default class OrmRequest extends Request {
|
|
|
1331
1366
|
return 404;
|
|
1332
1367
|
const relatedData = record.__relationships[relationshipName];
|
|
1333
1368
|
const baseUrl = getBaseUrl(request);
|
|
1334
|
-
// THE
|
|
1335
|
-
// `
|
|
1336
|
-
//
|
|
1337
|
-
//
|
|
1338
|
-
//
|
|
1339
|
-
//
|
|
1340
|
-
//
|
|
1369
|
+
// THE ONE READ SURFACE THAT DOES NOT GO THROUGH `toJSON()`. It builds
|
|
1370
|
+
// `{ type, id }` BY HAND, which is why #234's linkage filter never
|
|
1371
|
+
// reached it and why this half belongs to abofs/stonyx-orm#232 rather
|
|
1372
|
+
// than to #234: on this route the linkage IS the primary data of an
|
|
1373
|
+
// opt-in request, so filtering it changes the route's MEMBERSHIP
|
|
1374
|
+
// semantics, not the ids named inside somebody else's document.
|
|
1375
|
+
//
|
|
1376
|
+
// DELIBERATELY NOT STATED AS A COUNT. README.md's Consumer Contracts
|
|
1377
|
+
// section enumerates the surfaces on which the framework resolves a
|
|
1378
|
+
// verdict and hands it to `toJSON()`, and that enumeration GROWS --
|
|
1379
|
+
// abofs/stonyx-orm#235 adds the two write handlers and the `included`
|
|
1380
|
+
// records. This route is not on that list under any count, because it
|
|
1381
|
+
// never calls `toJSON()`: whatever it filters, it filters here. A
|
|
1382
|
+
// number written into this comment would be false the next time that
|
|
1383
|
+
// list changes, and the README already carries the enumeration.
|
|
1341
1384
|
//
|
|
1342
1385
|
// Same filter, same argument-one decision, same residual as
|
|
1343
1386
|
// `/:id/{relationship}` above -- read the block there.
|
|
@@ -1356,10 +1399,10 @@ export default class OrmRequest extends Request {
|
|
|
1356
1399
|
.map(r => ({ type: r.__model.__name, id: r.id }));
|
|
1357
1400
|
}
|
|
1358
1401
|
else {
|
|
1359
|
-
// belongsTo - return single linkage or null
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1402
|
+
// belongsTo - return single linkage or null. A DENIED target is
|
|
1403
|
+
// `data: null`, indistinguishable from a genuinely empty one -- see
|
|
1404
|
+
// the measured oracle in the `/:id/{relationship}` block above.
|
|
1405
|
+
if (isOrmRecord(relatedData) && relatedData.__model && isLinkable(relatedData)) {
|
|
1363
1406
|
data = { type: relatedData.__model.__name, id: relatedData.id };
|
|
1364
1407
|
}
|
|
1365
1408
|
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
|
|
@@ -1385,13 +1395,25 @@ export default class OrmRequest extends Request {
|
|
|
1385
1395
|
// ARGUMENT ONE IS THE LIVE REQUEST, NOT A DERIVED ONE. A fabricated
|
|
1386
1396
|
// request addressing the RELATED resource was the original design and
|
|
1387
1397
|
// it is dropped: #241 removed the shipped fixture's read of argument
|
|
1388
|
-
// one, so a fabricated value changes nothing it could observe
|
|
1389
|
-
//
|
|
1390
|
-
//
|
|
1391
|
-
//
|
|
1392
|
-
//
|
|
1393
|
-
//
|
|
1394
|
-
//
|
|
1398
|
+
// one, so a fabricated value changes nothing it could observe.
|
|
1399
|
+
// `createLinkageFilter` is also a published public export
|
|
1400
|
+
// (src/index.ts) whose resolution granularity is per TYPE; supplying a
|
|
1401
|
+
// per-RECORD request would mean widening it, which takes a consumer
|
|
1402
|
+
// `access()` from ~2 calls to ~7 on a plain `GET /animals`. That is a
|
|
1403
|
+
// separate, consumer-visible story.
|
|
1404
|
+
//
|
|
1405
|
+
// GUARDED BY OWN-PROPERTY IDENTITY, NOT BY THE #234 AC13 PIN. That pin
|
|
1406
|
+
// (test/unit/linkage-verdict-test.ts, `strictEqual(seen[0].request,
|
|
1407
|
+
// READ_REQUEST)`) calls `createLinkageFilter` DIRECTLY, so it pins the
|
|
1408
|
+
// function's pass-through and constrains no call site -- an earlier
|
|
1409
|
+
// revision of this comment cited it for this decision and was wrong.
|
|
1410
|
+
// `Object.create(request)` here measured 1015 / 0 with nothing red.
|
|
1411
|
+
// test/integration/orm-test.ts, `#232 AC9`, now asserts that the object
|
|
1412
|
+
// the predicate is handed OWNS `params` (`Object.hasOwn`) and has
|
|
1413
|
+
// nothing request-shaped behind it on the prototype chain. A derived
|
|
1414
|
+
// request inherits `params` -- so it satisfies every value assertion
|
|
1415
|
+
// there -- and reds on those two. Measured: with the derived request in
|
|
1416
|
+
// place, 1014 / 1, and that one is this guard.
|
|
1395
1417
|
//
|
|
1396
1418
|
// THE RESIDUAL THAT FOLLOWS FROM THAT IS DISCLOSED, NOT PAPERED OVER.
|
|
1397
1419
|
// `recordId` is `null` here and the request names a record of a
|
|
@@ -1418,21 +1440,34 @@ export default class OrmRequest extends Request {
|
|
|
1418
1440
|
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
1419
1441
|
data = related.filter(isLinkable).map(r => r.toJSON?.({ baseUrl, linkage }));
|
|
1420
1442
|
} else {
|
|
1421
|
-
// belongsTo - return single or null. A DENIED target is
|
|
1422
|
-
//
|
|
1423
|
-
//
|
|
1424
|
-
//
|
|
1443
|
+
// belongsTo - return single or null. A DENIED target is `data: null`,
|
|
1444
|
+
// BYTE-IDENTICAL to a relationship that is genuinely empty, for the
|
|
1445
|
+
// same reason the hasMany branch above drops rather than errors: this
|
|
1446
|
+
// route must not be an existence oracle for the RELATED record.
|
|
1447
|
+
//
|
|
1448
|
+
// THE OTHER SPELLING WAS 404 AND IT WAS MEASURED AS A DISCLOSURE.
|
|
1449
|
+
// Unauthenticated, zero query parameters, one request each, on `tag`
|
|
1450
|
+
// -- the model with no route mounted at all, which is exactly what
|
|
1451
|
+
// #240 AC5 exists to protect:
|
|
1452
|
+
//
|
|
1453
|
+
// GET /traits/1/tag [ABSENT] -> 200 application/json len 68
|
|
1454
|
+
// GET /traits/2/tag [DENIED] -> 404 text/plain len 9
|
|
1425
1455
|
//
|
|
1426
|
-
//
|
|
1427
|
-
//
|
|
1428
|
-
//
|
|
1429
|
-
//
|
|
1430
|
-
//
|
|
1431
|
-
//
|
|
1432
|
-
//
|
|
1433
|
-
//
|
|
1456
|
+
// and `GET /traits/1` and `GET /traits/2` both report
|
|
1457
|
+
// `relationships.tag = {"data":null}` byte-identical modulo the id,
|
|
1458
|
+
// because #234 closed THAT oracle deliberately. A 404 here would let
|
|
1459
|
+
// a caller ask which of those two nulls was a denial. Under
|
|
1460
|
+
// `data: null` the pair closes completely: 200/200, same
|
|
1461
|
+
// content-type, same content-length, bodies identical modulo the
|
|
1462
|
+
// parent id the caller put in the URL. It opens nothing -- `links`
|
|
1463
|
+
// are entirely parent-derived, there is no `meta` and no counts.
|
|
1464
|
+
//
|
|
1465
|
+
// This is also what README.md's module-wide rule already demanded:
|
|
1466
|
+
// every status on a record route must be identical for filtered-out
|
|
1467
|
+
// and does-not-exist. The route now CONFORMS to that rule rather than
|
|
1468
|
+
// carving an exception out of it.
|
|
1434
1469
|
if (!isOrmRecord(relatedData)) data = null;
|
|
1435
|
-
else if (!isLinkable(relatedData))
|
|
1470
|
+
else if (!isLinkable(relatedData)) data = null;
|
|
1436
1471
|
else data = relatedData.toJSON?.({ baseUrl, linkage });
|
|
1437
1472
|
}
|
|
1438
1473
|
|
|
@@ -1451,13 +1486,21 @@ export default class OrmRequest extends Request {
|
|
|
1451
1486
|
const relatedData = record.__relationships[relationshipName];
|
|
1452
1487
|
const baseUrl = getBaseUrl(request);
|
|
1453
1488
|
|
|
1454
|
-
// THE
|
|
1455
|
-
// `
|
|
1456
|
-
//
|
|
1457
|
-
//
|
|
1458
|
-
//
|
|
1459
|
-
//
|
|
1460
|
-
//
|
|
1489
|
+
// THE ONE READ SURFACE THAT DOES NOT GO THROUGH `toJSON()`. It builds
|
|
1490
|
+
// `{ type, id }` BY HAND, which is why #234's linkage filter never
|
|
1491
|
+
// reached it and why this half belongs to abofs/stonyx-orm#232 rather
|
|
1492
|
+
// than to #234: on this route the linkage IS the primary data of an
|
|
1493
|
+
// opt-in request, so filtering it changes the route's MEMBERSHIP
|
|
1494
|
+
// semantics, not the ids named inside somebody else's document.
|
|
1495
|
+
//
|
|
1496
|
+
// DELIBERATELY NOT STATED AS A COUNT. README.md's Consumer Contracts
|
|
1497
|
+
// section enumerates the surfaces on which the framework resolves a
|
|
1498
|
+
// verdict and hands it to `toJSON()`, and that enumeration GROWS --
|
|
1499
|
+
// abofs/stonyx-orm#235 adds the two write handlers and the `included`
|
|
1500
|
+
// records. This route is not on that list under any count, because it
|
|
1501
|
+
// never calls `toJSON()`: whatever it filters, it filters here. A
|
|
1502
|
+
// number written into this comment would be false the next time that
|
|
1503
|
+
// list changes, and the README already carries the enumeration.
|
|
1461
1504
|
//
|
|
1462
1505
|
// Same filter, same argument-one decision, same residual as
|
|
1463
1506
|
// `/:id/{relationship}` above -- read the block there.
|
|
@@ -1477,10 +1520,10 @@ export default class OrmRequest extends Request {
|
|
|
1477
1520
|
.filter(isLinkable)
|
|
1478
1521
|
.map(r => ({ type: r.__model.__name, id: r.id }));
|
|
1479
1522
|
} else {
|
|
1480
|
-
// belongsTo - return single linkage or null
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1523
|
+
// belongsTo - return single linkage or null. A DENIED target is
|
|
1524
|
+
// `data: null`, indistinguishable from a genuinely empty one -- see
|
|
1525
|
+
// the measured oracle in the `/:id/{relationship}` block above.
|
|
1526
|
+
if (isOrmRecord(relatedData) && relatedData.__model && isLinkable(relatedData)) {
|
|
1484
1527
|
data = { type: relatedData.__model.__name, id: relatedData.id };
|
|
1485
1528
|
} else {
|
|
1486
1529
|
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
|
}
|