@stonyx/orm 0.3.2-beta.155 → 0.3.2-beta.157

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
@@ -315,15 +315,49 @@ Access classes define models and provide custom filtering/authorization logic.
315
315
  > [Identifying the collection](#identifying-the-collection) before copying this.**
316
316
  > Every attempt to identify the collection by parsing the request target has
317
317
  > failed **open** — five distinct variants of this same example, each found only
318
- > after the previous was fixed, by five different people. The sample below does
319
- > not parse anything: it reads `request.baseUrl`, the mount Express actually
320
- > matched.
318
+ > after the previous was fixed, by five different people. That section is now a
319
+ > record of what not to do, not a matching recipe: the sample below reads `model`
320
+ > from [the access context](#the-access-context-second-argument) and never looks
321
+ > at the mount at all, so variants 1, 2, 4 and 5 are **unconstructible** against
322
+ > it rather than merely handled. **Variant 3 survives.** It is the general shape
323
+ > "a hand-written matcher normalises differently from the router", and the
324
+ > migrated sample still runs one string comparison — the `/archived` sub-path
325
+ > deny — which folds case but does not decode, so `GET /owners/%61rchived` steps
326
+ > past it ([#228](https://github.com/abofs/stonyx-orm/issues/228)).
327
+ >
328
+ > **Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
329
+ > [#237](https://github.com/abofs/stonyx-orm/issues/237), and left standing rather
330
+ > than rewritten.** Both claims above are now false: `#228` is **closed**, and the
331
+ > one string comparison variant 3 lived in is gone — the sample below compares the
332
+ > **decoded `recordId`** the access context supplies, so there is no comparison
333
+ > left to step around. The paragraph about `request.path` further down is
334
+ > superseded the same way. Nothing here is deleted because the same "variant 3
335
+ > survives" wording sits at four sites (this file twice, `src/orm-request.ts`, and
336
+ > the test fixture) and retiring one of four leaves the shipped copies
337
+ > contradicting each other; retiring all four **with the measurement that retires
338
+ > them** is [#238](https://github.com/abofs/stonyx-orm/issues/238), which also owns
339
+ > this blockquote and the reference section below.
321
340
  >
322
341
  > That is still a stopgap. **The real fix is
323
342
  > [#202](https://github.com/abofs/stonyx-orm/issues/202)** — `access()` should
324
343
  > receive the model, the operation and the record, so there is nothing to
325
344
  > identify. Until it lands, prefer the array shape (`['read']`) or `false` where
326
345
  > you can: the **function** shape is the one that requires any matching at all.
346
+ >
347
+ > The one read of argument **one** that survives is `request.path`, for the
348
+ > `/archived` sub-path deny — and it has to. The context names which model and
349
+ > which verb, not which route, so that deny **cannot be expressed from the
350
+ > context alone** and a context-only rewrite would silently turn it into an
351
+ > allow.
352
+ >
353
+ > **Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
354
+ > [#237](https://github.com/abofs/stonyx-orm/issues/237)** — see the dated note
355
+ > above. No read of argument **one** survives in the sample below: the context
356
+ > carries `recordId`, the decoded route-parameter id, so the `/archived` deny **is**
357
+ > expressible from the context alone. It still must not be dropped — expressible is
358
+ > not optional. Retirement of this wording:
359
+ > [#238](https://github.com/abofs/stonyx-orm/issues/238).
360
+ >
327
361
  > The same warning is repeated at the top of `src/orm-request.ts`, which ships;
328
362
  > the longer write-up in `docs/usage-patterns.md` does **not** ship, so this
329
363
  > README and that source header are the two copies a consumer sees.
@@ -337,28 +371,73 @@ Access classes define models and provide custom filtering/authorization logic.
337
371
  export default class GlobalAccess {
338
372
  models = ['owner', 'animal'];
339
373
 
340
- access(request) {
341
- // `request.baseUrl` is the mount Express matched `/owners`, or
342
- // `/api/owners` under ORM_REST_ROUTE=/api. Never parse `originalUrl`: it is
343
- // the raw request target and can be absolute-form.
344
- const mount = request.baseUrl;
345
-
346
- // FAIL CLOSED. If Express did not tell us what it matched we are not behind
347
- // the mount we think we are, and an unidentifiable request denies rather
348
- // than falling through to the CRUD grant at the bottom.
349
- if (typeof mount !== 'string' || mount === '') return false;
350
-
351
- // Lower-cased because the router matched case-INSENSITIVELY, and a matcher
352
- // stricter than the router that dispatched the request can be stepped
353
- // around. The PATH only — record ids stay at their real case below.
354
- const collection = mount.toLowerCase();
355
-
356
- // `request.path` is mount-relative and query-free, so sub-path rules need no
357
- // prefix arithmetic either. false 403 for the whole request.
358
- const path = String(request.path ?? '').toLowerCase();
359
-
360
- if (collection.endsWith('/owners')) {
361
- if (path === '/archived' || path.startsWith('/archived/')) return false;
374
+ access(request, { model, operation, recordId }) {
375
+ // `model` is the model this route was mounted for. It is assigned once, at
376
+ // mount time, and no request can influence it — not a mount prefix, not a
377
+ // query string, not a case-varied path, not an absolute-form request
378
+ // target. `recordId` is the record this route was ADDRESSED TO, decoded by
379
+ // the router and coerced to the key the store lookup uses. Nothing below
380
+ // parses anything, and since abofs/stonyx-orm#236 nothing below reads
381
+ // argument one AT ALL. Variants 1, 2, 4 and 5 were already unconstructible;
382
+ // the sub-path STRING COMPARISON that variant 3 lived in is gone too,
383
+ // replaced by a comparison against the decoded id. Retiring the "variant 3
384
+ // survives" wording at the four sites that still carry it — with the
385
+ // measurement that retires it, rather than by deletion — is
386
+ // abofs/stonyx-orm#238.
387
+ //
388
+ // `operation` is destructured to name the whole contract at the point of
389
+ // use. This sample's rules are per-model and per-record rather than
390
+ // per-verb, so it does not branch on it; the permission array at the bottom
391
+ // is where the verb is answered.
392
+
393
+ // FAIL CLOSED ON AN UNIDENTIFIABLE MODEL. `model` is absent for any caller
394
+ // that resolved this predicate without supplying the context, and a request
395
+ // this function cannot identify DENIES rather than falling through to the
396
+ // CRUD grant at the bottom. An unidentifiable input must never be the
397
+ // permissive path.
398
+ if (typeof model !== 'string' || model === '') return false;
399
+
400
+ if (model === 'owner') {
401
+ // FAIL CLOSED ON AN ABSENT `recordId` TOO, AND `undefined` IS THE ONLY
402
+ // SPELLING OF ABSENT. `auth()` ALWAYS sets the key — `null` on a
403
+ // collection route, which is addressed to no record — so `undefined`
404
+ // means the context did not come from `auth()`: it was hand-assembled by
405
+ // a caller resolving this predicate through the documented
406
+ // `Orm.instance.getAccess()` path. Letting that through would fall
407
+ // straight to the per-record filter below, which is a DENY becoming an
408
+ // ALLOW. This is the same rule the old guard on `request.path` enforced,
409
+ // moved to the argument this predicate now actually reads.
410
+ if (recordId === undefined) return false;
411
+
412
+ // THE `/archived` DENY, EXPRESSED AGAINST THE DECODED ID. It used to be
413
+ // `request.path.toLowerCase()` compared against `'/archived'`, and that
414
+ // was wrong in both directions at once.
415
+ //
416
+ // TOO PERMISSIVE: express sets `request.path` from the RAW pathname while
417
+ // the router DECODES `:id`, so `GET /owners/%61rchived` reached the
418
+ // comparison as `/%61rchived`, walked past the deny and was dispatched as
419
+ // the record `archived` — 200 with the record in full, and DELETE
420
+ // answered 204 with the record DESTROYED, unauthenticated. 255
421
+ // non-canonical spellings of that 8-character id decode to the same key,
422
+ // so no deny-list of spellings was ever going to close it.
423
+ //
424
+ // TOO STRICT: a record id is a VALUE, not a literal route segment, and
425
+ // express's `case sensitive routing` governs literal segments only. With
426
+ // a distinct owner seeded at `ARCHIVED`, the `.toLowerCase()` 403'd
427
+ // `GET /owners/ARCHIVED` — the wrong record — while still admitting
428
+ // `GET /owners/%41RCHIVED`, the same record encoded.
429
+ //
430
+ // SO DO NOT NORMALISE `recordId`. It is already decoded, exactly ONCE,
431
+ // which is what a route parameter means: `/owners/%2561rchived` is the
432
+ // legitimate id `%61rchived`, and decoding until stable would deny it. Do
433
+ // not case-fold it. Do not rebuild it from `request.path` — decoding the
434
+ // whole path decodes THEN splits while the router splits THEN decodes,
435
+ // which over-denies the distinct record at `/owners/archived%2fx`.
436
+ //
437
+ // THE DENY IS NOW EXPRESSIBLE FROM THE CONTEXT ALONE, which is exactly
438
+ // what `recordId` bought — and it still must not be dropped. Deleting it
439
+ // does not remove a rule loudly, it turns a deny into an ALLOW, silently.
440
+ if (recordId === 'archived') return false;
362
441
 
363
442
  // Returning a function plugs it in as a per-record filter, and it is
364
443
  // enforced on every surface addressed to one of these records:
@@ -373,7 +452,7 @@ export default class GlobalAccess {
373
452
  // inert. Deliberately NO `?? record.owner` fallback: accepting the raw
374
453
  // shape as well as the resolved one would absorb a resolution regression
375
454
  // silently, which is exactly what blinded this fixture before.
376
- if (collection.endsWith('/animals')) return record => record.owner?.id !== 'restricted';
455
+ if (model === 'animal') return record => record.owner?.id !== 'restricted';
377
456
 
378
457
  // Allows full access to all calls that don't match any of the above conditions
379
458
  return ['read', 'create', 'update', 'delete'];
@@ -441,6 +520,16 @@ access class shipped with this repo has such a rule: its `/archived` deny
441
520
  **cannot be expressed from the context alone**, and a predicate migrated to
442
521
  context-only would silently drop it — a deny becoming an allow.
443
522
 
523
+ **Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
524
+ [#237](https://github.com/abofs/stonyx-orm/issues/237).** The context also carries
525
+ `recordId` — the record this route was addressed to, already decoded — so the
526
+ `/archived` deny **is** expressible from the context alone, and the shipped sample
527
+ no longer reads `request.path`. The full contract is `AccessContext.recordId` in
528
+ `src/types/orm-types.ts`, which ships. This section — the signature, the key table
529
+ and this paragraph — is corrected by
530
+ [#238](https://github.com/abofs/stonyx-orm/issues/238); the pointer is here because
531
+ what it currently says is an instruction, and the instruction is wrong.
532
+
444
533
  Note also that the related-resource and `?include=` surfaces serve *another
445
534
  model's* records under `model: 'owner'`, and the context gives a predicate no
446
535
  signal that it is authorizing a related-resource route. That is
@@ -523,9 +612,10 @@ sample, `getAccess('owner') === getAccess('animal')`.
523
612
  #### Passing the context makes a model-correct answer *possible*
524
613
 
525
614
  It does not make the answer model-correct on its own. **The resolved predicate
526
- has to read the context.** Measured against the access class shipped with this
527
- repo, on a request Express dispatched to `GET /owners/angela`, asked about
528
- **animals**:
615
+ has to read the context.** Against a predicate that ignores it the failure is
616
+ measurable. On a request Express dispatched to `GET /owners/angela`, asked about
617
+ **animals**, the sample as it shipped before
618
+ [#222](https://github.com/abofs/stonyx-orm/issues/222) answered:
529
619
 
530
620
  ```
531
621
  getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
@@ -533,18 +623,26 @@ getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
533
623
  ```
534
624
 
535
625
  That is the **owners** filter, and it returns `true` for animal 21 — the record
536
- hidden on every animal surface. Under a mount that predicate recognizes neither
537
- way it is worse still: it falls through to
538
- `['read', 'create', 'update', 'delete']`, a full CRUD grant.
539
-
540
- Either way the context was supplied and the answer is not the animal answer, and
541
- it is wrong in the direction that **grants**. That predicate is single-argument
542
- and identifies its collection from the request, so it answered about the
543
- collection the request is *addressed to* while being asked about another one.
544
- Every predicate in this repo, and in every consumer tree, is single-argument on
545
- the day this ships, and a caller has no supported way to tell which kind it
546
- resolved. The boot-time arity warning that would surface it is
547
- [#213](https://github.com/abofs/stonyx-orm/issues/213).
626
+ hidden on every animal surface. Under a mount such a predicate recognizes
627
+ neither way it is worse still: it falls through to
628
+ `['read', 'create', 'update', 'delete']`, a full CRUD grant. Either way the
629
+ context was supplied and the answer is not the animal answer, and it is wrong in
630
+ the direction that **grants** because that predicate was single-argument and
631
+ identified its collection from the request, so it answered about the collection
632
+ the request was *addressed to* while being asked about another one.
633
+
634
+ The sample shipped with this repo has since been migrated to read the context,
635
+ and the same call now answers with the **animal** filter:
636
+
637
+ ```
638
+ getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
639
+ -> record => record.owner?.id !== 'restricted'
640
+ ```
641
+
642
+ A single-argument predicate remains the default in every consumer tree, and a
643
+ caller has no supported way to tell which kind it resolved. The boot-time arity
644
+ warning that surfaces one is
645
+ [#221](https://github.com/abofs/stonyx-orm/issues/221).
548
646
 
549
647
  So: pass the context, and do not treat a resolved predicate's answer as
550
648
  model-specific until that predicate has been migrated to read it.
@@ -676,9 +774,17 @@ no hidden records to disclose.
676
774
 
677
775
  ### Identifying the collection
678
776
 
679
- **Do not reconstruct the request path.** Every version of this sample that tried
680
- to has failed **open**, and each variant was found only after the previous one
681
- was fixed:
777
+ **Do not reconstruct the request path and since
778
+ [#202](https://github.com/abofs/stonyx-orm/issues/202) you do not have to
779
+ identify the collection at all.** Read `model` from
780
+ [the access context](#the-access-context-second-argument): it is fixed at mount
781
+ time, no request can influence it, and there is nothing left to parse.
782
+
783
+ **Everything below is the record of what happened when this sample did parse
784
+ it.** It is kept as history, not as a recipe — none of these matching strategies
785
+ should be written into a new predicate. Every version of this sample that tried
786
+ to identify the collection from the request target failed **open**, and each
787
+ variant was found only after the previous one was fixed:
682
788
 
683
789
  | # | Variant | Why it fails open |
684
790
  |---|---|---|
@@ -688,13 +794,27 @@ was fixed:
688
794
  | 4 | hard-coded `/owners` | With `ORM_REST_ROUTE=/api` every url becomes `/api/owners/...` and the sample matches nothing — environment-specifically, which is harder to notice than failing everywhere. The remediation this document used to give was itself broken: `` `${config.orm.restServer.route}owners` `` evaluates to **`/apiowners`**, so a reader who followed the correction exactly still failed open and believed they had handled it. |
689
795
  | 5 | any match on `originalUrl` at all | HTTP/1.1 permits an **absolute-form** request-target. Express routes on `parseurl(req).pathname`, so the request dispatches normally — but `originalUrl` is the raw target. `GET http://anything.example/owners/angela` yields `originalUrl === 'http://anything.example/owners/angela'`, which has no `/owners` prefix. Measured: the record came back in full, `DELETE` succeeded, and it walked past a hard `return false` deny the same way. |
690
796
 
691
- **The fix is not a sixth rule.** It is to stop parsing:
692
-
693
- **Use `request.baseUrl`.** It is the mount Express *actually matched* when it
694
- dispatched the request. It carries no query string (variant 2), it is not
695
- mount-relative (variant 1), it already contains the configured `ORM_REST_ROUTE`
696
- prefix (variant 4 there is nothing left to derive, so `/apiowners` is
697
- unconstructible), and it is unaffected by an absolute-form target (variant 5).
797
+ **The fix is not a sixth rule, and it is not a better string to match.** It is
798
+ to stop identifying the collection at all. That is a statement about
799
+ **identifying the collection**, and it is not a statement about the sample as a
800
+ whole: the `/archived` sub-path rule *is* still a string match, and
801
+ [#228](https://github.com/abofs/stonyx-orm/issues/228) is a sixth spelling that
802
+ gets past it. Sub-path rules are the residue this fix does not cover, which is
803
+ why they must normalise the way the router does.
804
+
805
+ An intermediate revision read **`request.baseUrl`** — the mount Express
806
+ *actually matched*. That closed all five variants: it carries no query string
807
+ (variant 2), it is not mount-relative (variant 1), it already contains the
808
+ configured `ORM_REST_ROUTE` prefix (variant 4 — there is nothing left to derive,
809
+ so `/apiowners` is unconstructible), and it is unaffected by an absolute-form
810
+ target (variant 5). It was still a transport artifact standing in for a
811
+ structural fact, and it is **no longer what the sample does**: the sample reads
812
+ `model`, so variants 1, 2, 4 and 5 are unconstructible against it rather than
813
+ handled. **Variant 3 survives**, in the one string comparison the migration
814
+ leaves behind: the `/archived` sub-path deny folds case but does not decode
815
+ ([#228](https://github.com/abofs/stonyx-orm/issues/228)). The table below is
816
+ retained as the measured evidence behind the five variants, not because any of
817
+ these values should be matched on:
698
818
 
699
819
  | request | `request.url` | `request.originalUrl` | `request.baseUrl` | `request.path` |
700
820
  |---|---|---|---|---|
@@ -705,21 +825,60 @@ unconstructible), and it is unaffected by an absolute-form target (variant 5).
705
825
  | `GET http://anything.example/owners/angela` | `http://anything.example/angela` | `http://anything.example/owners/angela` | `/owners` | `/angela` |
706
826
  | `GET /api/animals/22` (`ORM_REST_ROUTE=/api`) | `/22` | `/api/animals/22` | `/api/animals` | `/22` |
707
827
 
708
- Two rules remain, and they are the whole list:
709
-
710
- **1. Compare lower-cased.** `baseUrl` is the text the caller sent, not the
711
- registered mount `GET /OwNeRs/angela` yields `/OwNeRs`. The router matched it
712
- case-insensitively, so a case-sensitive comparison here is stricter than the
713
- router and can be walked past. Lower-case the **mount and path only**; record ids
714
- are case-sensitive and must be compared at their real case.
715
-
716
- **2. Fail closed when `baseUrl` is absent.** `String(request.originalUrl ?? '')`
717
- was added to stop a `TypeError`, and it traded fail-closed for fail-**open**: an
718
- empty string matches no collection, so `access()` fell through to the permission
719
- array and granted full CRUD. An input you cannot identify must **deny**.
720
-
721
- Use `request.path`mount-relative and query-free if you need to distinguish
722
- sub-paths beneath the mount, as the `/archived` deny above does.
828
+ **Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
829
+ [#237](https://github.com/abofs/stonyx-orm/issues/237) — "Variant 3 survives" above,
830
+ and the two paragraphs below, are no longer true.** The access context now carries
831
+ `recordId`, the **decoded** route-parameter id, and the sample compares against it:
832
+ the one string comparison variant 3 lived in is gone, `#228` is **closed**, and no
833
+ read of argument one survives in the sample. The wording is left standing rather
834
+ than deleted because it appears at four sites (this file twice,
835
+ `src/orm-request.ts`, and the test fixture) and retiring one of four leaves the
836
+ shipped copies contradicting each other; retiring all four **with the measurement
837
+ that retires them** is [#238](https://github.com/abofs/stonyx-orm/issues/238).
838
+
839
+ **One read of argument one survives, and it must: `request.path`.** It is
840
+ mount-relative and query-free, and it is for rules that distinguish **sub-paths**
841
+ beneath the mount as the `/archived` deny in the sample above does. The context
842
+ names which model and which verb, **not which route**, so that deny *cannot be
843
+ expressed from the context alone*, and a context-only rewrite would silently turn
844
+ it into an allow.
845
+
846
+ **Normalise the way the router that dispatched the request does — and
847
+ case-folding alone does not.** A matcher stricter than the router can be stepped
848
+ around, so the sample lower-cases before comparing (the router matched
849
+ case-insensitively). That closes the case gap and **it is not the whole rule**:
850
+ Express sets `request.path` from the **raw, undecoded** pathname while the router
851
+ **decodes** `:id`, so `GET /owners/%61rchived` reaches a `path === '/archived'`
852
+ comparison as `/%61rchived`, walks past the deny, and is dispatched as the record
853
+ `archived`. That gap is live in the sample above and is tracked as
854
+ [#228](https://github.com/abofs/stonyx-orm/issues/228) — **do not read the
855
+ `.toLowerCase()` there as a complete normalisation recipe.** Record ids are
856
+ case-sensitive and must be compared at their real case.
857
+
858
+ **Do not follow the two paragraphs above — superseded 2026-09-01 by
859
+ [#236](https://github.com/abofs/stonyx-orm/issues/236) /
860
+ [#237](https://github.com/abofs/stonyx-orm/issues/237).** They are *instructions*,
861
+ not merely stale observations, which is why this note is louder than a date. The
862
+ sample no longer reads `request.path` and no longer calls `.toLowerCase()` on
863
+ anything it compares: `.toLowerCase()` was measured wrong in **both directions at
864
+ once** — with a distinct owner seeded at `ARCHIVED`, `GET /owners/ARCHIVED` was a
865
+ false **deny** on the wrong record and `GET /owners/%41RCHIVED` a false **allow**
866
+ on that same record. Compare `recordId` **as it arrives**: do not case-fold it, do
867
+ not decode it, do not derive it from `request.path`. The contract is
868
+ `AccessContext.recordId` in `src/types/orm-types.ts`, which ships and says "Do NOT
869
+ case-fold it". Retirement of this wording, with its measurement:
870
+ [#238](https://github.com/abofs/stonyx-orm/issues/238).
871
+
872
+ **Fail closed on anything you cannot identify — on *either* argument.**
873
+ `String(request.originalUrl ?? '')` was once added here to stop a `TypeError`,
874
+ and it traded fail-closed for fail-**open**: an empty string matched no
875
+ collection, so `access()` fell through to the permission array and granted full
876
+ CRUD. The same rule applies to the context — the sample returns `false` for an
877
+ absent `model` rather than falling through. Since #202 the guard and the read can
878
+ sit on **different objects**, and a guard on argument two does not protect a read
879
+ of argument one: the sample therefore also returns `false` when `request.path` is
880
+ absent or is not a string, rather than letting `?? ''` fall through to the
881
+ per-record filter. An input you cannot identify must **deny**.
723
882
 
724
883
  ### Known limitations
725
884
 
@@ -742,11 +901,14 @@ sub-paths beneath the mount, as the `/archived` deny above does.
742
901
  see [The access context](#the-access-context-second-argument):
743
902
  `Orm.instance.getAccess(modelName)` makes another model's predicate
744
903
  **reachable**, and `context.model` makes a **model-correct answer possible** —
745
- possible, not guaranteed: the resolved predicate has to read the context, and
746
- every predicate in tree is still single-argument
747
- ([#213](https://github.com/abofs/stonyx-orm/issues/213)), so today it answers
748
- about the collection the request is addressed to. **The mechanism exists; the
749
- ORM does not yet use it on this path.** The re-parenting write above is still
904
+ possible, not guaranteed: the resolved predicate has to read the context. The
905
+ sample shipped with this repo now does
906
+ ([#222](https://github.com/abofs/stonyx-orm/issues/222)), so
907
+ `getAccess('animal')` answers with the animal filter; a predicate that ignores
908
+ the second argument still answers about the collection the request is
909
+ addressed to, and the boot-time warning that surfaces one is
910
+ [#221](https://github.com/abofs/stonyx-orm/issues/221). **The mechanism
911
+ exists; the ORM does not yet use it on this path.** The re-parenting write above is still
750
912
  **not refused** — that enforcement is
751
913
  [#196](https://github.com/abofs/stonyx-orm/issues/196) and
752
914
  [#207](https://github.com/abofs/stonyx-orm/issues/207), which were blocked on
@@ -1087,6 +1249,16 @@ Each hook receives a context object with comprehensive information:
1087
1249
  - It contains a deep copy of the record's state **before** the operation executes (captured before the `before` hook fires)
1088
1250
  - The deep copy is created via JSON serialization (`JSON.parse(JSON.stringify())`) to ensure complete isolation
1089
1251
  - For `delete` operations, `recordId` is provided in after hooks since the record may no longer exist in the store
1252
+ - **`context.recordId` here is NOT `AccessContext.recordId`.** Same name, same-shaped
1253
+ object, different coverage: `_withHooks` sets this key **only** under
1254
+ `operation === 'delete'`, so on `get` / `list` / `create` / `update` the key is
1255
+ **absent** — `beforeHook('update', 'owner', ctx => ctx.recordId === 'archived' ? 403 : undefined)`
1256
+ never fires (measured: `PATCH /owners/{id}` → 200 with `ctx.recordId === undefined`
1257
+ and the id sitting in `ctx.params`). The access context, by contrast, carries
1258
+ `recordId` on every route it classifies and spells absence as `null`, never
1259
+ `undefined`. Tracked as
1260
+ [#242](https://github.com/abofs/stonyx-orm/issues/242); see
1261
+ `AccessContext.recordId` in `src/types/orm-types.ts` for the other side.
1090
1262
  - `oldState` is captured as a deep copy of the record's data before the operation, providing access to the previous field values
1091
1263
 
1092
1264
  ### Usage Examples
package/dist/hooks.d.ts CHANGED
@@ -20,7 +20,21 @@ export interface HookContext {
20
20
  state?: Record<string, unknown>;
21
21
  /** Previous record state (available in update hooks). */
22
22
  oldState?: unknown;
23
- /** Target record ID for single-record operations. */
23
+ /**
24
+ * Target record ID for single-record operations.
25
+ *
26
+ * SET ONLY UNDER `delete`. `_withHooks` assigns this key in the two
27
+ * `operation === 'delete'` branches and nowhere else, so on `get`, `list`,
28
+ * `create` and `update` the key is ABSENT -- not `undefined`-valued, absent.
29
+ * A hook rule written as `ctx.recordId === '<id>'` never fires on an update;
30
+ * the addressed id is in `ctx.params`. Tracked as abofs/stonyx-orm#242.
31
+ *
32
+ * @see AccessContext.recordId in ./types/orm-types.ts -- an identically-named
33
+ * key on an identically-shaped context object, and NOT interchangeable with
34
+ * this one: it is present on every route `auth()` classifies, and spells
35
+ * absence as `null` rather than `undefined`. They differ in coverage on four
36
+ * of five operations, not only in the absence spelling.
37
+ */
24
38
  recordId?: string | number;
25
39
  /** Response data (available in after hooks). */
26
40
  response?: unknown;
@@ -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
@@ -98,8 +107,8 @@
98
107
  *
99
108
  * PASSING THE CONTEXT MAKES A MODEL-CORRECT ANSWER POSSIBLE. It does not make
100
109
  * the answer model-correct on its own -- the resolved predicate has to READ it.
101
- * Measured against this repo's own shipped access class, on a request express
102
- * dispatched to `GET /owners/angela`, asked about ANIMALS:
110
+ * Measured against an ARITY-1 predicate, on a request express dispatched to
111
+ * `GET /owners/angela`, asked about ANIMALS:
103
112
  *
104
113
  * getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
105
114
  * -> record => record.id !== 'angela' && record.id !== 'restricted'
@@ -110,23 +119,30 @@
110
119
  * `['read', 'create', 'update', 'delete']`, a full CRUD grant. Either way the
111
120
  * context was supplied and the answer is not the animal answer, and it is wrong
112
121
  * in the GRANTING direction, because that predicate is arity-1 and identifies
113
- * its collection from the request. (The first of these is asserted on a live
114
- * dispatch by AC9 in test/integration/orm-test.ts.)
122
+ * its collection from the request. (Asserted on a live dispatch by AC9 in
123
+ * test/integration/orm-test.ts, against a deliberately arity-1 predicate.)
115
124
  *
116
- * Every predicate in this repo and in every consumer tree is arity-1 on the day
117
- * this ships, and the caller has no supported way to tell which kind it got --
118
- * the boot-time arity warning that would surface it is abofs/stonyx-orm#213.
125
+ * This repo's own sample access class has since been MIGRATED to read the
126
+ * context (abofs/stonyx-orm#222), so `getAccess('animal')` here now answers
127
+ * with the animal filter. That is not true of a consumer tree: an arity-1
128
+ * predicate keeps working -- the second argument is additive -- and the caller
129
+ * has no supported way to tell which kind it got. The boot-time arity warning
130
+ * that surfaces one is abofs/stonyx-orm#221.
119
131
  * So: pass the context, and do not treat a resolved predicate's answer as
120
132
  * model-specific until that predicate has been migrated to read the context.
121
133
  *
122
134
  * ---------------------------------------------------------------------------
123
135
  * DO NOT RECONSTRUCT THE REQUEST PATH INSIDE `access()`.
124
136
  * ---------------------------------------------------------------------------
125
- * `auth()` below hands your `access(request)` a raw transport artifact and asks
126
- * you to work out which collection it addresses. Every attempt to do that by
127
- * parsing the request target has failed OPEN. Five distinct variants of the
128
- * same three-line example have now been found, each after the previous was
129
- * fixed, by five different people:
137
+ * You do not have to. `auth()` below hands your predicate the ACCESS CONTEXT as
138
+ * argument two, and `context.model` already names the collection -- see the
139
+ * contract section above. Argument ONE is still the raw transport artifact, and
140
+ * everything from here to the end of this banner is the record of what happened
141
+ * when predicates worked the collection out from it. IT IS HISTORY, NOT
142
+ * GUIDANCE: do not write any of it into a new predicate. Every attempt to
143
+ * identify the collection by parsing the request target has failed OPEN. Five
144
+ * distinct variants of the same three-line example have now been found, each
145
+ * after the previous was fixed, by five different people:
130
146
  *
131
147
  * 1. `request.url` is mount-relative under `RestServer.mountRoute`, so a
132
148
  * prefix match against it is ALWAYS false.
@@ -145,22 +161,89 @@
145
161
  * last, and the record comes back in full. It walks past a hard
146
162
  * `return false` deny the same way.
147
163
  *
148
- * The fix is not a sixth rule. It is to stop parsing:
164
+ * The fix is not a sixth rule, and it is not a better string to match. It is to
165
+ * stop identifying the collection at all: read `context.model`. That is a claim
166
+ * about IDENTIFYING THE COLLECTION, not about the sample as a whole -- the
167
+ * `/archived` SUB-PATH rule is still a string match, and abofs/stonyx-orm#228 is
168
+ * a sixth spelling that gets past it.
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
+ *
175
+ * An intermediate revision of the sample read `request.baseUrl` -- the mount
176
+ * Express ACTUALLY MATCHED. That closed all five variants (no query string,
177
+ * not mount-relative, unaffected by absolute-form, already carrying the
178
+ * configured `ORM_REST_ROUTE` prefix), but it was a transport artifact
179
+ * standing in for a structural fact and the sample no longer does it.
180
+ * `context.model` IS the structural fact, so variants 1, 2, 4 and 5 are
181
+ * unconstructible against a migrated predicate rather than handled.
182
+ *
183
+ * VARIANT 3 SURVIVES, and is deliberately not in that list. It is the general
184
+ * shape "a hand-written matcher normalises differently from the router", and a
185
+ * migrated predicate still runs one string comparison for any SUB-PATH rule --
186
+ * in the shipped sample, the `/archived` deny. That comparison folds case but
187
+ * does not decode, so `GET /owners/%61rchived` steps past it. See the
188
+ * normalisation paragraph below and abofs/stonyx-orm#228.
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.
149
197
  *
150
- * `request.baseUrl` is the mount Express ACTUALLY MATCHED when it dispatched
151
- * the request. It carries no query string, it is not mount-relative, it is
152
- * unaffected by absolute-form, and it already includes the configured
153
- * `ORM_REST_ROUTE` prefix -- so there is nothing to derive and nothing to
154
- * join. Compare it lower-cased (the router matched case-insensitively) and
155
- * fail CLOSED when it is absent. Use `request.path` -- mount-relative and
156
- * query-free -- if you need to distinguish sub-paths.
198
+ * ONE READ OF ARGUMENT ONE SURVIVES, AND IT MUST: `request.path`. It is
199
+ * mount-relative and query-free, and it is for rules that distinguish SUB-PATHS
200
+ * beneath the mount. The context names which model and which verb, NOT which
201
+ * route, so the sample's `/archived` deny cannot be expressed from the context
202
+ * alone and a context-ONLY rewrite would silently turn that deny into an allow.
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
+ *
210
+ * NORMALISE THE WAY THE ROUTER DOES, AND CASE-FOLDING ALONE IS NOT THAT. The
211
+ * sample lower-cases before comparing, because a matcher stricter than the
212
+ * case-insensitive router can be stepped around. That closes the case gap only.
213
+ * Express sets `request.path` from the RAW, UNDECODED pathname while the router
214
+ * DECODES `:id`, so `GET /owners/%61rchived` reaches a `path === '/archived'`
215
+ * comparison as `/%61rchived` and walks past the deny. That gap is live in the
216
+ * sample and is tracked as abofs/stonyx-orm#228; the `.toLowerCase()` is not a
217
+ * complete normalisation recipe. Compare record ids at their real case.
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.
157
232
  *
158
233
  * `?? ''` is not a defence. It converts an absent request target into an empty
159
234
  * string, which matches no collection, which falls through to the permission
160
- * array -- a total grant. An input you cannot identify must DENY.
235
+ * array -- a total grant. An input you cannot identify must DENY, and that
236
+ * applies to BOTH arguments: since #202 the guard and the read can sit on
237
+ * different objects, and a guard on argument two does not protect a read of
238
+ * argument one. The sample returns `false` for an absent `model` AND for an
239
+ * absent or non-string `request.path`, rather than falling through either way.
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.
161
246
  *
162
- * THAT IS STILL A STOPGAP. `baseUrl` closes all five variants, but it is a
163
- * transport artifact being asked to stand in for a structural fact.
164
247
  * THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
165
248
  * the operation and the record. Prefer the array shape (`['read']`) or `false`
166
249
  * until #202 lands; the function shape is what requires any matching at all.