@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 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. Nothing below parses anything, so variants 1, 2, 4 and 5 are not
358
- // constructible against this predicate any more they are history, not
359
- // rules to follow. VARIANT 3 IS THE EXCEPTION AND THE CLAIM IS NARROWER
360
- // THAN IT WAS: a matcher stricter than the router can still be stepped
361
- // around, because the sub-path rule below is still a string comparison.
362
- // Case is handled; percent-encoding is not abofs/stonyx-orm#228.
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-sub-path rather than
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 ARGUMENT TWO. `model` is absent for any caller that
370
- // resolved this predicate without supplying the context, and a request this
371
- // function cannot identify DENIES rather than falling through to the CRUD
372
- // grant at the bottom. An unidentifiable input must never be the permissive
373
- // path. Argument ONE is guarded at its own read, below — this guard does
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
- // The context names WHICH MODEL and WHICH VERB not which route. Six
379
- // distinct owner surfaces produce one identical context, so a rule that
380
- // depends on the SUB-PATH still needs argument one. `request.path` is
381
- // mount-relative and query-free, and it is the one read of the raw
382
- // request the README sanctions. false 403 for the whole request.
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
- // THIS DENY CANNOT BE EXPRESSED FROM THE CONTEXT ALONE. Migrating it away
385
- // does not remove a rule, it turns a deny into an ALLOW, silently.
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
- // FAIL CLOSED ON ARGUMENT ONE TOO. The guard above covers the context;
388
- // this one covers the request, and since #202 they are two different
389
- // objects. A caller that resolves this predicate through the documented
390
- // `Orm.instance.getAccess()` path and hand-assembles a request can supply
391
- // a perfectly valid context with no usable `path` and
392
- // `String(request.path ?? '')` is then `''`, which matches no sub-path
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
- // CASE-FOLDING ALONE IS NOT A SUFFICIENT NORMALISATION, and this line is
404
- // not a recipe for one. Express sets `request.path` from the RAW pathname
405
- // while the router DECODES `:id`, so `GET /owners/%61rchived` reaches this
406
- // comparison as `/%61rchived`, walks past the deny, and is dispatched as
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:
@@ -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
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-alpha.67",
7
+ "version": "0.3.2-alpha.69",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -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;
@@ -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
  /**