@stonyx/orm 0.3.2-alpha.73 → 0.3.2-alpha.74
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 +57 -35
- package/dist/orm-request.js +117 -30
- package/package.json +1 -1
- package/src/orm-request.ts +119 -32
package/README.md
CHANGED
|
@@ -930,20 +930,36 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
930
930
|
`include=`, related-resource routes and relationship-linkage routes. This is
|
|
931
931
|
**membership** — whether the related resource is served at all — and it is a
|
|
932
932
|
different question from which ids a document may *name*, immediately below.
|
|
933
|
-
- **Relationship linkage is filtered on
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
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
|
|
938
939
|
([#234](https://github.com/abofs/stonyx-orm/issues/234)). The ORM now resolves
|
|
939
|
-
the **related** model's own access class on `GET /:models`, `GET /:models/:id
|
|
940
|
-
|
|
941
|
-
`
|
|
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
|
|
942
954
|
(`getAccess()` → `undefined`) and a predicate that throws both **deny**. A
|
|
943
955
|
filtered-out relationship is **indistinguishable from a genuinely empty one** —
|
|
944
956
|
an emptied `hasMany` is `data: []` and an emptied `belongsTo` is `data: null`,
|
|
945
957
|
both **keeping their `links`**, which are built from the serialized record's
|
|
946
|
-
own id and never from the related one.
|
|
958
|
+
own id and never from the related one. On the two **write** surfaces there are
|
|
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,
|
|
947
963
|
because throwing here would be an existence oracle *and* would throw out of
|
|
948
964
|
the enclosing `JSON.stringify`.
|
|
949
965
|
|
|
@@ -970,28 +986,32 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
970
986
|
**Not yet covered, and each one still publishes ids the surfaces above
|
|
971
987
|
withhold:**
|
|
972
988
|
|
|
973
|
-
- **`included`** — [#235](https://github.com/abofs/stonyx-orm/issues/235). A
|
|
974
|
-
permitted record sideloaded by `?include=` emits its **own**
|
|
975
|
-
`relationships.*.data` unfiltered, so `GET /animals/1?include=owner,owner.pets`
|
|
976
|
-
returns `owner.data: null` on the primary document and then names angela in
|
|
977
|
-
`included`, along with eight permitted animals that each name
|
|
978
|
-
`{"type":"owner","id":"angela"}`. Separately,
|
|
979
|
-
[#233](https://github.com/abofs/stonyx-orm/issues/233) owns whether a
|
|
980
|
-
resource appears in `included` **at all** — that is membership, a different
|
|
981
|
-
question, and following it will not lead you to this residual.
|
|
982
|
-
- **The `POST`/`PATCH` response documents** —
|
|
983
|
-
[#235](https://github.com/abofs/stonyx-orm/issues/235). `createHandler` and
|
|
984
|
-
`updateHandler` destructure the request rather than binding it, so wiring
|
|
985
|
-
them needs a signature change rather than an argument. Until then **one HTTP
|
|
986
|
-
verb defeats the filter on the same record**: measured, `GET /animals/1`
|
|
987
|
-
returns `owner.data: null` and `PATCH /animals/1` returns **200 naming
|
|
988
|
-
angela**, seconds apart, with no query string and no relationship route. Any
|
|
989
|
-
caller who can read a record can also write it and be handed the id the read
|
|
990
|
-
withheld.
|
|
991
989
|
- **`GET /:models/:id/relationships/{relationship}`**, whose *primary data* is
|
|
992
990
|
linkage, so filtering it is a **membership** decision —
|
|
993
991
|
[#232](https://github.com/abofs/stonyx-orm/issues/232), the filed child of
|
|
994
|
-
[#196](https://github.com/abofs/stonyx-orm/issues/196).
|
|
992
|
+
[#196](https://github.com/abofs/stonyx-orm/issues/196). This route builds
|
|
993
|
+
its `{type, id}` objects by hand and never calls `toJSON`, so it does not
|
|
994
|
+
see a `linkage` option no matter who supplies one. Measured:
|
|
995
|
+
`GET /animals/1/relationships/owner` answers
|
|
996
|
+
`{"type":"owner","id":"angela"}` while `GET /owners/angela` is `404`.
|
|
997
|
+
- **Whether a related resource appears in `included` at all** —
|
|
998
|
+
[#233](https://github.com/abofs/stonyx-orm/issues/233). #235 filters what a
|
|
999
|
+
record *already in* `included` may **name**; a hidden record is still a
|
|
1000
|
+
**member** of that array. The two are different questions and neither closes
|
|
1001
|
+
the other: after #235, `GET /animals/1?include=owner,owner.pets` returns
|
|
1002
|
+
`owner.data: null` on every permitted animal it sideloads **and still
|
|
1003
|
+
includes the hidden owner as a resource**.
|
|
1004
|
+
- **A computed attribute that interpolates a related record's id.** This is a
|
|
1005
|
+
**consumer-side** residual and the ORM cannot close it. `relationships.*.data`
|
|
1006
|
+
is a structure this module builds, so it can be filtered; a computed
|
|
1007
|
+
property is arbitrary consumer code returning an arbitrary value, and
|
|
1008
|
+
deciding which substrings of it are identifiers is not something the
|
|
1009
|
+
framework can do. Measured on this repo's own fixture, where the `animal`
|
|
1010
|
+
model has a `get tag()` that interpolates `owner.id`: **every** animal
|
|
1011
|
+
document on **every** surface — including the ones above — carries
|
|
1012
|
+
`attributes.tag: "angela's small dog"` for an owner that answers `404`. If
|
|
1013
|
+
your access rules hide a record, audit your computed properties for its
|
|
1014
|
+
identifiers.
|
|
995
1015
|
- **A bare `toJSON()` still emits unfiltered linkage, and that is deliberate.**
|
|
996
1016
|
`Record.toJSON()` **applies** a verdict; it never **resolves** one. It has no
|
|
997
1017
|
request, and the documented `access()` contract permits a predicate to read
|
|
@@ -1389,13 +1409,15 @@ GET /animals/1
|
|
|
1389
1409
|
#### Limitations
|
|
1390
1410
|
|
|
1391
1411
|
- Only available on GET endpoints (not POST/PATCH)
|
|
1392
|
-
- **`included`
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
[
|
|
1412
|
+
- **`included` is access-filtered on one of the two questions, not both.** What
|
|
1413
|
+
a record already in `included` may **name** in its own
|
|
1414
|
+
`relationships.*.data` is filtered
|
|
1415
|
+
([#235](https://github.com/abofs/stonyx-orm/issues/235)) — `?include=` no
|
|
1416
|
+
longer republishes ids the primary document withholds. Whether a resource
|
|
1417
|
+
appears in `included` **at all** is *membership* and is still unfiltered
|
|
1418
|
+
([#233](https://github.com/abofs/stonyx-orm/issues/233)): a record that is
|
|
1419
|
+
404 on its own routes is still served as an `included` resource, attributes
|
|
1420
|
+
and all. See [Consumer Contracts](#consumer-contracts).
|
|
1399
1421
|
|
|
1400
1422
|
## Lifecycle Hooks
|
|
1401
1423
|
|
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
|
|
@@ -1267,6 +1332,28 @@ export default class OrmRequest extends Request {
|
|
|
1267
1332
|
};
|
|
1268
1333
|
};
|
|
1269
1334
|
// 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.
|
|
1270
1357
|
routes[`/:id/relationships/${dasherizedName}`] = async (request, { filter } = {}) => {
|
|
1271
1358
|
const record = await store.find(model, getId(request.params));
|
|
1272
1359
|
if (!record)
|
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
|
-
//
|
|
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.
|
|
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.
|
|
729
763
|
//
|
|
730
|
-
// The
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
//
|
|
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 }) => {
|
|
@@ -1388,6 +1453,28 @@ export default class OrmRequest extends Request {
|
|
|
1388
1453
|
};
|
|
1389
1454
|
|
|
1390
1455
|
// 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.
|
|
1391
1478
|
routes[`/:id/relationships/${dasherizedName}`] = async (request: OrmRequest$, { filter }: { [key: string]: unknown } = {}) => {
|
|
1392
1479
|
const record = await store.find(model, getId(request.params)) as OrmRecord | undefined;
|
|
1393
1480
|
if (!record) return 404;
|