@stonyx/orm 0.3.2-alpha.72 → 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 +176 -81
- package/dist/access-verdict.js +31 -1
- package/dist/hooks.d.ts +15 -1
- package/dist/orm-request.d.ts +48 -0
- package/dist/orm-request.js +208 -30
- package/dist/types/orm-types.d.ts +95 -0
- package/package.json +1 -1
- package/src/access-verdict.ts +31 -1
- package/src/hooks.ts +15 -1
- package/src/orm-request.ts +210 -32
- package/src/types/orm-types.ts +96 -0
package/dist/orm-request.js
CHANGED
|
@@ -69,6 +69,15 @@
|
|
|
69
69
|
* records under `model: 'owner'`, and the context gives no signal of that
|
|
70
70
|
* (abofs/stonyx-orm#196).
|
|
71
71
|
*
|
|
72
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237, AND KEPT FOR THE
|
|
73
|
+
* CONSTRAINT IT STATES RATHER THAN AS A DESCRIPTION OF THE CODE. The context
|
|
74
|
+
* now also carries `recordId` -- the DECODED route-parameter id, see
|
|
75
|
+
* `AccessContext.recordId` in ./types/orm-types.ts -- so the fixture's
|
|
76
|
+
* `/archived` deny IS expressible from the context alone, and the shipped
|
|
77
|
+
* sample no longer reads `request.path` at all. Retiring this wording WITH the
|
|
78
|
+
* measurement that retires it, rather than by deletion, is
|
|
79
|
+
* abofs/stonyx-orm#238.
|
|
80
|
+
*
|
|
72
81
|
* `record` IS NOT IN THIS CONTEXT, deliberately. `auth()` runs after route
|
|
73
82
|
* matching but BEFORE any handler executes (`@stonyx/rest-server`
|
|
74
83
|
* `src/request.ts:58-60`), so nothing has been fetched yet -- supplying a
|
|
@@ -158,6 +167,11 @@
|
|
|
158
167
|
* `/archived` SUB-PATH rule is still a string match, and abofs/stonyx-orm#228 is
|
|
159
168
|
* a sixth spelling that gets past it.
|
|
160
169
|
*
|
|
170
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237: the `/archived` rule is
|
|
171
|
+
* no longer a string match against the request target -- it compares the
|
|
172
|
+
* decoded `recordId` the framework supplies -- and abofs/stonyx-orm#228 is
|
|
173
|
+
* CLOSED. Retirement of this wording: abofs/stonyx-orm#238.
|
|
174
|
+
*
|
|
161
175
|
* An intermediate revision of the sample read `request.baseUrl` -- the mount
|
|
162
176
|
* Express ACTUALLY MATCHED. That closed all five variants (no query string,
|
|
163
177
|
* not mount-relative, unaffected by absolute-form, already carrying the
|
|
@@ -173,12 +187,26 @@
|
|
|
173
187
|
* does not decode, so `GET /owners/%61rchived` steps past it. See the
|
|
174
188
|
* normalisation paragraph below and abofs/stonyx-orm#228.
|
|
175
189
|
*
|
|
190
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237. Variant 3 lived in that
|
|
191
|
+
* one string comparison, and the comparison is gone: the sample compares the
|
|
192
|
+
* decoded `recordId`. Left standing rather than edited because the same
|
|
193
|
+
* "variant 3 survives" wording sits at four sites -- this header, README.md
|
|
194
|
+
* twice, and test/sample/access/global-access.ts -- three of which SHIP, so
|
|
195
|
+
* retiring one of four leaves the shipped copies contradicting each other.
|
|
196
|
+
* Retiring all four WITH their measurement is abofs/stonyx-orm#238.
|
|
197
|
+
*
|
|
176
198
|
* ONE READ OF ARGUMENT ONE SURVIVES, AND IT MUST: `request.path`. It is
|
|
177
199
|
* mount-relative and query-free, and it is for rules that distinguish SUB-PATHS
|
|
178
200
|
* beneath the mount. The context names which model and which verb, NOT which
|
|
179
201
|
* route, so the sample's `/archived` deny cannot be expressed from the context
|
|
180
202
|
* alone and a context-ONLY rewrite would silently turn that deny into an allow.
|
|
181
203
|
*
|
|
204
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237: NO read of argument one
|
|
205
|
+
* survives in the shipped sample. `recordId` names WHICH RECORD the route was
|
|
206
|
+
* addressed to, so the `/archived` deny is expressible from the context alone
|
|
207
|
+
* -- and it still must not be dropped; expressible is not optional. Retirement
|
|
208
|
+
* of this wording: abofs/stonyx-orm#238.
|
|
209
|
+
*
|
|
182
210
|
* NORMALISE THE WAY THE ROUTER DOES, AND CASE-FOLDING ALONE IS NOT THAT. The
|
|
183
211
|
* sample lower-cases before comparing, because a matcher stricter than the
|
|
184
212
|
* case-insensitive router can be stepped around. That closes the case gap only.
|
|
@@ -188,6 +216,20 @@
|
|
|
188
216
|
* sample and is tracked as abofs/stonyx-orm#228; the `.toLowerCase()` is not a
|
|
189
217
|
* complete normalisation recipe. Compare record ids at their real case.
|
|
190
218
|
*
|
|
219
|
+
* DO NOT FOLLOW THE PARAGRAPH ABOVE. SUPERSEDED 2026-09-01 BY
|
|
220
|
+
* abofs/stonyx-orm#236/#237, and flagged here rather than merely dated because
|
|
221
|
+
* it is an INSTRUCTION, not a stale observation. `.toLowerCase()` on the access
|
|
222
|
+
* path was measured WRONG IN BOTH DIRECTIONS AT ONCE: with a distinct owner
|
|
223
|
+
* seeded at `ARCHIVED`, `GET /owners/ARCHIVED` was a false DENY on the wrong
|
|
224
|
+
* record and `GET /owners/%41RCHIVED` a false ALLOW on that same record. A
|
|
225
|
+
* record id is a VALUE, not a literal route segment, and express's
|
|
226
|
+
* `case sensitive routing` governs literal segments only. Compare
|
|
227
|
+
* `context.recordId` AS IT ARRIVES: do not case-fold it, do not decode it, do
|
|
228
|
+
* not derive it from `request.path`. `AccessContext.recordId` in
|
|
229
|
+
* ./types/orm-types.ts is the contract and says "Do NOT case-fold it"; the same
|
|
230
|
+
* published tarball ships both files, and THIS paragraph is the one that is
|
|
231
|
+
* wrong. Retiring it WITH its measurement is abofs/stonyx-orm#238.
|
|
232
|
+
*
|
|
191
233
|
* `?? ''` is not a defence. It converts an absent request target into an empty
|
|
192
234
|
* string, which matches no collection, which falls through to the permission
|
|
193
235
|
* array -- a total grant. An input you cannot identify must DENY, and that
|
|
@@ -196,6 +238,12 @@
|
|
|
196
238
|
* argument one. The sample returns `false` for an absent `model` AND for an
|
|
197
239
|
* absent or non-string `request.path`, rather than falling through either way.
|
|
198
240
|
*
|
|
241
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237 as to WHAT is guarded --
|
|
242
|
+
* the principle is unchanged. The sample no longer reads `request.path`, so it
|
|
243
|
+
* returns `false` for an absent `model` AND for an absent `recordId`
|
|
244
|
+
* (`undefined`, the one spelling `auth()` never produces). Retirement of this
|
|
245
|
+
* wording: abofs/stonyx-orm#238.
|
|
246
|
+
*
|
|
199
247
|
* THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
|
|
200
248
|
* the operation and the record. Prefer the array shape (`['read']`) or `false`
|
|
201
249
|
* until #202 lands; the function shape is what requires any matching at all.
|
|
@@ -376,7 +424,7 @@ function normalizeBodyId(id) {
|
|
|
376
424
|
return coerceId(id);
|
|
377
425
|
}
|
|
378
426
|
function buildResponse(data, includeParam, recordOrRecords, options = {}) {
|
|
379
|
-
const { links, baseUrl } = options;
|
|
427
|
+
const { links, baseUrl, linkage } = options;
|
|
380
428
|
const response = { data };
|
|
381
429
|
// Add top-level links
|
|
382
430
|
if (links) {
|
|
@@ -389,14 +437,49 @@ function buildResponse(data, includeParam, recordOrRecords, options = {}) {
|
|
|
389
437
|
return response;
|
|
390
438
|
const includedRecords = collectIncludedRecords(recordOrRecords, includes);
|
|
391
439
|
if (includedRecords.length > 0) {
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
|
|
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 }));
|
|
400
483
|
}
|
|
401
484
|
return response;
|
|
402
485
|
}
|
|
@@ -578,7 +661,11 @@ export default class OrmRequest extends Request {
|
|
|
578
661
|
const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl, linkage }));
|
|
579
662
|
return buildResponse(data, request.query?.include, recordsToReturn, {
|
|
580
663
|
links: { self: `${baseUrl}/${pluralizedModel}` },
|
|
581
|
-
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
|
|
582
669
|
});
|
|
583
670
|
};
|
|
584
671
|
const getSingleHandler = async (request, { filter }) => {
|
|
@@ -594,28 +681,28 @@ export default class OrmRequest extends Request {
|
|
|
594
681
|
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
595
682
|
const baseUrl = getBaseUrl(request);
|
|
596
683
|
const linkage = createLinkageFilter(request);
|
|
597
|
-
// `buildResponse`
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
// - WHETHER A RESOURCE APPEARS in `included` at all is MEMBERSHIP ->
|
|
601
|
-
// abofs/stonyx-orm#233.
|
|
602
|
-
// - What a record already IN `included` may NAME is LINKAGE -- the same
|
|
603
|
-
// question #234 answers for the primary document -- and it is
|
|
604
|
-
// 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.
|
|
605
687
|
//
|
|
606
|
-
// The
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
// `included`. One query parameter deep. Only the PRIMARY document's
|
|
612
|
-
// 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.
|
|
613
693
|
return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
|
|
614
694
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
|
|
615
|
-
baseUrl
|
|
695
|
+
baseUrl,
|
|
696
|
+
linkage
|
|
616
697
|
});
|
|
617
698
|
};
|
|
618
|
-
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;
|
|
619
706
|
const { type, id, attributes, relationships: rels } = (body?.data || {});
|
|
620
707
|
if (!type)
|
|
621
708
|
return 400; // Bad request
|
|
@@ -854,9 +941,28 @@ export default class OrmRequest extends Request {
|
|
|
854
941
|
}
|
|
855
942
|
return 403;
|
|
856
943
|
}
|
|
857
|
-
|
|
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) }) };
|
|
858
959
|
};
|
|
859
|
-
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;
|
|
860
966
|
const found = await store.find(model, getId(params));
|
|
861
967
|
if (!found || !isOrmRecord(found))
|
|
862
968
|
return 404;
|
|
@@ -913,7 +1019,14 @@ export default class OrmRequest extends Request {
|
|
|
913
1019
|
updateRecord(record, relUpdates, { _skipAutoPersist: true });
|
|
914
1020
|
}
|
|
915
1021
|
}
|
|
916
|
-
|
|
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) }) };
|
|
917
1030
|
};
|
|
918
1031
|
const deleteHandler = async ({ params }, { filter }) => {
|
|
919
1032
|
// Coerced ONCE. `getId(params)` was evaluated twice here -- once to find
|
|
@@ -1219,6 +1332,28 @@ export default class OrmRequest extends Request {
|
|
|
1219
1332
|
};
|
|
1220
1333
|
};
|
|
1221
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.
|
|
1222
1357
|
routes[`/:id/relationships/${dasherizedName}`] = async (request, { filter } = {}) => {
|
|
1223
1358
|
const record = await store.find(model, getId(request.params));
|
|
1224
1359
|
if (!record)
|
|
@@ -1306,10 +1441,53 @@ export default class OrmRequest extends Request {
|
|
|
1306
1441
|
// src/types/orm-types.ts. Nothing is fetched at this point and adding a
|
|
1307
1442
|
// lookup here would put a store read in the middle of an authorization
|
|
1308
1443
|
// path. The function return shape below IS the per-record hook.
|
|
1444
|
+
//
|
|
1445
|
+
// -------------------------------------------------------------------------
|
|
1446
|
+
// #236 -- `recordId`, the DECODED route-parameter id, for the same reason.
|
|
1447
|
+
//
|
|
1448
|
+
// WHICH RECORD is the third structural fact the framework already holds and
|
|
1449
|
+
// the consumer was left to re-derive, and re-deriving it failed OPEN. The
|
|
1450
|
+
// documented sample compared `request.path` -- the RAW, undecoded pathname
|
|
1451
|
+
// -- against a literal `/archived`, while the router DECODES `:id`. So
|
|
1452
|
+
// `GET /owners/%61rchived` walked past the deny and was dispatched as the
|
|
1453
|
+
// record `archived`: 200 with the record in full, and DELETE answered 204
|
|
1454
|
+
// with the record destroyed, unauthenticated. Four spellings measured, all
|
|
1455
|
+
// four through; 255 non-canonical spellings of that 8-character id decode
|
|
1456
|
+
// to the same key, so this was never a deny-list of one.
|
|
1457
|
+
//
|
|
1458
|
+
// TWO CONSUMER-SIDE NORMALISATIONS WERE MEASURED WRONG IN OPPOSITE
|
|
1459
|
+
// DIRECTIONS, which is the argument for doing it once, here.
|
|
1460
|
+
// `.toLowerCase()` case-folds a route-parameter VALUE on the axis that
|
|
1461
|
+
// governs literal SEGMENTS: with a distinct owner seeded at `ARCHIVED`,
|
|
1462
|
+
// `GET /owners/ARCHIVED` was a false DENY on the wrong record and
|
|
1463
|
+
// `GET /owners/%41RCHIVED` a false ALLOW on that same one.
|
|
1464
|
+
// `decodeURIComponent(request.path)` decodes THEN splits while the router
|
|
1465
|
+
// splits THEN decodes, so it over-denied `/owners/archived%2fx` -- 403 for
|
|
1466
|
+
// a genuinely distinct record. Failing closed there was luck, not design.
|
|
1467
|
+
//
|
|
1468
|
+
// `getId(request.params)` AND NOT `request.params.id`, for exactly the
|
|
1469
|
+
// reason `operation` is a `methodAccessMap` lookup: it is the SAME single
|
|
1470
|
+
// coercion the store lookup one layer down performs, so the predicate and
|
|
1471
|
+
// the dispatch cannot disagree about which record a request addresses.
|
|
1472
|
+
// The raw string would reintroduce that divergence on hex-shaped ids --
|
|
1473
|
+
// `GET /animals/0x2391` looks up record `9105`.
|
|
1474
|
+
//
|
|
1475
|
+
// NOTHING HERE PARSES THE REQUEST TARGET EITHER. `request.params` is what
|
|
1476
|
+
// the router matched, so a mount prefix, an absolute-form target, a query
|
|
1477
|
+
// string or a case-varied mount cannot move this value -- the same
|
|
1478
|
+
// guarantee `model` carries, by the same means.
|
|
1479
|
+
//
|
|
1480
|
+
// `null` and not `undefined` on a collection route, so the KEY IS ALWAYS
|
|
1481
|
+
// PRESENT -- the rule `operation`'s own docblock already establishes. A
|
|
1482
|
+
// context reaching a predicate WITHOUT the key therefore did not come from
|
|
1483
|
+
// here; it was hand-assembled by a caller resolving the predicate through
|
|
1484
|
+
// `Orm.instance.getAccess()`, and that absence stays deniable only because
|
|
1485
|
+
// `auth()` never produces it.
|
|
1309
1486
|
// -------------------------------------------------------------------------
|
|
1310
1487
|
const context = {
|
|
1311
1488
|
model: this.model,
|
|
1312
1489
|
operation: methodAccessMap[request.method],
|
|
1490
|
+
recordId: request.params && 'id' in request.params ? getId(request.params) : null,
|
|
1313
1491
|
};
|
|
1314
1492
|
let access;
|
|
1315
1493
|
try {
|
|
@@ -251,6 +251,101 @@ export interface AccessContext {
|
|
|
251
251
|
* from one that classified the request and found nothing.
|
|
252
252
|
*/
|
|
253
253
|
operation: AccessOperation | undefined;
|
|
254
|
+
/**
|
|
255
|
+
* The record this route was addressed to, as the store key -- or `null` on a
|
|
256
|
+
* collection route, which is addressed to no record (abofs/stonyx-orm#236).
|
|
257
|
+
*
|
|
258
|
+
* IT IS ALREADY DECODED, AND THAT IS THE WHOLE POINT. Express decodes route
|
|
259
|
+
* PARAMETERS while leaving `request.path` raw, so a consumer comparing
|
|
260
|
+
* `request.path` against a literal compares an undecoded string against a
|
|
261
|
+
* decoded dispatch. `GET /owners/%61rchived` reached such a comparison as
|
|
262
|
+
* `/%61rchived`, walked past a `/archived` deny, and was dispatched as the
|
|
263
|
+
* record `archived` -- 200 with the record in full, and `DELETE` destroyed
|
|
264
|
+
* it, unauthenticated. 255 non-canonical spellings of an 8-character id
|
|
265
|
+
* decode to the same key, so a deny-list of spellings is the wrong shape.
|
|
266
|
+
*
|
|
267
|
+
* SO DO NOT NORMALISE THIS, AND DO NOT NORMALISE ANYTHING ELSE INSTEAD:
|
|
268
|
+
*
|
|
269
|
+
* - Do NOT decode it. Express decodes exactly ONCE, which is what a route
|
|
270
|
+
* parameter means. `GET /owners/%2561rchived` is the legitimate id
|
|
271
|
+
* `%61rchived`, not a second-order spelling of `archived`; a predicate that
|
|
272
|
+
* decoded until stable would deny a record it was never asked about.
|
|
273
|
+
* - Do NOT case-fold it. A record id is a VALUE, not a literal route segment,
|
|
274
|
+
* and express's `case sensitive routing` governs literal segments only.
|
|
275
|
+
* With a distinct owner seeded at `ARCHIVED`, `.toLowerCase()` was measured
|
|
276
|
+
* wrong in BOTH directions at once: `GET /owners/ARCHIVED` 403 (a false
|
|
277
|
+
* deny, on the wrong record) and `GET /owners/%41RCHIVED` 200 (a false
|
|
278
|
+
* allow, on that same record).
|
|
279
|
+
* - Do NOT derive it from `request.path` or the request target. Decoding the
|
|
280
|
+
* whole path decodes THEN splits, while the router splits THEN decodes, so
|
|
281
|
+
* `/owners/archived%2fx` -- a genuinely distinct record whose id is
|
|
282
|
+
* `archived/x` -- was measured over-denied 403.
|
|
283
|
+
*
|
|
284
|
+
* IT IS `getId(request.params)`, BYTE FOR BYTE -- the same single coercion
|
|
285
|
+
* the store lookup uses, exactly as `operation` is the same `methodAccessMap`
|
|
286
|
+
* lookup the permission-array branch uses. The predicate and the dispatch
|
|
287
|
+
* therefore cannot disagree about which record a request addresses. Handing
|
|
288
|
+
* over the raw `request.params.id` instead would reintroduce that divergence
|
|
289
|
+
* on hex-shaped ids: `GET /animals/0x2391` looks up record `9105`.
|
|
290
|
+
*
|
|
291
|
+
* It inherits abofs/stonyx-orm#209 along with that coercion -- on a model
|
|
292
|
+
* declaring `id = attr('string')`, `'9107'` arrives here as the number
|
|
293
|
+
* `9107`. That is consistency WITH THE LOOKUP, which is the property this key
|
|
294
|
+
* exists to buy; it is not a defect to repair here.
|
|
295
|
+
*
|
|
296
|
+
* `null`, not `undefined`, on a collection route -- and the KEY IS ALWAYS
|
|
297
|
+
* PRESENT, the same rule `operation` states above. `auth()` always sets it,
|
|
298
|
+
* so a context arriving WITHOUT the key did not come from `auth()`: it was
|
|
299
|
+
* hand-assembled by a caller resolving the predicate through
|
|
300
|
+
* `Orm.instance.getAccess()`. That absence stays a distinguishable, deniable
|
|
301
|
+
* signal only because the framework never produces it.
|
|
302
|
+
*
|
|
303
|
+
* IT DISAGREES WITH THE HOOK VOCABULARY, AND NOT ONLY ON THE ABSENCE
|
|
304
|
+
* SPELLING. `HookContext.recordId` (`src/hooks.ts`) is an identically-named
|
|
305
|
+
* key on an identically-shaped context object, which is the exact
|
|
306
|
+
* configuration that makes `operation` fail-open shaped -- a hook sees
|
|
307
|
+
* `'get'` where `access()` sees `'read'`. An earlier revision of THIS
|
|
308
|
+
* docblock asserted the opposite ("here they AGREE... they differ in ONE way
|
|
309
|
+
* and it is the absence spelling"). That was measured false, in the fail-open
|
|
310
|
+
* direction, and it is corrected here rather than deleted.
|
|
311
|
+
*
|
|
312
|
+
* MEASURED over the live dispatch, before-hooks registered for all five
|
|
313
|
+
* operations on one model:
|
|
314
|
+
*
|
|
315
|
+
* before:list key ABSENT ('recordId' in context === false)
|
|
316
|
+
* before:get key ABSENT params={"id":"visible1"}
|
|
317
|
+
* before:create key ABSENT
|
|
318
|
+
* before:update key ABSENT params={"id":"visible2"}
|
|
319
|
+
* before:delete recordId="visible3"
|
|
320
|
+
* after:delete recordId="visible3"
|
|
321
|
+
*
|
|
322
|
+
* `_withHooks` assigns `context.recordId` at exactly TWO sites in
|
|
323
|
+
* `src/orm-request.ts`, and BOTH sit inside an `operation === 'delete'`
|
|
324
|
+
* branch. So the two keys differ in COVERAGE, on four of five operations: on
|
|
325
|
+
* a hook context the key is absent for get, list, create and update, while
|
|
326
|
+
* this key is present on every route `auth()` classifies. The absence
|
|
327
|
+
* spelling is the smaller half of the difference, not the whole of it.
|
|
328
|
+
*
|
|
329
|
+
* AND THAT INVERTS THE ARGUMENT ABOVE WHEN IT IS READ ACROSS THE TWO. Here,
|
|
330
|
+
* a missing `recordId` means "did not come from `auth()`" and is deniable.
|
|
331
|
+
* On a hook context it means "this is a get / list / create / update" -- an
|
|
332
|
+
* ordinary request. A consumer who writes the hook-side half of the same
|
|
333
|
+
* rule --
|
|
334
|
+
*
|
|
335
|
+
* beforeHook('update', 'owner', ctx => ctx.recordId === 'archived' ? 403 : undefined)
|
|
336
|
+
*
|
|
337
|
+
* -- gets a deny that NEVER FIRES: measured, `PATCH /owners/visible2` -> 200,
|
|
338
|
+
* with `ctx.recordId === undefined` while the addressed record sits in
|
|
339
|
+
* `ctx.params`. The hook side is abofs/stonyx-orm#242 and is deliberately not
|
|
340
|
+
* repaired here. A predicate must not read `undefined` here as "collection",
|
|
341
|
+
* and nothing in this contract makes it safe to read the two keys as one key.
|
|
342
|
+
*
|
|
343
|
+
* IT NAMES WHICH RECORD, NOT WHICH SURFACE. `GET /owners/gina`,
|
|
344
|
+
* `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` all
|
|
345
|
+
* carry `recordId: 'gina'`; the related-resource gap is abofs/stonyx-orm#196
|
|
346
|
+
* and is untouched by this key.
|
|
347
|
+
*/
|
|
348
|
+
recordId: string | number | null;
|
|
254
349
|
}
|
|
255
350
|
/**
|
|
256
351
|
* A consumer `access()` predicate.
|
package/package.json
CHANGED
package/src/access-verdict.ts
CHANGED
|
@@ -162,7 +162,37 @@ function resolveVerdict(request: unknown, type: string): AccessVerdict {
|
|
|
162
162
|
let access: AccessMethod;
|
|
163
163
|
|
|
164
164
|
try {
|
|
165
|
-
|
|
165
|
+
// `recordId: null`, AND NOT `request.params.id`. THE TEMPTING WRONG ANSWER
|
|
166
|
+
// IS RIGHT THERE, so this is pinned by assertion as well as by comment --
|
|
167
|
+
// test/unit/linkage-verdict-test.ts, `#234 + #241 -- recordId is null`.
|
|
168
|
+
//
|
|
169
|
+
// `AccessContext.recordId` (src/types/orm-types.ts, abofs/stonyx-orm#236 /
|
|
170
|
+
// #241) means "the record THIS ROUTE WAS ADDRESSED TO, as the store key of
|
|
171
|
+
// the model being authorised", and `null` means "addressed to no record".
|
|
172
|
+
// The id sitting on the request in hand names the PRIMARY record, which
|
|
173
|
+
// belongs to a DIFFERENT model -- `GET /owners/gina` carries
|
|
174
|
+
// `params.id === 'gina'`, and the ask being made HERE is about `animal` or
|
|
175
|
+
// `trait`. Filling this in from the request would hand the related model's
|
|
176
|
+
// predicate an id belonging to another model, which is byte-for-byte the
|
|
177
|
+
// cross-model confusion abofs/stonyx-orm#202 introduced this context to
|
|
178
|
+
// eliminate: the predicate would compare an owner's id against its own
|
|
179
|
+
// records and answer a question nobody asked. There is no record of THIS
|
|
180
|
+
// model addressed by this request, so `null` is the honest value -- the
|
|
181
|
+
// same spelling `auth()` uses for a collection route.
|
|
182
|
+
//
|
|
183
|
+
// NOR ANY RECORD'S OWN ID, WHICH IS THE SECOND-MOST TEMPTING ANSWER. This
|
|
184
|
+
// verdict is resolved ONCE PER TYPE and cached in `byType` below, before
|
|
185
|
+
// any record has been looked at; there is no per-record `AccessContext`
|
|
186
|
+
// built anywhere on this path. Seeding it from the first record of a type
|
|
187
|
+
// would let that record's identity answer for every later record of the
|
|
188
|
+
// same type -- the same "one record's verdict answers for another" defect
|
|
189
|
+
// the `decisions` raw-key argument below exists to prevent, just one level
|
|
190
|
+
// coarser. And it is unnecessary: `AccessContext` deliberately carries no
|
|
191
|
+
// `record` because auth-time and record-time are separate decision points,
|
|
192
|
+
// and the per-record point already receives the WHOLE record, id included,
|
|
193
|
+
// through `verdict.filter(record)`. A predicate that wants a record's id
|
|
194
|
+
// has the contract's own channel for it.
|
|
195
|
+
access = predicate(request, { model: type, operation: 'read', recordId: null });
|
|
166
196
|
} catch (error) {
|
|
167
197
|
log.error?.(`[@stonyx/orm] access() threw while resolving linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
|
|
168
198
|
|
package/src/hooks.ts
CHANGED
|
@@ -37,7 +37,21 @@ export interface HookContext {
|
|
|
37
37
|
state?: Record<string, unknown>;
|
|
38
38
|
/** Previous record state (available in update hooks). */
|
|
39
39
|
oldState?: unknown;
|
|
40
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Target record ID for single-record operations.
|
|
42
|
+
*
|
|
43
|
+
* SET ONLY UNDER `delete`. `_withHooks` assigns this key in the two
|
|
44
|
+
* `operation === 'delete'` branches and nowhere else, so on `get`, `list`,
|
|
45
|
+
* `create` and `update` the key is ABSENT -- not `undefined`-valued, absent.
|
|
46
|
+
* A hook rule written as `ctx.recordId === '<id>'` never fires on an update;
|
|
47
|
+
* the addressed id is in `ctx.params`. Tracked as abofs/stonyx-orm#242.
|
|
48
|
+
*
|
|
49
|
+
* @see AccessContext.recordId in ./types/orm-types.ts -- an identically-named
|
|
50
|
+
* key on an identically-shaped context object, and NOT interchangeable with
|
|
51
|
+
* this one: it is present on every route `auth()` classifies, and spells
|
|
52
|
+
* absence as `null` rather than `undefined`. They differ in coverage on four
|
|
53
|
+
* of five operations, not only in the absence spelling.
|
|
54
|
+
*/
|
|
41
55
|
recordId?: string | number;
|
|
42
56
|
/** Response data (available in after hooks). */
|
|
43
57
|
response?: unknown;
|