@stonyx/orm 0.3.2-beta.156 → 0.3.2-beta.158

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
@@ -325,6 +325,19 @@ Access classes define models and provide custom filtering/authorization logic.
325
325
  > deny — which folds case but does not decode, so `GET /owners/%61rchived` steps
326
326
  > past it ([#228](https://github.com/abofs/stonyx-orm/issues/228)).
327
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
+ >
328
341
  > That is still a stopgap. **The real fix is
329
342
  > [#202](https://github.com/abofs/stonyx-orm/issues/202)** — `access()` should
330
343
  > receive the model, the operation and the record, so there is nothing to
@@ -337,6 +350,14 @@ Access classes define models and provide custom filtering/authorization logic.
337
350
  > context alone** and a context-only rewrite would silently turn it into an
338
351
  > allow.
339
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
+ >
340
361
  > The same warning is repeated at the top of `src/orm-request.ts`, which ships;
341
362
  > the longer write-up in `docs/usage-patterns.md` does **not** ship, so this
342
363
  > README and that source header are the two copies a consumer sees.
@@ -350,66 +371,73 @@ Access classes define models and provide custom filtering/authorization logic.
350
371
  export default class GlobalAccess {
351
372
  models = ['owner', 'animal'];
352
373
 
353
- access(request, { model, operation }) {
374
+ access(request, { model, operation, recordId }) {
354
375
  // `model` is the model this route was mounted for. It is assigned once, at
355
376
  // mount time, and no request can influence it — not a mount prefix, not a
356
377
  // query string, not a case-varied path, not an absolute-form request
357
- // target. Nothing below parses anything, so variants 1, 2, 4 and 5 are not
358
- // constructible against this predicate any more they are history, not
359
- // rules to follow. VARIANT 3 IS THE EXCEPTION AND THE CLAIM IS NARROWER
360
- // THAN IT WAS: a matcher stricter than the router can still be stepped
361
- // around, because the sub-path rule below is still a string comparison.
362
- // Case is handled; percent-encoding is not abofs/stonyx-orm#228.
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.
363
387
  //
364
388
  // `operation` is destructured to name the whole contract at the point of
365
- // use. This sample's rules are per-model and per-sub-path rather than
389
+ // use. This sample's rules are per-model and per-record rather than
366
390
  // per-verb, so it does not branch on it; the permission array at the bottom
367
391
  // is where the verb is answered.
368
392
 
369
- // FAIL CLOSED ON ARGUMENT TWO. `model` is absent for any caller that
370
- // resolved this predicate without supplying the context, and a request this
371
- // function cannot identify DENIES rather than falling through to the CRUD
372
- // grant at the bottom. An unidentifiable input must never be the permissive
373
- // path. Argument ONE is guarded at its own read, below — this guard does
374
- // not cover it.
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.
375
398
  if (typeof model !== 'string' || model === '') return false;
376
399
 
377
400
  if (model === 'owner') {
378
- // The context names WHICH MODEL and WHICH VERB not which route. Six
379
- // distinct owner surfaces produce one identical context, so a rule that
380
- // depends on the SUB-PATH still needs argument one. `request.path` is
381
- // mount-relative and query-free, and it is the one read of the raw
382
- // request the README sanctions. false 403 for the whole request.
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.
383
415
  //
384
- // THIS DENY CANNOT BE EXPRESSED FROM THE CONTEXT ALONE. Migrating it away
385
- // does not remove a rule, it turns a deny into an ALLOW, silently.
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.
386
423
  //
387
- // FAIL CLOSED ON ARGUMENT ONE TOO. The guard above covers the context;
388
- // this one covers the request, and since #202 they are two different
389
- // objects. A caller that resolves this predicate through the documented
390
- // `Orm.instance.getAccess()` path and hand-assembles a request can supply
391
- // a perfectly valid context with no usable `path` — and
392
- // `String(request.path ?? '')` is then `''`, which matches no sub-path
393
- // rule and falls straight through to the per-record filter below. That is
394
- // a DENY becoming an ALLOW. An input this function cannot identify DENIES,
395
- // whichever ARGUMENT it arrived on — which is also why the `?? ''` this
396
- // file's header condemns does not appear below.
397
- if (typeof request?.path !== 'string' || request.path === '') return false;
398
-
399
- // Lower-cased because the router matched case-insensitively, so a
400
- // case-sensitive rule here would be stricter than the router and could be
401
- // stepped around.
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.
402
429
  //
403
- // CASE-FOLDING ALONE IS NOT A SUFFICIENT NORMALISATION, and this line is
404
- // not a recipe for one. Express sets `request.path` from the RAW pathname
405
- // while the router DECODES `:id`, so `GET /owners/%61rchived` reaches this
406
- // comparison as `/%61rchived`, walks past the deny, and is dispatched as
407
- // the record `archived` abofs/stonyx-orm#228. A matcher must normalise
408
- // the way the router that dispatched the request does. Record ids are
409
- // case-sensitive and must be compared at their real case.
410
- const path = request.path.toLowerCase();
411
-
412
- if (path === '/archived' || path.startsWith('/archived/')) return false;
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;
413
441
 
414
442
  // Returning a function plugs it in as a per-record filter, and it is
415
443
  // enforced on every surface addressed to one of these records:
@@ -492,6 +520,16 @@ access class shipped with this repo has such a rule: its `/archived` deny
492
520
  **cannot be expressed from the context alone**, and a predicate migrated to
493
521
  context-only would silently drop it — a deny becoming an allow.
494
522
 
523
+ **Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
524
+ [#237](https://github.com/abofs/stonyx-orm/issues/237).** The context also carries
525
+ `recordId` — the record this route was addressed to, already decoded — so the
526
+ `/archived` deny **is** expressible from the context alone, and the shipped sample
527
+ no longer reads `request.path`. The full contract is `AccessContext.recordId` in
528
+ `src/types/orm-types.ts`, which ships. This section — the signature, the key table
529
+ and this paragraph — is corrected by
530
+ [#238](https://github.com/abofs/stonyx-orm/issues/238); the pointer is here because
531
+ what it currently says is an instruction, and the instruction is wrong.
532
+
495
533
  Note also that the related-resource and `?include=` surfaces serve *another
496
534
  model's* records under `model: 'owner'`, and the context gives a predicate no
497
535
  signal that it is authorizing a related-resource route. That is
@@ -787,6 +825,17 @@ these values should be matched on:
787
825
  | `GET http://anything.example/owners/angela` | `http://anything.example/angela` | `http://anything.example/owners/angela` | `/owners` | `/angela` |
788
826
  | `GET /api/animals/22` (`ORM_REST_ROUTE=/api`) | `/22` | `/api/animals/22` | `/api/animals` | `/22` |
789
827
 
828
+ **Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
829
+ [#237](https://github.com/abofs/stonyx-orm/issues/237) — "Variant 3 survives" above,
830
+ and the two paragraphs below, are no longer true.** The access context now carries
831
+ `recordId`, the **decoded** route-parameter id, and the sample compares against it:
832
+ the one string comparison variant 3 lived in is gone, `#228` is **closed**, and no
833
+ read of argument one survives in the sample. The wording is left standing rather
834
+ than deleted because it appears at four sites (this file twice,
835
+ `src/orm-request.ts`, and the test fixture) and retiring one of four leaves the
836
+ shipped copies contradicting each other; retiring all four **with the measurement
837
+ that retires them** is [#238](https://github.com/abofs/stonyx-orm/issues/238).
838
+
790
839
  **One read of argument one survives, and it must: `request.path`.** It is
791
840
  mount-relative and query-free, and it is for rules that distinguish **sub-paths**
792
841
  beneath the mount — as the `/archived` deny in the sample above does. The context
@@ -806,6 +855,20 @@ comparison as `/%61rchived`, walks past the deny, and is dispatched as the recor
806
855
  `.toLowerCase()` there as a complete normalisation recipe.** Record ids are
807
856
  case-sensitive and must be compared at their real case.
808
857
 
858
+ **Do not follow the two paragraphs above — superseded 2026-09-01 by
859
+ [#236](https://github.com/abofs/stonyx-orm/issues/236) /
860
+ [#237](https://github.com/abofs/stonyx-orm/issues/237).** They are *instructions*,
861
+ not merely stale observations, which is why this note is louder than a date. The
862
+ sample no longer reads `request.path` and no longer calls `.toLowerCase()` on
863
+ anything it compares: `.toLowerCase()` was measured wrong in **both directions at
864
+ once** — with a distinct owner seeded at `ARCHIVED`, `GET /owners/ARCHIVED` was a
865
+ false **deny** on the wrong record and `GET /owners/%41RCHIVED` a false **allow**
866
+ on that same record. Compare `recordId` **as it arrives**: do not case-fold it, do
867
+ not decode it, do not derive it from `request.path`. The contract is
868
+ `AccessContext.recordId` in `src/types/orm-types.ts`, which ships and says "Do NOT
869
+ case-fold it". Retirement of this wording, with its measurement:
870
+ [#238](https://github.com/abofs/stonyx-orm/issues/238).
871
+
809
872
  **Fail closed on anything you cannot identify — on *either* argument.**
810
873
  `String(request.originalUrl ?? '')` was once added here to stop a `TypeError`,
811
874
  and it traded fail-closed for fail-**open**: an empty string matched no
@@ -864,7 +927,97 @@ per-record filter. An input you cannot identify must **deny**.
864
927
  related record without resolving that model's own access class, so a filter on
865
928
  `/owners` does not hide an owner reached through `/animals`. Tracked as
866
929
  [#196](https://github.com/abofs/stonyx-orm/issues/196), which covers
867
- `include=`, related-resource routes and relationship-linkage routes.
930
+ `include=`, related-resource routes and relationship-linkage routes. This is
931
+ **membership** — whether the related resource is served at all — and it is a
932
+ different question from which ids a document may *name*, immediately below.
933
+ - **Relationship linkage is filtered on the four request-bound read surfaces,
934
+ and only there.** A document's `relationships.*.data` used to publish the id
935
+ of every related record unconditionally, so a record hidden on every one of
936
+ its own surfaces was still named inside another model's document — with no
937
+ `include=`, no relationship route and no query string
938
+ ([#234](https://github.com/abofs/stonyx-orm/issues/234)). The ORM now resolves
939
+ the **related** model's own access class on `GET /:models`, `GET /:models/:id`
940
+ and both `GET /:models/:id/{relationship}` shapes, and asks it
941
+ `{ model: <related>, operation: 'read' }`. An unresolvable class
942
+ (`getAccess()` → `undefined`) and a predicate that throws both **deny**. A
943
+ filtered-out relationship is **indistinguishable from a genuinely empty one** —
944
+ an emptied `hasMany` is `data: []` and an emptied `belongsTo` is `data: null`,
945
+ both **keeping their `links`**, which are built from the serialized record's
946
+ own id and never from the related one. Nothing errors and no status changes,
947
+ because throwing here would be an existence oracle *and* would throw out of
948
+ the enclosing `JSON.stringify`.
949
+
950
+ **That resolves the right class; it does not guarantee a model-correct
951
+ answer, and the failure direction is not the safe one.** Only a predicate that
952
+ *reads* `context.model` can answer about the model it was asked about — see
953
+ [Passing the context makes a model-correct answer *possible*](#passing-the-context-makes-a-model-correct-answer-possible)
954
+ above. A **single-argument predicate remains the default in every consumer
955
+ tree**, it identifies its collection from the request, and asked about
956
+ `owner` on a request dispatched to `/animals` it answers about **animals**.
957
+ Measured against this repo's own fixture with an arity-1 predicate registered
958
+ for `owner`: `GET /owners` correctly returns `["gina","michael","bob"]` while
959
+ `GET /animals/1` returns `owner.data {"type":"owner","id":"angela"}` — the
960
+ #234 defect, on the #234 surface, after the #234 fix. This is not a
961
+ regression (the id was published unconditionally before), it cannot be fixed
962
+ from this side, and the signal that surfaces such a predicate is
963
+ [#221](https://github.com/abofs/stonyx-orm/issues/221) /
964
+ [#213](https://github.com/abofs/stonyx-orm/issues/213). **Migrate your
965
+ predicates to read the context before relying on this filter.** A migrated,
966
+ context-reading predicate degrades the other way — it can over-deny a
967
+ *permitted* related record, which is recorded in the release notes as a
968
+ breaking change.
969
+
970
+ **Not yet covered, and each one still publishes ids the surfaces above
971
+ withhold:**
972
+
973
+ - **`included`** — [#235](https://github.com/abofs/stonyx-orm/issues/235). A
974
+ permitted record sideloaded by `?include=` emits its **own**
975
+ `relationships.*.data` unfiltered, so `GET /animals/1?include=owner,owner.pets`
976
+ returns `owner.data: null` on the primary document and then names angela in
977
+ `included`, along with eight permitted animals that each name
978
+ `{"type":"owner","id":"angela"}`. Separately,
979
+ [#233](https://github.com/abofs/stonyx-orm/issues/233) owns whether a
980
+ resource appears in `included` **at all** — that is membership, a different
981
+ question, and following it will not lead you to this residual.
982
+ - **The `POST`/`PATCH` response documents** —
983
+ [#235](https://github.com/abofs/stonyx-orm/issues/235). `createHandler` and
984
+ `updateHandler` destructure the request rather than binding it, so wiring
985
+ them needs a signature change rather than an argument. Until then **one HTTP
986
+ verb defeats the filter on the same record**: measured, `GET /animals/1`
987
+ returns `owner.data: null` and `PATCH /animals/1` returns **200 naming
988
+ angela**, seconds apart, with no query string and no relationship route. Any
989
+ caller who can read a record can also write it and be handed the id the read
990
+ withheld.
991
+ - **`GET /:models/:id/relationships/{relationship}`**, whose *primary data* is
992
+ linkage, so filtering it is a **membership** decision —
993
+ [#232](https://github.com/abofs/stonyx-orm/issues/232), the filed child of
994
+ [#196](https://github.com/abofs/stonyx-orm/issues/196).
995
+ - **A bare `toJSON()` still emits unfiltered linkage, and that is deliberate.**
996
+ `Record.toJSON()` **applies** a verdict; it never **resolves** one. It has no
997
+ request, and the documented `access()` contract permits a predicate to read
998
+ one — the sample in this README does, for its sub-path rule — so a filter
999
+ resolved inside `toJSON()` denies *permitted* records rather than hidden ones
1000
+ (measured: 967 → 964, all three failures over-denials). `toJSON` is also the
1001
+ `JSON.stringify` hook, so `JSON.stringify(record)`, `res.json(record)` and
1002
+ `console.log(JSON.stringify(record))` reach it with a **string** in the
1003
+ options slot and have no syntactic place to pass a verdict. The no-argument
1004
+ call therefore returns the pre-#234 document unchanged. Fail-closed by default
1005
+ is not available either: `Orm.instance.accessFunctions` is `{}` in any process
1006
+ that never ran `setup-rest-server` — a CLI, an SQL-only process, a test — so
1007
+ it would empty every relationship on every document in processes with no REST
1008
+ surface to protect. Closing the residual means moving JSON:API serialization
1009
+ **off** the `toJSON` name, tracked as
1010
+ [#230](https://github.com/abofs/stonyx-orm/issues/230). If you hand a `Record`
1011
+ to an untrusted consumer, serialize it through the REST layer, or resolve a
1012
+ verdict with the **exported** `createLinkageFilter(request)` and pass it as
1013
+ the `linkage` option — do not write your own reading of `access()`. This is a
1014
+ consumer obligation with no signal when it lapses; it is stated once, in full,
1015
+ under [Consumer Contracts](#consumer-contracts) below.
1016
+ - **`format()` and `serialize()` are deliberately not filtered, and must stay
1017
+ that way.** `format()` is the **persistence** path — its output is what
1018
+ `Orm.db.save()` writes to disk — so applying an access filter there would
1019
+ write a truncated database. That is **data loss**, not disclosure prevention.
1020
+ Neither method appears anywhere in the REST response path.
868
1021
  - **A before-hook that returns a value short-circuits the request.** On write
869
1022
  operations addressed to a record the filter is consulted first, so a hook
870
1023
  cannot answer for a record the caller may not see. On reads it is not, so a
@@ -923,6 +1076,110 @@ per-record filter. An input you cannot identify must **deny**.
923
1076
  back, and without the guard a denied `403` would delete a record the request
924
1077
  did not create.
925
1078
 
1079
+ ### Consumer Contracts
1080
+
1081
+ Obligations this package **cannot enforce**, where nothing fails, warns or
1082
+ changes shape when a consumer omits them. One place, findable, per
1083
+ `quality.md` rule 2 — if you are relying on `@stonyx/orm` for access control,
1084
+ read all of these.
1085
+
1086
+ #### `Record.toJSON()` does not filter relationship linkage unless you pass a verdict
1087
+
1088
+ **The framework owns this on four surfaces. You own it everywhere else.**
1089
+
1090
+ `GET /:models`, `GET /:models/:id` and both `GET /:models/:id/{relationship}`
1091
+ shapes resolve a linkage verdict and pass it to `toJSON()` for you. Any other
1092
+ path to a document — `JSON.stringify(record)`, `res.json(record)`,
1093
+ `console.log(record)`, a custom route, a queue payload, a websocket frame —
1094
+ calls `toJSON()` with no verdict, and **the no-verdict document names every
1095
+ related id, including records hidden on every one of their own surfaces**
1096
+ ([#234](https://github.com/abofs/stonyx-orm/issues/234)). That default is
1097
+ deliberate and cannot be inverted; the reasons are in
1098
+ [Known limitations](#known-limitations) above.
1099
+
1100
+ **There is no signal when you omit it.** `linkage` is optional, absent is the
1101
+ default, the default is the unfiltered document, and a filtered relationship is
1102
+ byte-identical to a genuinely empty one — so nothing on the wire distinguishes
1103
+ "filtered" from "forgotten".
1104
+
1105
+ Do this:
1106
+
1107
+ ```js
1108
+ import { createLinkageFilter } from '@stonyx/orm';
1109
+
1110
+ // `request` is the live request the caller was authorised against. The verdict
1111
+ // is REQUEST-SCOPED: build one per request and never cache it across requests,
1112
+ // or a second caller is answered with the first caller's authorization.
1113
+ const linkage = createLinkageFilter(request);
1114
+
1115
+ res.json({ data: record.toJSON({ baseUrl, linkage }) });
1116
+ ```
1117
+
1118
+ Not this:
1119
+
1120
+ ```js
1121
+ // A second, unreviewed reading of access(). It will drift from the one in
1122
+ // src/access-verdict.ts, and it will drift in consumer code where no reviewer
1123
+ // of this repository will ever see it.
1124
+ const linkage = (type, r) => Orm.instance.getAccess(type)?.(request)?.(r) ?? true;
1125
+ ```
1126
+
1127
+ **`createLinkageFilter` requires a live request, and there is no safe call
1128
+ without one.** `request` is the only authorization input the filter has — it is
1129
+ handed straight to your `access()` predicates, and a predicate that does not
1130
+ *read* it cannot fail closed when it is missing. Passing `undefined`, `null` or
1131
+ any non-object therefore denies **all** linkage and logs, once, at construction.
1132
+ Measured before that guard existed, `createLinkageFilter(undefined)` granted
1133
+ four of the five models in this repository's own fixture, silently.
1134
+
1135
+ **This is the catch for the request-less contexts named above.** In a queue
1136
+ consumer or a websocket handler there is no live request, so there is nothing to
1137
+ authorize against and nothing this package can resolve for you. Either carry the
1138
+ originating request through to the point of serialization, or publish no linkage
1139
+ at all — `record.toJSON({ linkage: () => false })` emits the document with every
1140
+ relationship empty. A stand-in is **not** a substitute: `{}` is an object
1141
+ and passes the guard, and any predicate that ignores its request will grant.
1142
+
1143
+ **`linkage` itself is validated, and an unusable value DENIES.** `undefined`
1144
+ means "no verdict supplied" and emits today's document. Anything else must be a
1145
+ **synchronous function that answers with a boolean**. Each of the following
1146
+ drops **all** linkage on that document and logs once:
1147
+
1148
+ - **A non-function** — `null`, `0`, `false`, `''`, `true`, a string, an object.
1149
+ `null` is the natural return of a resolver that could not resolve a session:
1150
+ it used to be read as "absent" and emit the full document silently.
1151
+ - **An `async` function, a generator function, or any predicate that returns a
1152
+ promise or thenable.** `toJSON` is the `JSON.stringify` hook and cannot await
1153
+ a verdict, and **an `async` resolver returns a promise, a promise is
1154
+ truthy**, so every related id was published, silently, exactly as if this fix
1155
+ were not here. If your
1156
+ authorization lookup is asynchronous, `await` it *before* you serialize and
1157
+ close over the result.
1158
+ - **Any answer that is not a boolean** — `{}`, `'no'`, `1`, `undefined`. A
1159
+ non-boolean is a resolver that did not answer, and a truthy one granted.
1160
+ - **A predicate that throws**, including a `class` passed by mistake. It is
1161
+ caught and denied; it used to escape the enclosing `JSON.stringify` and take
1162
+ the rest of that serialization down with it.
1163
+
1164
+ #### A predicate that ignores `context.model` makes cross-model resolution GRANT
1165
+
1166
+ The linkage filter above asks the **related** model's access class the
1167
+ model-correct question, but only a predicate that *reads*
1168
+ [`context.model`](#the-access-context-second-argument) can give a model-correct
1169
+ answer. A single-argument predicate identifies its collection from the request
1170
+ and therefore answers about the collection the request was *addressed to* —
1171
+ which is the direction that **grants**. Measured, and worked through in
1172
+ [Known limitations](#known-limitations). There is no boot-time warning yet
1173
+ ([#221](https://github.com/abofs/stonyx-orm/issues/221)). **Migrate your
1174
+ predicates to the two-argument contract.**
1175
+
1176
+ #### `format()` and `serialize()` are never filtered, by design
1177
+
1178
+ They are the persistence path. Do not hand their output to an untrusted
1179
+ consumer, and do not add a filter to them — `Orm.db.save()` writes `format()`
1180
+ output to disk, so filtering there is data loss rather than disclosure
1181
+ prevention.
1182
+
926
1183
  ### Breaking changes
927
1184
 
928
1185
  These land in the next published build. There is no changelog or release-notes channel yet
@@ -1132,6 +1389,13 @@ GET /animals/1
1132
1389
  #### Limitations
1133
1390
 
1134
1391
  - Only available on GET endpoints (not POST/PATCH)
1392
+ - **`included` records are not access-filtered, on either question.** Whether a
1393
+ resource appears in `included` at all is
1394
+ [#233](https://github.com/abofs/stonyx-orm/issues/233); a record that *is*
1395
+ permitted still emits its **own** `relationships.*.data` unfiltered, so
1396
+ `?include=` republishes ids the primary document withholds
1397
+ ([#235](https://github.com/abofs/stonyx-orm/issues/235)). See
1398
+ [Consumer Contracts](#consumer-contracts).
1135
1399
 
1136
1400
  ## Lifecycle Hooks
1137
1401
 
@@ -1186,6 +1450,16 @@ Each hook receives a context object with comprehensive information:
1186
1450
  - It contains a deep copy of the record's state **before** the operation executes (captured before the `before` hook fires)
1187
1451
  - The deep copy is created via JSON serialization (`JSON.parse(JSON.stringify())`) to ensure complete isolation
1188
1452
  - For `delete` operations, `recordId` is provided in after hooks since the record may no longer exist in the store
1453
+ - **`context.recordId` here is NOT `AccessContext.recordId`.** Same name, same-shaped
1454
+ object, different coverage: `_withHooks` sets this key **only** under
1455
+ `operation === 'delete'`, so on `get` / `list` / `create` / `update` the key is
1456
+ **absent** — `beforeHook('update', 'owner', ctx => ctx.recordId === 'archived' ? 403 : undefined)`
1457
+ never fires (measured: `PATCH /owners/{id}` → 200 with `ctx.recordId === undefined`
1458
+ and the id sitting in `ctx.params`). The access context, by contrast, carries
1459
+ `recordId` on every route it classifies and spells absence as `null`, never
1460
+ `undefined`. Tracked as
1461
+ [#242](https://github.com/abofs/stonyx-orm/issues/242); see
1462
+ `AccessContext.recordId` in `src/types/orm-types.ts` for the other side.
1189
1463
  - `oldState` is captured as a deep copy of the record's data before the operation, providing access to the previous field values
1190
1464
 
1191
1465
  ### Usage Examples
@@ -0,0 +1,85 @@
1
+ import type { AccessMethod, AccessOperation, LinkageFilter } from './types/orm-types.js';
2
+ /**
3
+ * The classified reading of one `access()` return value.
4
+ *
5
+ * `granted: false` is a total denial. `granted: true` with no `filter` is an
6
+ * unconditional grant. `granted: true` WITH a filter means "grant, subject to
7
+ * this per-record predicate" -- the function return shape, which is the
8
+ * per-record hook `AccessContext` deliberately does not provide.
9
+ */
10
+ export interface AccessVerdict {
11
+ granted: boolean;
12
+ filter?: (record: unknown) => boolean;
13
+ }
14
+ /**
15
+ * Classify one `access()` return value. Extracted verbatim from `auth()`, which
16
+ * now calls this; the branch ORDER is load-bearing and is preserved exactly.
17
+ *
18
+ * `operation` is the verb being authorised. `undefined` -- reachable, because
19
+ * express delivers HEAD to the GET handler and `methodAccessMap` has no entry
20
+ * for it -- falls through `permitted.includes(undefined)` to a denial, which is
21
+ * the same answer `auth()` gave before the extraction.
22
+ */
23
+ export declare function interpretAccess(access: AccessMethod, operation: AccessOperation | undefined): AccessVerdict;
24
+ /**
25
+ * Build a request-scoped linkage filter.
26
+ *
27
+ * TWO CACHES, AND BOTH ARE LOAD-BEARING RATHER THAN AN OPTIMISATION:
28
+ *
29
+ * - one verdict per TYPE. Resolving means CALLING the consumer's `access()`,
30
+ * which is arbitrary code with arbitrary cost and which the module has
31
+ * already had to guard for throwing.
32
+ * - one decision per `(type, id)`. `included` is deduplicated by
33
+ * `buildResponse`; LINKAGE is not deduplicated at all, so it re-asks once
34
+ * per record. Measured on a bare `GET /animals` with no `include=`:
35
+ * 48 linkage entries -> 7 distinct `(type, id)` pairs (owner 20, trait 28),
36
+ * a 6.9x reduction and 41 predicate calls saved.
37
+ *
38
+ * The `(type, id)` cache is a `Map` per type keyed on the RAW id, not on a
39
+ * template-string composite. `Map` compares with SameValueZero, so the numeric
40
+ * id `1` and the string id `'1'` stay DISTINCT, where `` `${type}:${id}` `` --
41
+ * or a bare `String(id)` -- collapses them onto one entry and answers the second
42
+ * record with the first record's verdict.
43
+ *
44
+ * WHAT THAT DOES AND DOES NOT PROTECT. It cannot cross MODELS. `decisions` is
45
+ * already partitioned per type by `byType`, so a composite key inside a per-type
46
+ * map is one-to-one with the raw one and no owner's verdict could ever answer
47
+ * for an animal -- the claim that once stood here. The real exposure is narrower
48
+ * and entirely WITHIN one model: two records of the same type whose ids differ
49
+ * only by JavaScript type, which a per-record predicate may legitimately answer
50
+ * differently about (an id read off a JSON body is a string; the same id
51
+ * assigned by the server is a number). Pinned by unit assertion, because this
52
+ * fixture cannot produce the collision on its own -- `owner` ids are strings and
53
+ * `animal` ids are numbers.
54
+ *
55
+ * SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
56
+ * it -- a verdict cached across requests would answer a second caller with the
57
+ * first caller's authorization.
58
+ *
59
+ * A REQUEST IS REQUIRED, AND ITS ABSENCE IS CHECKED HERE RATHER THAN DELEGATED.
60
+ * This function is EXPORTED (src/index.ts), and the README's Consumer Contracts
61
+ * section points consumers at exactly the contexts that have no live request --
62
+ * a queue payload, a websocket frame, a custom route. Without one there is no
63
+ * caller to authorise against, and this file's header already says so: the
64
+ * shipped sample reads `request.path` and fail-closes when it is absent, so
65
+ * `getAccess('owner')(undefined, ...)` is `false`, while
66
+ * `getAccess('animal')(undefined, ...)` returns a per-record predicate and
67
+ * GRANTS. Measured on this repo's own fixture before this guard existed:
68
+ *
69
+ * createLinkageFilter(undefined | null | {} | 'x' | 0)
70
+ * -> owner=false animal=TRUE trait=TRUE category=TRUE phone-number=TRUE
71
+ *
72
+ * Four of five claimed models granted, with no log, because whether an absent
73
+ * request fails closed was left ENTIRELY to consumer predicates -- and a
74
+ * predicate that ignores its request cannot fail closed on one that is missing.
75
+ * A nullish or primitive `request` therefore denies every model outright and
76
+ * says so once, at construction, so the signal exists even for a caller that
77
+ * goes on to serialize nothing.
78
+ *
79
+ * WHAT THIS CANNOT CHECK: `{}` is an object and passes. There is no request
80
+ * contract this module owns -- `auth()` reads `.method`, the shipped sample
81
+ * reads `.path`, a consumer's reads whatever it likes -- so anything past
82
+ * "is it an object" would be this module inventing a shape for someone else's
83
+ * framework. The residual is documented in the README under Consumer Contracts.
84
+ */
85
+ export declare function createLinkageFilter(request: unknown): LinkageFilter;