@stonyx/orm 0.3.2-alpha.89 → 0.3.2-alpha.90

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
@@ -309,1389 +309,19 @@ import setupRestServer from '@stonyx/orm/setup-rest-server';
309
309
  await setupRestServer('/', './access');
310
310
  ```
311
311
 
312
- Access classes define models and provide custom filtering/authorization logic.
313
-
314
- > **Do not reconstruct the request path inside `access()`. Read
315
- > [Identifying the collection](#identifying-the-collection) before copying this.**
316
- > Every attempt to identify the collection by parsing the request target has
317
- > failed **open** — five distinct variants of this same example, each found only
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.
340
- >
341
- > That is still a stopgap. **The real fix is
342
- > [#202](https://github.com/abofs/stonyx-orm/issues/202)** — `access()` should
343
- > receive the model, the operation and the record, so there is nothing to
344
- > identify. Until it lands, prefer the array shape (`['read']`) or `false` where
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
- >
361
- > The same warning is repeated at the top of `src/orm-request.ts`, which ships;
362
- > the longer write-up in `docs/usage-patterns.md` does **not** ship, so this
363
- > README and that source header are the two copies a consumer sees.
364
- >
365
- > This sample is the same code as the shipped test fixture, and a test asserts
366
- > the two `access()` bodies are identical line for line. For four rounds they
367
- > were two independently written copies — and the fifth fail-open variant was
368
- > found in the one nothing was mutating.
312
+ Access classes define models and provide custom filtering/authorization logic:
369
313
 
370
314
  ```js
371
315
  export default class GlobalAccess {
372
316
  models = ['owner', 'animal'];
373
317
 
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;
441
-
442
- // Returning a function plugs it in as a per-record filter. It is enforced
443
- // on every surface addressed to one of these records —
444
- // /owners, /owners/:id, /owners/:id/pets, /owners/:id/relationships/pets
445
- // — AND, since #232, on every surface that reaches one of these records
446
- // as the RELATED resource of another model:
447
- // /animals/:id/owner, /animals/:id/relationships/owner
448
- // Both readings are the same rule: an owner this predicate rejects is
449
- // withheld wherever she is reachable, not only on /owners.
450
- //
451
- // NOTHING HERE IS AN EXISTENCE ORACLE, AND THE SPELLING DIFFERS BY WHOSE
452
- // RECORD IS BEING REJECTED. A rejected ADDRESSED record is 404 — the same
453
- // status as a record that does not exist. A rejected RELATED record is
454
- // `data: null` at 200 — byte-identical to a relationship that is
455
- // genuinely empty, because on those routes 404 is already the answer for
456
- // a PARENT that does not exist. In both cases "rejected" and "not there"
457
- // are the same answer, which is the property that matters.
458
- return record => record.id !== 'angela' && record.id !== 'restricted';
459
- }
460
-
461
- // `record.owner` resolves to an OrmRecord, not to the owner's id string —
462
- // comparing it directly against a string is the bug that made this predicate
463
- // inert. Deliberately NO `?? record.owner` fallback: accepting the raw
464
- // shape as well as the resolved one would absorb a resolution regression
465
- // silently, which is exactly what blinded this fixture before.
466
- // `record.id !== 18` hides one animal whose OWNER is permitted. It is the
467
- // fixture that makes the `hasMany` half of the relationship-route rules
468
- // observable: gina is served, animal 18 is not, and every surface that
469
- // names gina's pets has to drop it.
470
- if (model === 'animal') return record => record.owner?.id !== 'restricted' && record.id !== 18;
471
-
472
- // Allows full access to all calls that don't match any of the above conditions
318
+ access(request) {
319
+ if (request.url.endsWith('/owner/angela')) return false;
473
320
  return ['read', 'create', 'update', 'delete'];
474
321
  }
475
322
  }
476
323
  ```
477
324
 
478
-
479
- ### The access context (second argument)
480
-
481
- `access()` is called with **two** arguments:
482
-
483
- ```js
484
- access(request, { model, operation })
485
- ```
486
-
487
- The second is the **access context** — the structural facts about the request,
488
- which the framework already holds at authorization time. Read these instead of
489
- parsing anything.
490
-
491
- | Key | Value |
492
- |---|---|
493
- | `model` | The model this route was mounted for, as a **model name**: kebab-case, exactly as declared under `config.orm.paths.model` and keyed in the store — `'owner'`, `'animal'`, `'phone-number'`. **Not** the pluralized, dasherized, mount-prefixed *route* name. |
494
- | `operation` | One of **`'read'`, `'create'`, `'update'`, `'delete'`** — and no second vocabulary *on this path*. Never an HTTP method name like `'GET'`, and **not** the hook vocabulary either (see [below](#operation-is-not-the-hook-operation)). `undefined` when the dispatched method has no entry in the framework's method map. |
495
-
496
- So a predicate can be written without reference to any URL:
497
-
498
- ```js
499
- export default class OwnerAccess {
500
- models = ['owner'];
501
-
502
- access(request, { model, operation }) {
503
- if (model === 'owner' && operation === 'read') {
504
- return record => record.id !== 'angela';
505
- }
506
-
507
- return ['read'];
508
- }
509
- }
510
- ```
511
-
512
- There is no string to parse, no variant to miss, and no way to fail open through
513
- a URL shape nobody anticipated. `model` is fixed at mount time and no request
514
- can influence it — not a mount prefix, not a query string, not a case-varied
515
- path, not an absolute-form request target.
516
-
517
- #### What the context does not tell you: which surface
518
-
519
- It names **which model and which verb**, not **which route**. Measured over the
520
- live router, six surfaces produce one identical context:
521
-
522
- ```
523
- GET /owners { model: 'owner', operation: 'read' }
524
- GET /owners/gina { model: 'owner', operation: 'read' }
525
- GET /owners/gina/pets { model: 'owner', operation: 'read' }
526
- GET /owners/gina/relationships/pets { model: 'owner', operation: 'read' }
527
- GET /owners/archived { model: 'owner', operation: 'read' }
528
- GET /owners/gina?include=pets { model: 'owner', operation: 'read' }
529
- ```
530
-
531
- So a rule that depends on the **sub-path** still needs `request.path` —
532
- mount-relative and query-free, and the one read of argument one that
533
- [Identifying the collection](#identifying-the-collection) sanctions. The sample
534
- access class shipped with this repo has such a rule: its `/archived` deny
535
- **cannot be expressed from the context alone**, and a predicate migrated to
536
- context-only would silently drop it — a deny becoming an allow.
537
-
538
- **Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
539
- [#237](https://github.com/abofs/stonyx-orm/issues/237).** The context also carries
540
- `recordId` — the record this route was addressed to, already decoded — so the
541
- `/archived` deny **is** expressible from the context alone, and the shipped sample
542
- no longer reads `request.path`. The full contract is `AccessContext.recordId` in
543
- `src/types/orm-types.ts`, which ships. This section — the signature, the key table
544
- and this paragraph — is corrected by
545
- [#238](https://github.com/abofs/stonyx-orm/issues/238); the pointer is here because
546
- what it currently says is an instruction, and the instruction is wrong.
547
-
548
- Note also that the related-resource and `?include=` surfaces serve *another
549
- model's* records under `model: 'owner'`, and the context gives a predicate no
550
- signal that it is authorizing a related-resource route. That is
551
- [#196](https://github.com/abofs/stonyx-orm/issues/196).
552
-
553
- #### `operation` is not the hook `operation`
554
-
555
- This module exposes a **second** `operation` vocabulary, on an identically-named
556
- key of an identically-shaped context object:
557
- [hook contexts](#hook-context-object) carry `list` / `get` / `create` /
558
- `update` / `delete`. The access vocabulary collapses `list` and `get` into
559
- `'read'`, so for one `GET /animals/1` a hook sees `'get'` while `access()` sees
560
- `'read'` — and a predicate cannot distinguish a collection read from a
561
- record read.
562
-
563
- "No second vocabulary" above is a statement about the **access path**, where
564
- both the context and the permission array come from one method map. It is not a
565
- statement about the module. Writing `operation === 'get'` in a predicate never
566
- matches, and a predicate that stops matching falls through to the permission
567
- array — so the misreading is fail-open shaped. In TypeScript the exported
568
- `AccessOperation` union makes it a compile error.
569
-
570
- The four `operation` values are the same four strings the permission-array
571
- return shape is written in (`['read', 'create', 'update', 'delete']`), because
572
- both come from one method map inside the framework. The two forms cannot
573
- disagree about the same request.
574
-
575
- **`operation` is `undefined`, never defaulted, for an unmapped method.** Express
576
- delivers `HEAD` to the `GET` handler, so this is reachable. It is deliberately
577
- not defaulted to `'read'`: a fabricated operation would turn an unclassified
578
- request into an authorized one. Treat `undefined` as *not classified* and deny.
579
-
580
- **The second argument is additive.** JavaScript ignores extra arguments, so an
581
- existing `access(request)` predicate keeps working exactly as it did. Nothing
582
- needs to be migrated to keep running — but note that argument **one** is still
583
- the raw request, so the warning in
584
- [Identifying the collection](#identifying-the-collection) still applies to any
585
- predicate that reads it.
586
-
587
- #### `record` is not in the context
588
-
589
- Deliberately, and it is not an oversight. `auth()` runs after route matching but
590
- **before any handler executes**, so nothing has been fetched yet. Supplying a
591
- record would force a pre-fetch on every request — a second store hit, a new
592
- failure mode, and an ordering change in the middle of an authorization path.
593
-
594
- It is also unnecessary: the **function** return shape already *is* the
595
- per-record hook. Return `(record) => boolean` and the handlers apply it to every
596
- record the request touches. Auth-time and record-time are separate decision
597
- points, and the contract keeps them separate.
598
-
599
- #### Reaching another model's predicate
600
-
601
- The model → predicate map is published on the ORM instance at boot, before any
602
- route is mounted, so a predicate can be resolved by model name and asked about a
603
- request routed to a *different* model:
604
-
605
- ```js
606
- import Orm from '@stonyx/orm';
607
-
608
- const predicate = Orm.instance.getAccess('animal');
609
- if (!predicate) return deny;
610
-
611
- const verdict = predicate(request, { model: 'animal', operation: 'read' });
612
- ```
613
-
614
- **`undefined` means no predicate could be resolved — not that the model is
615
- unrestricted. Treat it as deny.** It covers a model with no access class *and* a
616
- model whose access class failed to **load**: a load failure is caught and warned
617
- about, and the partial map is published anyway, so a missing key is not evidence
618
- of an unrestricted model. This is the same rule as `operation === undefined`
619
- above, and for the same reason.
620
-
621
- The raw map is `Orm.instance.accessFunctions`, keyed by model name; prefer
622
- `getAccess()` — it is guarded against inherited `Object.prototype` members and a
623
- direct index is not. Note that it maps a model name to the predicate of the
624
- access *class* that claims it, which may claim many models: against this repo's
625
- sample, `getAccess('owner') === getAccess('animal')`.
626
-
627
- #### Passing the context makes a model-correct answer *possible*
628
-
629
- It does not make the answer model-correct on its own. **The resolved predicate
630
- has to read the context.** Against a predicate that ignores it the failure is
631
- measurable. On a request Express dispatched to `GET /owners/angela`, asked about
632
- **animals**, the sample as it shipped before
633
- [#222](https://github.com/abofs/stonyx-orm/issues/222) answered:
634
-
635
- ```
636
- getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
637
- -> record => record.id !== 'angela' && record.id !== 'restricted'
638
- ```
639
-
640
- That is the **owners** filter, and it returns `true` for animal 21 — the record
641
- hidden on every animal surface. Under a mount such a predicate recognizes
642
- neither way it is worse still: it falls through to
643
- `['read', 'create', 'update', 'delete']`, a full CRUD grant. Either way the
644
- context was supplied and the answer is not the animal answer, and it is wrong in
645
- the direction that **grants** — because that predicate was single-argument and
646
- identified its collection from the request, so it answered about the collection
647
- the request was *addressed to* while being asked about another one.
648
-
649
- The sample shipped with this repo has since been migrated to read the context,
650
- and the same call now answers with the **animal** filter:
651
-
652
- ```
653
- getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
654
- -> record => record.owner?.id !== 'restricted'
655
- ```
656
-
657
- A single-argument predicate remains the default in every consumer tree, and a
658
- caller has no supported way to tell which kind it resolved. The boot-time arity
659
- warning that surfaces one is
660
- [#221](https://github.com/abofs/stonyx-orm/issues/221).
661
-
662
- So: pass the context, and do not treat a resolved predicate's answer as
663
- model-specific until that predicate has been migrated to read it.
664
-
665
- ### Return values
666
-
667
- | `access()` returns | Effect |
668
- |---|---|
669
- | `false` (or any falsy value) | `403` for the whole request |
670
- | `true` | full access, no filter |
671
- | `['read', 'create', 'update', 'delete']` | the listed operations only; anything else is `403` |
672
- | `'read'` (a bare string) | **one** permission, equivalent to `['read']` |
673
- | a function | a per-record filter — see below |
674
- | anything else | `403` — unknown shapes fail **closed** |
675
-
676
- A `throw` inside `access()` is a **denial**, not a 500.
677
-
678
- ### Filter functions
679
-
680
- A function return value is a **per-record predicate**, and it is enforced on
681
- every endpoint that is addressed to a record — not only on the collection.
682
-
683
- It is evaluated against the record the route is *addressed to*. On the two
684
- relationship route families it is **also** evaluated against the **related**
685
- record, by that record's *own* model's predicate — see the two `{relationship}`
686
- rows below. It is not a guarantee that a hidden record cannot be reached or
687
- modified: a write to a *different* collection can still re-parent one. See
688
- [Known limitations](#known-limitations) and
689
- [#207](https://github.com/abofs/stonyx-orm/issues/207).
690
-
691
- | Endpoint | A record the predicate rejects |
692
- |---|---|
693
- | `GET /:models` | omitted from the collection |
694
- | `GET /:models/:id` | `404` |
695
- | `GET /:models/:id/{relationship}` | the **addressed** record → `404`. The **related** record → `200` with `data: null` for a `belongsTo`, or dropped from the array for a `hasMany` |
696
- | `GET /:models/:id/relationships/{relationship}` | same, on the linkage objects |
697
- | `PATCH /:models/:id` | `404`, no attribute is applied |
698
- | `DELETE /:models/:id` | `404`, the record is not removed and no SQL `DELETE` is issued |
699
- | `POST /:models` | `403`, and a record **this request inserted** is rolled back — see [Known limitations](#known-limitations) for why the rollback is conditional |
700
-
701
- **A withheld related record is not an error, and that is the same rule.** The
702
- addressed record is filtered with `404` and the related one with `data: null`
703
- because in both cases the answer must be **identical to the answer for a record
704
- that does not exist**. A `belongsTo` whose target is genuinely absent already
705
- answers `200 {"data": null}`; a `hasMany` with no members already answers `200`
706
- with an empty array. Withholding therefore has to be spelled the same way, or
707
- the route becomes an existence oracle for a record on a collection the caller
708
- may have no access to at all. Measured before this was closed — unauthenticated,
709
- zero query parameters, one request each:
710
-
711
- ```
712
- GET /traits/1/tag [target absent] -> 200 application/json 68 bytes
713
- GET /traits/2/tag [target denied] -> 404 text/plain 9 bytes
714
- ```
715
-
716
- `tag` is a model with **no route mounted at all**, so those two requests were the
717
- only way to ask about it — and they answered differently. Both now answer
718
- `200 {"data": null}`.
719
-
720
- **Denied record-level requests return 404, not 403.** This is deliberate and it
721
- is the property most easily "improved" away. 403 would confirm that the record
722
- exists to a caller who is not allowed to know that, which turns the filter into
723
- an existence oracle: `404` means "no such record", `403` means "there is one and
724
- it is not yours". Every status on a record route must therefore be identical for
725
- "filtered out" and "does not exist" — including `DELETE`, which is why deleting
726
- a record that never existed also returns 404 rather than 204, and including the
727
- **related** record on the two relationship families, which is why a denied
728
- `belongsTo` target is `200 {"data": null}` rather than `404`: on that route
729
- `404` is the answer for a parent that does not exist, so it is `data: null`, and
730
- not the status, that carries "no target you may see".
731
-
732
- `POST` is the one exception and returns **403**, because 404 on a mounted
733
- collection route is indistinguishable from "model not mounted" — a genuinely
734
- different failure a developer needs to diagnose.
735
-
736
- **A client-supplied `id` on `POST` is refused with `403` whenever a function
737
- filter is in force.** This is the part that keeps `POST` from being an
738
- enumeration oracle, and it is worth understanding rather than working around.
739
- The duplicate-id check has to run before the filter, and it sees records the
740
- filter hides, so the *status* of a `POST` otherwise leaks whether an id is
741
- taken:
742
-
743
- | `POST /animals` with a payload the caller may create | before | now |
744
- |---|---|---|
745
- | an id held by a record the filter **hides** | `403` | `403` |
746
- | an id that is **free** | `200` | `403` |
747
- | an id held by a record the caller **can see** | `409` | `403` |
748
-
749
- Three outcomes, one request per id, the whole id space. Filtering only the
750
- *collision* status narrows that to callers who cannot create a record they are
751
- allowed to see; it does not close it. It cannot be closed while a caller both
752
- chooses the id and learns whether the create succeeded — so under a filter the
753
- caller does not choose the id. The refusal happens before any store lookup, so
754
- neither the status nor the response time depends on whether the id exists.
755
-
756
- Let the server assign the id and read it back from the response — and read it
757
- back rather than predicting it, because the value it returns is documented but
758
- not stable across model kinds: a numeric-id model gets `max + 1` (or, at the
759
- numeric ceiling, the lowest free integer), and a string-id model gets
760
- `<model>-<n>`. See breaking change 8.
761
-
762
- **What a server-assigned id is not.** It is not a secret. On a string-id
763
- collection it is dense and enumerable from `1`, where previously it inherited
764
- whatever entropy the last-inserted id happened to carry — a UUID-seeded store
765
- answered a UUID-derived key. If a collection has **no** `access` config its
766
- record-level routes are ungated, so the id was the only thing standing between
767
- an unauthenticated caller and `GET`/`PATCH`/`DELETE` on a record. That was never
768
- a control and must not become one; configure `access`.
769
-
770
- **And the id itself is an occupancy signal — on both model kinds.**
771
- `assignRecordId` reads the whole store, not the caller's filtered view — it
772
- never sees `state.filter` — so the id it returns is a function of records the
773
- caller may not be permitted to read. **This applies to numeric-id collections
774
- as well as string-id ones**, and the conditions differ, so read both:
775
-
776
- - **String-id collections, always.** The assigned `n` is the smallest positive
777
- integer whose landing key is free, which tells the caller that every key
778
- below it is taken, hidden or not.
779
- - **Numeric-id collections, once one record sits at the numeric ceiling.** The
780
- normal answer is `max + 1`, which discloses only the maximum. But `max + 1`
781
- is not representable at or above 2^53, so the walk restarts from `1` (see
782
- breaking change 8) and the assigned id becomes the smallest free integer —
783
- the same occupancy predicate, now over arbitrary low keys. Each subsequent
784
- no-id `POST` names the next free one, so a caller can enumerate the holes in
785
- a range it cannot read.
786
-
787
- **A ceiling record reaches a filter-protected collection even though `POST`
788
- refuses caller ids on one.** Breaking change 3 makes
789
- `POST /animals {"id": 9007199254740992}` answer `403`, but the same id lands
790
- through a *relationship write on another collection* —
791
- `POST /owners` carrying `attributes: { pets: [{ "id": 9007199254740992 }] }`
792
- creates the animal under that key
793
- ([#207](https://github.com/abofs/stonyx-orm/issues/207), the same channel the
794
- **Known limitations** re-parenting note describes). So the precondition is
795
- reachable by an unauthenticated caller on exactly the collections `access`
796
- exists to protect. Measured on the sample fixture, with every animal hidden by
797
- the `/animals` predicate and keys 4 and 7 deleted:
798
-
799
- ```
800
- GET /animals -> 200 [] (nothing visible)
801
- GET /animals/4 -> 404 (free — indistinguishable from hidden)
802
- POST /animals {"id":4} -> 403 (breaking change 3)
803
- POST /owners {... pets:[{"id":9007199254740992}]} -> 200 (#207 plants the ceiling record)
804
- POST /animals (no id) -> 200 id=4 <- names a hole in the hidden range
805
- POST /animals (no id) -> 200 id=7 <- and the other one
806
- POST /animals (no id) -> 200 id=13
807
- POST /animals (no id) -> 200 id=14
808
- ```
809
-
810
- Closing this requires the assignment to be filter-aware, which is a change to
811
- the `access` contract rather than a fix; it is stated here rather than left to
812
- be discovered. Callers with no function-style filter are unaffected — there are
813
- no hidden records to disclose.
814
-
815
- ### Identifying the collection
816
-
817
- **Do not reconstruct the request path — and since
818
- [#202](https://github.com/abofs/stonyx-orm/issues/202) you do not have to
819
- identify the collection at all.** Read `model` from
820
- [the access context](#the-access-context-second-argument): it is fixed at mount
821
- time, no request can influence it, and there is nothing left to parse.
822
-
823
- **Everything below is the record of what happened when this sample did parse
824
- it.** It is kept as history, not as a recipe — none of these matching strategies
825
- should be written into a new predicate. Every version of this sample that tried
826
- to identify the collection from the request target failed **open**, and each
827
- variant was found only after the previous one was fixed:
828
-
829
- | # | Variant | Why it fails open |
830
- |---|---|---|
831
- | 1 | match `request.url` | `RestServer.mountRoute` mounts each model as an Express **sub-app**, so `url` is mount-relative — `GET /owners/angela` arrives as `/angela`. A `/owners` prefix match is **always false**, so the branch never fires and `access()` falls through to whatever it returns last. |
832
- | 2 | anchored match on a raw `request.originalUrl` | `originalUrl` carries the query string, so `=== '/owners'` misses `/owners?filter[age]=30` and that collection comes back unfiltered. `endsWith('/owners')` is the older half of the same trap: it leaves every record route unguarded. |
833
- | 3 | case-sensitive matcher | `RestServer` mounts with a bare `express()`, whose default is `caseSensitive: false`. A matcher stricter than the router that dispatched the request can simply be stepped around: `GET /owners/angela` → 404 but `GET /OwNeRs/angela` → 200 in full, and `DELETE /ANIMALS/22` destroyed a hidden record. Router-side fix: [stonyx-rest-server#47](https://github.com/abofs/stonyx-rest-server/issues/47). |
834
- | 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. |
835
- | 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. |
836
-
837
- **The fix is not a sixth rule, and it is not a better string to match.** It is
838
- to stop identifying the collection at all. That is a statement about
839
- **identifying the collection**, and it is not a statement about the sample as a
840
- whole: the `/archived` sub-path rule *is* still a string match, and
841
- [#228](https://github.com/abofs/stonyx-orm/issues/228) is a sixth spelling that
842
- gets past it. Sub-path rules are the residue this fix does not cover, which is
843
- why they must normalise the way the router does.
844
-
845
- An intermediate revision read **`request.baseUrl`** — the mount Express
846
- *actually matched*. That closed all five variants: it carries no query string
847
- (variant 2), it is not mount-relative (variant 1), it already contains the
848
- configured `ORM_REST_ROUTE` prefix (variant 4 — there is nothing left to derive,
849
- so `/apiowners` is unconstructible), and it is unaffected by an absolute-form
850
- target (variant 5). It was still a transport artifact standing in for a
851
- structural fact, and it is **no longer what the sample does**: the sample reads
852
- `model`, so variants 1, 2, 4 and 5 are unconstructible against it rather than
853
- handled. **Variant 3 survives**, in the one string comparison the migration
854
- leaves behind: the `/archived` sub-path deny folds case but does not decode
855
- ([#228](https://github.com/abofs/stonyx-orm/issues/228)). The table below is
856
- retained as the measured evidence behind the five variants, not because any of
857
- these values should be matched on:
858
-
859
- | request | `request.url` | `request.originalUrl` | `request.baseUrl` | `request.path` |
860
- |---|---|---|---|---|
861
- | `GET /owners` | `/` | `/owners` | `/owners` | `/` |
862
- | `GET /owners/angela` | `/angela` | `/owners/angela` | `/owners` | `/angela` |
863
- | `GET /owners/angela?filter[age]=30` | `/angela?filter[age]=30` | `/owners/angela?filter[age]=30` | `/owners` | `/angela` |
864
- | `GET /OwNeRs/angela` | `/angela` | `/OwNeRs/angela` | `/OwNeRs` | `/angela` |
865
- | `GET http://anything.example/owners/angela` | `http://anything.example/angela` | `http://anything.example/owners/angela` | `/owners` | `/angela` |
866
- | `GET /api/animals/22` (`ORM_REST_ROUTE=/api`) | `/22` | `/api/animals/22` | `/api/animals` | `/22` |
867
-
868
- **Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
869
- [#237](https://github.com/abofs/stonyx-orm/issues/237) — "Variant 3 survives" above,
870
- and the two paragraphs below, are no longer true.** The access context now carries
871
- `recordId`, the **decoded** route-parameter id, and the sample compares against it:
872
- the one string comparison variant 3 lived in is gone, `#228` is **closed**, and no
873
- read of argument one survives in the sample. The wording is left standing rather
874
- than deleted because it appears at four sites (this file twice,
875
- `src/orm-request.ts`, and the test fixture) and retiring one of four leaves the
876
- shipped copies contradicting each other; retiring all four **with the measurement
877
- that retires them** is [#238](https://github.com/abofs/stonyx-orm/issues/238).
878
-
879
- **One read of argument one survives, and it must: `request.path`.** It is
880
- mount-relative and query-free, and it is for rules that distinguish **sub-paths**
881
- beneath the mount — as the `/archived` deny in the sample above does. The context
882
- names which model and which verb, **not which route**, so that deny *cannot be
883
- expressed from the context alone*, and a context-only rewrite would silently turn
884
- it into an allow.
885
-
886
- **Normalise the way the router that dispatched the request does — and
887
- case-folding alone does not.** A matcher stricter than the router can be stepped
888
- around, so the sample lower-cases before comparing (the router matched
889
- case-insensitively). That closes the case gap and **it is not the whole rule**:
890
- Express sets `request.path` from the **raw, undecoded** pathname while the router
891
- **decodes** `:id`, so `GET /owners/%61rchived` reaches a `path === '/archived'`
892
- comparison as `/%61rchived`, walks past the deny, and is dispatched as the record
893
- `archived`. That gap is live in the sample above and is tracked as
894
- [#228](https://github.com/abofs/stonyx-orm/issues/228) — **do not read the
895
- `.toLowerCase()` there as a complete normalisation recipe.** Record ids are
896
- case-sensitive and must be compared at their real case.
897
-
898
- **Do not follow the two paragraphs above — superseded 2026-09-01 by
899
- [#236](https://github.com/abofs/stonyx-orm/issues/236) /
900
- [#237](https://github.com/abofs/stonyx-orm/issues/237).** They are *instructions*,
901
- not merely stale observations, which is why this note is louder than a date. The
902
- sample no longer reads `request.path` and no longer calls `.toLowerCase()` on
903
- anything it compares: `.toLowerCase()` was measured wrong in **both directions at
904
- once** — with a distinct owner seeded at `ARCHIVED`, `GET /owners/ARCHIVED` was a
905
- false **deny** on the wrong record and `GET /owners/%41RCHIVED` a false **allow**
906
- on that same record. Compare `recordId` **as it arrives**: do not case-fold it, do
907
- not decode it, do not derive it from `request.path`. The contract is
908
- `AccessContext.recordId` in `src/types/orm-types.ts`, which ships and says "Do NOT
909
- case-fold it". Retirement of this wording, with its measurement:
910
- [#238](https://github.com/abofs/stonyx-orm/issues/238).
911
-
912
- **Fail closed on anything you cannot identify — on *either* argument.**
913
- `String(request.originalUrl ?? '')` was once added here to stop a `TypeError`,
914
- and it traded fail-closed for fail-**open**: an empty string matched no
915
- collection, so `access()` fell through to the permission array and granted full
916
- CRUD. The same rule applies to the context — the sample returns `false` for an
917
- absent `model` rather than falling through. Since #202 the guard and the read can
918
- sit on **different objects**, and a guard on argument two does not protect a read
919
- of argument one: the sample therefore also returns `false` when `request.path` is
920
- absent or is not a string, rather than letting `?? ''` fall through to the
921
- per-record filter. An input you cannot identify must **deny**.
922
-
923
- ### Known limitations
924
-
925
- - **A function-style filter is not a guarantee that a hidden record cannot be
926
- modified.** A write to a *different* collection can re-parent one and de-hide
927
- it: `POST /owners` (or `PATCH /owners/{id}`) carrying
928
- `relationships: { pets: { data: { id: 21 } } }` — or
929
- `attributes: { pets: [21, 22] } `, which never enters the relationships loop at
930
- all — re-parents animal 21 onto an owner the caller is permitted to write. The
931
- animal's `owner` is the field the `/animals` predicate reads, so the record
932
- stops being rejected: it becomes readable through `GET /animals/21` and
933
- deletable through `DELETE /animals/21`. **Reachable unauthenticated** wherever
934
- one collection is writable and another is filtered on a field the first can
935
- set. Blocking it requires checking animal 21 against the **animal** model's
936
- predicate while servicing an **owners** route — cross-model access resolution,
937
- which the contract could not express before
938
- [#202](https://github.com/abofs/stonyx-orm/issues/202): `access()` never
939
- received the model structurally and `setup-rest-server.ts` discarded the
940
- model→predicate map at boot. **#202 has landed and both halves now exist** —
941
- see [The access context](#the-access-context-second-argument):
942
- `Orm.instance.getAccess(modelName)` makes another model's predicate
943
- **reachable**, and `context.model` makes a **model-correct answer possible** —
944
- possible, not guaranteed: the resolved predicate has to read the context. The
945
- sample shipped with this repo now does
946
- ([#222](https://github.com/abofs/stonyx-orm/issues/222)), so
947
- `getAccess('animal')` answers with the animal filter; a predicate that ignores
948
- the second argument still answers about the collection the request is
949
- addressed to, and the boot-time warning that surfaces one is
950
- [#221](https://github.com/abofs/stonyx-orm/issues/221). **The mechanism
951
- exists; the ORM does not yet use it on this path.** The re-parenting write above is still
952
- **not refused** — that enforcement is
953
- [#196](https://github.com/abofs/stonyx-orm/issues/196) and
954
- [#207](https://github.com/abofs/stonyx-orm/issues/207), which were blocked on
955
- #202 and are now free to proceed. Until they land, do not rely on a filter to
956
- keep a record unmodifiable; keep the *writable* collections' predicates as tight as
957
- the hidden ones.
958
- - **Authorization by identifying the collection is a consumer-side
959
- reconstruction of information the framework already holds.** `access()`
960
- receives a transport artifact and is asked to work out which model, which
961
- operation and which record the request addresses. The five variants above are
962
- the five ways that has been observed to fail open so far. Tracked as
963
- [#202](https://github.com/abofs/stonyx-orm/issues/202).
964
- - **The two relationship route families now resolve the *related* model's own
965
- access class — `GET /:models/:id/{relationship}` and
966
- `GET /:models/:id/relationships/{relationship}`**
967
- ([#232](https://github.com/abofs/stonyx-orm/issues/232)). This is
968
- **membership**: the related resource is the route's *primary* data, so the
969
- filter decides whether it is served at all, not merely which ids a document
970
- may name. A denied `hasMany` member is **dropped from the array** and nothing
971
- in the relationship marks the drop: `links` intact, no `errors` member, same
972
- status, and an array of survivors shaped exactly like one from a parent that
973
- only ever had those members. A denied `belongsTo` target answers **`200` with
974
- `data: null`**, byte-identical to a target that is genuinely absent, for the
975
- same reason.
976
-
977
- **That is a claim about the relationship, not about the document, and the gap
978
- is measurable in this repo's own fixture.** `owner` declares a computed
979
- `totalPets` returning `this.pets.length`, which reads the **store** and is
980
- never filtered. Measured on this branch, unauthenticated, at zero query
981
- parameters: `GET /owners/gina` answers `attributes.totalPets: 5` while its
982
- `relationships.pets.data` names **four** ids, and both relationship routes
983
- serve the same four. The relationship discloses nothing; the document it
984
- arrives in discloses that exactly one child was withheld. Do not read
985
- "indistinguishable" as a property of the response — it is a property of the
986
- relationship member alone. The other channels in the same class are
987
- [#245](https://github.com/abofs/stonyx-orm/issues/245) (computed attributes,
988
- which is the one measured above) and
989
- [#246](https://github.com/abofs/stonyx-orm/issues/246) (the
990
- `attributes.<fk>` echo of an unresolved `belongsTo` target) — **both still
991
- open**. `included` membership was the third,
992
- [#233](https://github.com/abofs/stonyx-orm/issues/233), and it is **closed**:
993
- a related record its own model's access class rejects is dropped at the
994
- traversal's push site and is no longer a member. **Audit your
995
- computed properties before you treat a dropped member as unobservable.** Nothing on either family errors and no status changes — the
996
- status on these routes belongs to the **parent**, and `data` carries the
997
- answer about the related record. The `/relationships/` family built its `{type, id}` by
998
- hand rather than through `toJSON()`, which is why the linkage filter shipped in
999
- [#234](https://github.com/abofs/stonyx-orm/issues/234) did not reach it.
1000
-
1001
- Before this, both families served a record hidden on every one of its own
1002
- surfaces, in full, from another model's route, at **zero query parameters**.
1003
- The severe case is a model **claimed by no access class**: `getAccess()`
1004
- returns `undefined`, no route is mounted for it at all, and it was still
1005
- readable as a related resource — a collection the consumer deliberately never
1006
- exposed.
1007
-
1008
- **The `belongsTo` shape is not an existence oracle, and it was measured
1009
- rather than reasoned about.** An earlier revision of this fix answered `404`
1010
- for a denied target, which made it distinguishable from a target that is
1011
- genuinely absent. Unauthenticated, zero query parameters, one request each, on
1012
- `tag` — a model with **no route mounted at all**:
1013
-
1014
- ```
1015
- GET /traits/1/tag [target absent] -> 200 application/json 68 bytes
1016
- GET /traits/2/tag [target denied] -> 404 text/plain 9 bytes
1017
- ```
1018
-
1019
- `GET /traits/1` and `GET /traits/2` report `relationships.tag = {"data":null}`
1020
- byte-identical modulo the id, because
1021
- [#234](https://github.com/abofs/stonyx-orm/issues/234) closed that oracle
1022
- deliberately — so this route was the one remaining way to ask which of those
1023
- two nulls was a denial. Under `data: null` both requests answer `200`, same
1024
- content-type, same content-length, same bytes modulo the parent id the caller
1025
- put in the URL. It discloses nothing further: `links` on these routes are
1026
- derived entirely from the parent and the relationship name, there is no `meta`
1027
- and there are no counts. This also brings the two families back into line with
1028
- the module-wide rule under [Filter functions](#filter-functions) — *every
1029
- status on a record route must be identical for "filtered out" and "does not
1030
- exist"* — which the `404` spelling was an exception to.
1031
-
1032
- **Per-record denies for a related resource are not expressible.** A predicate resolved for a
1033
- related resource on these routes receives `recordId: null` and a `request`
1034
- whose `params` name a record of a **different model**. So the inputs it has
1035
- are the model name, the operation and the request — and **a rule that needs to
1036
- know *which* related record it is being asked about cannot be written**.
1037
- Model-level denies (`return false` for a model) work. Request-level denies (a
1038
- rule reading a header, a tenant, the method) work. The per-record **filter**
1039
- shape works too — `access()` may return a function, and that function receives
1040
- the whole record, id included. What does not work is branching on the record's
1041
- identity *before* returning, because `access()` is not told it.
1042
-
1043
- This is not an oversight and it is not closed here. The verdict is resolved
1044
- **once per type**, cached, before any record has been examined — a `hasMany`
1045
- related-resource route returns many records of one type, so seeding `recordId`
1046
- from a record would let the first one decide the context for all of them. The
1047
- rule the framework holds to is: **`recordId` may name a record only where the
1048
- route addresses exactly one record of the model being asked about.** That is
1049
- true for `GET /owners/{id}`, false for linkage, and false for a `hasMany`
1050
- related-resource route.
1051
-
1052
- - **`?include=` records are filtered on both questions now, and so are the
1053
- relationship routes.** *Re-specified twice, each time by the story that
1054
- falsified it, and recorded here rather than deleted. The original said all
1055
- three surfaces were unfiltered.
1056
- [#232](https://github.com/abofs/stonyx-orm/issues/232) made two of them
1057
- filtered and the bullet became "**`?include=` records are still not
1058
- filtered — the relationship routes now are**", with the body "**`?include=owner`
1059
- still does not**: it serializes the related record without resolving that
1060
- class, so a filter on `/owners` does not hide an owner reached through
1061
- `?include=` on `/animals`."
1062
- [#233](https://github.com/abofs/stonyx-orm/issues/233) falsified that half
1063
- too.* `GET /animals/1/owner` and
1064
- `GET /animals/1/relationships/owner` resolve the related model's own access
1065
- class (see the bullet above). **`?include=owner` now resolves it as well**,
1066
- at the traversal's push site, so a filter on `/owners` *does* hide an owner
1067
- reached through `?include=` on `/animals`: measured on this branch,
1068
- `GET /animals/1?include=owner` answers `200` with **no `included` array at
1069
- all**, where before it served the hidden owner's full document.
1070
- There were **two** questions here and they were owned separately — #233, the
1071
- remaining child of
1072
- [#196](https://github.com/abofs/stonyx-orm/issues/196), owns whether a
1073
- resource enters `included` **at all** (membership), and
1074
- [#235](https://github.com/abofs/stonyx-orm/issues/235) owns the
1075
- `relationships.*.data` emitted **inside** a record that is already there
1076
- (linkage). **Both have now landed, and neither closed the other** — they are
1077
- still answered by different mechanisms at different sites, and a resource can
1078
- legitimately be a member while its own linkage is filtered. Membership —
1079
- whether the related resource is served at all — remains a different question
1080
- from which ids a document may *name*, immediately below.
1081
- - **Relationship linkage is filtered on every request-bound surface that
1082
- serializes a record — the reads, the two writes, and `included`.** A
1083
- document's `relationships.*.data` used to publish the id of every related
1084
- record unconditionally, so a record hidden on every one of its own surfaces
1085
- was still named inside another model's document — with no `include=`, no
1086
- relationship route and no query string
1087
- ([#234](https://github.com/abofs/stonyx-orm/issues/234)). The ORM now resolves
1088
- the **related** model's own access class on `GET /:models`, `GET /:models/:id`,
1089
- both `GET /:models/:id/{relationship}` shapes, the `POST /:models` and
1090
- `PATCH /:models/:id` **response documents**, and every record inside an
1091
- `?include=` **`included`** array
1092
- ([#235](https://github.com/abofs/stonyx-orm/issues/235)), and asks it
1093
- `{ model: <related>, operation: 'read' }`. **`operation` is `'read'` even on a
1094
- write route, and that is correct rather than an oversight** — the question
1095
- asked of the *related* model is "may this caller **read** this id", not "may
1096
- they update it". An access class that grants `['create']` but not `['read']`
1097
- on the related model therefore denies that linkage on its own `POST`
1098
- response; that is the fail-closed direction. Do **not** wire these handlers to
1099
- `methodAccessMap[request.method]`: it would ask a different question on a
1100
- write route than on a read route, which is the two-vocabularies failure
1101
- `createLinkageFilter` exists to prevent. An unresolvable class
1102
- (`getAccess()` → `undefined`) and a predicate that throws both **deny**.
1103
-
1104
- **What the two write surfaces cost before #235, measured rather than
1105
- described:** one HTTP verb defeated the filter on the same record. On
1106
- `dev @ 8dda5d6`, seconds apart, with no query string and no relationship
1107
- route, `GET /animals/1` returned `owner.data: null` while `PATCH /animals/1`
1108
- returned **200 naming angela**. Any caller who could read a record could also
1109
- write it and be handed the id the read withheld. That consequence is kept here
1110
- after the fix, and stated as a measurement, because **naming the two handlers
1111
- is not a substitute for it** — a reader who is told only that `POST` and
1112
- `PATCH` are now covered cannot tell what was wrong, and a reviewer cannot tell
1113
- whether the fix addressed it. A
1114
- filtered-out relationship is **indistinguishable from a genuinely empty one** —
1115
- an emptied `hasMany` is `data: []` and an emptied `belongsTo` is `data: null`,
1116
- both **keeping their `links`**, which are built from the serialized record's
1117
- own id and never from the related one. On the two **write** surfaces there are
1118
- no `links` to keep: neither handler passes a `baseUrl`, so a filtered and a
1119
- genuinely-empty relationship are both a bare `{ "data": … }` there. That is
1120
- pre-existing and deliberate — adding `baseUrl` to the write handlers would be
1121
- an unrelated change to their response shape. Nothing errors and no status changes,
1122
- because throwing here would be an existence oracle *and* would throw out of
1123
- the enclosing `JSON.stringify`.
1124
-
1125
- **[#232](https://github.com/abofs/stonyx-orm/issues/232) holds to the same
1126
- spelling on the routes where that linkage is the *primary* data.** A denied
1127
- member is dropped from the `hasMany` array and a denied `belongsTo` target is
1128
- `data: null`, at `200`, `links` intact — so the claim above is true of both
1129
- `GET /:models/:id/{relationship}` shapes as *routes* and not only as linkage
1130
- emitted inside somebody else's document. An earlier revision of #232 answered
1131
- `404` on the `belongsTo` shape and did contradict this paragraph; that is
1132
- measured and closed in the #232 bullet above.
1133
-
1134
- **[#233](https://github.com/abofs/stonyx-orm/issues/233) owns whether a
1135
- related resource appears in `included` at all, and that question is now
1136
- answered too.** #235 filters what a record *already in* `included` may
1137
- **name**; #233 decides **membership**. They remain different questions
1138
- answered by different mechanisms and neither closes the other — a resource
1139
- can legitimately be a member while its own linkage is filtered. A related
1140
- resource is judged by **its own** model's access class at the traversal's
1141
- push site, so a record that class's **per-record filter** rejects is not a
1142
- member, and **the
1143
- subtree beneath it is never traversed**: dropping a parent *after* descending
1144
- through it would publish that parent's exact child set. Measured on
1145
- `dev @ c106cf9`, `GET /animals/1?include=owner,owner.pets` returned nine
1146
- resources — the hidden owner plus her eight animals
1147
- `[1, 3, 7, 10, 11, 15, 17, 20]`, which *is* her `pets` array, reconstructed
1148
- for a caller who is `404` on the parent. It now returns no `included` array
1149
- at all. A model **no access class claims** (`getAccess()` → `undefined`) is
1150
- denied on this path too, so a collection the consumer never exposed is not
1151
- reachable as a sideloaded resource either. **Drop, never error:** a pruned
1152
- sideload is byte-identical to a genuinely empty one — same `200`, the same
1153
- top-level keys, no `included` member on either, no `errors` — so its absence
1154
- carries no signal about whether the record exists.
1155
-
1156
- **Read "per-record filter" literally — it is not "everything the
1157
- record-addressed route refuses", and the difference is measurable.** The
1158
- linkage ask carries `recordId: null`, so a deny expressed as a
1159
- request-scoped `return false` cannot fire on this path at all. The shipped
1160
- sample expresses the `archived` owner's deny that way, and measured
1161
- unauthenticated on this branch — byte-identically on `dev @ b23cfec`, so this
1162
- is not a regression this change introduces —
1163
- `GET /owners/archived` is `403` while `GET /animals/9500?include=owner`
1164
- answers `200` with her full document. That is
1165
- [#243](https://github.com/abofs/stonyx-orm/issues/243)'s mechanism, and #243
1166
- records it on `GET /owners` only; it reaches `included` membership and the
1167
- `#232` related-resource route the same way. **#233 does not close it, and a
1168
- consumer who needs `?include=` to honour a per-record deny must express that
1169
- deny as a filter.**
1170
-
1171
- **And read "carries no signal" as a property of the `included` member, the
1172
- same way [Known limitations](#known-limitations) already asks you to read
1173
- "indistinguishable" — not as a property of the whole response.** The prune is
1174
- unobservable in `included`; the *rest* of the document is a separate
1175
- question, and the same computed-and-serialized-attribute channels recorded
1176
- above still apply to it. Measured counter-example on this very fixture pair:
1177
- `GET /traits/2?include=tag` prunes the unclaimed `tag` from `included` and
1178
- still serves `attributes.tag: "never-mounted"`, while `GET /traits/1` has no
1179
- `tag` key at all — an existence oracle in `attributes`, from
1180
- `src/serializer.ts` echoing the raw foreign key of a `belongsTo` target that
1181
- did not resolve. That is
1182
- [#246](https://github.com/abofs/stonyx-orm/issues/246)'s mechanism reached on
1183
- a **read**, with [#248](https://github.com/abofs/stonyx-orm/issues/248) as
1184
- its precondition — **audit your attributes before you treat a pruned
1185
- sideload as unobservable.**
1186
-
1187
- **That resolves the right class; it does not guarantee a model-correct
1188
- answer, and the failure direction is not the safe one.** Only a predicate that
1189
- *reads* `context.model` can answer about the model it was asked about — see
1190
- [Passing the context makes a model-correct answer *possible*](#passing-the-context-makes-a-model-correct-answer-possible)
1191
- above. A **single-argument predicate remains the default in every consumer
1192
- tree**, it identifies its collection from the request, and asked about
1193
- `owner` on a request dispatched to `/animals` it answers about **animals**.
1194
- Measured against this repo's own fixture with an arity-1 predicate registered
1195
- for `owner`: `GET /owners` correctly returns `["gina","michael","bob"]` while
1196
- `GET /animals/1` returns `owner.data {"type":"owner","id":"angela"}` — the
1197
- #234 defect, on the #234 surface, after the #234 fix. This is not a
1198
- regression (the id was published unconditionally before), it cannot be fixed
1199
- from this side, and the signal that surfaces such a predicate is
1200
- [#221](https://github.com/abofs/stonyx-orm/issues/221) /
1201
- [#213](https://github.com/abofs/stonyx-orm/issues/213). **Migrate your
1202
- predicates to read the context before relying on this filter.** A migrated,
1203
- context-reading predicate degrades the other way — it can over-deny a
1204
- *permitted* related record, which is recorded in the release notes as a
1205
- breaking change.
1206
-
1207
- **Not yet covered by #235. Each still publishes ids the surfaces above
1208
- withhold, except where its own owning issue has since closed it — the first
1209
- entry names an issue that is in flight as this is written:**
1210
-
1211
- - **`GET /:models/:id/relationships/{relationship}`, and its state is #232's
1212
- to report rather than this entry's.**
1213
- [#232](https://github.com/abofs/stonyx-orm/issues/232) owns the
1214
- relationships-linkage route. Its *primary data* is linkage,
1215
- so filtering it is a **membership** decision — which is why it is the filed
1216
- child of [#196](https://github.com/abofs/stonyx-orm/issues/196) and not of
1217
- #234. The route builds its `{type, id}` objects by hand and never calls
1218
- `toJSON`, so the `linkage` **option** never reaches it; whatever that route
1219
- filters, it filters itself. Measured **on `dev @ 8dda5d6`**, the commit
1220
- #235 branched from: `GET /animals/1/relationships/owner` answered
1221
- `{"type":"owner","id":"angela"}` while `GET /owners/angela` was `404`. That
1222
- measurement is pinned to a commit on purpose, so that it does not quietly
1223
- become a false claim about `dev`. **PR
1224
- [#247](https://github.com/abofs/stonyx-orm/pull/247) is in flight against
1225
- this entry**; if it has landed, this route is covered and the bullet #247
1226
- adds above supersedes this one.
1227
- - **A computed attribute that interpolates a related record's id.**
1228
- [#245](https://github.com/abofs/stonyx-orm/issues/245) owns this channel,
1229
- and **it is open as this is written**. `relationships.*.data` is a structure
1230
- this module builds, so it can be filtered; a computed property is arbitrary
1231
- consumer code returning an arbitrary value. Whether that makes the channel a
1232
- **framework defect** the ORM should close — by handing computed getters a
1233
- verdict, or by refusing to run them while a filter is in force — or a
1234
- **consumer contract** the ORM should only document, is the question #245
1235
- must decide. **This README does not decide it; neither reading should be
1236
- read out of the text here.** Measured on this repo's own fixture, where the
1237
- `animal` model has a `get tag()` that interpolates `owner.id`: **every**
1238
- animal document on **every** surface — including the ones above — carries
1239
- `attributes.tag: "angela's small dog"` for an owner that answers `404`. That
1240
- measurement is where #245 starts, and it holds whichever way the decision
1241
- lands. Until it lands, if your access rules hide a record, audit your
1242
- computed properties for its identifiers.
1243
- - **The absence of `attributes.<fk>` on a `POST` response proves a hidden
1244
- record exists** — [#246](https://github.com/abofs/stonyx-orm/issues/246).
1245
- `createHandler` copies each supplied relationship's raw id into
1246
- `attributes`; when the related record resolves, the value is consumed and
1247
- does not appear, and when it does not resolve, it survives. The oracle runs
1248
- in the negative space, so nothing this list's surfaces withhold is
1249
- *published* — the **absence** is the signal. Pre-existing, and it does not
1250
- compose with the two relationship families above: they emit no `attributes`
1251
- for a related record at all.
1252
- - **A bare `toJSON()` still emits unfiltered linkage, and that is deliberate.**
1253
- `Record.toJSON()` **applies** a verdict; it never **resolves** one. It has no
1254
- request, and the documented `access()` contract permits a predicate to read
1255
- one — the sample in this README does, for its sub-path rule — so a filter
1256
- resolved inside `toJSON()` denies *permitted* records rather than hidden ones
1257
- (measured: 967 → 964, all three failures over-denials). `toJSON` is also the
1258
- `JSON.stringify` hook, so `JSON.stringify(record)`, `res.json(record)` and
1259
- `console.log(JSON.stringify(record))` reach it with a **string** in the
1260
- options slot and have no syntactic place to pass a verdict. The no-argument
1261
- call therefore returns the pre-#234 document unchanged. Fail-closed by default
1262
- is not available either: `Orm.instance.accessFunctions` is `{}` in any process
1263
- that never ran `setup-rest-server` — a CLI, an SQL-only process, a test — so
1264
- it would empty every relationship on every document in processes with no REST
1265
- surface to protect. Closing the residual means moving JSON:API serialization
1266
- **off** the `toJSON` name, tracked as
1267
- [#230](https://github.com/abofs/stonyx-orm/issues/230). If you hand a `Record`
1268
- to an untrusted consumer, serialize it through the REST layer, or resolve a
1269
- verdict with the **exported** `createLinkageFilter(request)` and pass it as
1270
- the `linkage` option — do not write your own reading of `access()`. This is a
1271
- consumer obligation with no signal when it lapses; it is stated once, in full,
1272
- under [Consumer Contracts](#consumer-contracts) below.
1273
- - **`format()` and `serialize()` are deliberately not filtered, and must stay
1274
- that way.** `format()` is the **persistence** path — its output is what
1275
- `Orm.db.save()` writes to disk — so applying an access filter there would
1276
- write a truncated database. That is **data loss**, not disclosure prevention.
1277
- Neither method appears anywhere in the REST response path.
1278
- - **A before-hook that returns a value short-circuits the request.** On write
1279
- operations addressed to a record the filter is consulted first, so a hook
1280
- cannot answer for a record the caller may not see. On reads it is not, so a
1281
- `beforeHook('get', ...)` read-through cache can answer past the filter.
1282
- - **`beforeHook('create', ...)` still runs for a `POST` that is denied.** For
1283
- `create` there is no record to test until the handler has built one, so the
1284
- denial is not knowable in time. Every *after*-hook is gated, and every
1285
- before-hook on `update` and `delete` is gated; before-`create` is the one
1286
- exception. A before-`create` hook must not assume the create will succeed.
1287
- - **A caller can still learn that a collection *has* a per-record filter**, by
1288
- observing `403` rather than `409`/`200` for an id-bearing `POST`. That
1289
- discloses a configuration fact, not the existence of any record.
1290
- - **Enforcement is post-fetch.** The record is loaded and then tested, which
1291
- leaves a small timing difference between a hidden record and one that never
1292
- existed. Tracked as [#197](https://github.com/abofs/stonyx-orm/issues/197).
1293
- - **A `relationships` key that is not a declared relationship is still applied
1294
- to the record.** The key comes verbatim from the request body and is checked
1295
- against nothing except `id`, which is stripped. On a `POST` that makes an
1296
- undeclared key a mass-assigned attribute; on a `PATCH` it is passed to
1297
- `updateRecord`. `id` is stripped in both handlers because it defeats breaking
1298
- change 3 above; the general form is tracked as
1299
- [#204](https://github.com/abofs/stonyx-orm/issues/204).
1300
- - **A `POST` body `id` that the duplicate check cannot resolve can still
1301
- overwrite a different record on an unfiltered collection.** The lookup is
1302
- correct and deliberately does not coerce: `"9105h"` is rejected as a string
1303
- rather than truncated, and `9105.5`, `[9105]` and `true` are passed through as
1304
- themselves. The model's id transform then coerces anyway — a bare `parseInt`
1305
- with no such guard — so the create lands on `9105` (or on `NaN`) and
1306
- overwrites whatever is there. **This is not string-only**: any body id whose
1307
- transform output differs from its lookup key is the same defect. Filtered
1308
- collections are unaffected — breaking change 3 refuses any client-supplied id
1309
- — so this reaches consumers with **no** function-style filter. Tracked as
1310
- [#205](https://github.com/abofs/stonyx-orm/issues/205). Its sibling
1311
- [#203](https://github.com/abofs/stonyx-orm/issues/203) — a **server-assigned**
1312
- id landing on an occupied slot — is **fixed**; see breaking change 8. #205 is
1313
- the client-supplied half and is still open.
1314
- - **`context.record` is `undefined` for an after-`create` hook when a string-id
1315
- model is given a numeric-looking id.** The post-create lookup uses the same id
1316
- coercion as every other surface, which resolves `'9107'` to the number `9107`,
1317
- while a model declaring `id = attr('string')` files the record under the string
1318
- key. The create itself succeeds and `context.response.data` is correct; only
1319
- the hook's view of the record is wrong, and it is wrong *silently*. Tracked as
1320
- [#209](https://github.com/abofs/stonyx-orm/issues/209).
1321
- - **A denied `POST` rolls back only a record it *inserted*.** The rollback
1322
- requires the store to have grown, because removing by id alone is a write
1323
- primitive keyed by a caller-supplied value. **The reachability condition this
1324
- bullet used to state is gone**: it was
1325
- [#203](https://github.com/abofs/stonyx-orm/issues/203) — `assignRecordId`
1326
- returned last-*inserted* + 1, so a server-assigned id could land on an
1327
- occupied slot and `createRecord` would update it in place — and #203 is fixed
1328
- (breaking change 8). A server-assigned create can no longer overwrite, so on a
1329
- collection whose only id channel is `createHandler` this guard has no
1330
- observable effect today. It is kept because a caller-supplied id reaching
1331
- `createRecord` from another route — a relationship write,
1332
- [#207](https://github.com/abofs/stonyx-orm/issues/207) — puts the condition
1333
- back, and without the guard a denied `403` would delete a record the request
1334
- did not create.
1335
-
1336
- ### Consumer Contracts
1337
-
1338
- Obligations this package **cannot enforce**, where nothing fails, warns or
1339
- changes shape when a consumer omits them. One place, findable, per
1340
- `quality.md` rule 2 — if you are relying on `@stonyx/orm` for access control,
1341
- read all of these.
1342
-
1343
- #### `Record.toJSON()` does not filter relationship linkage unless you pass a verdict
1344
-
1345
- **The framework resolves a verdict for you on every request-bound surface that
1346
- serializes a record through `toJSON()`. You own it everywhere else.**
1347
-
1348
- Those surfaces are `GET /:models`, `GET /:models/:id`, both shapes of
1349
- `GET /:models/:id/{relationship}`, the `POST /:models` and `PATCH /:models/:id`
1350
- **response documents**, and every record inside an `?include=` **`included`**
1351
- array ([#234](https://github.com/abofs/stonyx-orm/issues/234) for the four
1352
- reads, [#235](https://github.com/abofs/stonyx-orm/issues/235) for the two
1353
- writes and `included`). Each resolves a linkage verdict and passes it to
1354
- `toJSON()` for you.
1355
-
1356
- **`GET /:models/:id/relationships/{relationship}` is not on that list, and its
1357
- state is not this section's to report.** It builds its `{ type, id }` objects by
1358
- hand instead of calling `toJSON()`, so the `linkage` **option** never reaches it
1359
- — whatever that route filters, it filters itself. And because its linkage *is*
1360
- its primary data, filtering it is a **membership** decision rather than a
1361
- linkage one. Membership on both relationship route families is owned by
1362
- [#232](https://github.com/abofs/stonyx-orm/issues/232) (PR
1363
- [#247](https://github.com/abofs/stonyx-orm/pull/247), in flight as this is
1364
- written); read that issue for its state rather than inferring it here, because
1365
- this section describes only what `toJSON()` filters.
1366
-
1367
- Any other path to a document — `JSON.stringify(record)`, `res.json(record)`,
1368
- `console.log(record)`, a custom route, a queue payload, a websocket frame —
1369
- calls `toJSON()` with no verdict, and **the no-verdict document names every
1370
- related id, including records hidden on every one of their own surfaces**
1371
- ([#234](https://github.com/abofs/stonyx-orm/issues/234)). That default is
1372
- deliberate and cannot be inverted; the reasons are in
1373
- [Known limitations](#known-limitations) above.
1374
-
1375
- **There is no signal when you omit it.** `linkage` is optional, absent is the
1376
- default, the default is the unfiltered document, and a filtered relationship is
1377
- byte-identical to a genuinely empty one — so nothing on the wire distinguishes
1378
- "filtered" from "forgotten".
1379
-
1380
- **And `linkage` cannot reach a document you build by hand.** It is an *option to
1381
- `toJSON()`*, so it filters only what goes through `toJSON()`. The ORM's own
1382
- `GET /:models/:id/relationships/{relationship}` route is the worked example: its
1383
- primary data *is* linkage, it assembles `{ type, id }` directly rather than
1384
- serializing a record, and it therefore resolves and applies the verdict itself
1385
- ([#232](https://github.com/abofs/stonyx-orm/issues/232)). If you assemble
1386
- linkage the same way anywhere — a custom relationship route, a projection, a
1387
- hand-built document — **passing `linkage` to `toJSON()` does nothing for it and
1388
- nothing warns**. Build the filter and consult it before you emit an id:
1389
-
1390
- ```js
1391
- const linkage = createLinkageFilter(request);
1392
-
1393
- if (related && linkage(related.__model.__name, related)) {
1394
- data = { type: related.__model.__name, id: related.id };
1395
- } else {
1396
- data = null; // withheld and genuinely-empty must be the SAME answer
1397
- }
1398
- ```
1399
-
1400
- The `else` branch is the part that is easy to get wrong. Answering `404`, `403`
1401
- or an `errors` member for the withheld case makes the route an **existence
1402
- oracle** — see [Filter functions](#filter-functions) for the rule and for the
1403
- measurement that closed it on this route.
1404
-
1405
- **A per-record deny for a *related* resource cannot be expressed, and nothing
1406
- tells you so at the point you would write it.** `createLinkageFilter` resolves
1407
- the related model's access class by **type**: `context.recordId` is `null`, and
1408
- `request.params` names a record of a **different model** — the one the route is
1409
- addressed to. So `access()` is handed the model, the operation and the request,
1410
- and **a rule that has to know *which* related record it is being asked about
1411
- cannot be written**. Model-level denies (`return false` for a model) work.
1412
- Request-level denies (a header, a tenant, the method) work. The per-record
1413
- **filter** shape works too — `access()` may return a function, and that function
1414
- receives the whole record, id included. What does not work is branching on the
1415
- record's identity *before* returning, because `access()` is not told it.
1416
-
1417
- This is a fixed property of the mechanism rather than a defect awaiting a fix.
1418
- The verdict is resolved **once per type** and cached before any record has been
1419
- examined, so seeding `recordId` from a record would let the first member of a
1420
- `hasMany` decide the context for all of them. The rule the framework holds to is
1421
- **`recordId` may name a record only where the route addresses exactly one record
1422
- of the model being asked about** — true for `GET /owners/{id}`, false for
1423
- linkage, and false for a `hasMany` related-resource route. The consumer-facing
1424
- consequence is the part to check: a predicate that branches on `recordId` sees
1425
- `null` here and takes whichever branch `null` takes, with no warning, and if
1426
- that branch grants then it **grants**. Express the rule as a returned filter
1427
- function instead. Stated again, with the same label, under
1428
- [Known limitations](#known-limitations)
1429
- ([#232](https://github.com/abofs/stonyx-orm/issues/232)).
1430
-
1431
- Do this:
1432
-
1433
- ```js
1434
- import { createLinkageFilter } from '@stonyx/orm';
1435
-
1436
- // `request` is the live request the caller was authorised against. The verdict
1437
- // is REQUEST-SCOPED: build one per request and never cache it across requests,
1438
- // or a second caller is answered with the first caller's authorization.
1439
- const linkage = createLinkageFilter(request);
1440
-
1441
- res.json({ data: record.toJSON({ baseUrl, linkage }) });
1442
- ```
1443
-
1444
- Not this:
1445
-
1446
- ```js
1447
- // A second, unreviewed reading of access(). It will drift from the one in
1448
- // src/access-verdict.ts, and it will drift in consumer code where no reviewer
1449
- // of this repository will ever see it.
1450
- const linkage = (type, r) => Orm.instance.getAccess(type)?.(request)?.(r) ?? true;
1451
- ```
1452
-
1453
- **`createLinkageFilter` requires a live request, and there is no safe call
1454
- without one.** `request` is the only authorization input the filter has — it is
1455
- handed straight to your `access()` predicates, and a predicate that does not
1456
- *read* it cannot fail closed when it is missing. Passing `undefined`, `null` or
1457
- any non-object therefore denies **all** linkage and logs, once, at construction.
1458
- Measured before that guard existed, `createLinkageFilter(undefined)` granted
1459
- four of the five models in this repository's own fixture, silently.
1460
-
1461
- **This is the catch for the request-less contexts named above.** In a queue
1462
- consumer or a websocket handler there is no live request, so there is nothing to
1463
- authorize against and nothing this package can resolve for you. Either carry the
1464
- originating request through to the point of serialization, or publish no linkage
1465
- at all — `record.toJSON({ linkage: () => false })` emits the document with every
1466
- relationship empty. A stand-in is **not** a substitute: `{}` is an object
1467
- and passes the guard, and any predicate that ignores its request will grant.
1468
-
1469
- **`linkage` itself is validated, and an unusable value DENIES.** `undefined`
1470
- means "no verdict supplied" and emits today's document. Anything else must be a
1471
- **synchronous function that answers with a boolean**. Each of the following
1472
- drops **all** linkage on that document and logs once:
1473
-
1474
- - **A non-function** — `null`, `0`, `false`, `''`, `true`, a string, an object.
1475
- `null` is the natural return of a resolver that could not resolve a session:
1476
- it used to be read as "absent" and emit the full document silently.
1477
- - **An `async` function, a generator function, or any predicate that returns a
1478
- promise or thenable.** `toJSON` is the `JSON.stringify` hook and cannot await
1479
- a verdict, and **an `async` resolver returns a promise, a promise is
1480
- truthy**, so every related id was published, silently, exactly as if this fix
1481
- were not here. If your
1482
- authorization lookup is asynchronous, `await` it *before* you serialize and
1483
- close over the result.
1484
- - **Any answer that is not a boolean** — `{}`, `'no'`, `1`, `undefined`. A
1485
- non-boolean is a resolver that did not answer, and a truthy one granted.
1486
- - **A predicate that throws**, including a `class` passed by mistake. It is
1487
- caught and denied; it used to escape the enclosing `JSON.stringify` and take
1488
- the rest of that serialization down with it.
1489
-
1490
- #### A predicate that ignores `context.model` makes cross-model resolution GRANT
1491
-
1492
- The linkage filter above asks the **related** model's access class the
1493
- model-correct question, but only a predicate that *reads*
1494
- [`context.model`](#the-access-context-second-argument) can give a model-correct
1495
- answer. A single-argument predicate identifies its collection from the request
1496
- and therefore answers about the collection the request was *addressed to* —
1497
- which is the direction that **grants**. Measured, and worked through in
1498
- [Known limitations](#known-limitations). There is no boot-time warning yet
1499
- ([#221](https://github.com/abofs/stonyx-orm/issues/221)). **Migrate your
1500
- predicates to the two-argument contract.**
1501
-
1502
- #### `format()` and `serialize()` are never filtered, by design
1503
-
1504
- They are the persistence path. Do not hand their output to an untrusted
1505
- consumer, and do not add a filter to them — `Orm.db.save()` writes `format()`
1506
- output to disk, so filtering there is data loss rather than disclosure
1507
- prevention.
1508
-
1509
- ### Breaking changes
1510
-
1511
- These land in the next published build. There is no changelog or release-notes channel yet
1512
- ([stonyx-workflows#17](https://github.com/abofs/stonyx-workflows/issues/17)), so
1513
- they are recorded here.
1514
-
1515
- 1. **`DELETE /{collection}/{id}` on a record that does not exist returns `404`
1516
- instead of `204`.** This affects **every** consumer issuing a DELETE against
1517
- any mounted collection, whether or not an access filter is configured, and
1518
- `models: '*'` mounts every model by default. It is not optional: if a denied
1519
- delete returned 404 while a missing one returned 204, the pair would be a
1520
- perfect existence oracle and the filter would be worthless.
1521
- 2. **After-hooks no longer fire for a request that failed** — denied, missing,
1522
- `400` or `409`. The gate is on the handler's status, not on the operation, so
1523
- it covers reads as well: a `GET /:id` that answers 404 runs no after-`get`
1524
- hook either. Previously `afterHook('delete', ...)` ran with a populated
1525
- `context.recordId` on a request that deleted nothing, so a consumer cascade
1526
- destroyed children behind a 404.
1527
- 3. **`POST` with a client-supplied `id` returns `403` when a function-style
1528
- `access` filter is in force**, whatever the payload and whether or not the id
1529
- exists, and *before* any store lookup — so neither the status nor the lookup
1530
- cost can depend on whether that id exists. Only affects function-style
1531
- `access` users. See [Filter functions](#filter-functions) for why, and let the
1532
- server assign the id instead. `409`-on-duplicate is unchanged for everyone
1533
- else.
1534
-
1535
- "Whatever the payload" is a statement about the **`id` member of the resource
1536
- object**, and it holds only because that is the sole channel a caller id can
1537
- arrive on. It was not always: a caller id moved into
1538
- `relationships: {"id": {"data": {"id": 21}}}` reached `createRecord` past this
1539
- refusal and overwrote a hidden record in place. Both strips — `attributes.id`
1540
- and `relationships.id` — are part of this behaviour, not tidiness. A future
1541
- change that adds a third channel without stripping it re-opens the oracle;
1542
- see [#204](https://github.com/abofs/stonyx-orm/issues/204).
1543
- 4. **Function-style `access` is now enforced on all seven surfaces.** Records
1544
- previously reachable by id despite being filtered from the collection now
1545
- return 404. Only affects function-style `access` users, for whom the old
1546
- behaviour was the bypass.
1547
-
1548
- "Seven surfaces" means the seven endpoints of **the filtered model** —
1549
- `GET /:models`, `GET /:models/:id`, `GET /:models/:id/{relationship}`,
1550
- `GET /:models/:id/relationships/{relationship}`, `POST /:models`,
1551
- `PATCH /:models/:id` and `DELETE /:models/:id`. That count is still seven and
1552
- is still the model's own endpoints, but **it is no longer the whole
1553
- population**: the boundary moved outward rather than the number changing, and
1554
- this sentence used to be read as saying a filtered model's predicate is
1555
- consulted nowhere else. It now is, in two further places, both from
1556
- *another* model's routes —
1557
-
1558
- - on **both relationship route families**, where the related record is the
1559
- primary data and the filtered model's own class decides whether it is
1560
- served at all (breaking change 9 below,
1561
- [#232](https://github.com/abofs/stonyx-orm/issues/232)); and
1562
- - on the `relationships.*.data` **linkage** of every request-bound surface
1563
- that serializes a record through `toJSON()`
1564
- ([#234](https://github.com/abofs/stonyx-orm/issues/234),
1565
- [#235](https://github.com/abofs/stonyx-orm/issues/235)), which decides
1566
- which ids another model's document may name.
1567
-
1568
- A **write** to another collection can still reach one of its records through
1569
- a relationship — [#207](https://github.com/abofs/stonyx-orm/issues/207),
1570
- which is **not** closed here. That is the half of the old sentence that
1571
- survives, and it is a read/write asymmetry now rather than a blanket
1572
- statement about cross-model reach.
1573
- 5. **A predicate that throws is treated as a denial** rather than propagating to
1574
- Express's default 500 handler. So is an `access()` that throws.
1575
- 6. **`access()` returning a bare string is one permission, not full access.**
1576
- `AccessMethod` declares `string` legal, and it previously fell through every
1577
- branch and granted all four operations — `return 'read'` allowed `DELETE`.
1578
- It is now equivalent to `['read']`. Any other unrecognised shape (an object,
1579
- a number) now returns `403` rather than granting full access.
1580
- 7. **`POST` with a client-supplied `id` normalises the body id before the
1581
- duplicate check**, so an id shape that previously *missed* the store's key
1582
- now finds it. `POST {"id":"0x2391"}` and `POST {"id":" 21 "}` answer `409`
1583
- where they answered `200`, and the `200` was not a success: the lookup missed,
1584
- the duplicate check was skipped, and `createRecord` overwrote the colliding
1585
- record in place. **This one reaches consumers with no filter at all** — the
1586
- population breaking changes 3 and 4 explicitly exempt. If you were relying on
1587
- a hex-shaped or whitespace-padded id creating a second record, it never did.
1588
-
1589
- 8. **Server-assigned ids change value on string-id models, numeric ids stop
1590
- being monotonic at the numeric ceiling, and the create route gains a
1591
- `409`.** Three consumer-visible changes from
1592
- [#203](https://github.com/abofs/stonyx-orm/issues/203).
1593
-
1594
- **The value.** A `POST` with no `id` against a model declaring
1595
- `id = attr('string')` previously produced the *last-inserted* id with `1`
1596
- concatenated onto it — an owner store holding `['gina', 'bob']` answered
1597
- `'bob1'`. It now answers `'owner-1'`: the model name, a hyphen, and the
1598
- lowest positive integer whose landing key is free. **No test in this repo
1599
- pinned the old value**, so a consumer relying on it gets no failing test, no
1600
- deprecation and no other signal — which is why it is recorded here. Numeric
1601
- id models (`id = attr('number')`, the default) are unaffected in shape: they
1602
- still get an integer, but it is now the **maximum** existing id plus one
1603
- rather than the last-inserted id plus one, which is the defect #203 is about.
1604
- They are **not** unaffected in *sequence* — see the monotonicity half below.
1605
-
1606
- The value is deliberately **not** numeric-looking, and that is not cosmetic.
1607
- Every id-bearing surface resolves a numeric-looking string id to a **number**
1608
- (`GET /owners/1` looks up `1`), while a string-id model files its records
1609
- under the **string** key `'1'`. A server-assigned `'1'` would therefore be a
1610
- record that was created successfully and could not be fetched, updated or
1611
- deleted by id, and whose after-`create` hook received
1612
- `context.record === undefined`
1613
- ([#209](https://github.com/abofs/stonyx-orm/issues/209)).
1614
-
1615
- **Numeric ids are no longer monotonic, and deleted ids can be re-issued.**
1616
- The precondition is narrow but it is reachable, and there is no signal when
1617
- it is met: **one record filed at or above 2^53** (`9007199254740992`). `max
1618
- + 1` is not representable there, so assignment restarts from `1` and walks
1619
- up to the lowest free key — which means the id of a *deleted* record is
1620
- handed to the next `POST`. Both `dev` and every prior release were strictly
1621
- monotonic and never re-issued a numeric id, so a consumer that relied on
1622
- that — audit rows, cursors, cached authorization decisions, external
1623
- references keyed on the id — now has a stale reference that silently points
1624
- at a **different record, created by a different caller**, rather than at a
1625
- deleted one. Nothing fails; the reference simply resolves to the wrong
1626
- record.
1627
-
1628
- The restart is deliberate and is not itself optional: without it, one record
1629
- at the ceiling made every subsequent server-assigned create on that
1630
- collection fail permanently. Re-use is the cost of keeping the collection
1631
- writable. **If you need monotonic ids, assign them yourself** rather than
1632
- letting the server assign, and note that a ceiling record can be planted by
1633
- an unauthenticated caller — see *And the id itself is an occupancy signal*
1634
- under [Filter functions](#filter-functions) for the reachability path.
1635
- String-id models are unaffected by this half: their keys are
1636
- `<model>-<n>` and were never monotonic over an integer sequence.
1637
-
1638
- **The status.** `POST /{collection}` can now answer `409` for a reason other
1639
- than a duplicate id: the server could not derive a free id. That requires a
1640
- **non-injective** id transform — one that maps distinct candidates onto the
1641
- same store key, such as `boolean`, or anything you registered on
1642
- `Orm.instance.transforms` and named as an id type. It is a configuration
1643
- fault rather than a request fault; the message is logged through
1644
- `stonyx/log`. Previously this case threw out of the handler and express
1645
- answered `500` with a stack trace.
1646
-
1647
- 9. **Both relationship route families now resolve the *related* model's own
1648
- access class, so a related record that is hidden on its own routes is no
1649
- longer served through another model's.**
1650
- [#232](https://github.com/abofs/stonyx-orm/issues/232). Affects
1651
- function-style `access` users with relationships between filtered models. The
1652
- old behaviour was a bypass at **zero query parameters** and with no
1653
- `include=`: measured on `dev @ 8dda5d6`, `GET /animals/1/owner` returned
1654
- angela's full document and `GET /animals/1/relationships/owner` returned
1655
- `{"type":"owner","id":"angela"}`, while `GET /owners/angela` answered `404`.
1656
- The severe case is a model claimed by **no** access class — `getAccess()`
1657
- returns `undefined`, no route is mounted for it at all, and it was still
1658
- readable as a related resource.
1659
-
1660
- **The shapes, on both families.** A denied `hasMany` member is **dropped from
1661
- the array**: `200`, `links` intact, no `errors` member. A denied `belongsTo`
1662
- target answers **`200` with `data: null`**, byte-identical to a target that
1663
- genuinely does not exist — same status, same bytes modulo the parent id the
1664
- caller put in the URL. `404` on these routes is now reserved for the
1665
- **parent**.
1666
-
1667
- **`data: null` and not `404`, deliberately.** The 404 spelling was an
1668
- existence oracle and was measured as one on this branch: unauthenticated, no
1669
- query string, one request each, against `tag` — the model with no route
1670
- mounted at all — `GET /traits/1/tag` (absent) answered `200`
1671
- `application/json` at 68 bytes while `GET /traits/2/tag` (denied) answered
1672
- `404` `text/plain` at 9 bytes, and the document surface reported both as
1673
- `{"data":null}`. It also brings these two routes into line with this module's
1674
- rule that every status on a record route is identical for filtered-out and
1675
- does-not-exist (see [Filter functions](#filter-functions)), which the `404`
1676
- spelling was the one exception to.
1677
-
1678
- **What to check before you upgrade.** If a consumer reaches a related record
1679
- through `GET /:models/:id/{relationship}` that it cannot reach on that
1680
- record's own collection route, it was relying on the bypass and will now get
1681
- `data: null` or a shorter array. And the related model's class is resolved
1682
- through the same `Orm.instance.getAccess` path as the linkage filter, so it
1683
- inherits the same arity limit: a **single-argument** predicate answers about
1684
- the collection the request was *addressed to*, not the one it was asked
1685
- about, and that is the direction that **grants**. See
1686
- [Known limitations](#known-limitations) and
1687
- [#221](https://github.com/abofs/stonyx-orm/issues/221).
1688
-
1689
- **Not closed here:** whether a related resource appears in `included` at all
1690
- ([#233](https://github.com/abofs/stonyx-orm/issues/233)), and the
1691
- re-parenting write ([#207](https://github.com/abofs/stonyx-orm/issues/207)).
1692
- A **per-record** deny for a related resource is not expressible on these
1693
- routes at all — see [Consumer Contracts](#consumer-contracts).
1694
-
1695
325
  ### Include Parameter (Sideloading Relationships)
1696
326
 
1697
327
  The ORM supports JSON API-compliant relationship sideloading via the `include` query parameter. This reduces the need for multiple API requests by embedding related records in a single response.
@@ -1779,41 +409,11 @@ GET /animals/1
1779
409
  2. Recursively traverses relationships depth-first
1780
410
  3. Deduplication still by type+id (no duplicates in included array)
1781
411
  4. Gracefully handles null/missing relationships at any depth
1782
- 5. Each related record is judged by **its own** model's access class at the
1783
- push site before it is added, so a denied record neither enters `included`
1784
- nor becomes a parent at the next depth
1785
- ([#233](https://github.com/abofs/stonyx-orm/issues/233)) — see the
1786
- Limitations below for what "denied" does and does not cover
1787
- 6. Each included record gets full `toJSON()` representation, with its
1788
- **linkage filtered** by the same verdict object the primary document was
1789
- serialized with ([#235](https://github.com/abofs/stonyx-orm/issues/235))
412
+ 5. Each included record gets full `toJSON()` representation
1790
413
 
1791
414
  #### Limitations
1792
415
 
1793
416
  - Only available on GET endpoints (not POST/PATCH)
1794
- - **`included` is access-filtered on both of the two questions.** *Re-specified
1795
- by [#233](https://github.com/abofs/stonyx-orm/issues/233). The sentence this
1796
- replaces said membership "is still unfiltered (#233): a record that is 404 on
1797
- its own routes is still served as an `included` resource, attributes and
1798
- all."* What a record already in `included` may **name** in its own
1799
- `relationships.*.data` is filtered
1800
- ([#235](https://github.com/abofs/stonyx-orm/issues/235)) — `?include=` no
1801
- longer republishes ids the primary document withholds. Whether a resource
1802
- appears in `included` **at all** is *membership*, and #233 filters it: a
1803
- related resource is judged by **its own** model's access class at the
1804
- traversal's push site, so a record that class's **per-record filter** rejects
1805
- is not a member, and the subtree beneath it is never traversed.
1806
- - **Membership is filtered by the per-record filter, not by everything a
1807
- record-addressed route refuses.** A deny expressed as a request-scoped
1808
- `return false` — the shape the shipped sample uses for the `archived`
1809
- owner — is **not** expressible on this path, because the linkage ask carries
1810
- `recordId: null`. Measured on this branch and byte-identically on `dev`:
1811
- `GET /owners/archived` is `403` while `GET /animals/9500?include=owner`
1812
- serves her document in full. That is
1813
- [#243](https://github.com/abofs/stonyx-orm/issues/243)'s mechanism, not
1814
- #233's, and it reaches every linkage surface rather than only `GET /owners`.
1815
- Express a per-record deny as a **filter** if you need `?include=` to honour
1816
- it. See [Consumer Contracts](#consumer-contracts).
1817
417
 
1818
418
  ## Lifecycle Hooks
1819
419
 
@@ -1868,16 +468,6 @@ Each hook receives a context object with comprehensive information:
1868
468
  - It contains a deep copy of the record's state **before** the operation executes (captured before the `before` hook fires)
1869
469
  - The deep copy is created via JSON serialization (`JSON.parse(JSON.stringify())`) to ensure complete isolation
1870
470
  - For `delete` operations, `recordId` is provided in after hooks since the record may no longer exist in the store
1871
- - **`context.recordId` here is NOT `AccessContext.recordId`.** Same name, same-shaped
1872
- object, different coverage: `_withHooks` sets this key **only** under
1873
- `operation === 'delete'`, so on `get` / `list` / `create` / `update` the key is
1874
- **absent** — `beforeHook('update', 'owner', ctx => ctx.recordId === 'archived' ? 403 : undefined)`
1875
- never fires (measured: `PATCH /owners/{id}` → 200 with `ctx.recordId === undefined`
1876
- and the id sitting in `ctx.params`). The access context, by contrast, carries
1877
- `recordId` on every route it classifies and spells absence as `null`, never
1878
- `undefined`. Tracked as
1879
- [#242](https://github.com/abofs/stonyx-orm/issues/242); see
1880
- `AccessContext.recordId` in `src/types/orm-types.ts` for the other side.
1881
471
  - `oldState` is captured as a deep copy of the record's data before the operation, providing access to the previous field values
1882
472
 
1883
473
  ### Usage Examples
@@ -2005,19 +595,7 @@ afterHook('delete', 'animal', async (context) => {
2005
595
  // Additional access control - halt with 403 if unauthorized
2006
596
  beforeHook('delete', 'animal', (context) => {
2007
597
  const user = context.state.currentUser;
2008
-
2009
- // `context.oldState` — NOT a store lookup. For `update` and `delete` the ORM
2010
- // has already fetched the record (and already applied the access filter to
2011
- // it) before this hook runs, so re-fetching it here is a fourth id coercion
2012
- // that has to agree with three others.
2013
- //
2014
- // And it would not agree. `context.params.id` is the raw url segment, always
2015
- // a string, while the store keys numeric-id models by NUMBER — so
2016
- // `store.get('animal', '21')` misses the record held under `21`, and so does
2017
- // `store.find('animal', '21')`: neither coerces. The miss reads as "no such
2018
- // record", which in an authorization hook fails whichever way your code
2019
- // happens to handle a null.
2020
- const animal = context.oldState;
598
+ const animal = store.get('animal', context.params.id);
2021
599
 
2022
600
  if (animal.owner !== user.id && !user.isAdmin) {
2023
601
  return 403; // Forbidden
@@ -2025,10 +603,6 @@ beforeHook('delete', 'animal', (context) => {
2025
603
  });
2026
604
  ```
2027
605
 
2028
- > If you do need a lookup for some *other* model inside a hook, coerce the id
2029
- > yourself to the type that model's `id` attribute declares — the store is a
2030
- > `Map` and `'21'` and `21` are different keys.
2031
-
2032
606
  #### Auditing
2033
607
 
2034
608
  ```javascript
@@ -2162,29 +736,11 @@ beforeHook('create', 'post', (context) => {
2162
736
 
2163
737
  ### Hook Execution Order
2164
738
 
2165
- 1. **Authorization is evaluated first for `update` and `delete`.** A record the
2166
- access filter rejects returns `404` **before any before-hook runs**, so a
2167
- hook never sees a record or a `context.oldState` — that the caller is not
2168
- allowed to read. `create` is the exception: there is no record to test until
2169
- the handler has built one, so `beforeHook('create', ...)` **does** fire for a
2170
- `POST` that goes on to answer `403`.
2171
- 2. **Before hooks** fire next (sequentially, in registration order).
2172
- 3. **Main operation** executes (if no before hook halted).
2173
- 4. **After hooks** fire last (sequentially, in registration order) — **only if
2174
- the request succeeded.**
2175
-
2176
- Before hooks can halt the operation by returning a value, and that value becomes
2177
- the response.
2178
-
2179
- **After hooks do not run for a failed request.** Any status `>= 400` — denied,
2180
- missing, `400`, `409` — skips the entire after-hook pipeline, along with SQL
2181
- persistence and `onUpdate` autosave. This is a behaviour change; see
2182
- [Breaking changes](#breaking-changes). It applies to the samples above: the
2183
- `afterHook('delete', ...)` auditing hook writes **no** audit row for a `DELETE`
2184
- that answered `404`, and the `afterHook('update', ...)` change-tracking hook
2185
- writes none for a `PATCH` that answered `404` or `400`. If you need a record of
2186
- refused requests, log them from a before-hook or from your own middleware —
2187
- `after<operation>` fires only for an operation that actually happened.
739
+ 1. **Before hooks** fire first (sequentially, in registration order)
740
+ 2. **Main operation** executes (if no before hook halted)
741
+ 3. **After hooks** fire last (sequentially, in registration order)
742
+
743
+ Before hooks can halt the operation by returning a value. After hooks run after completion and cannot halt.
2188
744
 
2189
745
  ### Best Practices
2190
746