@stonyx/orm 0.3.2-alpha.75 → 0.3.2-alpha.76
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 +117 -116
- package/dist/orm-request.js +145 -113
- package/package.json +1 -1
- package/src/orm-request.ts +147 -116
package/README.md
CHANGED
|
@@ -452,11 +452,7 @@ export default class GlobalAccess {
|
|
|
452
452
|
// inert. Deliberately NO `?? record.owner` fallback: accepting the raw
|
|
453
453
|
// shape as well as the resolved one would absorb a resolution regression
|
|
454
454
|
// silently, which is exactly what blinded this fixture before.
|
|
455
|
-
|
|
456
|
-
// fixture that makes the `hasMany` half of the relationship-route rules
|
|
457
|
-
// observable: gina is served, animal 18 is not, and every surface that
|
|
458
|
-
// names gina's pets has to drop it.
|
|
459
|
-
if (model === 'animal') return record => record.owner?.id !== 'restricted' && record.id !== 18;
|
|
455
|
+
if (model === 'animal') return record => record.owner?.id !== 'restricted';
|
|
460
456
|
|
|
461
457
|
// Allows full access to all calls that don't match any of the above conditions
|
|
462
458
|
return ['read', 'create', 'update', 'delete'];
|
|
@@ -925,81 +921,56 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
925
921
|
operation and which record the request addresses. The five variants above are
|
|
926
922
|
the five ways that has been observed to fail open so far. Tracked as
|
|
927
923
|
[#202](https://github.com/abofs/stonyx-orm/issues/202).
|
|
928
|
-
- **
|
|
929
|
-
|
|
930
|
-
`GET
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
surfaces, in full, from another model's route, at **zero query parameters**.
|
|
944
|
-
The severe case is a model **claimed by no access class**: `getAccess()`
|
|
945
|
-
returns `undefined`, no route is mounted for it at all, and it was still
|
|
946
|
-
readable as a related resource — a collection the consumer deliberately never
|
|
947
|
-
exposed.
|
|
948
|
-
|
|
949
|
-
**Residual on the `belongsTo` shape, stated rather than left to be found:** a
|
|
950
|
-
denied target answers 404 while a genuinely *absent* one answers 200 with
|
|
951
|
-
`data: null`, so those two cases are distinguishable. That asymmetry is
|
|
952
|
-
inherited — a denied *parent* has always answered 404 while an existing parent
|
|
953
|
-
with an empty relationship answers 200 — and changing it is a change to this
|
|
954
|
-
module's whole spelling of denial, not to these two routes.
|
|
955
|
-
|
|
956
|
-
**Per-record denies for a related resource are not expressible.** A predicate resolved for a
|
|
957
|
-
related resource on these routes receives `recordId: null` and a `request`
|
|
958
|
-
whose `params` name a record of a **different model**. So the inputs it has
|
|
959
|
-
are the model name, the operation and the request — and **a rule that needs to
|
|
960
|
-
know *which* related record it is being asked about cannot be written**.
|
|
961
|
-
Model-level denies (`return false` for a model) work. Request-level denies (a
|
|
962
|
-
rule reading a header, a tenant, the method) work. The per-record **filter**
|
|
963
|
-
shape works too — `access()` may return a function, and that function receives
|
|
964
|
-
the whole record, id included. What does not work is branching on the record's
|
|
965
|
-
identity *before* returning, because `access()` is not told it.
|
|
966
|
-
|
|
967
|
-
This is not an oversight and it is not closed here. The verdict is resolved
|
|
968
|
-
**once per type**, cached, before any record has been examined — a `hasMany`
|
|
969
|
-
related-resource route returns many records of one type, so seeding `recordId`
|
|
970
|
-
from a record would let the first one decide the context for all of them. The
|
|
971
|
-
rule the framework holds to is: **`recordId` may name a record only where the
|
|
972
|
-
route addresses exactly one record of the model being asked about.** That is
|
|
973
|
-
true for `GET /owners/{id}`, false for linkage, and false for a `hasMany`
|
|
974
|
-
related-resource route.
|
|
975
|
-
|
|
976
|
-
- **`?include=` records are still not filtered — the relationship routes now
|
|
977
|
-
are.** *Re-specified by [#232](https://github.com/abofs/stonyx-orm/issues/232);
|
|
978
|
-
the sentence this replaces said all three surfaces were unfiltered, and two of
|
|
979
|
-
them no longer are.* `GET /animals/1/owner` and
|
|
980
|
-
`GET /animals/1/relationships/owner` resolve the related model's own access
|
|
981
|
-
class (see the bullet above). **`?include=owner` still does not**: it
|
|
982
|
-
serializes the related record without resolving that class, so a filter on
|
|
983
|
-
`/owners` does not hide an owner reached through `?include=` on `/animals`.
|
|
984
|
-
Tracked as [#233](https://github.com/abofs/stonyx-orm/issues/233), the
|
|
985
|
-
remaining child of [#196](https://github.com/abofs/stonyx-orm/issues/196).
|
|
986
|
-
This is **membership** — whether the related resource is served at all — and
|
|
987
|
-
it is a different question from which ids a document may *name*, immediately
|
|
988
|
-
below.
|
|
989
|
-
- **Relationship linkage is filtered on the four request-bound read surfaces,
|
|
990
|
-
and only there.** A document's `relationships.*.data` used to publish the id
|
|
991
|
-
of every related record unconditionally, so a record hidden on every one of
|
|
992
|
-
its own surfaces was still named inside another model's document — with no
|
|
993
|
-
`include=`, no relationship route and no query string
|
|
924
|
+
- **Related and included records are not filtered.** The predicate is evaluated
|
|
925
|
+
against the record the route is *addressed to*. `GET /animals/1/owner`,
|
|
926
|
+
`GET /animals/1/relationships/owner` and `?include=owner` all serialize the
|
|
927
|
+
related record without resolving that model's own access class, so a filter on
|
|
928
|
+
`/owners` does not hide an owner reached through `/animals`. Tracked as
|
|
929
|
+
[#196](https://github.com/abofs/stonyx-orm/issues/196), which covers
|
|
930
|
+
`include=`, related-resource routes and relationship-linkage routes. This is
|
|
931
|
+
**membership** — whether the related resource is served at all — and it is a
|
|
932
|
+
different question from which ids a document may *name*, immediately below.
|
|
933
|
+
- **Relationship linkage is filtered on every request-bound surface that
|
|
934
|
+
serializes a record — the reads, the two writes, and `included`.** A
|
|
935
|
+
document's `relationships.*.data` used to publish the id of every related
|
|
936
|
+
record unconditionally, so a record hidden on every one of its own surfaces
|
|
937
|
+
was still named inside another model's document — with no `include=`, no
|
|
938
|
+
relationship route and no query string
|
|
994
939
|
([#234](https://github.com/abofs/stonyx-orm/issues/234)). The ORM now resolves
|
|
995
|
-
the **related** model's own access class on `GET /:models`, `GET /:models/:id
|
|
996
|
-
|
|
997
|
-
`
|
|
998
|
-
|
|
940
|
+
the **related** model's own access class on `GET /:models`, `GET /:models/:id`,
|
|
941
|
+
both `GET /:models/:id/{relationship}` shapes, the `POST /:models` and
|
|
942
|
+
`PATCH /:models/:id` **response documents**, and every record inside an
|
|
943
|
+
`?include=` **`included`** array
|
|
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
|
|
999
966
|
filtered-out relationship is **indistinguishable from a genuinely empty one** —
|
|
1000
967
|
an emptied `hasMany` is `data: []` and an emptied `belongsTo` is `data: null`,
|
|
1001
968
|
both **keeping their `links`**, which are built from the serialized record's
|
|
1002
|
-
own id and never from the related one.
|
|
969
|
+
own id and never from the related one. On the two **write** surfaces there are
|
|
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,
|
|
1003
974
|
because throwing here would be an existence oracle *and* would throw out of
|
|
1004
975
|
the enclosing `JSON.stringify`.
|
|
1005
976
|
|
|
@@ -1023,35 +994,45 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
1023
994
|
*permitted* related record, which is recorded in the release notes as a
|
|
1024
995
|
breaking change.
|
|
1025
996
|
|
|
1026
|
-
**Not yet covered
|
|
1027
|
-
withhold
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
997
|
+
**Not yet covered by #235. Each still publishes ids the surfaces above
|
|
998
|
+
withhold, except where its own owning issue has since closed it — the first
|
|
999
|
+
entry names an issue that is in flight as this is written:**
|
|
1000
|
+
|
|
1001
|
+
- **`GET /:models/:id/relationships/{relationship}`, and its state is #232's
|
|
1002
|
+
to report rather than this entry's.**
|
|
1003
|
+
[#232](https://github.com/abofs/stonyx-orm/issues/232) owns the
|
|
1004
|
+
relationships-linkage route. Its *primary data* is linkage,
|
|
1005
|
+
so filtering it is a **membership** decision — which is why it is the filed
|
|
1006
|
+
child of [#196](https://github.com/abofs/stonyx-orm/issues/196) and not of
|
|
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.**
|
|
1035
1018
|
[#233](https://github.com/abofs/stonyx-orm/issues/233) owns whether a
|
|
1036
|
-
resource appears in `included
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
`
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
which is why it belonged to #232 and not to
|
|
1054
|
-
[#234](https://github.com/abofs/stonyx-orm/issues/234).
|
|
1019
|
+
related resource appears in `included`. #235 filters what a record
|
|
1020
|
+
*already in* `included` may **name**; a hidden record is still a
|
|
1021
|
+
**member** of that array. The two are different questions and neither closes
|
|
1022
|
+
the other: after #235, `GET /animals/1?include=owner,owner.pets` returns
|
|
1023
|
+
`owner.data: null` on every permitted animal it sideloads **and still
|
|
1024
|
+
includes the hidden owner as a resource**.
|
|
1025
|
+
- **A computed attribute that interpolates a related record's id.** This is a
|
|
1026
|
+
**consumer-side** residual and the ORM cannot close it. `relationships.*.data`
|
|
1027
|
+
is a structure this module builds, so it can be filtered; a computed
|
|
1028
|
+
property is arbitrary consumer code returning an arbitrary value, and
|
|
1029
|
+
deciding which substrings of it are identifiers is not something the
|
|
1030
|
+
framework can do. Measured on this repo's own fixture, where the `animal`
|
|
1031
|
+
model has a `get tag()` that interpolates `owner.id`: **every** animal
|
|
1032
|
+
document on **every** surface — including the ones above — carries
|
|
1033
|
+
`attributes.tag: "angela's small dog"` for an owner that answers `404`. If
|
|
1034
|
+
your access rules hide a record, audit your computed properties for its
|
|
1035
|
+
identifiers.
|
|
1055
1036
|
- **A bare `toJSON()` still emits unfiltered linkage, and that is deliberate.**
|
|
1056
1037
|
`Record.toJSON()` **applies** a verdict; it never **resolves** one. It has no
|
|
1057
1038
|
request, and the documented `access()` contract permits a predicate to read
|
|
@@ -1145,11 +1126,29 @@ read all of these.
|
|
|
1145
1126
|
|
|
1146
1127
|
#### `Record.toJSON()` does not filter relationship linkage unless you pass a verdict
|
|
1147
1128
|
|
|
1148
|
-
**The framework
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1129
|
+
**The framework resolves a verdict for you on every request-bound surface that
|
|
1130
|
+
serializes a record through `toJSON()`. You own it everywhere else.**
|
|
1131
|
+
|
|
1132
|
+
Those surfaces are `GET /:models`, `GET /:models/:id`, both shapes of
|
|
1133
|
+
`GET /:models/:id/{relationship}`, the `POST /:models` and `PATCH /:models/:id`
|
|
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)`,
|
|
1153
1152
|
`console.log(record)`, a custom route, a queue payload, a websocket frame —
|
|
1154
1153
|
calls `toJSON()` with no verdict, and **the no-verdict document names every
|
|
1155
1154
|
related id, including records hidden on every one of their own surfaces**
|
|
@@ -1449,13 +1448,15 @@ GET /animals/1
|
|
|
1449
1448
|
#### Limitations
|
|
1450
1449
|
|
|
1451
1450
|
- Only available on GET endpoints (not POST/PATCH)
|
|
1452
|
-
- **`included`
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
[
|
|
1451
|
+
- **`included` is access-filtered on one of the two questions, not both.** What
|
|
1452
|
+
a record already in `included` may **name** in its own
|
|
1453
|
+
`relationships.*.data` is filtered
|
|
1454
|
+
([#235](https://github.com/abofs/stonyx-orm/issues/235)) — `?include=` no
|
|
1455
|
+
longer republishes ids the primary document withholds. Whether a resource
|
|
1456
|
+
appears in `included` **at all** is *membership* and is still unfiltered
|
|
1457
|
+
([#233](https://github.com/abofs/stonyx-orm/issues/233)): a record that is
|
|
1458
|
+
404 on its own routes is still served as an `included` resource, attributes
|
|
1459
|
+
and all. See [Consumer Contracts](#consumer-contracts).
|
|
1459
1460
|
|
|
1460
1461
|
## Lifecycle Hooks
|
|
1461
1462
|
|
package/dist/orm-request.js
CHANGED
|
@@ -424,7 +424,7 @@ function normalizeBodyId(id) {
|
|
|
424
424
|
return coerceId(id);
|
|
425
425
|
}
|
|
426
426
|
function buildResponse(data, includeParam, recordOrRecords, options = {}) {
|
|
427
|
-
const { links, baseUrl } = options;
|
|
427
|
+
const { links, baseUrl, linkage } = options;
|
|
428
428
|
const response = { data };
|
|
429
429
|
// Add top-level links
|
|
430
430
|
if (links) {
|
|
@@ -437,14 +437,49 @@ function buildResponse(data, includeParam, recordOrRecords, options = {}) {
|
|
|
437
437
|
return response;
|
|
438
438
|
const includedRecords = collectIncludedRecords(recordOrRecords, includes);
|
|
439
439
|
if (includedRecords.length > 0) {
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
|
|
440
|
+
// LINKAGE, NOT MEMBERSHIP -- and the distinction is the whole reason this
|
|
441
|
+
// line is one story's and the line above it is another's
|
|
442
|
+
// (abofs/stonyx-orm#235 and #233 respectively).
|
|
443
|
+
//
|
|
444
|
+
// - WHICH RESOURCES REACH THIS ARRAY is decided by
|
|
445
|
+
// `collectIncludedRecords` on the line above. That is MEMBERSHIP, it is
|
|
446
|
+
// #233's, and it is deliberately untouched here: a hidden owner is
|
|
447
|
+
// still a member of `included` after this change. Pinned green by
|
|
448
|
+
// `[GUARD] #235 X1` so that #235 cannot close #233 incidentally.
|
|
449
|
+
// - WHAT A RECORD ALREADY IN THIS ARRAY MAY NAME in its own
|
|
450
|
+
// `relationships.*.data` is LINKAGE -- the same question #234 answers
|
|
451
|
+
// for the primary document -- and that is what the `linkage` option
|
|
452
|
+
// below decides. Before it, `GET /animals/1?include=owner,owner.pets`
|
|
453
|
+
// filtered the primary document's `owner.data` to `null` and then
|
|
454
|
+
// handed back nine PERMITTED animals in `included` each naming
|
|
455
|
+
// `{"type":"owner","id":"angela"}`. Neither #233 nor #234 closes that.
|
|
456
|
+
//
|
|
457
|
+
// THE FILTER IS THE CALLER'S, PASSED IN, NOT BUILT HERE. Both call sites
|
|
458
|
+
// already hold one for the primary document, and sharing it is what keeps
|
|
459
|
+
// the per-type verdict cache and the per-(type, id) decision cache alive
|
|
460
|
+
// across the primary document AND the sideload -- one verdict resolution
|
|
461
|
+
// per type for the whole response, pinned by `[GUARD] #235 C1`. Building a
|
|
462
|
+
// fresh filter here would resolve the consumer's `access()` once per
|
|
463
|
+
// included record instead.
|
|
464
|
+
//
|
|
465
|
+
// `linkage` IS OPTIONAL IN THE TYPE AND IS NOT OPTIONAL IN PRACTICE.
|
|
466
|
+
// Stating it precisely because the opposite claim stood here in an earlier
|
|
467
|
+
// draft of this change: BOTH of this function's callers supply a filter
|
|
468
|
+
// (`getCollectionHandler` and `getSingleHandler`, the only two), so the
|
|
469
|
+
// `undefined` branch has no live caller in this module today. It is
|
|
470
|
+
// optional so that omitting it degrades to the PRE-#234 document rather
|
|
471
|
+
// than to a denial -- `Record.toJSON` reads an ABSENT option as "no verdict
|
|
472
|
+
// was supplied" and emits linkage in full.
|
|
473
|
+
//
|
|
474
|
+
// WHAT IT MUST NEVER BE HANDED IS A NON-FUNCTION. `toJSON` does NOT read a
|
|
475
|
+
// non-function as absent: `Object.prototype.toString.call(linkage)` must be
|
|
476
|
+
// `'[object Function]'`, and anything else -- `null`, an `AsyncFunction`,
|
|
477
|
+
// and INCLUDING the primitive `true` -- DENIES every relationship on the
|
|
478
|
+
// document and logs once. `toJSON({ linkage: true })` emits `null` linkage.
|
|
479
|
+
// So do not "simplify" this to a boolean, and do not make it default to
|
|
480
|
+
// `true`: both spellings look like "allow everything" and mean the exact
|
|
481
|
+
// opposite (abofs/stonyx-orm#224).
|
|
482
|
+
response.included = includedRecords.map(record => record.toJSON?.({ baseUrl, linkage }));
|
|
448
483
|
}
|
|
449
484
|
return response;
|
|
450
485
|
}
|
|
@@ -626,7 +661,11 @@ export default class OrmRequest extends Request {
|
|
|
626
661
|
const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl, linkage }));
|
|
627
662
|
return buildResponse(data, request.query?.include, recordsToReturn, {
|
|
628
663
|
links: { self: `${baseUrl}/${pluralizedModel}` },
|
|
629
|
-
baseUrl
|
|
664
|
+
baseUrl,
|
|
665
|
+
// THE SAME filter object the primary documents above were serialized
|
|
666
|
+
// with, deliberately: it carries the caches, and rebuilding one here
|
|
667
|
+
// would re-resolve every type (abofs/stonyx-orm#235).
|
|
668
|
+
linkage
|
|
630
669
|
});
|
|
631
670
|
};
|
|
632
671
|
const getSingleHandler = async (request, { filter }) => {
|
|
@@ -642,28 +681,28 @@ export default class OrmRequest extends Request {
|
|
|
642
681
|
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
643
682
|
const baseUrl = getBaseUrl(request);
|
|
644
683
|
const linkage = createLinkageFilter(request);
|
|
645
|
-
// `buildResponse`
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
// - WHETHER A RESOURCE APPEARS in `included` at all is MEMBERSHIP ->
|
|
649
|
-
// abofs/stonyx-orm#233.
|
|
650
|
-
// - What a record already IN `included` may NAME is LINKAGE -- the same
|
|
651
|
-
// question #234 answers for the primary document -- and it is
|
|
652
|
-
// abofs/stonyx-orm#235, which also owns createHandler/updateHandler.
|
|
684
|
+
// `buildResponse` IS given the filter now (abofs/stonyx-orm#235), and it
|
|
685
|
+
// is the SAME object the primary document is serialized with -- one
|
|
686
|
+
// verdict per type for the whole response, sideload included.
|
|
653
687
|
//
|
|
654
|
-
// The
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
//
|
|
658
|
-
//
|
|
659
|
-
// `included`. One query parameter deep. Only the PRIMARY document's
|
|
660
|
-
// linkage is filtered here.
|
|
688
|
+
// The boundary that remains, so the next reader does not have to derive
|
|
689
|
+
// it: this closes what a record already in `included` may NAME. WHETHER a
|
|
690
|
+
// resource appears in `included` at all is MEMBERSHIP and it is
|
|
691
|
+
// abofs/stonyx-orm#233's -- a hidden owner is still a member here.
|
|
692
|
+
// Neither question closes the other.
|
|
661
693
|
return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
|
|
662
694
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
|
|
663
|
-
baseUrl
|
|
695
|
+
baseUrl,
|
|
696
|
+
linkage
|
|
664
697
|
});
|
|
665
698
|
};
|
|
666
|
-
const createHandler = async (
|
|
699
|
+
const createHandler = async (request, { filter }) => {
|
|
700
|
+
// BOUND, not destructured (abofs/stonyx-orm#235). `HandlerFn` has always
|
|
701
|
+
// delivered the request as argument one; this handler simply discarded
|
|
702
|
+
// the binding, which is why its response document named ids every read
|
|
703
|
+
// surface withholds. `createLinkageFilter` needs the live request and
|
|
704
|
+
// there is no signature change involved in giving it one.
|
|
705
|
+
const { body, query } = request;
|
|
667
706
|
const { type, id, attributes, relationships: rels } = (body?.data || {});
|
|
668
707
|
if (!type)
|
|
669
708
|
return 400; // Bad request
|
|
@@ -902,9 +941,28 @@ export default class OrmRequest extends Request {
|
|
|
902
941
|
}
|
|
903
942
|
return 403;
|
|
904
943
|
}
|
|
905
|
-
|
|
944
|
+
// The filter is built HERE, per invocation, and never hoisted into the
|
|
945
|
+
// OrmRequest constructor where the other per-mount values live: a verdict
|
|
946
|
+
// cached across requests answers a second caller with the first caller's
|
|
947
|
+
// authorization (src/access-verdict.ts says so at the constructor an
|
|
948
|
+
// implementer would reach for).
|
|
949
|
+
//
|
|
950
|
+
// AND IT IS BUILT AFTER `createRecord`, AFTER THE ROLLBACK WINDOW AND
|
|
951
|
+
// AFTER `isDenied`, so the record is in its final form at the call. The
|
|
952
|
+
// filter is lazy per type and per (type, id), so it cannot observe a
|
|
953
|
+
// pre-write state even if it were built earlier.
|
|
954
|
+
//
|
|
955
|
+
// `fields` is passed here and NOT in `updateHandler`: the two handlers
|
|
956
|
+
// are asymmetric on purpose (`updateHandler` has no `fieldsMap` in
|
|
957
|
+
// scope), and a single copy-pasted wiring would drop it from one of them.
|
|
958
|
+
return { data: record.toJSON?.({ fields: modelFields, linkage: createLinkageFilter(request) }) };
|
|
906
959
|
};
|
|
907
|
-
const updateHandler = async (
|
|
960
|
+
const updateHandler = async (request, { filter }) => {
|
|
961
|
+
// Bound rather than destructured, for the reason given in
|
|
962
|
+
// `createHandler` above (abofs/stonyx-orm#235). `PATCH /animals/1`
|
|
963
|
+
// returned 200 naming angela seconds after `GET /animals/1` returned
|
|
964
|
+
// `owner.data: null` for the same record -- one HTTP verb apart.
|
|
965
|
+
const { body, params } = request;
|
|
908
966
|
const found = await store.find(model, getId(params));
|
|
909
967
|
if (!found || !isOrmRecord(found))
|
|
910
968
|
return 404;
|
|
@@ -961,7 +1019,14 @@ export default class OrmRequest extends Request {
|
|
|
961
1019
|
updateRecord(record, relUpdates, { _skipAutoPersist: true });
|
|
962
1020
|
}
|
|
963
1021
|
}
|
|
964
|
-
|
|
1022
|
+
// No `fields` and no `baseUrl`, both unchanged: `updateHandler` has no
|
|
1023
|
+
// `fieldsMap` in scope, and adding `baseUrl` would put `links` on a
|
|
1024
|
+
// document that has never carried them -- an unrelated behaviour change.
|
|
1025
|
+
// #224 AC6's "emits `data: []` WITH links" is a statement about the READ
|
|
1026
|
+
// surfaces; on these two handlers a filtered relationship and a
|
|
1027
|
+
// genuinely-empty one are both a bare `{ data }`, which is what makes
|
|
1028
|
+
// them indistinguishable here too.
|
|
1029
|
+
return { data: record.toJSON?.({ linkage: createLinkageFilter(request) }) };
|
|
965
1030
|
};
|
|
966
1031
|
const deleteHandler = async ({ params }, { filter }) => {
|
|
967
1032
|
// Coerced ONCE. `getId(params)` was evaluated twice here -- once to find
|
|
@@ -1245,77 +1310,21 @@ export default class OrmRequest extends Request {
|
|
|
1245
1310
|
return 404;
|
|
1246
1311
|
const relatedData = record.__relationships[relationshipName];
|
|
1247
1312
|
const baseUrl = getBaseUrl(request);
|
|
1248
|
-
//
|
|
1249
|
-
//
|
|
1250
|
-
//
|
|
1251
|
-
//
|
|
1252
|
-
//
|
|
1253
|
-
// related resource is PRIMARY data on this route, so there is no
|
|
1254
|
-
// linkage-consistency question to answer separately.
|
|
1255
|
-
//
|
|
1256
|
-
// Until #232 this route filtered only the PARENT, so a record its own
|
|
1257
|
-
// model's predicate hides was served in full from another model's
|
|
1258
|
-
// route, at ZERO query parameters. Measured on dev @ 8dda5d6:
|
|
1259
|
-
//
|
|
1260
|
-
// GET /owners/angela -> 404
|
|
1261
|
-
// GET /animals/1/owner -> 200, owner:angela, full attributes
|
|
1262
|
-
// GET /traits/2/tag -> 200, a model NO access class
|
|
1263
|
-
// claims, on a collection that has
|
|
1264
|
-
// no mounted route at all
|
|
1265
|
-
//
|
|
1266
|
-
// ARGUMENT ONE IS THE LIVE REQUEST, NOT A DERIVED ONE. A fabricated
|
|
1267
|
-
// request addressing the RELATED resource was the original design and
|
|
1268
|
-
// it is dropped: #241 removed the shipped fixture's read of argument
|
|
1269
|
-
// one, so a fabricated value changes nothing it could observe, and
|
|
1270
|
-
// test/unit/linkage-verdict-test.ts:484 pins the predicate as receiving
|
|
1271
|
-
// the live request BY IDENTITY. `createLinkageFilter` is also a
|
|
1272
|
-
// published public export (src/index.ts) whose resolution granularity
|
|
1273
|
-
// is per TYPE; supplying a per-RECORD request would mean widening it,
|
|
1274
|
-
// which takes a consumer `access()` from ~2 calls to ~7 on a plain
|
|
1275
|
-
// `GET /animals`. That is a separate, consumer-visible story.
|
|
1276
|
-
//
|
|
1277
|
-
// THE RESIDUAL THAT FOLLOWS FROM THAT IS DISCLOSED, NOT PAPERED OVER.
|
|
1278
|
-
// `recordId` is `null` here and the request names a record of a
|
|
1279
|
-
// DIFFERENT model, so a consumer predicate can express a model-level or
|
|
1280
|
-
// a request-level deny for a related resource, but NOT a per-record
|
|
1281
|
-
// one. README.md and docs/usage-patterns.md say so; a ledger assertion
|
|
1282
|
-
// in test/unit/relationship-route-access-test.ts keeps them saying it.
|
|
1313
|
+
// LINKAGE ONLY. This filter decides which ids the emitted documents may
|
|
1314
|
+
// NAME in their own `relationships.*.data`; it does NOT decide whether
|
|
1315
|
+
// the related records themselves are served -- that is the parent-only
|
|
1316
|
+
// filtering this route has done since #190, and widening it to the
|
|
1317
|
+
// related record is abofs/stonyx-orm#196.
|
|
1283
1318
|
const linkage = createLinkageFilter(request);
|
|
1284
|
-
// FAIL CLOSED ON A RECORD WHOSE TYPE CANNOT BE NAMED. `isLinkable` is
|
|
1285
|
-
// keyed on the model name; without one there is no predicate to ask,
|
|
1286
|
-
// and an unidentifiable input must never be the permissive path.
|
|
1287
|
-
const isLinkable = (r) => {
|
|
1288
|
-
const type = r.__model?.__name;
|
|
1289
|
-
return typeof type === 'string' && type !== '' && linkage(type, r);
|
|
1290
|
-
};
|
|
1291
1319
|
let data;
|
|
1292
1320
|
if (info.isArray) {
|
|
1293
|
-
// hasMany - return array
|
|
1294
|
-
// Dropped, never errored: the result is byte-identical to a genuinely
|
|
1295
|
-
// empty relationship, so this route is not an existence oracle.
|
|
1321
|
+
// hasMany - return array
|
|
1296
1322
|
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
1297
|
-
data = related.
|
|
1323
|
+
data = related.map(r => r.toJSON?.({ baseUrl, linkage }));
|
|
1298
1324
|
}
|
|
1299
1325
|
else {
|
|
1300
|
-
// belongsTo - return single or null
|
|
1301
|
-
|
|
1302
|
-
// parent that does not exist, and the same status the related
|
|
1303
|
-
// record's own route answers.
|
|
1304
|
-
//
|
|
1305
|
-
// KNOWN RESIDUAL, stated here because it is the one place a reader
|
|
1306
|
-
// will look: 404 is distinguishable from a relationship that is
|
|
1307
|
-
// genuinely EMPTY, which answers 200 with `data: null`. So a caller
|
|
1308
|
-
// can tell "there is a target you may not see" from "there is no
|
|
1309
|
-
// target". That asymmetry is inherited -- a denied PARENT has always
|
|
1310
|
-
// answered 404 while an existing parent with an empty relationship
|
|
1311
|
-
// answers 200 -- and closing it is a change to the module's whole
|
|
1312
|
-
// denial spelling, not to this route.
|
|
1313
|
-
if (!isOrmRecord(relatedData))
|
|
1314
|
-
data = null;
|
|
1315
|
-
else if (!isLinkable(relatedData))
|
|
1316
|
-
return 404;
|
|
1317
|
-
else
|
|
1318
|
-
data = relatedData.toJSON?.({ baseUrl, linkage });
|
|
1326
|
+
// belongsTo - return single or null
|
|
1327
|
+
data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl, linkage }) : null;
|
|
1319
1328
|
}
|
|
1320
1329
|
return {
|
|
1321
1330
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}/${dasherizedName}` },
|
|
@@ -1323,6 +1332,47 @@ export default class OrmRequest extends Request {
|
|
|
1323
1332
|
};
|
|
1324
1333
|
};
|
|
1325
1334
|
// Relationship linkage route: GET /:id/relationships/{relationship}
|
|
1335
|
+
//
|
|
1336
|
+
// NO `linkage` FILTER FROM abofs/stonyx-orm#235, AND THAT IS A SCOPE
|
|
1337
|
+
// BOUNDARY RATHER THAN AN OVERSIGHT -- abofs/stonyx-orm#232 OWNS THIS
|
|
1338
|
+
// ROUTE, and PR #247 is IN FLIGHT against it in this same sprint. If you
|
|
1339
|
+
// are reading this after #247 landed, the filtering below is #232's and
|
|
1340
|
+
// this note records why it was never #235's to add.
|
|
1341
|
+
//
|
|
1342
|
+
// The three sites #235 does own (`buildResponse`'s `included`, the
|
|
1343
|
+
// related-resource branch above, and the two write handlers) all reach
|
|
1344
|
+
// the filter through `record.toJSON()`, which is where the `linkage`
|
|
1345
|
+
// OPTION is applied. This branch builds its `{ type, id }` objects BY
|
|
1346
|
+
// HAND and never calls `toJSON` at all, so the `linkage` option cannot
|
|
1347
|
+
// reach it -- whatever this route filters, it has to filter itself, which
|
|
1348
|
+
// is precisely why doing so is a separate change with a separate owner.
|
|
1349
|
+
//
|
|
1350
|
+
// It is also a DIFFERENT QUESTION. Everywhere #235 touches, linkage is
|
|
1351
|
+
// metadata ABOUT a document. Here the linkage IS the primary data, so
|
|
1352
|
+
// dropping an entry is a MEMBERSHIP decision about what this route
|
|
1353
|
+
// serves -- the same class as abofs/stonyx-orm#233 and #196, not the
|
|
1354
|
+
// class #234/#235 close. That is why it is absent from #224 §2a's
|
|
1355
|
+
// seven-site inventory.
|
|
1356
|
+
//
|
|
1357
|
+
// MEASURED, so the next person does not re-derive it. Against this
|
|
1358
|
+
// branch's baseline of 1011/0, wiring `createLinkageFilter` into the
|
|
1359
|
+
// belongsTo branch below takes the suite to 1009/2, reddening
|
|
1360
|
+
// `[GUARD] #235 X2` and the
|
|
1361
|
+
// `GET /animals/:id/relationships/owner returns relationship linkage`
|
|
1362
|
+
// test -- the latter is #232's own reproduction, not a regression.
|
|
1363
|
+
//
|
|
1364
|
+
// THE BASELINE IS QUOTED WITH THE RESULT BECAUSE AN EARLIER REVISION OF
|
|
1365
|
+
// THIS COMMENT SAID 993/2 AND SHIPPED IT. This file lands in consumers'
|
|
1366
|
+
// `node_modules`, so a wrong number here is a wrong number in the
|
|
1367
|
+
// published package. 993+2 = 995 is the DEV baseline, carried over from
|
|
1368
|
+
// a branch on which `[GUARD] #235 X2` does not exist. A pass/fail pair
|
|
1369
|
+
// with no baseline beside it cannot be checked by reading, which is how
|
|
1370
|
+
// it survived three artifacts and a review; the qualitative claim was
|
|
1371
|
+
// right the whole time and only the count was wrong.
|
|
1372
|
+
//
|
|
1373
|
+
// `[GUARD] #235 X2` in test/integration/orm-test.ts pins the OWNERSHIP
|
|
1374
|
+
// BOUNDARY here rather than this route's current answer, so that it
|
|
1375
|
+
// survives #247 landing. Read its comment before changing it.
|
|
1326
1376
|
routes[`/:id/relationships/${dasherizedName}`] = async (request, { filter } = {}) => {
|
|
1327
1377
|
const record = await store.find(model, getId(request.params));
|
|
1328
1378
|
if (!record)
|
|
@@ -1331,35 +1381,17 @@ export default class OrmRequest extends Request {
|
|
|
1331
1381
|
return 404;
|
|
1332
1382
|
const relatedData = record.__relationships[relationshipName];
|
|
1333
1383
|
const baseUrl = getBaseUrl(request);
|
|
1334
|
-
// THE ONLY ONE OF THE FOUR READ SURFACES THAT DOES NOT GO THROUGH
|
|
1335
|
-
// `toJSON()`. It builds `{ type, id }` BY HAND, which is why #234's
|
|
1336
|
-
// linkage filter never reached it and why this half belongs to
|
|
1337
|
-
// abofs/stonyx-orm#232 rather than to #234: on this route the linkage
|
|
1338
|
-
// IS the primary data of an opt-in request, so filtering it changes the
|
|
1339
|
-
// route's MEMBERSHIP semantics, not the ids named inside somebody
|
|
1340
|
-
// else's document.
|
|
1341
|
-
//
|
|
1342
|
-
// Same filter, same argument-one decision, same residual as
|
|
1343
|
-
// `/:id/{relationship}` above -- read the block there.
|
|
1344
|
-
const linkage = createLinkageFilter(request);
|
|
1345
|
-
const isLinkable = (r) => {
|
|
1346
|
-
const type = r.__model?.__name;
|
|
1347
|
-
return typeof type === 'string' && type !== '' && linkage(type, r);
|
|
1348
|
-
};
|
|
1349
1384
|
let data;
|
|
1350
1385
|
if (info.isArray) {
|
|
1351
1386
|
// hasMany - return array of linkage objects
|
|
1352
1387
|
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
1353
1388
|
data = related
|
|
1354
1389
|
.filter((r) => Boolean(r.__model))
|
|
1355
|
-
.filter(isLinkable)
|
|
1356
1390
|
.map(r => ({ type: r.__model.__name, id: r.id }));
|
|
1357
1391
|
}
|
|
1358
1392
|
else {
|
|
1359
1393
|
// belongsTo - return single linkage or null
|
|
1360
1394
|
if (isOrmRecord(relatedData) && relatedData.__model) {
|
|
1361
|
-
if (!isLinkable(relatedData))
|
|
1362
|
-
return 404;
|
|
1363
1395
|
data = { type: relatedData.__model.__name, id: relatedData.id };
|
|
1364
1396
|
}
|
|
1365
1397
|
else {
|
package/package.json
CHANGED
package/src/orm-request.ts
CHANGED
|
@@ -267,7 +267,7 @@ import { getBeforeHooks, getAfterHooks } from './hooks.js';
|
|
|
267
267
|
import type { HookContext } from './hooks.js';
|
|
268
268
|
import config from 'stonyx/config';
|
|
269
269
|
import log from 'stonyx/log';
|
|
270
|
-
import type { OrmRecord, AccessContext, AccessFunction, AccessMethod, AccessOperation } from './types/orm-types.js';
|
|
270
|
+
import type { OrmRecord, AccessContext, AccessFunction, AccessMethod, AccessOperation, LinkageFilter } from './types/orm-types.js';
|
|
271
271
|
import { isOrmRecord, NO_FREE_ID_ERROR } from './utils.js';
|
|
272
272
|
import { interpretAccess, createLinkageFilter } from './access-verdict.js';
|
|
273
273
|
|
|
@@ -465,9 +465,9 @@ function buildResponse(
|
|
|
465
465
|
data: unknown,
|
|
466
466
|
includeParam: string | undefined,
|
|
467
467
|
recordOrRecords: OrmRecord | OrmRecord[],
|
|
468
|
-
options: { links?: { [key: string]: string }; baseUrl?: string } = {}
|
|
468
|
+
options: { links?: { [key: string]: string }; baseUrl?: string; linkage?: LinkageFilter } = {}
|
|
469
469
|
): JsonApiResponse {
|
|
470
|
-
const { links, baseUrl } = options;
|
|
470
|
+
const { links, baseUrl, linkage } = options;
|
|
471
471
|
const response: JsonApiResponse = { data };
|
|
472
472
|
|
|
473
473
|
// Add top-level links
|
|
@@ -482,14 +482,49 @@ function buildResponse(
|
|
|
482
482
|
|
|
483
483
|
const includedRecords = collectIncludedRecords(recordOrRecords, includes);
|
|
484
484
|
if (includedRecords.length > 0) {
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
|
|
485
|
+
// LINKAGE, NOT MEMBERSHIP -- and the distinction is the whole reason this
|
|
486
|
+
// line is one story's and the line above it is another's
|
|
487
|
+
// (abofs/stonyx-orm#235 and #233 respectively).
|
|
488
|
+
//
|
|
489
|
+
// - WHICH RESOURCES REACH THIS ARRAY is decided by
|
|
490
|
+
// `collectIncludedRecords` on the line above. That is MEMBERSHIP, it is
|
|
491
|
+
// #233's, and it is deliberately untouched here: a hidden owner is
|
|
492
|
+
// still a member of `included` after this change. Pinned green by
|
|
493
|
+
// `[GUARD] #235 X1` so that #235 cannot close #233 incidentally.
|
|
494
|
+
// - WHAT A RECORD ALREADY IN THIS ARRAY MAY NAME in its own
|
|
495
|
+
// `relationships.*.data` is LINKAGE -- the same question #234 answers
|
|
496
|
+
// for the primary document -- and that is what the `linkage` option
|
|
497
|
+
// below decides. Before it, `GET /animals/1?include=owner,owner.pets`
|
|
498
|
+
// filtered the primary document's `owner.data` to `null` and then
|
|
499
|
+
// handed back nine PERMITTED animals in `included` each naming
|
|
500
|
+
// `{"type":"owner","id":"angela"}`. Neither #233 nor #234 closes that.
|
|
501
|
+
//
|
|
502
|
+
// THE FILTER IS THE CALLER'S, PASSED IN, NOT BUILT HERE. Both call sites
|
|
503
|
+
// already hold one for the primary document, and sharing it is what keeps
|
|
504
|
+
// the per-type verdict cache and the per-(type, id) decision cache alive
|
|
505
|
+
// across the primary document AND the sideload -- one verdict resolution
|
|
506
|
+
// per type for the whole response, pinned by `[GUARD] #235 C1`. Building a
|
|
507
|
+
// fresh filter here would resolve the consumer's `access()` once per
|
|
508
|
+
// included record instead.
|
|
509
|
+
//
|
|
510
|
+
// `linkage` IS OPTIONAL IN THE TYPE AND IS NOT OPTIONAL IN PRACTICE.
|
|
511
|
+
// Stating it precisely because the opposite claim stood here in an earlier
|
|
512
|
+
// draft of this change: BOTH of this function's callers supply a filter
|
|
513
|
+
// (`getCollectionHandler` and `getSingleHandler`, the only two), so the
|
|
514
|
+
// `undefined` branch has no live caller in this module today. It is
|
|
515
|
+
// optional so that omitting it degrades to the PRE-#234 document rather
|
|
516
|
+
// than to a denial -- `Record.toJSON` reads an ABSENT option as "no verdict
|
|
517
|
+
// was supplied" and emits linkage in full.
|
|
518
|
+
//
|
|
519
|
+
// WHAT IT MUST NEVER BE HANDED IS A NON-FUNCTION. `toJSON` does NOT read a
|
|
520
|
+
// non-function as absent: `Object.prototype.toString.call(linkage)` must be
|
|
521
|
+
// `'[object Function]'`, and anything else -- `null`, an `AsyncFunction`,
|
|
522
|
+
// and INCLUDING the primitive `true` -- DENIES every relationship on the
|
|
523
|
+
// document and logs once. `toJSON({ linkage: true })` emits `null` linkage.
|
|
524
|
+
// So do not "simplify" this to a boolean, and do not make it default to
|
|
525
|
+
// `true`: both spellings look like "allow everything" and mean the exact
|
|
526
|
+
// opposite (abofs/stonyx-orm#224).
|
|
527
|
+
response.included = includedRecords.map(record => record.toJSON?.({ baseUrl, linkage }));
|
|
493
528
|
}
|
|
494
529
|
|
|
495
530
|
return response;
|
|
@@ -700,7 +735,11 @@ export default class OrmRequest extends Request {
|
|
|
700
735
|
|
|
701
736
|
return buildResponse(data, request.query?.include, recordsToReturn, {
|
|
702
737
|
links: { self: `${baseUrl}/${pluralizedModel}` },
|
|
703
|
-
baseUrl
|
|
738
|
+
baseUrl,
|
|
739
|
+
// THE SAME filter object the primary documents above were serialized
|
|
740
|
+
// with, deliberately: it carries the caches, and rebuilding one here
|
|
741
|
+
// would re-resolve every type (abofs/stonyx-orm#235).
|
|
742
|
+
linkage
|
|
704
743
|
});
|
|
705
744
|
};
|
|
706
745
|
|
|
@@ -718,29 +757,29 @@ export default class OrmRequest extends Request {
|
|
|
718
757
|
const baseUrl = getBaseUrl(request);
|
|
719
758
|
const linkage = createLinkageFilter(request);
|
|
720
759
|
|
|
721
|
-
// `buildResponse`
|
|
722
|
-
//
|
|
760
|
+
// `buildResponse` IS given the filter now (abofs/stonyx-orm#235), and it
|
|
761
|
+
// is the SAME object the primary document is serialized with -- one
|
|
762
|
+
// verdict per type for the whole response, sideload included.
|
|
723
763
|
//
|
|
724
|
-
//
|
|
725
|
-
//
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
//
|
|
729
|
-
//
|
|
730
|
-
// The residual, stated so the next reader does not have to derive it:
|
|
731
|
-
// `buildResponse` calls `record.toJSON?.({ baseUrl })` with no `linkage`
|
|
732
|
-
// argument, so a PERMITTED record in `included` emits the full pre-#234
|
|
733
|
-
// document. Measured: `GET /animals/1?include=owner` returns
|
|
734
|
-
// `owner.data: null` on the primary document and `owner:angela` in
|
|
735
|
-
// `included`. One query parameter deep. Only the PRIMARY document's
|
|
736
|
-
// linkage is filtered here.
|
|
764
|
+
// The boundary that remains, so the next reader does not have to derive
|
|
765
|
+
// it: this closes what a record already in `included` may NAME. WHETHER a
|
|
766
|
+
// resource appears in `included` at all is MEMBERSHIP and it is
|
|
767
|
+
// abofs/stonyx-orm#233's -- a hidden owner is still a member here.
|
|
768
|
+
// Neither question closes the other.
|
|
737
769
|
return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
|
|
738
770
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
|
|
739
|
-
baseUrl
|
|
771
|
+
baseUrl,
|
|
772
|
+
linkage
|
|
740
773
|
});
|
|
741
774
|
};
|
|
742
775
|
|
|
743
|
-
const createHandler: HandlerFn = async (
|
|
776
|
+
const createHandler: HandlerFn = async (request, { filter }) => {
|
|
777
|
+
// BOUND, not destructured (abofs/stonyx-orm#235). `HandlerFn` has always
|
|
778
|
+
// delivered the request as argument one; this handler simply discarded
|
|
779
|
+
// the binding, which is why its response document named ids every read
|
|
780
|
+
// surface withholds. `createLinkageFilter` needs the live request and
|
|
781
|
+
// there is no signature change involved in giving it one.
|
|
782
|
+
const { body, query } = request;
|
|
744
783
|
const { type, id, attributes, relationships: rels } = (body?.data || {}) as {
|
|
745
784
|
type?: string;
|
|
746
785
|
id?: string | number;
|
|
@@ -995,10 +1034,29 @@ export default class OrmRequest extends Request {
|
|
|
995
1034
|
return 403;
|
|
996
1035
|
}
|
|
997
1036
|
|
|
998
|
-
|
|
1037
|
+
// The filter is built HERE, per invocation, and never hoisted into the
|
|
1038
|
+
// OrmRequest constructor where the other per-mount values live: a verdict
|
|
1039
|
+
// cached across requests answers a second caller with the first caller's
|
|
1040
|
+
// authorization (src/access-verdict.ts says so at the constructor an
|
|
1041
|
+
// implementer would reach for).
|
|
1042
|
+
//
|
|
1043
|
+
// AND IT IS BUILT AFTER `createRecord`, AFTER THE ROLLBACK WINDOW AND
|
|
1044
|
+
// AFTER `isDenied`, so the record is in its final form at the call. The
|
|
1045
|
+
// filter is lazy per type and per (type, id), so it cannot observe a
|
|
1046
|
+
// pre-write state even if it were built earlier.
|
|
1047
|
+
//
|
|
1048
|
+
// `fields` is passed here and NOT in `updateHandler`: the two handlers
|
|
1049
|
+
// are asymmetric on purpose (`updateHandler` has no `fieldsMap` in
|
|
1050
|
+
// scope), and a single copy-pasted wiring would drop it from one of them.
|
|
1051
|
+
return { data: record.toJSON?.({ fields: modelFields, linkage: createLinkageFilter(request) }) };
|
|
999
1052
|
};
|
|
1000
1053
|
|
|
1001
|
-
const updateHandler: HandlerFn = async (
|
|
1054
|
+
const updateHandler: HandlerFn = async (request, { filter }) => {
|
|
1055
|
+
// Bound rather than destructured, for the reason given in
|
|
1056
|
+
// `createHandler` above (abofs/stonyx-orm#235). `PATCH /animals/1`
|
|
1057
|
+
// returned 200 naming angela seconds after `GET /animals/1` returned
|
|
1058
|
+
// `owner.data: null` for the same record -- one HTTP verb apart.
|
|
1059
|
+
const { body, params } = request;
|
|
1002
1060
|
const found = await store.find(model, getId(params));
|
|
1003
1061
|
if (!found || !isOrmRecord(found)) return 404;
|
|
1004
1062
|
// Checked BEFORE any attribute is applied. 404 rather than 403 for the
|
|
@@ -1057,7 +1115,14 @@ export default class OrmRequest extends Request {
|
|
|
1057
1115
|
}
|
|
1058
1116
|
}
|
|
1059
1117
|
|
|
1060
|
-
|
|
1118
|
+
// No `fields` and no `baseUrl`, both unchanged: `updateHandler` has no
|
|
1119
|
+
// `fieldsMap` in scope, and adding `baseUrl` would put `links` on a
|
|
1120
|
+
// document that has never carried them -- an unrelated behaviour change.
|
|
1121
|
+
// #224 AC6's "emits `data: []` WITH links" is a statement about the READ
|
|
1122
|
+
// surfaces; on these two handlers a filtered relationship and a
|
|
1123
|
+
// genuinely-empty one are both a bare `{ data }`, which is what makes
|
|
1124
|
+
// them indistinguishable here too.
|
|
1125
|
+
return { data: record.toJSON?.({ linkage: createLinkageFilter(request) }) };
|
|
1061
1126
|
};
|
|
1062
1127
|
|
|
1063
1128
|
const deleteHandler: HandlerFn = async ({ params }, { filter }) => {
|
|
@@ -1364,76 +1429,21 @@ export default class OrmRequest extends Request {
|
|
|
1364
1429
|
const relatedData = record.__relationships[relationshipName];
|
|
1365
1430
|
const baseUrl = getBaseUrl(request);
|
|
1366
1431
|
|
|
1367
|
-
//
|
|
1368
|
-
//
|
|
1369
|
-
//
|
|
1370
|
-
//
|
|
1371
|
-
//
|
|
1372
|
-
// related resource is PRIMARY data on this route, so there is no
|
|
1373
|
-
// linkage-consistency question to answer separately.
|
|
1374
|
-
//
|
|
1375
|
-
// Until #232 this route filtered only the PARENT, so a record its own
|
|
1376
|
-
// model's predicate hides was served in full from another model's
|
|
1377
|
-
// route, at ZERO query parameters. Measured on dev @ 8dda5d6:
|
|
1378
|
-
//
|
|
1379
|
-
// GET /owners/angela -> 404
|
|
1380
|
-
// GET /animals/1/owner -> 200, owner:angela, full attributes
|
|
1381
|
-
// GET /traits/2/tag -> 200, a model NO access class
|
|
1382
|
-
// claims, on a collection that has
|
|
1383
|
-
// no mounted route at all
|
|
1384
|
-
//
|
|
1385
|
-
// ARGUMENT ONE IS THE LIVE REQUEST, NOT A DERIVED ONE. A fabricated
|
|
1386
|
-
// request addressing the RELATED resource was the original design and
|
|
1387
|
-
// it is dropped: #241 removed the shipped fixture's read of argument
|
|
1388
|
-
// one, so a fabricated value changes nothing it could observe, and
|
|
1389
|
-
// test/unit/linkage-verdict-test.ts:484 pins the predicate as receiving
|
|
1390
|
-
// the live request BY IDENTITY. `createLinkageFilter` is also a
|
|
1391
|
-
// published public export (src/index.ts) whose resolution granularity
|
|
1392
|
-
// is per TYPE; supplying a per-RECORD request would mean widening it,
|
|
1393
|
-
// which takes a consumer `access()` from ~2 calls to ~7 on a plain
|
|
1394
|
-
// `GET /animals`. That is a separate, consumer-visible story.
|
|
1395
|
-
//
|
|
1396
|
-
// THE RESIDUAL THAT FOLLOWS FROM THAT IS DISCLOSED, NOT PAPERED OVER.
|
|
1397
|
-
// `recordId` is `null` here and the request names a record of a
|
|
1398
|
-
// DIFFERENT model, so a consumer predicate can express a model-level or
|
|
1399
|
-
// a request-level deny for a related resource, but NOT a per-record
|
|
1400
|
-
// one. README.md and docs/usage-patterns.md say so; a ledger assertion
|
|
1401
|
-
// in test/unit/relationship-route-access-test.ts keeps them saying it.
|
|
1432
|
+
// LINKAGE ONLY. This filter decides which ids the emitted documents may
|
|
1433
|
+
// NAME in their own `relationships.*.data`; it does NOT decide whether
|
|
1434
|
+
// the related records themselves are served -- that is the parent-only
|
|
1435
|
+
// filtering this route has done since #190, and widening it to the
|
|
1436
|
+
// related record is abofs/stonyx-orm#196.
|
|
1402
1437
|
const linkage = createLinkageFilter(request);
|
|
1403
1438
|
|
|
1404
|
-
// FAIL CLOSED ON A RECORD WHOSE TYPE CANNOT BE NAMED. `isLinkable` is
|
|
1405
|
-
// keyed on the model name; without one there is no predicate to ask,
|
|
1406
|
-
// and an unidentifiable input must never be the permissive path.
|
|
1407
|
-
const isLinkable = (r: OrmRecord) => {
|
|
1408
|
-
const type = (r as { __model?: { __name?: string } }).__model?.__name;
|
|
1409
|
-
|
|
1410
|
-
return typeof type === 'string' && type !== '' && linkage(type, r);
|
|
1411
|
-
};
|
|
1412
|
-
|
|
1413
1439
|
let data: unknown;
|
|
1414
1440
|
if (info.isArray) {
|
|
1415
|
-
// hasMany - return array
|
|
1416
|
-
// Dropped, never errored: the result is byte-identical to a genuinely
|
|
1417
|
-
// empty relationship, so this route is not an existence oracle.
|
|
1441
|
+
// hasMany - return array
|
|
1418
1442
|
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
1419
|
-
data = related.
|
|
1443
|
+
data = related.map(r => r.toJSON?.({ baseUrl, linkage }));
|
|
1420
1444
|
} else {
|
|
1421
|
-
// belongsTo - return single or null
|
|
1422
|
-
|
|
1423
|
-
// parent that does not exist, and the same status the related
|
|
1424
|
-
// record's own route answers.
|
|
1425
|
-
//
|
|
1426
|
-
// KNOWN RESIDUAL, stated here because it is the one place a reader
|
|
1427
|
-
// will look: 404 is distinguishable from a relationship that is
|
|
1428
|
-
// genuinely EMPTY, which answers 200 with `data: null`. So a caller
|
|
1429
|
-
// can tell "there is a target you may not see" from "there is no
|
|
1430
|
-
// target". That asymmetry is inherited -- a denied PARENT has always
|
|
1431
|
-
// answered 404 while an existing parent with an empty relationship
|
|
1432
|
-
// answers 200 -- and closing it is a change to the module's whole
|
|
1433
|
-
// denial spelling, not to this route.
|
|
1434
|
-
if (!isOrmRecord(relatedData)) data = null;
|
|
1435
|
-
else if (!isLinkable(relatedData)) return 404;
|
|
1436
|
-
else data = relatedData.toJSON?.({ baseUrl, linkage });
|
|
1445
|
+
// belongsTo - return single or null
|
|
1446
|
+
data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl, linkage }) : null;
|
|
1437
1447
|
}
|
|
1438
1448
|
|
|
1439
1449
|
return {
|
|
@@ -1443,6 +1453,47 @@ export default class OrmRequest extends Request {
|
|
|
1443
1453
|
};
|
|
1444
1454
|
|
|
1445
1455
|
// Relationship linkage route: GET /:id/relationships/{relationship}
|
|
1456
|
+
//
|
|
1457
|
+
// NO `linkage` FILTER FROM abofs/stonyx-orm#235, AND THAT IS A SCOPE
|
|
1458
|
+
// BOUNDARY RATHER THAN AN OVERSIGHT -- abofs/stonyx-orm#232 OWNS THIS
|
|
1459
|
+
// ROUTE, and PR #247 is IN FLIGHT against it in this same sprint. If you
|
|
1460
|
+
// are reading this after #247 landed, the filtering below is #232's and
|
|
1461
|
+
// this note records why it was never #235's to add.
|
|
1462
|
+
//
|
|
1463
|
+
// The three sites #235 does own (`buildResponse`'s `included`, the
|
|
1464
|
+
// related-resource branch above, and the two write handlers) all reach
|
|
1465
|
+
// the filter through `record.toJSON()`, which is where the `linkage`
|
|
1466
|
+
// OPTION is applied. This branch builds its `{ type, id }` objects BY
|
|
1467
|
+
// HAND and never calls `toJSON` at all, so the `linkage` option cannot
|
|
1468
|
+
// reach it -- whatever this route filters, it has to filter itself, which
|
|
1469
|
+
// is precisely why doing so is a separate change with a separate owner.
|
|
1470
|
+
//
|
|
1471
|
+
// It is also a DIFFERENT QUESTION. Everywhere #235 touches, linkage is
|
|
1472
|
+
// metadata ABOUT a document. Here the linkage IS the primary data, so
|
|
1473
|
+
// dropping an entry is a MEMBERSHIP decision about what this route
|
|
1474
|
+
// serves -- the same class as abofs/stonyx-orm#233 and #196, not the
|
|
1475
|
+
// class #234/#235 close. That is why it is absent from #224 §2a's
|
|
1476
|
+
// seven-site inventory.
|
|
1477
|
+
//
|
|
1478
|
+
// MEASURED, so the next person does not re-derive it. Against this
|
|
1479
|
+
// branch's baseline of 1011/0, wiring `createLinkageFilter` into the
|
|
1480
|
+
// belongsTo branch below takes the suite to 1009/2, reddening
|
|
1481
|
+
// `[GUARD] #235 X2` and the
|
|
1482
|
+
// `GET /animals/:id/relationships/owner returns relationship linkage`
|
|
1483
|
+
// test -- the latter is #232's own reproduction, not a regression.
|
|
1484
|
+
//
|
|
1485
|
+
// THE BASELINE IS QUOTED WITH THE RESULT BECAUSE AN EARLIER REVISION OF
|
|
1486
|
+
// THIS COMMENT SAID 993/2 AND SHIPPED IT. This file lands in consumers'
|
|
1487
|
+
// `node_modules`, so a wrong number here is a wrong number in the
|
|
1488
|
+
// published package. 993+2 = 995 is the DEV baseline, carried over from
|
|
1489
|
+
// a branch on which `[GUARD] #235 X2` does not exist. A pass/fail pair
|
|
1490
|
+
// with no baseline beside it cannot be checked by reading, which is how
|
|
1491
|
+
// it survived three artifacts and a review; the qualitative claim was
|
|
1492
|
+
// right the whole time and only the count was wrong.
|
|
1493
|
+
//
|
|
1494
|
+
// `[GUARD] #235 X2` in test/integration/orm-test.ts pins the OWNERSHIP
|
|
1495
|
+
// BOUNDARY here rather than this route's current answer, so that it
|
|
1496
|
+
// survives #247 landing. Read its comment before changing it.
|
|
1446
1497
|
routes[`/:id/relationships/${dasherizedName}`] = async (request: OrmRequest$, { filter }: { [key: string]: unknown } = {}) => {
|
|
1447
1498
|
const record = await store.find(model, getId(request.params)) as OrmRecord | undefined;
|
|
1448
1499
|
if (!record) return 404;
|
|
@@ -1451,36 +1502,16 @@ export default class OrmRequest extends Request {
|
|
|
1451
1502
|
const relatedData = record.__relationships[relationshipName];
|
|
1452
1503
|
const baseUrl = getBaseUrl(request);
|
|
1453
1504
|
|
|
1454
|
-
// THE ONLY ONE OF THE FOUR READ SURFACES THAT DOES NOT GO THROUGH
|
|
1455
|
-
// `toJSON()`. It builds `{ type, id }` BY HAND, which is why #234's
|
|
1456
|
-
// linkage filter never reached it and why this half belongs to
|
|
1457
|
-
// abofs/stonyx-orm#232 rather than to #234: on this route the linkage
|
|
1458
|
-
// IS the primary data of an opt-in request, so filtering it changes the
|
|
1459
|
-
// route's MEMBERSHIP semantics, not the ids named inside somebody
|
|
1460
|
-
// else's document.
|
|
1461
|
-
//
|
|
1462
|
-
// Same filter, same argument-one decision, same residual as
|
|
1463
|
-
// `/:id/{relationship}` above -- read the block there.
|
|
1464
|
-
const linkage = createLinkageFilter(request);
|
|
1465
|
-
const isLinkable = (r: OrmRecord) => {
|
|
1466
|
-
const type = (r as { __model?: { __name?: string } }).__model?.__name;
|
|
1467
|
-
|
|
1468
|
-
return typeof type === 'string' && type !== '' && linkage(type, r);
|
|
1469
|
-
};
|
|
1470
|
-
|
|
1471
1505
|
let data: unknown;
|
|
1472
1506
|
if (info.isArray) {
|
|
1473
1507
|
// hasMany - return array of linkage objects
|
|
1474
1508
|
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
1475
1509
|
data = related
|
|
1476
1510
|
.filter((r): r is OrmRecord & { __model: { __name: string } } => Boolean(r.__model))
|
|
1477
|
-
.filter(isLinkable)
|
|
1478
1511
|
.map(r => ({ type: r.__model.__name, id: r.id }));
|
|
1479
1512
|
} else {
|
|
1480
1513
|
// belongsTo - return single linkage or null
|
|
1481
1514
|
if (isOrmRecord(relatedData) && relatedData.__model) {
|
|
1482
|
-
if (!isLinkable(relatedData)) return 404;
|
|
1483
|
-
|
|
1484
1515
|
data = { type: relatedData.__model.__name, id: relatedData.id };
|
|
1485
1516
|
} else {
|
|
1486
1517
|
data = null;
|