@stonyx/orm 0.3.2-alpha.67 → 0.3.2-alpha.69
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 +53 -46
- package/dist/orm-request.js +43 -0
- package/dist/types/orm-types.d.ts +66 -0
- package/package.json +1 -1
- package/src/orm-request.ts +43 -0
- package/src/types/orm-types.ts +67 -0
package/README.md
CHANGED
|
@@ -350,66 +350,73 @@ Access classes define models and provide custom filtering/authorization logic.
|
|
|
350
350
|
export default class GlobalAccess {
|
|
351
351
|
models = ['owner', 'animal'];
|
|
352
352
|
|
|
353
|
-
access(request, { model, operation }) {
|
|
353
|
+
access(request, { model, operation, recordId }) {
|
|
354
354
|
// `model` is the model this route was mounted for. It is assigned once, at
|
|
355
355
|
// mount time, and no request can influence it — not a mount prefix, not a
|
|
356
356
|
// query string, not a case-varied path, not an absolute-form request
|
|
357
|
-
// target.
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
357
|
+
// target. `recordId` is the record this route was ADDRESSED TO, decoded by
|
|
358
|
+
// the router and coerced to the key the store lookup uses. Nothing below
|
|
359
|
+
// parses anything, and since abofs/stonyx-orm#236 nothing below reads
|
|
360
|
+
// argument one AT ALL. Variants 1, 2, 4 and 5 were already unconstructible;
|
|
361
|
+
// the sub-path STRING COMPARISON that variant 3 lived in is gone too,
|
|
362
|
+
// replaced by a comparison against the decoded id. Retiring the "variant 3
|
|
363
|
+
// survives" wording at the four sites that still carry it — with the
|
|
364
|
+
// measurement that retires it, rather than by deletion — is
|
|
365
|
+
// abofs/stonyx-orm#238.
|
|
363
366
|
//
|
|
364
367
|
// `operation` is destructured to name the whole contract at the point of
|
|
365
|
-
// use. This sample's rules are per-model and per-
|
|
368
|
+
// use. This sample's rules are per-model and per-record rather than
|
|
366
369
|
// per-verb, so it does not branch on it; the permission array at the bottom
|
|
367
370
|
// is where the verb is answered.
|
|
368
371
|
|
|
369
|
-
// FAIL CLOSED ON
|
|
370
|
-
// resolved this predicate without supplying the context, and a request
|
|
371
|
-
// function cannot identify DENIES rather than falling through to the
|
|
372
|
-
// grant at the bottom. An unidentifiable input must never be the
|
|
373
|
-
// path.
|
|
374
|
-
// not cover it.
|
|
372
|
+
// FAIL CLOSED ON AN UNIDENTIFIABLE MODEL. `model` is absent for any caller
|
|
373
|
+
// that resolved this predicate without supplying the context, and a request
|
|
374
|
+
// this function cannot identify DENIES rather than falling through to the
|
|
375
|
+
// CRUD grant at the bottom. An unidentifiable input must never be the
|
|
376
|
+
// permissive path.
|
|
375
377
|
if (typeof model !== 'string' || model === '') return false;
|
|
376
378
|
|
|
377
379
|
if (model === 'owner') {
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
//
|
|
380
|
+
// FAIL CLOSED ON AN ABSENT `recordId` TOO, AND `undefined` IS THE ONLY
|
|
381
|
+
// SPELLING OF ABSENT. `auth()` ALWAYS sets the key — `null` on a
|
|
382
|
+
// collection route, which is addressed to no record — so `undefined`
|
|
383
|
+
// means the context did not come from `auth()`: it was hand-assembled by
|
|
384
|
+
// a caller resolving this predicate through the documented
|
|
385
|
+
// `Orm.instance.getAccess()` path. Letting that through would fall
|
|
386
|
+
// straight to the per-record filter below, which is a DENY becoming an
|
|
387
|
+
// ALLOW. This is the same rule the old guard on `request.path` enforced,
|
|
388
|
+
// moved to the argument this predicate now actually reads.
|
|
389
|
+
if (recordId === undefined) return false;
|
|
390
|
+
|
|
391
|
+
// THE `/archived` DENY, EXPRESSED AGAINST THE DECODED ID. It used to be
|
|
392
|
+
// `request.path.toLowerCase()` compared against `'/archived'`, and that
|
|
393
|
+
// was wrong in both directions at once.
|
|
394
|
+
//
|
|
395
|
+
// TOO PERMISSIVE: express sets `request.path` from the RAW pathname while
|
|
396
|
+
// the router DECODES `:id`, so `GET /owners/%61rchived` reached the
|
|
397
|
+
// comparison as `/%61rchived`, walked past the deny and was dispatched as
|
|
398
|
+
// the record `archived` — 200 with the record in full, and DELETE
|
|
399
|
+
// answered 204 with the record DESTROYED, unauthenticated. 255
|
|
400
|
+
// non-canonical spellings of that 8-character id decode to the same key,
|
|
401
|
+
// so no deny-list of spellings was ever going to close it.
|
|
383
402
|
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
403
|
+
// TOO STRICT: a record id is a VALUE, not a literal route segment, and
|
|
404
|
+
// express's `case sensitive routing` governs literal segments only. With
|
|
405
|
+
// a distinct owner seeded at `ARCHIVED`, the `.toLowerCase()` 403'd
|
|
406
|
+
// `GET /owners/ARCHIVED` — the wrong record — while still admitting
|
|
407
|
+
// `GET /owners/%41RCHIVED`, the same record encoded.
|
|
386
408
|
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
// rule and falls straight through to the per-record filter below. That is
|
|
394
|
-
// a DENY becoming an ALLOW. An input this function cannot identify DENIES,
|
|
395
|
-
// whichever ARGUMENT it arrived on — which is also why the `?? ''` this
|
|
396
|
-
// file's header condemns does not appear below.
|
|
397
|
-
if (typeof request?.path !== 'string' || request.path === '') return false;
|
|
398
|
-
|
|
399
|
-
// Lower-cased because the router matched case-insensitively, so a
|
|
400
|
-
// case-sensitive rule here would be stricter than the router and could be
|
|
401
|
-
// stepped around.
|
|
409
|
+
// SO DO NOT NORMALISE `recordId`. It is already decoded, exactly ONCE,
|
|
410
|
+
// which is what a route parameter means: `/owners/%2561rchived` is the
|
|
411
|
+
// legitimate id `%61rchived`, and decoding until stable would deny it. Do
|
|
412
|
+
// not case-fold it. Do not rebuild it from `request.path` — decoding the
|
|
413
|
+
// whole path decodes THEN splits while the router splits THEN decodes,
|
|
414
|
+
// which over-denies the distinct record at `/owners/archived%2fx`.
|
|
402
415
|
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
|
|
407
|
-
// the record `archived` — abofs/stonyx-orm#228. A matcher must normalise
|
|
408
|
-
// the way the router that dispatched the request does. Record ids are
|
|
409
|
-
// case-sensitive and must be compared at their real case.
|
|
410
|
-
const path = request.path.toLowerCase();
|
|
411
|
-
|
|
412
|
-
if (path === '/archived' || path.startsWith('/archived/')) return false;
|
|
416
|
+
// THE DENY IS NOW EXPRESSIBLE FROM THE CONTEXT ALONE, which is exactly
|
|
417
|
+
// what `recordId` bought — and it still must not be dropped. Deleting it
|
|
418
|
+
// does not remove a rule loudly, it turns a deny into an ALLOW, silently.
|
|
419
|
+
if (recordId === 'archived') return false;
|
|
413
420
|
|
|
414
421
|
// Returning a function plugs it in as a per-record filter, and it is
|
|
415
422
|
// enforced on every surface addressed to one of these records:
|
package/dist/orm-request.js
CHANGED
|
@@ -1269,10 +1269,53 @@ export default class OrmRequest extends Request {
|
|
|
1269
1269
|
// src/types/orm-types.ts. Nothing is fetched at this point and adding a
|
|
1270
1270
|
// lookup here would put a store read in the middle of an authorization
|
|
1271
1271
|
// path. The function return shape below IS the per-record hook.
|
|
1272
|
+
//
|
|
1273
|
+
// -------------------------------------------------------------------------
|
|
1274
|
+
// #236 -- `recordId`, the DECODED route-parameter id, for the same reason.
|
|
1275
|
+
//
|
|
1276
|
+
// WHICH RECORD is the third structural fact the framework already holds and
|
|
1277
|
+
// the consumer was left to re-derive, and re-deriving it failed OPEN. The
|
|
1278
|
+
// documented sample compared `request.path` -- the RAW, undecoded pathname
|
|
1279
|
+
// -- against a literal `/archived`, while the router DECODES `:id`. So
|
|
1280
|
+
// `GET /owners/%61rchived` walked past the deny and was dispatched as the
|
|
1281
|
+
// record `archived`: 200 with the record in full, and DELETE answered 204
|
|
1282
|
+
// with the record destroyed, unauthenticated. Four spellings measured, all
|
|
1283
|
+
// four through; 255 non-canonical spellings of that 8-character id decode
|
|
1284
|
+
// to the same key, so this was never a deny-list of one.
|
|
1285
|
+
//
|
|
1286
|
+
// TWO CONSUMER-SIDE NORMALISATIONS WERE MEASURED WRONG IN OPPOSITE
|
|
1287
|
+
// DIRECTIONS, which is the argument for doing it once, here.
|
|
1288
|
+
// `.toLowerCase()` case-folds a route-parameter VALUE on the axis that
|
|
1289
|
+
// governs literal SEGMENTS: with a distinct owner seeded at `ARCHIVED`,
|
|
1290
|
+
// `GET /owners/ARCHIVED` was a false DENY on the wrong record and
|
|
1291
|
+
// `GET /owners/%41RCHIVED` a false ALLOW on that same one.
|
|
1292
|
+
// `decodeURIComponent(request.path)` decodes THEN splits while the router
|
|
1293
|
+
// splits THEN decodes, so it over-denied `/owners/archived%2fx` -- 403 for
|
|
1294
|
+
// a genuinely distinct record. Failing closed there was luck, not design.
|
|
1295
|
+
//
|
|
1296
|
+
// `getId(request.params)` AND NOT `request.params.id`, for exactly the
|
|
1297
|
+
// reason `operation` is a `methodAccessMap` lookup: it is the SAME single
|
|
1298
|
+
// coercion the store lookup one layer down performs, so the predicate and
|
|
1299
|
+
// the dispatch cannot disagree about which record a request addresses.
|
|
1300
|
+
// The raw string would reintroduce that divergence on hex-shaped ids --
|
|
1301
|
+
// `GET /animals/0x2391` looks up record `9105`.
|
|
1302
|
+
//
|
|
1303
|
+
// NOTHING HERE PARSES THE REQUEST TARGET EITHER. `request.params` is what
|
|
1304
|
+
// the router matched, so a mount prefix, an absolute-form target, a query
|
|
1305
|
+
// string or a case-varied mount cannot move this value -- the same
|
|
1306
|
+
// guarantee `model` carries, by the same means.
|
|
1307
|
+
//
|
|
1308
|
+
// `null` and not `undefined` on a collection route, so the KEY IS ALWAYS
|
|
1309
|
+
// PRESENT -- the rule `operation`'s own docblock already establishes. A
|
|
1310
|
+
// context reaching a predicate WITHOUT the key therefore did not come from
|
|
1311
|
+
// here; it was hand-assembled by a caller resolving the predicate through
|
|
1312
|
+
// `Orm.instance.getAccess()`, and that absence stays deniable only because
|
|
1313
|
+
// `auth()` never produces it.
|
|
1272
1314
|
// -------------------------------------------------------------------------
|
|
1273
1315
|
const context = {
|
|
1274
1316
|
model: this.model,
|
|
1275
1317
|
operation: methodAccessMap[request.method],
|
|
1318
|
+
recordId: request.params && 'id' in request.params ? getId(request.params) : null,
|
|
1276
1319
|
};
|
|
1277
1320
|
let access;
|
|
1278
1321
|
try {
|
|
@@ -242,6 +242,72 @@ export interface AccessContext {
|
|
|
242
242
|
* from one that classified the request and found nothing.
|
|
243
243
|
*/
|
|
244
244
|
operation: AccessOperation | undefined;
|
|
245
|
+
/**
|
|
246
|
+
* The record this route was addressed to, as the store key -- or `null` on a
|
|
247
|
+
* collection route, which is addressed to no record (abofs/stonyx-orm#236).
|
|
248
|
+
*
|
|
249
|
+
* IT IS ALREADY DECODED, AND THAT IS THE WHOLE POINT. Express decodes route
|
|
250
|
+
* PARAMETERS while leaving `request.path` raw, so a consumer comparing
|
|
251
|
+
* `request.path` against a literal compares an undecoded string against a
|
|
252
|
+
* decoded dispatch. `GET /owners/%61rchived` reached such a comparison as
|
|
253
|
+
* `/%61rchived`, walked past a `/archived` deny, and was dispatched as the
|
|
254
|
+
* record `archived` -- 200 with the record in full, and `DELETE` destroyed
|
|
255
|
+
* it, unauthenticated. 255 non-canonical spellings of an 8-character id
|
|
256
|
+
* decode to the same key, so a deny-list of spellings is the wrong shape.
|
|
257
|
+
*
|
|
258
|
+
* SO DO NOT NORMALISE THIS, AND DO NOT NORMALISE ANYTHING ELSE INSTEAD:
|
|
259
|
+
*
|
|
260
|
+
* - Do NOT decode it. Express decodes exactly ONCE, which is what a route
|
|
261
|
+
* parameter means. `GET /owners/%2561rchived` is the legitimate id
|
|
262
|
+
* `%61rchived`, not a second-order spelling of `archived`; a predicate that
|
|
263
|
+
* decoded until stable would deny a record it was never asked about.
|
|
264
|
+
* - Do NOT case-fold it. A record id is a VALUE, not a literal route segment,
|
|
265
|
+
* and express's `case sensitive routing` governs literal segments only.
|
|
266
|
+
* With a distinct owner seeded at `ARCHIVED`, `.toLowerCase()` was measured
|
|
267
|
+
* wrong in BOTH directions at once: `GET /owners/ARCHIVED` 403 (a false
|
|
268
|
+
* deny, on the wrong record) and `GET /owners/%41RCHIVED` 200 (a false
|
|
269
|
+
* allow, on that same record).
|
|
270
|
+
* - Do NOT derive it from `request.path` or the request target. Decoding the
|
|
271
|
+
* whole path decodes THEN splits, while the router splits THEN decodes, so
|
|
272
|
+
* `/owners/archived%2fx` -- a genuinely distinct record whose id is
|
|
273
|
+
* `archived/x` -- was measured over-denied 403.
|
|
274
|
+
*
|
|
275
|
+
* IT IS `getId(request.params)`, BYTE FOR BYTE -- the same single coercion
|
|
276
|
+
* the store lookup uses, exactly as `operation` is the same `methodAccessMap`
|
|
277
|
+
* lookup the permission-array branch uses. The predicate and the dispatch
|
|
278
|
+
* therefore cannot disagree about which record a request addresses. Handing
|
|
279
|
+
* over the raw `request.params.id` instead would reintroduce that divergence
|
|
280
|
+
* on hex-shaped ids: `GET /animals/0x2391` looks up record `9105`.
|
|
281
|
+
*
|
|
282
|
+
* It inherits abofs/stonyx-orm#209 along with that coercion -- on a model
|
|
283
|
+
* declaring `id = attr('string')`, `'9107'` arrives here as the number
|
|
284
|
+
* `9107`. That is consistency WITH THE LOOKUP, which is the property this key
|
|
285
|
+
* exists to buy; it is not a defect to repair here.
|
|
286
|
+
*
|
|
287
|
+
* `null`, not `undefined`, on a collection route -- and the KEY IS ALWAYS
|
|
288
|
+
* PRESENT, the same rule `operation` states above. `auth()` always sets it,
|
|
289
|
+
* so a context arriving WITHOUT the key did not come from `auth()`: it was
|
|
290
|
+
* hand-assembled by a caller resolving the predicate through
|
|
291
|
+
* `Orm.instance.getAccess()`. That absence stays a distinguishable, deniable
|
|
292
|
+
* signal only because the framework never produces it.
|
|
293
|
+
*
|
|
294
|
+
* IT IS THE ONE KEY THE HOOK VOCABULARY DOES *NOT* DISAGREE WITH.
|
|
295
|
+
* `HookContext.recordId` (`src/hooks.ts`) is an identically-named key on an
|
|
296
|
+
* identically-shaped context object, which is the exact configuration that
|
|
297
|
+
* makes `operation` fail-open shaped -- a hook sees `'get'` where `access()`
|
|
298
|
+
* sees `'read'`. Here they AGREE, and not by coincidence: `_withHooks` sets
|
|
299
|
+
* `context.recordId = getId(request.params)`, the same single coercion this
|
|
300
|
+
* key is built from. They differ in ONE way and it is the absence spelling --
|
|
301
|
+
* `HookContext.recordId` is optional and `undefined` when unset, while this
|
|
302
|
+
* key is always present and `null` on a collection route. A predicate must
|
|
303
|
+
* not read `undefined` here as "collection".
|
|
304
|
+
*
|
|
305
|
+
* IT NAMES WHICH RECORD, NOT WHICH SURFACE. `GET /owners/gina`,
|
|
306
|
+
* `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` all
|
|
307
|
+
* carry `recordId: 'gina'`; the related-resource gap is abofs/stonyx-orm#196
|
|
308
|
+
* and is untouched by this key.
|
|
309
|
+
*/
|
|
310
|
+
recordId: string | number | null;
|
|
245
311
|
}
|
|
246
312
|
/**
|
|
247
313
|
* A consumer `access()` predicate.
|
package/package.json
CHANGED
package/src/orm-request.ts
CHANGED
|
@@ -1389,10 +1389,53 @@ export default class OrmRequest extends Request {
|
|
|
1389
1389
|
// src/types/orm-types.ts. Nothing is fetched at this point and adding a
|
|
1390
1390
|
// lookup here would put a store read in the middle of an authorization
|
|
1391
1391
|
// path. The function return shape below IS the per-record hook.
|
|
1392
|
+
//
|
|
1393
|
+
// -------------------------------------------------------------------------
|
|
1394
|
+
// #236 -- `recordId`, the DECODED route-parameter id, for the same reason.
|
|
1395
|
+
//
|
|
1396
|
+
// WHICH RECORD is the third structural fact the framework already holds and
|
|
1397
|
+
// the consumer was left to re-derive, and re-deriving it failed OPEN. The
|
|
1398
|
+
// documented sample compared `request.path` -- the RAW, undecoded pathname
|
|
1399
|
+
// -- against a literal `/archived`, while the router DECODES `:id`. So
|
|
1400
|
+
// `GET /owners/%61rchived` walked past the deny and was dispatched as the
|
|
1401
|
+
// record `archived`: 200 with the record in full, and DELETE answered 204
|
|
1402
|
+
// with the record destroyed, unauthenticated. Four spellings measured, all
|
|
1403
|
+
// four through; 255 non-canonical spellings of that 8-character id decode
|
|
1404
|
+
// to the same key, so this was never a deny-list of one.
|
|
1405
|
+
//
|
|
1406
|
+
// TWO CONSUMER-SIDE NORMALISATIONS WERE MEASURED WRONG IN OPPOSITE
|
|
1407
|
+
// DIRECTIONS, which is the argument for doing it once, here.
|
|
1408
|
+
// `.toLowerCase()` case-folds a route-parameter VALUE on the axis that
|
|
1409
|
+
// governs literal SEGMENTS: with a distinct owner seeded at `ARCHIVED`,
|
|
1410
|
+
// `GET /owners/ARCHIVED` was a false DENY on the wrong record and
|
|
1411
|
+
// `GET /owners/%41RCHIVED` a false ALLOW on that same one.
|
|
1412
|
+
// `decodeURIComponent(request.path)` decodes THEN splits while the router
|
|
1413
|
+
// splits THEN decodes, so it over-denied `/owners/archived%2fx` -- 403 for
|
|
1414
|
+
// a genuinely distinct record. Failing closed there was luck, not design.
|
|
1415
|
+
//
|
|
1416
|
+
// `getId(request.params)` AND NOT `request.params.id`, for exactly the
|
|
1417
|
+
// reason `operation` is a `methodAccessMap` lookup: it is the SAME single
|
|
1418
|
+
// coercion the store lookup one layer down performs, so the predicate and
|
|
1419
|
+
// the dispatch cannot disagree about which record a request addresses.
|
|
1420
|
+
// The raw string would reintroduce that divergence on hex-shaped ids --
|
|
1421
|
+
// `GET /animals/0x2391` looks up record `9105`.
|
|
1422
|
+
//
|
|
1423
|
+
// NOTHING HERE PARSES THE REQUEST TARGET EITHER. `request.params` is what
|
|
1424
|
+
// the router matched, so a mount prefix, an absolute-form target, a query
|
|
1425
|
+
// string or a case-varied mount cannot move this value -- the same
|
|
1426
|
+
// guarantee `model` carries, by the same means.
|
|
1427
|
+
//
|
|
1428
|
+
// `null` and not `undefined` on a collection route, so the KEY IS ALWAYS
|
|
1429
|
+
// PRESENT -- the rule `operation`'s own docblock already establishes. A
|
|
1430
|
+
// context reaching a predicate WITHOUT the key therefore did not come from
|
|
1431
|
+
// here; it was hand-assembled by a caller resolving the predicate through
|
|
1432
|
+
// `Orm.instance.getAccess()`, and that absence stays deniable only because
|
|
1433
|
+
// `auth()` never produces it.
|
|
1392
1434
|
// -------------------------------------------------------------------------
|
|
1393
1435
|
const context: AccessContext = {
|
|
1394
1436
|
model: this.model,
|
|
1395
1437
|
operation: methodAccessMap[request.method],
|
|
1438
|
+
recordId: request.params && 'id' in request.params ? getId(request.params) : null,
|
|
1396
1439
|
};
|
|
1397
1440
|
|
|
1398
1441
|
let access: AccessMethod;
|
package/src/types/orm-types.ts
CHANGED
|
@@ -252,6 +252,73 @@ export interface AccessContext {
|
|
|
252
252
|
* from one that classified the request and found nothing.
|
|
253
253
|
*/
|
|
254
254
|
operation: AccessOperation | undefined;
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The record this route was addressed to, as the store key -- or `null` on a
|
|
258
|
+
* collection route, which is addressed to no record (abofs/stonyx-orm#236).
|
|
259
|
+
*
|
|
260
|
+
* IT IS ALREADY DECODED, AND THAT IS THE WHOLE POINT. Express decodes route
|
|
261
|
+
* PARAMETERS while leaving `request.path` raw, so a consumer comparing
|
|
262
|
+
* `request.path` against a literal compares an undecoded string against a
|
|
263
|
+
* decoded dispatch. `GET /owners/%61rchived` reached such a comparison as
|
|
264
|
+
* `/%61rchived`, walked past a `/archived` deny, and was dispatched as the
|
|
265
|
+
* record `archived` -- 200 with the record in full, and `DELETE` destroyed
|
|
266
|
+
* it, unauthenticated. 255 non-canonical spellings of an 8-character id
|
|
267
|
+
* decode to the same key, so a deny-list of spellings is the wrong shape.
|
|
268
|
+
*
|
|
269
|
+
* SO DO NOT NORMALISE THIS, AND DO NOT NORMALISE ANYTHING ELSE INSTEAD:
|
|
270
|
+
*
|
|
271
|
+
* - Do NOT decode it. Express decodes exactly ONCE, which is what a route
|
|
272
|
+
* parameter means. `GET /owners/%2561rchived` is the legitimate id
|
|
273
|
+
* `%61rchived`, not a second-order spelling of `archived`; a predicate that
|
|
274
|
+
* decoded until stable would deny a record it was never asked about.
|
|
275
|
+
* - Do NOT case-fold it. A record id is a VALUE, not a literal route segment,
|
|
276
|
+
* and express's `case sensitive routing` governs literal segments only.
|
|
277
|
+
* With a distinct owner seeded at `ARCHIVED`, `.toLowerCase()` was measured
|
|
278
|
+
* wrong in BOTH directions at once: `GET /owners/ARCHIVED` 403 (a false
|
|
279
|
+
* deny, on the wrong record) and `GET /owners/%41RCHIVED` 200 (a false
|
|
280
|
+
* allow, on that same record).
|
|
281
|
+
* - Do NOT derive it from `request.path` or the request target. Decoding the
|
|
282
|
+
* whole path decodes THEN splits, while the router splits THEN decodes, so
|
|
283
|
+
* `/owners/archived%2fx` -- a genuinely distinct record whose id is
|
|
284
|
+
* `archived/x` -- was measured over-denied 403.
|
|
285
|
+
*
|
|
286
|
+
* IT IS `getId(request.params)`, BYTE FOR BYTE -- the same single coercion
|
|
287
|
+
* the store lookup uses, exactly as `operation` is the same `methodAccessMap`
|
|
288
|
+
* lookup the permission-array branch uses. The predicate and the dispatch
|
|
289
|
+
* therefore cannot disagree about which record a request addresses. Handing
|
|
290
|
+
* over the raw `request.params.id` instead would reintroduce that divergence
|
|
291
|
+
* on hex-shaped ids: `GET /animals/0x2391` looks up record `9105`.
|
|
292
|
+
*
|
|
293
|
+
* It inherits abofs/stonyx-orm#209 along with that coercion -- on a model
|
|
294
|
+
* declaring `id = attr('string')`, `'9107'` arrives here as the number
|
|
295
|
+
* `9107`. That is consistency WITH THE LOOKUP, which is the property this key
|
|
296
|
+
* exists to buy; it is not a defect to repair here.
|
|
297
|
+
*
|
|
298
|
+
* `null`, not `undefined`, on a collection route -- and the KEY IS ALWAYS
|
|
299
|
+
* PRESENT, the same rule `operation` states above. `auth()` always sets it,
|
|
300
|
+
* so a context arriving WITHOUT the key did not come from `auth()`: it was
|
|
301
|
+
* hand-assembled by a caller resolving the predicate through
|
|
302
|
+
* `Orm.instance.getAccess()`. That absence stays a distinguishable, deniable
|
|
303
|
+
* signal only because the framework never produces it.
|
|
304
|
+
*
|
|
305
|
+
* IT IS THE ONE KEY THE HOOK VOCABULARY DOES *NOT* DISAGREE WITH.
|
|
306
|
+
* `HookContext.recordId` (`src/hooks.ts`) is an identically-named key on an
|
|
307
|
+
* identically-shaped context object, which is the exact configuration that
|
|
308
|
+
* makes `operation` fail-open shaped -- a hook sees `'get'` where `access()`
|
|
309
|
+
* sees `'read'`. Here they AGREE, and not by coincidence: `_withHooks` sets
|
|
310
|
+
* `context.recordId = getId(request.params)`, the same single coercion this
|
|
311
|
+
* key is built from. They differ in ONE way and it is the absence spelling --
|
|
312
|
+
* `HookContext.recordId` is optional and `undefined` when unset, while this
|
|
313
|
+
* key is always present and `null` on a collection route. A predicate must
|
|
314
|
+
* not read `undefined` here as "collection".
|
|
315
|
+
*
|
|
316
|
+
* IT NAMES WHICH RECORD, NOT WHICH SURFACE. `GET /owners/gina`,
|
|
317
|
+
* `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` all
|
|
318
|
+
* carry `recordId: 'gina'`; the related-resource gap is abofs/stonyx-orm#196
|
|
319
|
+
* and is untouched by this key.
|
|
320
|
+
*/
|
|
321
|
+
recordId: string | number | null;
|
|
255
322
|
}
|
|
256
323
|
|
|
257
324
|
/**
|