@stonyx/orm 0.3.2-alpha.74 → 0.3.2-alpha.75
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 +108 -70
- package/dist/orm-request.js +113 -126
- package/package.json +1 -1
- package/src/orm-request.ts +116 -128
package/README.md
CHANGED
|
@@ -452,7 +452,11 @@ 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
|
-
|
|
455
|
+
// `record.id !== 18` hides one animal whose OWNER is permitted. It is the
|
|
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;
|
|
456
460
|
|
|
457
461
|
// Allows full access to all calls that don't match any of the above conditions
|
|
458
462
|
return ['read', 'create', 'update', 'delete'];
|
|
@@ -921,45 +925,81 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
921
925
|
operation and which record the request addresses. The five variants above are
|
|
922
926
|
the five ways that has been observed to fail open so far. Tracked as
|
|
923
927
|
[#202](https://github.com/abofs/stonyx-orm/issues/202).
|
|
924
|
-
- **
|
|
925
|
-
|
|
926
|
-
`GET /
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
`
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
928
|
+
- **The two relationship route families now resolve the *related* model's own
|
|
929
|
+
access class — `GET /:models/:id/{relationship}` and
|
|
930
|
+
`GET /:models/:id/relationships/{relationship}`**
|
|
931
|
+
([#232](https://github.com/abofs/stonyx-orm/issues/232)). This is
|
|
932
|
+
**membership**: the related resource is the route's *primary* data, so the
|
|
933
|
+
filter decides whether it is served at all, not merely which ids a document
|
|
934
|
+
may name. A denied `hasMany` member is **dropped from the array** — the result
|
|
935
|
+
is shaped exactly like a genuinely empty relationship, `links` intact, no
|
|
936
|
+
`errors` member, same status. A denied `belongsTo` target answers **404**, the
|
|
937
|
+
same status the route already answered for a denied parent and for a parent
|
|
938
|
+
that does not exist. The `/relationships/` family built its `{type, id}` by
|
|
939
|
+
hand rather than through `toJSON()`, which is why the linkage filter shipped in
|
|
940
|
+
[#234](https://github.com/abofs/stonyx-orm/issues/234) did not reach it.
|
|
941
|
+
|
|
942
|
+
Before this, both families served a record hidden on every one of its own
|
|
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
|
|
939
994
|
([#234](https://github.com/abofs/stonyx-orm/issues/234)). The ORM now resolves
|
|
940
|
-
the **related** model's own access class on `GET /:models`, `GET /:models/:id
|
|
941
|
-
both `GET /:models/:id/{relationship}` shapes,
|
|
942
|
-
`
|
|
943
|
-
`?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
|
|
995
|
+
the **related** model's own access class on `GET /:models`, `GET /:models/:id`
|
|
996
|
+
and both `GET /:models/:id/{relationship}` shapes, and asks it
|
|
997
|
+
`{ model: <related>, operation: 'read' }`. An unresolvable class
|
|
954
998
|
(`getAccess()` → `undefined`) and a predicate that throws both **deny**. A
|
|
955
999
|
filtered-out relationship is **indistinguishable from a genuinely empty one** —
|
|
956
1000
|
an emptied `hasMany` is `data: []` and an emptied `belongsTo` is `data: null`,
|
|
957
1001
|
both **keeping their `links`**, which are built from the serialized record's
|
|
958
|
-
own id and never from the related one.
|
|
959
|
-
no `links` to keep: neither handler passes a `baseUrl`, so a filtered and a
|
|
960
|
-
genuinely-empty relationship are both a bare `{ "data": … }` there. That is
|
|
961
|
-
pre-existing and deliberate — adding `baseUrl` to the write handlers would be
|
|
962
|
-
an unrelated change to their response shape. Nothing errors and no status changes,
|
|
1002
|
+
own id and never from the related one. Nothing errors and no status changes,
|
|
963
1003
|
because throwing here would be an existence oracle *and* would throw out of
|
|
964
1004
|
the enclosing `JSON.stringify`.
|
|
965
1005
|
|
|
@@ -986,32 +1026,32 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
986
1026
|
**Not yet covered, and each one still publishes ids the surfaces above
|
|
987
1027
|
withhold:**
|
|
988
1028
|
|
|
989
|
-
- **`
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
`
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1029
|
+
- **`included`** — [#235](https://github.com/abofs/stonyx-orm/issues/235). A
|
|
1030
|
+
permitted record sideloaded by `?include=` emits its **own**
|
|
1031
|
+
`relationships.*.data` unfiltered, so `GET /animals/1?include=owner,owner.pets`
|
|
1032
|
+
returns `owner.data: null` on the primary document and then names angela in
|
|
1033
|
+
`included`, along with eight permitted animals that each name
|
|
1034
|
+
`{"type":"owner","id":"angela"}`. Separately,
|
|
1035
|
+
[#233](https://github.com/abofs/stonyx-orm/issues/233) owns whether a
|
|
1036
|
+
resource appears in `included` **at all** — that is membership, a different
|
|
1037
|
+
question, and following it will not lead you to this residual.
|
|
1038
|
+
- **The `POST`/`PATCH` response documents** —
|
|
1039
|
+
[#235](https://github.com/abofs/stonyx-orm/issues/235). `createHandler` and
|
|
1040
|
+
`updateHandler` destructure the request rather than binding it, so wiring
|
|
1041
|
+
them needs a signature change rather than an argument. Until then **one HTTP
|
|
1042
|
+
verb defeats the filter on the same record**: measured, `GET /animals/1`
|
|
1043
|
+
returns `owner.data: null` and `PATCH /animals/1` returns **200 naming
|
|
1044
|
+
angela**, seconds apart, with no query string and no relationship route. Any
|
|
1045
|
+
caller who can read a record can also write it and be handed the id the read
|
|
1046
|
+
withheld.
|
|
1047
|
+
- ~~**`GET /:models/:id/relationships/{relationship}`**~~ — **covered as of
|
|
1048
|
+
[#232](https://github.com/abofs/stonyx-orm/issues/232)**, together with
|
|
1049
|
+
`GET /:models/:id/{relationship}`. Left in place rather than deleted because
|
|
1050
|
+
this list is what a reader consults to find out what is *not* covered, and a
|
|
1051
|
+
silently vanishing entry reads as an entry that was never there. Its
|
|
1052
|
+
*primary data* is linkage, so filtering it was a **membership** decision —
|
|
1053
|
+
which is why it belonged to #232 and not to
|
|
1054
|
+
[#234](https://github.com/abofs/stonyx-orm/issues/234).
|
|
1015
1055
|
- **A bare `toJSON()` still emits unfiltered linkage, and that is deliberate.**
|
|
1016
1056
|
`Record.toJSON()` **applies** a verdict; it never **resolves** one. It has no
|
|
1017
1057
|
request, and the documented `access()` contract permits a predicate to read
|
|
@@ -1409,15 +1449,13 @@ GET /animals/1
|
|
|
1409
1449
|
#### Limitations
|
|
1410
1450
|
|
|
1411
1451
|
- Only available on GET endpoints (not POST/PATCH)
|
|
1412
|
-
- **`included`
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
404 on its own routes is still served as an `included` resource, attributes
|
|
1420
|
-
and all. See [Consumer Contracts](#consumer-contracts).
|
|
1452
|
+
- **`included` records are not access-filtered, on either question.** Whether a
|
|
1453
|
+
resource appears in `included` at all is
|
|
1454
|
+
[#233](https://github.com/abofs/stonyx-orm/issues/233); a record that *is*
|
|
1455
|
+
permitted still emits its **own** `relationships.*.data` unfiltered, so
|
|
1456
|
+
`?include=` republishes ids the primary document withholds
|
|
1457
|
+
([#235](https://github.com/abofs/stonyx-orm/issues/235)). See
|
|
1458
|
+
[Consumer Contracts](#consumer-contracts).
|
|
1421
1459
|
|
|
1422
1460
|
## Lifecycle Hooks
|
|
1423
1461
|
|
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
|
|
427
|
+
const { links, baseUrl } = options;
|
|
428
428
|
const response = { data };
|
|
429
429
|
// Add top-level links
|
|
430
430
|
if (links) {
|
|
@@ -437,49 +437,14 @@ 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
|
-
|
|
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 }));
|
|
440
|
+
// NO `linkage` ARGUMENT, deliberately, and abofs/stonyx-orm#235 owns adding
|
|
441
|
+
// one. Until it does, a PERMITTED record here emits the full pre-#234
|
|
442
|
+
// document: `GET /animals/1?include=owner` filters the primary document's
|
|
443
|
+
// `owner.data` to `null` and then names `owner:angela` in `included`.
|
|
444
|
+
// Whether a resource reaches this array at all is a different question
|
|
445
|
+
// (membership, abofs/stonyx-orm#233) and closing that one does not close
|
|
446
|
+
// this one.
|
|
447
|
+
response.included = includedRecords.map(record => record.toJSON?.({ baseUrl }));
|
|
483
448
|
}
|
|
484
449
|
return response;
|
|
485
450
|
}
|
|
@@ -661,11 +626,7 @@ export default class OrmRequest extends Request {
|
|
|
661
626
|
const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl, linkage }));
|
|
662
627
|
return buildResponse(data, request.query?.include, recordsToReturn, {
|
|
663
628
|
links: { self: `${baseUrl}/${pluralizedModel}` },
|
|
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
|
|
629
|
+
baseUrl
|
|
669
630
|
});
|
|
670
631
|
};
|
|
671
632
|
const getSingleHandler = async (request, { filter }) => {
|
|
@@ -681,28 +642,28 @@ export default class OrmRequest extends Request {
|
|
|
681
642
|
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
682
643
|
const baseUrl = getBaseUrl(request);
|
|
683
644
|
const linkage = createLinkageFilter(request);
|
|
684
|
-
// `buildResponse`
|
|
685
|
-
//
|
|
686
|
-
//
|
|
645
|
+
// `buildResponse` is deliberately NOT given the linkage filter, and the
|
|
646
|
+
// residual that leaves is NOT the one #233 owns. Two different questions:
|
|
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.
|
|
687
653
|
//
|
|
688
|
-
// The
|
|
689
|
-
//
|
|
690
|
-
//
|
|
691
|
-
//
|
|
692
|
-
//
|
|
654
|
+
// The residual, stated so the next reader does not have to derive it:
|
|
655
|
+
// `buildResponse` calls `record.toJSON?.({ baseUrl })` with no `linkage`
|
|
656
|
+
// argument, so a PERMITTED record in `included` emits the full pre-#234
|
|
657
|
+
// document. Measured: `GET /animals/1?include=owner` returns
|
|
658
|
+
// `owner.data: null` on the primary document and `owner:angela` in
|
|
659
|
+
// `included`. One query parameter deep. Only the PRIMARY document's
|
|
660
|
+
// linkage is filtered here.
|
|
693
661
|
return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
|
|
694
662
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
|
|
695
|
-
baseUrl
|
|
696
|
-
linkage
|
|
663
|
+
baseUrl
|
|
697
664
|
});
|
|
698
665
|
};
|
|
699
|
-
const createHandler = async (
|
|
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;
|
|
666
|
+
const createHandler = async ({ body, query }, { filter }) => {
|
|
706
667
|
const { type, id, attributes, relationships: rels } = (body?.data || {});
|
|
707
668
|
if (!type)
|
|
708
669
|
return 400; // Bad request
|
|
@@ -941,28 +902,9 @@ export default class OrmRequest extends Request {
|
|
|
941
902
|
}
|
|
942
903
|
return 403;
|
|
943
904
|
}
|
|
944
|
-
|
|
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) }) };
|
|
905
|
+
return { data: record.toJSON?.({ fields: modelFields }) };
|
|
959
906
|
};
|
|
960
|
-
const updateHandler = async (
|
|
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;
|
|
907
|
+
const updateHandler = async ({ body, params }, { filter }) => {
|
|
966
908
|
const found = await store.find(model, getId(params));
|
|
967
909
|
if (!found || !isOrmRecord(found))
|
|
968
910
|
return 404;
|
|
@@ -1019,14 +961,7 @@ export default class OrmRequest extends Request {
|
|
|
1019
961
|
updateRecord(record, relUpdates, { _skipAutoPersist: true });
|
|
1020
962
|
}
|
|
1021
963
|
}
|
|
1022
|
-
|
|
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) }) };
|
|
964
|
+
return { data: record.toJSON?.() };
|
|
1030
965
|
};
|
|
1031
966
|
const deleteHandler = async ({ params }, { filter }) => {
|
|
1032
967
|
// Coerced ONCE. `getId(params)` was evaluated twice here -- once to find
|
|
@@ -1310,21 +1245,77 @@ export default class OrmRequest extends Request {
|
|
|
1310
1245
|
return 404;
|
|
1311
1246
|
const relatedData = record.__relationships[relationshipName];
|
|
1312
1247
|
const baseUrl = getBaseUrl(request);
|
|
1313
|
-
//
|
|
1314
|
-
//
|
|
1315
|
-
//
|
|
1316
|
-
//
|
|
1317
|
-
// related record is
|
|
1248
|
+
// ONE FILTER, TWO JOBS, AND abofs/stonyx-orm#232 IS THE SECOND ONE.
|
|
1249
|
+
//
|
|
1250
|
+
// As LINKAGE (#234) it decides which ids the emitted documents may NAME
|
|
1251
|
+
// in their own `relationships.*.data`. As MEMBERSHIP (this issue) it
|
|
1252
|
+
// decides whether the related record is served here AT ALL -- the
|
|
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.
|
|
1318
1283
|
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
|
+
};
|
|
1319
1291
|
let data;
|
|
1320
1292
|
if (info.isArray) {
|
|
1321
|
-
// hasMany - return array
|
|
1293
|
+
// hasMany - return array, MINUS the members this caller may not see.
|
|
1294
|
+
// Dropped, never errored: the result is byte-identical to a genuinely
|
|
1295
|
+
// empty relationship, so this route is not an existence oracle.
|
|
1322
1296
|
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
1323
|
-
data = related.map(r => r.toJSON?.({ baseUrl, linkage }));
|
|
1297
|
+
data = related.filter(isLinkable).map(r => r.toJSON?.({ baseUrl, linkage }));
|
|
1324
1298
|
}
|
|
1325
1299
|
else {
|
|
1326
|
-
// belongsTo - return single or null
|
|
1327
|
-
|
|
1300
|
+
// belongsTo - return single or null. A DENIED target is 404, the same
|
|
1301
|
+
// status this route already returns for a denied PARENT and for a
|
|
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 });
|
|
1328
1319
|
}
|
|
1329
1320
|
return {
|
|
1330
1321
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}/${dasherizedName}` },
|
|
@@ -1332,28 +1323,6 @@ export default class OrmRequest extends Request {
|
|
|
1332
1323
|
};
|
|
1333
1324
|
};
|
|
1334
1325
|
// Relationship linkage route: GET /:id/relationships/{relationship}
|
|
1335
|
-
//
|
|
1336
|
-
// NO `linkage` FILTER HERE, AND IT IS NOT AN OVERSIGHT --
|
|
1337
|
-
// abofs/stonyx-orm#232 OWNS THIS ROUTE. The three sites that carry the
|
|
1338
|
-
// filter (`buildResponse`'s `included`, the related-resource branch
|
|
1339
|
-
// above, and the two write handlers) all call `record.toJSON()`, which is
|
|
1340
|
-
// where the `linkage` option is applied. This branch builds its
|
|
1341
|
-
// `{ type, id }` objects BY HAND and never calls `toJSON` at all, so it
|
|
1342
|
-
// cannot see a filter no matter who passes one.
|
|
1343
|
-
//
|
|
1344
|
-
// It is also a DIFFERENT QUESTION. Everywhere else, linkage is metadata
|
|
1345
|
-
// ABOUT a document. Here the linkage IS the primary data, so dropping an
|
|
1346
|
-
// entry is a MEMBERSHIP decision about what this route serves -- the same
|
|
1347
|
-
// class as abofs/stonyx-orm#233 and #196, not the class #234/#235 close.
|
|
1348
|
-
// That is why it is absent from #224 §2a's seven-site inventory.
|
|
1349
|
-
//
|
|
1350
|
-
// MEASURED, so the next person does not re-derive it: this route answers
|
|
1351
|
-
// `GET /animals/1/relationships/owner` with
|
|
1352
|
-
// `{"type":"owner","id":"angela"}` while `GET /owners/angela` is 404.
|
|
1353
|
-
// Wiring the filter in here takes the suite to 993/2 and turns the
|
|
1354
|
-
// `GET /animals/:id/relationships/owner returns relationship linkage`
|
|
1355
|
-
// test red -- which is #232's own reproduction, not a regression.
|
|
1356
|
-
// Pinned unchanged by `[GUARD] #235 X2` in test/integration/orm-test.ts.
|
|
1357
1326
|
routes[`/:id/relationships/${dasherizedName}`] = async (request, { filter } = {}) => {
|
|
1358
1327
|
const record = await store.find(model, getId(request.params));
|
|
1359
1328
|
if (!record)
|
|
@@ -1362,17 +1331,35 @@ export default class OrmRequest extends Request {
|
|
|
1362
1331
|
return 404;
|
|
1363
1332
|
const relatedData = record.__relationships[relationshipName];
|
|
1364
1333
|
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
|
+
};
|
|
1365
1349
|
let data;
|
|
1366
1350
|
if (info.isArray) {
|
|
1367
1351
|
// hasMany - return array of linkage objects
|
|
1368
1352
|
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
1369
1353
|
data = related
|
|
1370
1354
|
.filter((r) => Boolean(r.__model))
|
|
1355
|
+
.filter(isLinkable)
|
|
1371
1356
|
.map(r => ({ type: r.__model.__name, id: r.id }));
|
|
1372
1357
|
}
|
|
1373
1358
|
else {
|
|
1374
1359
|
// belongsTo - return single linkage or null
|
|
1375
1360
|
if (isOrmRecord(relatedData) && relatedData.__model) {
|
|
1361
|
+
if (!isLinkable(relatedData))
|
|
1362
|
+
return 404;
|
|
1376
1363
|
data = { type: relatedData.__model.__name, id: relatedData.id };
|
|
1377
1364
|
}
|
|
1378
1365
|
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
|
|
270
|
+
import type { OrmRecord, AccessContext, AccessFunction, AccessMethod, AccessOperation } 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 } = {}
|
|
469
469
|
): JsonApiResponse {
|
|
470
|
-
const { links, baseUrl
|
|
470
|
+
const { links, baseUrl } = options;
|
|
471
471
|
const response: JsonApiResponse = { data };
|
|
472
472
|
|
|
473
473
|
// Add top-level links
|
|
@@ -482,49 +482,14 @@ 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
|
-
|
|
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 }));
|
|
485
|
+
// NO `linkage` ARGUMENT, deliberately, and abofs/stonyx-orm#235 owns adding
|
|
486
|
+
// one. Until it does, a PERMITTED record here emits the full pre-#234
|
|
487
|
+
// document: `GET /animals/1?include=owner` filters the primary document's
|
|
488
|
+
// `owner.data` to `null` and then names `owner:angela` in `included`.
|
|
489
|
+
// Whether a resource reaches this array at all is a different question
|
|
490
|
+
// (membership, abofs/stonyx-orm#233) and closing that one does not close
|
|
491
|
+
// this one.
|
|
492
|
+
response.included = includedRecords.map(record => record.toJSON?.({ baseUrl }));
|
|
528
493
|
}
|
|
529
494
|
|
|
530
495
|
return response;
|
|
@@ -735,11 +700,7 @@ export default class OrmRequest extends Request {
|
|
|
735
700
|
|
|
736
701
|
return buildResponse(data, request.query?.include, recordsToReturn, {
|
|
737
702
|
links: { self: `${baseUrl}/${pluralizedModel}` },
|
|
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
|
|
703
|
+
baseUrl
|
|
743
704
|
});
|
|
744
705
|
};
|
|
745
706
|
|
|
@@ -757,29 +718,29 @@ export default class OrmRequest extends Request {
|
|
|
757
718
|
const baseUrl = getBaseUrl(request);
|
|
758
719
|
const linkage = createLinkageFilter(request);
|
|
759
720
|
|
|
760
|
-
// `buildResponse`
|
|
761
|
-
//
|
|
762
|
-
//
|
|
721
|
+
// `buildResponse` is deliberately NOT given the linkage filter, and the
|
|
722
|
+
// residual that leaves is NOT the one #233 owns. Two different questions:
|
|
723
|
+
//
|
|
724
|
+
// - WHETHER A RESOURCE APPEARS in `included` at all is MEMBERSHIP ->
|
|
725
|
+
// abofs/stonyx-orm#233.
|
|
726
|
+
// - What a record already IN `included` may NAME is LINKAGE -- the same
|
|
727
|
+
// question #234 answers for the primary document -- and it is
|
|
728
|
+
// abofs/stonyx-orm#235, which also owns createHandler/updateHandler.
|
|
763
729
|
//
|
|
764
|
-
// The
|
|
765
|
-
//
|
|
766
|
-
//
|
|
767
|
-
//
|
|
768
|
-
//
|
|
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.
|
|
769
737
|
return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
|
|
770
738
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
|
|
771
|
-
baseUrl
|
|
772
|
-
linkage
|
|
739
|
+
baseUrl
|
|
773
740
|
});
|
|
774
741
|
};
|
|
775
742
|
|
|
776
|
-
const createHandler: HandlerFn = async (
|
|
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;
|
|
743
|
+
const createHandler: HandlerFn = async ({ body, query }, { filter }) => {
|
|
783
744
|
const { type, id, attributes, relationships: rels } = (body?.data || {}) as {
|
|
784
745
|
type?: string;
|
|
785
746
|
id?: string | number;
|
|
@@ -1034,29 +995,10 @@ export default class OrmRequest extends Request {
|
|
|
1034
995
|
return 403;
|
|
1035
996
|
}
|
|
1036
997
|
|
|
1037
|
-
|
|
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) }) };
|
|
998
|
+
return { data: record.toJSON?.({ fields: modelFields }) };
|
|
1052
999
|
};
|
|
1053
1000
|
|
|
1054
|
-
const updateHandler: HandlerFn = async (
|
|
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;
|
|
1001
|
+
const updateHandler: HandlerFn = async ({ body, params }, { filter }) => {
|
|
1060
1002
|
const found = await store.find(model, getId(params));
|
|
1061
1003
|
if (!found || !isOrmRecord(found)) return 404;
|
|
1062
1004
|
// Checked BEFORE any attribute is applied. 404 rather than 403 for the
|
|
@@ -1115,14 +1057,7 @@ export default class OrmRequest extends Request {
|
|
|
1115
1057
|
}
|
|
1116
1058
|
}
|
|
1117
1059
|
|
|
1118
|
-
|
|
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) }) };
|
|
1060
|
+
return { data: record.toJSON?.() };
|
|
1126
1061
|
};
|
|
1127
1062
|
|
|
1128
1063
|
const deleteHandler: HandlerFn = async ({ params }, { filter }) => {
|
|
@@ -1429,21 +1364,76 @@ export default class OrmRequest extends Request {
|
|
|
1429
1364
|
const relatedData = record.__relationships[relationshipName];
|
|
1430
1365
|
const baseUrl = getBaseUrl(request);
|
|
1431
1366
|
|
|
1432
|
-
//
|
|
1433
|
-
//
|
|
1434
|
-
//
|
|
1435
|
-
//
|
|
1436
|
-
// related record is
|
|
1367
|
+
// ONE FILTER, TWO JOBS, AND abofs/stonyx-orm#232 IS THE SECOND ONE.
|
|
1368
|
+
//
|
|
1369
|
+
// As LINKAGE (#234) it decides which ids the emitted documents may NAME
|
|
1370
|
+
// in their own `relationships.*.data`. As MEMBERSHIP (this issue) it
|
|
1371
|
+
// decides whether the related record is served here AT ALL -- the
|
|
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.
|
|
1437
1402
|
const linkage = createLinkageFilter(request);
|
|
1438
1403
|
|
|
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
|
+
|
|
1439
1413
|
let data: unknown;
|
|
1440
1414
|
if (info.isArray) {
|
|
1441
|
-
// hasMany - return array
|
|
1415
|
+
// hasMany - return array, MINUS the members this caller may not see.
|
|
1416
|
+
// Dropped, never errored: the result is byte-identical to a genuinely
|
|
1417
|
+
// empty relationship, so this route is not an existence oracle.
|
|
1442
1418
|
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
1443
|
-
data = related.map(r => r.toJSON?.({ baseUrl, linkage }));
|
|
1419
|
+
data = related.filter(isLinkable).map(r => r.toJSON?.({ baseUrl, linkage }));
|
|
1444
1420
|
} else {
|
|
1445
|
-
// belongsTo - return single or null
|
|
1446
|
-
|
|
1421
|
+
// belongsTo - return single or null. A DENIED target is 404, the same
|
|
1422
|
+
// status this route already returns for a denied PARENT and for a
|
|
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 });
|
|
1447
1437
|
}
|
|
1448
1438
|
|
|
1449
1439
|
return {
|
|
@@ -1453,28 +1443,6 @@ export default class OrmRequest extends Request {
|
|
|
1453
1443
|
};
|
|
1454
1444
|
|
|
1455
1445
|
// Relationship linkage route: GET /:id/relationships/{relationship}
|
|
1456
|
-
//
|
|
1457
|
-
// NO `linkage` FILTER HERE, AND IT IS NOT AN OVERSIGHT --
|
|
1458
|
-
// abofs/stonyx-orm#232 OWNS THIS ROUTE. The three sites that carry the
|
|
1459
|
-
// filter (`buildResponse`'s `included`, the related-resource branch
|
|
1460
|
-
// above, and the two write handlers) all call `record.toJSON()`, which is
|
|
1461
|
-
// where the `linkage` option is applied. This branch builds its
|
|
1462
|
-
// `{ type, id }` objects BY HAND and never calls `toJSON` at all, so it
|
|
1463
|
-
// cannot see a filter no matter who passes one.
|
|
1464
|
-
//
|
|
1465
|
-
// It is also a DIFFERENT QUESTION. Everywhere else, linkage is metadata
|
|
1466
|
-
// ABOUT a document. Here the linkage IS the primary data, so dropping an
|
|
1467
|
-
// entry is a MEMBERSHIP decision about what this route serves -- the same
|
|
1468
|
-
// class as abofs/stonyx-orm#233 and #196, not the class #234/#235 close.
|
|
1469
|
-
// That is why it is absent from #224 §2a's seven-site inventory.
|
|
1470
|
-
//
|
|
1471
|
-
// MEASURED, so the next person does not re-derive it: this route answers
|
|
1472
|
-
// `GET /animals/1/relationships/owner` with
|
|
1473
|
-
// `{"type":"owner","id":"angela"}` while `GET /owners/angela` is 404.
|
|
1474
|
-
// Wiring the filter in here takes the suite to 993/2 and turns the
|
|
1475
|
-
// `GET /animals/:id/relationships/owner returns relationship linkage`
|
|
1476
|
-
// test red -- which is #232's own reproduction, not a regression.
|
|
1477
|
-
// Pinned unchanged by `[GUARD] #235 X2` in test/integration/orm-test.ts.
|
|
1478
1446
|
routes[`/:id/relationships/${dasherizedName}`] = async (request: OrmRequest$, { filter }: { [key: string]: unknown } = {}) => {
|
|
1479
1447
|
const record = await store.find(model, getId(request.params)) as OrmRecord | undefined;
|
|
1480
1448
|
if (!record) return 404;
|
|
@@ -1483,16 +1451,36 @@ export default class OrmRequest extends Request {
|
|
|
1483
1451
|
const relatedData = record.__relationships[relationshipName];
|
|
1484
1452
|
const baseUrl = getBaseUrl(request);
|
|
1485
1453
|
|
|
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
|
+
|
|
1486
1471
|
let data: unknown;
|
|
1487
1472
|
if (info.isArray) {
|
|
1488
1473
|
// hasMany - return array of linkage objects
|
|
1489
1474
|
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
1490
1475
|
data = related
|
|
1491
1476
|
.filter((r): r is OrmRecord & { __model: { __name: string } } => Boolean(r.__model))
|
|
1477
|
+
.filter(isLinkable)
|
|
1492
1478
|
.map(r => ({ type: r.__model.__name, id: r.id }));
|
|
1493
1479
|
} else {
|
|
1494
1480
|
// belongsTo - return single linkage or null
|
|
1495
1481
|
if (isOrmRecord(relatedData) && relatedData.__model) {
|
|
1482
|
+
if (!isLinkable(relatedData)) return 404;
|
|
1483
|
+
|
|
1496
1484
|
data = { type: relatedData.__model.__name, id: relatedData.id };
|
|
1497
1485
|
} else {
|
|
1498
1486
|
data = null;
|