@stonyx/orm 0.3.2-beta.157 → 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
@@ -927,7 +927,97 @@ per-record filter. An input you cannot identify must **deny**.
927
927
  related record without resolving that model's own access class, so a filter on
928
928
  `/owners` does not hide an owner reached through `/animals`. Tracked as
929
929
  [#196](https://github.com/abofs/stonyx-orm/issues/196), which covers
930
- `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.
931
1021
  - **A before-hook that returns a value short-circuits the request.** On write
932
1022
  operations addressed to a record the filter is consulted first, so a hook
933
1023
  cannot answer for a record the caller may not see. On reads it is not, so a
@@ -986,6 +1076,110 @@ per-record filter. An input you cannot identify must **deny**.
986
1076
  back, and without the guard a denied `403` would delete a record the request
987
1077
  did not create.
988
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
+
989
1183
  ### Breaking changes
990
1184
 
991
1185
  These land in the next published build. There is no changelog or release-notes channel yet
@@ -1195,6 +1389,13 @@ GET /animals/1
1195
1389
  #### Limitations
1196
1390
 
1197
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).
1198
1399
 
1199
1400
  ## Lifecycle Hooks
1200
1401
 
@@ -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;
@@ -0,0 +1,284 @@
1
+ /**
2
+ * The shared access-verdict primitive (abofs/stonyx-orm#234).
3
+ *
4
+ * ---------------------------------------------------------------------------
5
+ * WHY THIS FILE EXISTS: ONE INTERPRETER, NOT TWO
6
+ * ---------------------------------------------------------------------------
7
+ * A consumer `access()` may return six differently-shaped things -- `false`, a
8
+ * bare permission string, a permission array, `true`, a per-record function, or
9
+ * something the contract does not define at all -- and the reading of each one
10
+ * is a security decision. `auth()` has held that reading inline since #190.
11
+ * Every surface that needs to ask "may this caller see model X's record?" needs
12
+ * the SAME reading, or the second copy becomes an unreviewed second
13
+ * authorization vocabulary that answers differently about the same value.
14
+ *
15
+ * So `interpretAccess` is extracted here and `auth()` now calls it. It is the
16
+ * only place a return shape is classified, and abofs/stonyx-orm#232 and #233
17
+ * rebase onto it rather than re-deriving it.
18
+ *
19
+ * ---------------------------------------------------------------------------
20
+ * WHAT A LINKAGE FILTER IS, AND WHY THE CALLER BUILDS IT
21
+ * ---------------------------------------------------------------------------
22
+ * `Record.toJSON()` APPLIES a verdict; it never RESOLVES one. That is not a
23
+ * style choice, it is forced, and it was measured before it was decided:
24
+ *
25
+ * INPUT: origin/dev @ c5f7907, unpatched -> 967 pass / 0 fail
26
+ * INPUT: same + fail-closed resolution INSIDE toJSON() -> 964 pass / 3 fail
27
+ *
28
+ * and all three reds were over-denial of PERMITTED records, not the leak. Two
29
+ * independent reasons:
30
+ *
31
+ * 1. `toJSON()` has no request. The shipped, documented sample reads
32
+ * `request.path` for its `/archived` sub-path rule -- the one read of
33
+ * argument one the README sanctions -- and fail-closes when it is absent.
34
+ * Measured against the live registry:
35
+ *
36
+ * getAccess('owner')(undefined, { model:'owner', operation:'read' }) -> false
37
+ * getAccess('animal')(undefined,{ model:'animal', operation:'read' }) -> [Function]
38
+ *
39
+ * Same predicate object, two models, two different degradation modes,
40
+ * chosen by the consumer. Without a request there is no trustworthy
41
+ * answer to get.
42
+ *
43
+ * 2. `toJSON` is also the `JSON.stringify` hook, so `JSON.stringify({data:
44
+ * record})` calls `record.toJSON('data')` -- a STRING in the options slot.
45
+ * An implicit caller has no syntactic place to pass anything
46
+ * (abofs/stonyx-orm#230). The no-argument document must therefore stay
47
+ * byte-identical to what shipped, which also rules out fail-closed by
48
+ * default: `Orm.instance.accessFunctions` is `{}` in any process that
49
+ * never ran `setup-rest-server` (CLI, SQL-only, unit tests), so a
50
+ * fail-closed default would empty every relationship on every document in
51
+ * processes that have no REST surface to protect.
52
+ *
53
+ * The caller -- which still holds the request -- resolves the predicate,
54
+ * interprets it here, caches the answer, and hands `toJSON()` an already-decided
55
+ * `(type, record) => boolean`.
56
+ */
57
+ import Orm from '@stonyx/orm';
58
+ import log from 'stonyx/log';
59
+ const DENIED = Object.freeze({ granted: false });
60
+ const GRANTED = Object.freeze({ granted: true });
61
+ /**
62
+ * Classify one `access()` return value. Extracted verbatim from `auth()`, which
63
+ * now calls this; the branch ORDER is load-bearing and is preserved exactly.
64
+ *
65
+ * `operation` is the verb being authorised. `undefined` -- reachable, because
66
+ * express delivers HEAD to the GET handler and `methodAccessMap` has no entry
67
+ * for it -- falls through `permitted.includes(undefined)` to a denial, which is
68
+ * the same answer `auth()` gave before the extraction.
69
+ */
70
+ export function interpretAccess(access, operation) {
71
+ if (!access)
72
+ return DENIED;
73
+ // The function return shape IS the per-record hook. Grant the request and
74
+ // carry the predicate; the caller applies it per record.
75
+ if (typeof access === 'function')
76
+ return { granted: true, filter: access };
77
+ if (access === true)
78
+ return GRANTED;
79
+ // `AccessMethod` declares `string` legal and it fell through every branch
80
+ // above. A bare string is ONE permission, not a grant of all four -- reading
81
+ // it as a full grant is what once let `return 'read'` authorise DELETE.
82
+ const permitted = typeof access === 'string' ? [access] : access;
83
+ // Anything that is not a permission array by this point -- an object, a
84
+ // number, a Symbol -- is a consumer mistake, and the only safe reading of a
85
+ // shape the contract does not define is a denial. Fail CLOSED.
86
+ if (!Array.isArray(permitted))
87
+ return DENIED;
88
+ if (!permitted.includes(operation))
89
+ return DENIED;
90
+ return GRANTED;
91
+ }
92
+ /**
93
+ * Resolve model `type`'s verdict for a read, against the live `request`.
94
+ *
95
+ * Fails closed on both ambiguous inputs:
96
+ *
97
+ * - `getAccess(type)` -> `undefined`. That is NOT "this model is
98
+ * unrestricted". `setup-rest-server` catches an access-class load failure,
99
+ * warns, and publishes whatever PARTIAL map it had, so `undefined` covers
100
+ * both "no access class claims this model" and "the class that claims it
101
+ * failed to load" -- and the caller cannot tell them apart. Deny.
102
+ * - the predicate THROWS. Same reading `auth()` and `isDenied` already use:
103
+ * a throw is a denial, logged, never a 500 and never a grant.
104
+ *
105
+ * NOTE ON CROSS-MODEL ASKS -- READ THIS BEFORE REBASING #232 OR #233 ONTO IT.
106
+ * The predicate is asked about `type` while the request in hand was dispatched
107
+ * to a DIFFERENT model's route. This function makes another model's class
108
+ * REACHABLE and asks it the model-correct question (`{ model: type }`); whether
109
+ * the ANSWER is model-correct is the CONSUMER's, because only a predicate that
110
+ * READS `context.model` can give one. Since #222 this repo's fixture does. A
111
+ * consumer's arity-1 predicate does not, and there is no supported way to tell
112
+ * which kind was resolved (the boot-time arity warning is
113
+ * abofs/stonyx-orm#213/#221, unshipped).
114
+ *
115
+ * BOTH DEGRADATION DIRECTIONS ARE REACHABLE, AND THE SECOND ONE GRANTS. This is
116
+ * measured, not reasoned:
117
+ *
118
+ * - CLOSED. The migrated fixture's surviving `request.path` read means asking
119
+ * the OWNER predicate on a request dispatched to `GET /animals/archived`
120
+ * returns a bare `false` -- a whole-request deny bleeding across models,
121
+ * treated here as "deny this linkage", not as an error. That over-denies a
122
+ * PERMITTED record.
123
+ * - OPEN. An arity-1 predicate -- the shape `setup-rest-server.ts:15-18`
124
+ * still declares valid and the README calls the default in every consumer
125
+ * tree -- identifies its collection from the request, so asked about
126
+ * `owner` on a request dispatched to `/animals` it answers about ANIMALS.
127
+ * Measured against this repo's own fixture with `reg.owner` replaced by an
128
+ * arity-1 predicate that hides angela on `/owners`:
129
+ *
130
+ * GET /owners -> ["gina","michael","bob"] angela hidden, correctly
131
+ * GET /animals -> owners named: [angela, ...] LEAK
132
+ * GET /animals/1 -> owner.data {"type":"owner","id":"angela"}
133
+ *
134
+ * That is byte-for-byte the abofs/stonyx-orm#234 defect, on the surface
135
+ * #234 was filed for, AFTER this fix. It is not a regression -- dev
136
+ * published the same id unconditionally -- and this file cannot close it,
137
+ * because the arity signal is #213/#221. Do NOT write, here or anywhere
138
+ * else, that the cross-model ask degrades closed. The standing rule this
139
+ * paragraph is held to is in docs/project-structure.md.
140
+ */
141
+ function resolveVerdict(request, type) {
142
+ const predicate = Orm.instance?.getAccess?.(type);
143
+ if (typeof predicate !== 'function')
144
+ return DENIED;
145
+ let access;
146
+ try {
147
+ // `recordId: null`, AND NOT `request.params.id`. THE TEMPTING WRONG ANSWER
148
+ // IS RIGHT THERE, so this is pinned by assertion as well as by comment --
149
+ // test/unit/linkage-verdict-test.ts, `#234 + #241 -- recordId is null`.
150
+ //
151
+ // `AccessContext.recordId` (src/types/orm-types.ts, abofs/stonyx-orm#236 /
152
+ // #241) means "the record THIS ROUTE WAS ADDRESSED TO, as the store key of
153
+ // the model being authorised", and `null` means "addressed to no record".
154
+ // The id sitting on the request in hand names the PRIMARY record, which
155
+ // belongs to a DIFFERENT model -- `GET /owners/gina` carries
156
+ // `params.id === 'gina'`, and the ask being made HERE is about `animal` or
157
+ // `trait`. Filling this in from the request would hand the related model's
158
+ // predicate an id belonging to another model, which is byte-for-byte the
159
+ // cross-model confusion abofs/stonyx-orm#202 introduced this context to
160
+ // eliminate: the predicate would compare an owner's id against its own
161
+ // records and answer a question nobody asked. There is no record of THIS
162
+ // model addressed by this request, so `null` is the honest value -- the
163
+ // same spelling `auth()` uses for a collection route.
164
+ //
165
+ // NOR ANY RECORD'S OWN ID, WHICH IS THE SECOND-MOST TEMPTING ANSWER. This
166
+ // verdict is resolved ONCE PER TYPE and cached in `byType` below, before
167
+ // any record has been looked at; there is no per-record `AccessContext`
168
+ // built anywhere on this path. Seeding it from the first record of a type
169
+ // would let that record's identity answer for every later record of the
170
+ // same type -- the same "one record's verdict answers for another" defect
171
+ // the `decisions` raw-key argument below exists to prevent, just one level
172
+ // coarser. And it is unnecessary: `AccessContext` deliberately carries no
173
+ // `record` because auth-time and record-time are separate decision points,
174
+ // and the per-record point already receives the WHOLE record, id included,
175
+ // through `verdict.filter(record)`. A predicate that wants a record's id
176
+ // has the contract's own channel for it.
177
+ access = predicate(request, { model: type, operation: 'read', recordId: null });
178
+ }
179
+ catch (error) {
180
+ log.error?.(`[@stonyx/orm] access() threw while resolving linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
181
+ return DENIED;
182
+ }
183
+ return interpretAccess(access, 'read');
184
+ }
185
+ /**
186
+ * Build a request-scoped linkage filter.
187
+ *
188
+ * TWO CACHES, AND BOTH ARE LOAD-BEARING RATHER THAN AN OPTIMISATION:
189
+ *
190
+ * - one verdict per TYPE. Resolving means CALLING the consumer's `access()`,
191
+ * which is arbitrary code with arbitrary cost and which the module has
192
+ * already had to guard for throwing.
193
+ * - one decision per `(type, id)`. `included` is deduplicated by
194
+ * `buildResponse`; LINKAGE is not deduplicated at all, so it re-asks once
195
+ * per record. Measured on a bare `GET /animals` with no `include=`:
196
+ * 48 linkage entries -> 7 distinct `(type, id)` pairs (owner 20, trait 28),
197
+ * a 6.9x reduction and 41 predicate calls saved.
198
+ *
199
+ * The `(type, id)` cache is a `Map` per type keyed on the RAW id, not on a
200
+ * template-string composite. `Map` compares with SameValueZero, so the numeric
201
+ * id `1` and the string id `'1'` stay DISTINCT, where `` `${type}:${id}` `` --
202
+ * or a bare `String(id)` -- collapses them onto one entry and answers the second
203
+ * record with the first record's verdict.
204
+ *
205
+ * WHAT THAT DOES AND DOES NOT PROTECT. It cannot cross MODELS. `decisions` is
206
+ * already partitioned per type by `byType`, so a composite key inside a per-type
207
+ * map is one-to-one with the raw one and no owner's verdict could ever answer
208
+ * for an animal -- the claim that once stood here. The real exposure is narrower
209
+ * and entirely WITHIN one model: two records of the same type whose ids differ
210
+ * only by JavaScript type, which a per-record predicate may legitimately answer
211
+ * differently about (an id read off a JSON body is a string; the same id
212
+ * assigned by the server is a number). Pinned by unit assertion, because this
213
+ * fixture cannot produce the collision on its own -- `owner` ids are strings and
214
+ * `animal` ids are numbers.
215
+ *
216
+ * SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
217
+ * it -- a verdict cached across requests would answer a second caller with the
218
+ * first caller's authorization.
219
+ *
220
+ * A REQUEST IS REQUIRED, AND ITS ABSENCE IS CHECKED HERE RATHER THAN DELEGATED.
221
+ * This function is EXPORTED (src/index.ts), and the README's Consumer Contracts
222
+ * section points consumers at exactly the contexts that have no live request --
223
+ * a queue payload, a websocket frame, a custom route. Without one there is no
224
+ * caller to authorise against, and this file's header already says so: the
225
+ * shipped sample reads `request.path` and fail-closes when it is absent, so
226
+ * `getAccess('owner')(undefined, ...)` is `false`, while
227
+ * `getAccess('animal')(undefined, ...)` returns a per-record predicate and
228
+ * GRANTS. Measured on this repo's own fixture before this guard existed:
229
+ *
230
+ * createLinkageFilter(undefined | null | {} | 'x' | 0)
231
+ * -> owner=false animal=TRUE trait=TRUE category=TRUE phone-number=TRUE
232
+ *
233
+ * Four of five claimed models granted, with no log, because whether an absent
234
+ * request fails closed was left ENTIRELY to consumer predicates -- and a
235
+ * predicate that ignores its request cannot fail closed on one that is missing.
236
+ * A nullish or primitive `request` therefore denies every model outright and
237
+ * says so once, at construction, so the signal exists even for a caller that
238
+ * goes on to serialize nothing.
239
+ *
240
+ * WHAT THIS CANNOT CHECK: `{}` is an object and passes. There is no request
241
+ * contract this module owns -- `auth()` reads `.method`, the shipped sample
242
+ * reads `.path`, a consumer's reads whatever it likes -- so anything past
243
+ * "is it an object" would be this module inventing a shape for someone else's
244
+ * framework. The residual is documented in the README under Consumer Contracts.
245
+ */
246
+ export function createLinkageFilter(request) {
247
+ if (typeof request !== 'object' || request === null) {
248
+ log.error?.(`[@stonyx/orm] createLinkageFilter() was called with no request (received ${request === null ? 'null' : typeof request}) -- there is no caller to authorise against, so ALL relationship linkage it is asked about is denied.`);
249
+ return function isLinkable(_type, _record) {
250
+ return false;
251
+ };
252
+ }
253
+ const byType = new Map();
254
+ return function isLinkable(type, record) {
255
+ let entry = byType.get(type);
256
+ if (!entry) {
257
+ entry = { verdict: resolveVerdict(request, type), decisions: new Map() };
258
+ byType.set(type, entry);
259
+ }
260
+ const { verdict, decisions } = entry;
261
+ if (!verdict.granted)
262
+ return false;
263
+ if (!verdict.filter)
264
+ return true;
265
+ const id = record?.id;
266
+ const cached = decisions.get(id);
267
+ if (cached !== undefined)
268
+ return cached;
269
+ let allowed;
270
+ try {
271
+ allowed = Boolean(verdict.filter(record));
272
+ }
273
+ catch (error) {
274
+ // A predicate that throws is a denial -- the same reading `isDenied` uses
275
+ // one layer down. Logged, because a predicate that throws on every record
276
+ // empties every relationship and, silently, that is indistinguishable
277
+ // from a database with no relationships in it.
278
+ log.error?.(`[@stonyx/orm] access filter threw while filtering linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
279
+ allowed = false;
280
+ }
281
+ decisions.set(id, allowed);
282
+ return allowed;
283
+ };
284
+ }
package/dist/index.d.ts CHANGED
@@ -10,6 +10,8 @@ export { default } from './main.js';
10
10
  export { store, relationships } from './main.js';
11
11
  export type { PersistErrorDetail } from './main.js';
12
12
  export type { AccessContext, AccessFunction, AccessMethod, AccessOperation } from './types/orm-types.js';
13
+ export type { LinkageFilter } from './types/orm-types.js';
14
+ export { createLinkageFilter } from './access-verdict.js';
13
15
  export { Model, View, Serializer };
14
16
  export { attr, belongsTo, hasMany, createRecord, updateRecord };
15
17
  export { count, avg, sum, min, max };
package/dist/index.js CHANGED
@@ -23,6 +23,14 @@ import { createRecord, updateRecord } from './manage-record.js';
23
23
  import { count, avg, sum, min, max } from './aggregates.js';
24
24
  export { default } from './main.js';
25
25
  export { store, relationships } from './main.js';
26
+ // The request-scoped linkage-verdict factory (#234). PUBLIC on purpose: the
27
+ // README tells a consumer serializing a `Record` outside the REST layer to pass
28
+ // their own resolved `linkage` option, and without an exported factory the only
29
+ // way to follow that advice is to write a SECOND reading of `access()` in
30
+ // consumer code -- the exact "unreviewed second authorization vocabulary" that
31
+ // src/access-verdict.ts exists to prevent, reproduced where no reviewer sees it
32
+ // drift. Give them the one interpreter instead of an invitation to fork it.
33
+ export { createLinkageFilter } from './access-verdict.js';
26
34
  export { Model, View, Serializer }; // base classes
27
35
  export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
28
36
  export { count, avg, sum, min, max }; // aggregate helpers