@stonyx/orm 0.3.2-alpha.70 → 0.3.2-alpha.72

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.
@@ -0,0 +1,282 @@
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
+ import type { AccessMethod, AccessOperation, LinkageFilter } from './types/orm-types.js';
60
+
61
+ /**
62
+ * The classified reading of one `access()` return value.
63
+ *
64
+ * `granted: false` is a total denial. `granted: true` with no `filter` is an
65
+ * unconditional grant. `granted: true` WITH a filter means "grant, subject to
66
+ * this per-record predicate" -- the function return shape, which is the
67
+ * per-record hook `AccessContext` deliberately does not provide.
68
+ */
69
+ export interface AccessVerdict {
70
+ granted: boolean;
71
+ filter?: (record: unknown) => boolean;
72
+ }
73
+
74
+ const DENIED: AccessVerdict = Object.freeze({ granted: false });
75
+ const GRANTED: AccessVerdict = Object.freeze({ granted: true });
76
+
77
+ /**
78
+ * Classify one `access()` return value. Extracted verbatim from `auth()`, which
79
+ * now calls this; the branch ORDER is load-bearing and is preserved exactly.
80
+ *
81
+ * `operation` is the verb being authorised. `undefined` -- reachable, because
82
+ * express delivers HEAD to the GET handler and `methodAccessMap` has no entry
83
+ * for it -- falls through `permitted.includes(undefined)` to a denial, which is
84
+ * the same answer `auth()` gave before the extraction.
85
+ */
86
+ export function interpretAccess(access: AccessMethod, operation: AccessOperation | undefined): AccessVerdict {
87
+ if (!access) return DENIED;
88
+
89
+ // The function return shape IS the per-record hook. Grant the request and
90
+ // carry the predicate; the caller applies it per record.
91
+ if (typeof access === 'function') return { granted: true, filter: access as (record: unknown) => boolean };
92
+
93
+ if (access === true) return GRANTED;
94
+
95
+ // `AccessMethod` declares `string` legal and it fell through every branch
96
+ // above. A bare string is ONE permission, not a grant of all four -- reading
97
+ // it as a full grant is what once let `return 'read'` authorise DELETE.
98
+ const permitted = typeof access === 'string' ? [access] : access;
99
+
100
+ // Anything that is not a permission array by this point -- an object, a
101
+ // number, a Symbol -- is a consumer mistake, and the only safe reading of a
102
+ // shape the contract does not define is a denial. Fail CLOSED.
103
+ if (!Array.isArray(permitted)) return DENIED;
104
+ if (!permitted.includes(operation as string)) return DENIED;
105
+
106
+ return GRANTED;
107
+ }
108
+
109
+ /**
110
+ * Resolve model `type`'s verdict for a read, against the live `request`.
111
+ *
112
+ * Fails closed on both ambiguous inputs:
113
+ *
114
+ * - `getAccess(type)` -> `undefined`. That is NOT "this model is
115
+ * unrestricted". `setup-rest-server` catches an access-class load failure,
116
+ * warns, and publishes whatever PARTIAL map it had, so `undefined` covers
117
+ * both "no access class claims this model" and "the class that claims it
118
+ * failed to load" -- and the caller cannot tell them apart. Deny.
119
+ * - the predicate THROWS. Same reading `auth()` and `isDenied` already use:
120
+ * a throw is a denial, logged, never a 500 and never a grant.
121
+ *
122
+ * NOTE ON CROSS-MODEL ASKS -- READ THIS BEFORE REBASING #232 OR #233 ONTO IT.
123
+ * The predicate is asked about `type` while the request in hand was dispatched
124
+ * to a DIFFERENT model's route. This function makes another model's class
125
+ * REACHABLE and asks it the model-correct question (`{ model: type }`); whether
126
+ * the ANSWER is model-correct is the CONSUMER's, because only a predicate that
127
+ * READS `context.model` can give one. Since #222 this repo's fixture does. A
128
+ * consumer's arity-1 predicate does not, and there is no supported way to tell
129
+ * which kind was resolved (the boot-time arity warning is
130
+ * abofs/stonyx-orm#213/#221, unshipped).
131
+ *
132
+ * BOTH DEGRADATION DIRECTIONS ARE REACHABLE, AND THE SECOND ONE GRANTS. This is
133
+ * measured, not reasoned:
134
+ *
135
+ * - CLOSED. The migrated fixture's surviving `request.path` read means asking
136
+ * the OWNER predicate on a request dispatched to `GET /animals/archived`
137
+ * returns a bare `false` -- a whole-request deny bleeding across models,
138
+ * treated here as "deny this linkage", not as an error. That over-denies a
139
+ * PERMITTED record.
140
+ * - OPEN. An arity-1 predicate -- the shape `setup-rest-server.ts:15-18`
141
+ * still declares valid and the README calls the default in every consumer
142
+ * tree -- identifies its collection from the request, so asked about
143
+ * `owner` on a request dispatched to `/animals` it answers about ANIMALS.
144
+ * Measured against this repo's own fixture with `reg.owner` replaced by an
145
+ * arity-1 predicate that hides angela on `/owners`:
146
+ *
147
+ * GET /owners -> ["gina","michael","bob"] angela hidden, correctly
148
+ * GET /animals -> owners named: [angela, ...] LEAK
149
+ * GET /animals/1 -> owner.data {"type":"owner","id":"angela"}
150
+ *
151
+ * That is byte-for-byte the abofs/stonyx-orm#234 defect, on the surface
152
+ * #234 was filed for, AFTER this fix. It is not a regression -- dev
153
+ * published the same id unconditionally -- and this file cannot close it,
154
+ * because the arity signal is #213/#221. Do NOT write, here or anywhere
155
+ * else, that the cross-model ask degrades closed. The standing rule this
156
+ * paragraph is held to is in docs/project-structure.md.
157
+ */
158
+ function resolveVerdict(request: unknown, type: string): AccessVerdict {
159
+ const predicate = Orm.instance?.getAccess?.(type);
160
+ if (typeof predicate !== 'function') return DENIED;
161
+
162
+ let access: AccessMethod;
163
+
164
+ try {
165
+ access = predicate(request, { model: type, operation: 'read' });
166
+ } catch (error) {
167
+ log.error?.(`[@stonyx/orm] access() threw while resolving linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
168
+
169
+ return DENIED;
170
+ }
171
+
172
+ return interpretAccess(access, 'read');
173
+ }
174
+
175
+ /**
176
+ * Build a request-scoped linkage filter.
177
+ *
178
+ * TWO CACHES, AND BOTH ARE LOAD-BEARING RATHER THAN AN OPTIMISATION:
179
+ *
180
+ * - one verdict per TYPE. Resolving means CALLING the consumer's `access()`,
181
+ * which is arbitrary code with arbitrary cost and which the module has
182
+ * already had to guard for throwing.
183
+ * - one decision per `(type, id)`. `included` is deduplicated by
184
+ * `buildResponse`; LINKAGE is not deduplicated at all, so it re-asks once
185
+ * per record. Measured on a bare `GET /animals` with no `include=`:
186
+ * 48 linkage entries -> 7 distinct `(type, id)` pairs (owner 20, trait 28),
187
+ * a 6.9x reduction and 41 predicate calls saved.
188
+ *
189
+ * The `(type, id)` cache is a `Map` per type keyed on the RAW id, not on a
190
+ * template-string composite. `Map` compares with SameValueZero, so the numeric
191
+ * id `1` and the string id `'1'` stay DISTINCT, where `` `${type}:${id}` `` --
192
+ * or a bare `String(id)` -- collapses them onto one entry and answers the second
193
+ * record with the first record's verdict.
194
+ *
195
+ * WHAT THAT DOES AND DOES NOT PROTECT. It cannot cross MODELS. `decisions` is
196
+ * already partitioned per type by `byType`, so a composite key inside a per-type
197
+ * map is one-to-one with the raw one and no owner's verdict could ever answer
198
+ * for an animal -- the claim that once stood here. The real exposure is narrower
199
+ * and entirely WITHIN one model: two records of the same type whose ids differ
200
+ * only by JavaScript type, which a per-record predicate may legitimately answer
201
+ * differently about (an id read off a JSON body is a string; the same id
202
+ * assigned by the server is a number). Pinned by unit assertion, because this
203
+ * fixture cannot produce the collision on its own -- `owner` ids are strings and
204
+ * `animal` ids are numbers.
205
+ *
206
+ * SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
207
+ * it -- a verdict cached across requests would answer a second caller with the
208
+ * first caller's authorization.
209
+ *
210
+ * A REQUEST IS REQUIRED, AND ITS ABSENCE IS CHECKED HERE RATHER THAN DELEGATED.
211
+ * This function is EXPORTED (src/index.ts), and the README's Consumer Contracts
212
+ * section points consumers at exactly the contexts that have no live request --
213
+ * a queue payload, a websocket frame, a custom route. Without one there is no
214
+ * caller to authorise against, and this file's header already says so: the
215
+ * shipped sample reads `request.path` and fail-closes when it is absent, so
216
+ * `getAccess('owner')(undefined, ...)` is `false`, while
217
+ * `getAccess('animal')(undefined, ...)` returns a per-record predicate and
218
+ * GRANTS. Measured on this repo's own fixture before this guard existed:
219
+ *
220
+ * createLinkageFilter(undefined | null | {} | 'x' | 0)
221
+ * -> owner=false animal=TRUE trait=TRUE category=TRUE phone-number=TRUE
222
+ *
223
+ * Four of five claimed models granted, with no log, because whether an absent
224
+ * request fails closed was left ENTIRELY to consumer predicates -- and a
225
+ * predicate that ignores its request cannot fail closed on one that is missing.
226
+ * A nullish or primitive `request` therefore denies every model outright and
227
+ * says so once, at construction, so the signal exists even for a caller that
228
+ * goes on to serialize nothing.
229
+ *
230
+ * WHAT THIS CANNOT CHECK: `{}` is an object and passes. There is no request
231
+ * contract this module owns -- `auth()` reads `.method`, the shipped sample
232
+ * reads `.path`, a consumer's reads whatever it likes -- so anything past
233
+ * "is it an object" would be this module inventing a shape for someone else's
234
+ * framework. The residual is documented in the README under Consumer Contracts.
235
+ */
236
+ export function createLinkageFilter(request: unknown): LinkageFilter {
237
+ if (typeof request !== 'object' || request === null) {
238
+ 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.`);
239
+
240
+ return function isLinkable(_type: string, _record: unknown): boolean {
241
+ return false;
242
+ };
243
+ }
244
+
245
+ const byType = new Map<string, { verdict: AccessVerdict; decisions: Map<unknown, boolean> }>();
246
+
247
+ return function isLinkable(type: string, record: unknown): boolean {
248
+ let entry = byType.get(type);
249
+
250
+ if (!entry) {
251
+ entry = { verdict: resolveVerdict(request, type), decisions: new Map() };
252
+ byType.set(type, entry);
253
+ }
254
+
255
+ const { verdict, decisions } = entry;
256
+
257
+ if (!verdict.granted) return false;
258
+ if (!verdict.filter) return true;
259
+
260
+ const id = (record as { id?: unknown } | null)?.id;
261
+ const cached = decisions.get(id);
262
+ if (cached !== undefined) return cached;
263
+
264
+ let allowed: boolean;
265
+
266
+ try {
267
+ allowed = Boolean(verdict.filter(record));
268
+ } catch (error) {
269
+ // A predicate that throws is a denial -- the same reading `isDenied` uses
270
+ // one layer down. Logged, because a predicate that throws on every record
271
+ // empties every relationship and, silently, that is indistinguishable
272
+ // from a database with no relationships in it.
273
+ log.error?.(`[@stonyx/orm] access filter threw while filtering linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
274
+
275
+ allowed = false;
276
+ }
277
+
278
+ decisions.set(id, allowed);
279
+
280
+ return allowed;
281
+ };
282
+ }
package/src/hooks.ts CHANGED
@@ -37,21 +37,7 @@ export interface HookContext {
37
37
  state?: Record<string, unknown>;
38
38
  /** Previous record state (available in update hooks). */
39
39
  oldState?: unknown;
40
- /**
41
- * Target record ID for single-record operations.
42
- *
43
- * SET ONLY UNDER `delete`. `_withHooks` assigns this key in the two
44
- * `operation === 'delete'` branches and nowhere else, so on `get`, `list`,
45
- * `create` and `update` the key is ABSENT -- not `undefined`-valued, absent.
46
- * A hook rule written as `ctx.recordId === '<id>'` never fires on an update;
47
- * the addressed id is in `ctx.params`. Tracked as abofs/stonyx-orm#242.
48
- *
49
- * @see AccessContext.recordId in ./types/orm-types.ts -- an identically-named
50
- * key on an identically-shaped context object, and NOT interchangeable with
51
- * this one: it is present on every route `auth()` classifies, and spells
52
- * absence as `null` rather than `undefined`. They differ in coverage on four
53
- * of five operations, not only in the absence spelling.
54
- */
40
+ /** Target record ID for single-record operations. */
55
41
  recordId?: string | number;
56
42
  /** Response data (available in after hooks). */
57
43
  response?: unknown;
package/src/index.ts CHANGED
@@ -28,6 +28,15 @@ export { default } from './main.js';
28
28
  export { store, relationships } from './main.js';
29
29
  export type { PersistErrorDetail } from './main.js';
30
30
  export type { AccessContext, AccessFunction, AccessMethod, AccessOperation } from './types/orm-types.js'; // access() contract (#202)
31
+ export type { LinkageFilter } from './types/orm-types.js'; // linkage verdict contract (#234)
32
+ // The request-scoped linkage-verdict factory (#234). PUBLIC on purpose: the
33
+ // README tells a consumer serializing a `Record` outside the REST layer to pass
34
+ // their own resolved `linkage` option, and without an exported factory the only
35
+ // way to follow that advice is to write a SECOND reading of `access()` in
36
+ // consumer code -- the exact "unreviewed second authorization vocabulary" that
37
+ // src/access-verdict.ts exists to prevent, reproduced where no reviewer sees it
38
+ // drift. Give them the one interpreter instead of an invitation to fork it.
39
+ export { createLinkageFilter } from './access-verdict.js';
31
40
  export { Model, View, Serializer }; // base classes
32
41
  export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
33
42
  export { count, avg, sum, min, max }; // aggregate helpers
@@ -69,15 +69,6 @@
69
69
  * records under `model: 'owner'`, and the context gives no signal of that
70
70
  * (abofs/stonyx-orm#196).
71
71
  *
72
- * SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237, AND KEPT FOR THE
73
- * CONSTRAINT IT STATES RATHER THAN AS A DESCRIPTION OF THE CODE. The context
74
- * now also carries `recordId` -- the DECODED route-parameter id, see
75
- * `AccessContext.recordId` in ./types/orm-types.ts -- so the fixture's
76
- * `/archived` deny IS expressible from the context alone, and the shipped
77
- * sample no longer reads `request.path` at all. Retiring this wording WITH the
78
- * measurement that retires it, rather than by deletion, is
79
- * abofs/stonyx-orm#238.
80
- *
81
72
  * `record` IS NOT IN THIS CONTEXT, deliberately. `auth()` runs after route
82
73
  * matching but BEFORE any handler executes (`@stonyx/rest-server`
83
74
  * `src/request.ts:58-60`), so nothing has been fetched yet -- supplying a
@@ -167,11 +158,6 @@
167
158
  * `/archived` SUB-PATH rule is still a string match, and abofs/stonyx-orm#228 is
168
159
  * a sixth spelling that gets past it.
169
160
  *
170
- * SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237: the `/archived` rule is
171
- * no longer a string match against the request target -- it compares the
172
- * decoded `recordId` the framework supplies -- and abofs/stonyx-orm#228 is
173
- * CLOSED. Retirement of this wording: abofs/stonyx-orm#238.
174
- *
175
161
  * An intermediate revision of the sample read `request.baseUrl` -- the mount
176
162
  * Express ACTUALLY MATCHED. That closed all five variants (no query string,
177
163
  * not mount-relative, unaffected by absolute-form, already carrying the
@@ -187,26 +173,12 @@
187
173
  * does not decode, so `GET /owners/%61rchived` steps past it. See the
188
174
  * normalisation paragraph below and abofs/stonyx-orm#228.
189
175
  *
190
- * SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237. Variant 3 lived in that
191
- * one string comparison, and the comparison is gone: the sample compares the
192
- * decoded `recordId`. Left standing rather than edited because the same
193
- * "variant 3 survives" wording sits at four sites -- this header, README.md
194
- * twice, and test/sample/access/global-access.ts -- three of which SHIP, so
195
- * retiring one of four leaves the shipped copies contradicting each other.
196
- * Retiring all four WITH their measurement is abofs/stonyx-orm#238.
197
- *
198
176
  * ONE READ OF ARGUMENT ONE SURVIVES, AND IT MUST: `request.path`. It is
199
177
  * mount-relative and query-free, and it is for rules that distinguish SUB-PATHS
200
178
  * beneath the mount. The context names which model and which verb, NOT which
201
179
  * route, so the sample's `/archived` deny cannot be expressed from the context
202
180
  * alone and a context-ONLY rewrite would silently turn that deny into an allow.
203
181
  *
204
- * SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237: NO read of argument one
205
- * survives in the shipped sample. `recordId` names WHICH RECORD the route was
206
- * addressed to, so the `/archived` deny is expressible from the context alone
207
- * -- and it still must not be dropped; expressible is not optional. Retirement
208
- * of this wording: abofs/stonyx-orm#238.
209
- *
210
182
  * NORMALISE THE WAY THE ROUTER DOES, AND CASE-FOLDING ALONE IS NOT THAT. The
211
183
  * sample lower-cases before comparing, because a matcher stricter than the
212
184
  * case-insensitive router can be stepped around. That closes the case gap only.
@@ -216,20 +188,6 @@
216
188
  * sample and is tracked as abofs/stonyx-orm#228; the `.toLowerCase()` is not a
217
189
  * complete normalisation recipe. Compare record ids at their real case.
218
190
  *
219
- * DO NOT FOLLOW THE PARAGRAPH ABOVE. SUPERSEDED 2026-09-01 BY
220
- * abofs/stonyx-orm#236/#237, and flagged here rather than merely dated because
221
- * it is an INSTRUCTION, not a stale observation. `.toLowerCase()` on the access
222
- * path was measured WRONG IN BOTH DIRECTIONS AT ONCE: with a distinct owner
223
- * seeded at `ARCHIVED`, `GET /owners/ARCHIVED` was a false DENY on the wrong
224
- * record and `GET /owners/%41RCHIVED` a false ALLOW on that same record. A
225
- * record id is a VALUE, not a literal route segment, and express's
226
- * `case sensitive routing` governs literal segments only. Compare
227
- * `context.recordId` AS IT ARRIVES: do not case-fold it, do not decode it, do
228
- * not derive it from `request.path`. `AccessContext.recordId` in
229
- * ./types/orm-types.ts is the contract and says "Do NOT case-fold it"; the same
230
- * published tarball ships both files, and THIS paragraph is the one that is
231
- * wrong. Retiring it WITH its measurement is abofs/stonyx-orm#238.
232
- *
233
191
  * `?? ''` is not a defence. It converts an absent request target into an empty
234
192
  * string, which matches no collection, which falls through to the permission
235
193
  * array -- a total grant. An input you cannot identify must DENY, and that
@@ -238,12 +196,6 @@
238
196
  * argument one. The sample returns `false` for an absent `model` AND for an
239
197
  * absent or non-string `request.path`, rather than falling through either way.
240
198
  *
241
- * SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237 as to WHAT is guarded --
242
- * the principle is unchanged. The sample no longer reads `request.path`, so it
243
- * returns `false` for an absent `model` AND for an absent `recordId`
244
- * (`undefined`, the one spelling `auth()` never produces). Retirement of this
245
- * wording: abofs/stonyx-orm#238.
246
- *
247
199
  * THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
248
200
  * the operation and the record. Prefer the array shape (`['read']`) or `false`
249
201
  * until #202 lands; the function shape is what requires any matching at all.
@@ -269,6 +221,7 @@ import config from 'stonyx/config';
269
221
  import log from 'stonyx/log';
270
222
  import type { OrmRecord, AccessContext, AccessFunction, AccessMethod, AccessOperation } from './types/orm-types.js';
271
223
  import { isOrmRecord, NO_FREE_ID_ERROR } from './utils.js';
224
+ import { interpretAccess, createLinkageFilter } from './access-verdict.js';
272
225
 
273
226
  interface OrmRequest$ extends Request {
274
227
  protocol?: string;
@@ -481,6 +434,13 @@ function buildResponse(
481
434
 
482
435
  const includedRecords = collectIncludedRecords(recordOrRecords, includes);
483
436
  if (includedRecords.length > 0) {
437
+ // NO `linkage` ARGUMENT, deliberately, and abofs/stonyx-orm#235 owns adding
438
+ // one. Until it does, a PERMITTED record here emits the full pre-#234
439
+ // document: `GET /animals/1?include=owner` filters the primary document's
440
+ // `owner.data` to `null` and then names `owner:angela` in `included`.
441
+ // Whether a resource reaches this array at all is a different question
442
+ // (membership, abofs/stonyx-orm#233) and closing that one does not close
443
+ // this one.
484
444
  response.included = includedRecords.map(record => record.toJSON?.({ baseUrl }));
485
445
  }
486
446
 
@@ -681,7 +641,14 @@ export default class OrmRequest extends Request {
681
641
  if (queryFilterPredicate) recordsToReturn = recordsToReturn.filter(queryFilterPredicate as (record: OrmRecord) => boolean);
682
642
 
683
643
  const baseUrl = getBaseUrl(request);
684
- const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl }));
644
+
645
+ // ONE filter per REQUEST, not one per record: it carries the per-type
646
+ // verdict cache and the per-(type, id) decision cache, and both are
647
+ // worthless if it is rebuilt inside the map. Measured on this exact
648
+ // surface with no `include=`: 48 linkage entries collapse to 7 distinct
649
+ // (type, id) pairs.
650
+ const linkage = createLinkageFilter(request);
651
+ const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl, linkage }));
685
652
 
686
653
  return buildResponse(data, request.query?.include, recordsToReturn, {
687
654
  links: { self: `${baseUrl}/${pluralizedModel}` },
@@ -701,7 +668,25 @@ export default class OrmRequest extends Request {
701
668
  const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
702
669
 
703
670
  const baseUrl = getBaseUrl(request);
704
- return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl }), request.query?.include, record, {
671
+ const linkage = createLinkageFilter(request);
672
+
673
+ // `buildResponse` is deliberately NOT given the linkage filter, and the
674
+ // residual that leaves is NOT the one #233 owns. Two different questions:
675
+ //
676
+ // - WHETHER A RESOURCE APPEARS in `included` at all is MEMBERSHIP ->
677
+ // abofs/stonyx-orm#233.
678
+ // - What a record already IN `included` may NAME is LINKAGE -- the same
679
+ // question #234 answers for the primary document -- and it is
680
+ // abofs/stonyx-orm#235, which also owns createHandler/updateHandler.
681
+ //
682
+ // The residual, stated so the next reader does not have to derive it:
683
+ // `buildResponse` calls `record.toJSON?.({ baseUrl })` with no `linkage`
684
+ // argument, so a PERMITTED record in `included` emits the full pre-#234
685
+ // document. Measured: `GET /animals/1?include=owner` returns
686
+ // `owner.data: null` on the primary document and `owner:angela` in
687
+ // `included`. One query parameter deep. Only the PRIMARY document's
688
+ // linkage is filtered here.
689
+ return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
705
690
  links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
706
691
  baseUrl
707
692
  });
@@ -1331,14 +1316,21 @@ export default class OrmRequest extends Request {
1331
1316
  const relatedData = record.__relationships[relationshipName];
1332
1317
  const baseUrl = getBaseUrl(request);
1333
1318
 
1319
+ // LINKAGE ONLY. This filter decides which ids the emitted documents may
1320
+ // NAME in their own `relationships.*.data`; it does NOT decide whether
1321
+ // the related records themselves are served -- that is the parent-only
1322
+ // filtering this route has done since #190, and widening it to the
1323
+ // related record is abofs/stonyx-orm#196.
1324
+ const linkage = createLinkageFilter(request);
1325
+
1334
1326
  let data: unknown;
1335
1327
  if (info.isArray) {
1336
1328
  // hasMany - return array
1337
1329
  const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
1338
- data = related.map(r => r.toJSON?.({ baseUrl }));
1330
+ data = related.map(r => r.toJSON?.({ baseUrl, linkage }));
1339
1331
  } else {
1340
1332
  // belongsTo - return single or null
1341
- data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl }) : null;
1333
+ data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl, linkage }) : null;
1342
1334
  }
1343
1335
 
1344
1336
  return {
@@ -1437,53 +1429,10 @@ export default class OrmRequest extends Request {
1437
1429
  // src/types/orm-types.ts. Nothing is fetched at this point and adding a
1438
1430
  // lookup here would put a store read in the middle of an authorization
1439
1431
  // path. The function return shape below IS the per-record hook.
1440
- //
1441
- // -------------------------------------------------------------------------
1442
- // #236 -- `recordId`, the DECODED route-parameter id, for the same reason.
1443
- //
1444
- // WHICH RECORD is the third structural fact the framework already holds and
1445
- // the consumer was left to re-derive, and re-deriving it failed OPEN. The
1446
- // documented sample compared `request.path` -- the RAW, undecoded pathname
1447
- // -- against a literal `/archived`, while the router DECODES `:id`. So
1448
- // `GET /owners/%61rchived` walked past the deny and was dispatched as the
1449
- // record `archived`: 200 with the record in full, and DELETE answered 204
1450
- // with the record destroyed, unauthenticated. Four spellings measured, all
1451
- // four through; 255 non-canonical spellings of that 8-character id decode
1452
- // to the same key, so this was never a deny-list of one.
1453
- //
1454
- // TWO CONSUMER-SIDE NORMALISATIONS WERE MEASURED WRONG IN OPPOSITE
1455
- // DIRECTIONS, which is the argument for doing it once, here.
1456
- // `.toLowerCase()` case-folds a route-parameter VALUE on the axis that
1457
- // governs literal SEGMENTS: with a distinct owner seeded at `ARCHIVED`,
1458
- // `GET /owners/ARCHIVED` was a false DENY on the wrong record and
1459
- // `GET /owners/%41RCHIVED` a false ALLOW on that same one.
1460
- // `decodeURIComponent(request.path)` decodes THEN splits while the router
1461
- // splits THEN decodes, so it over-denied `/owners/archived%2fx` -- 403 for
1462
- // a genuinely distinct record. Failing closed there was luck, not design.
1463
- //
1464
- // `getId(request.params)` AND NOT `request.params.id`, for exactly the
1465
- // reason `operation` is a `methodAccessMap` lookup: it is the SAME single
1466
- // coercion the store lookup one layer down performs, so the predicate and
1467
- // the dispatch cannot disagree about which record a request addresses.
1468
- // The raw string would reintroduce that divergence on hex-shaped ids --
1469
- // `GET /animals/0x2391` looks up record `9105`.
1470
- //
1471
- // NOTHING HERE PARSES THE REQUEST TARGET EITHER. `request.params` is what
1472
- // the router matched, so a mount prefix, an absolute-form target, a query
1473
- // string or a case-varied mount cannot move this value -- the same
1474
- // guarantee `model` carries, by the same means.
1475
- //
1476
- // `null` and not `undefined` on a collection route, so the KEY IS ALWAYS
1477
- // PRESENT -- the rule `operation`'s own docblock already establishes. A
1478
- // context reaching a predicate WITHOUT the key therefore did not come from
1479
- // here; it was hand-assembled by a caller resolving the predicate through
1480
- // `Orm.instance.getAccess()`, and that absence stays deniable only because
1481
- // `auth()` never produces it.
1482
1432
  // -------------------------------------------------------------------------
1483
1433
  const context: AccessContext = {
1484
1434
  model: this.model,
1485
1435
  operation: methodAccessMap[request.method],
1486
- recordId: request.params && 'id' in request.params ? getId(request.params) : null,
1487
1436
  };
1488
1437
 
1489
1438
  let access: AccessMethod;
@@ -1498,24 +1447,23 @@ export default class OrmRequest extends Request {
1498
1447
  return 403; // Forbidden
1499
1448
  }
1500
1449
 
1501
- if (!access) return 403;
1502
- if (typeof access === 'function') {
1503
- state.filter = access;
1504
- return undefined;
1505
- }
1506
- if (access === true) return undefined;
1507
-
1508
- // `AccessMethod` declares `string` legal and it fell through every branch
1509
- // above, returning undefined -- i.e. FULL CRUD, no filter. `return 'read'`
1510
- // is the natural reading of a type that lists `string` first, and it
1511
- // granted DELETE. A bare string is one permission, not a grant of all four.
1512
- const permitted = typeof access === 'string' ? [access] : access;
1513
-
1514
- // Anything that is not a permission array by this point -- an object, a
1515
- // number, a Symbol -- is a consumer mistake, and the only safe reading of a
1516
- // shape the contract does not define is a denial. Fail CLOSED.
1517
- if (!Array.isArray(permitted)) return 403;
1518
- if (!permitted.includes(methodAccessMap[request.method])) return 403;
1450
+ // THE READING OF THE RETURN SHAPE LIVES IN ONE PLACE (#234).
1451
+ //
1452
+ // It used to be inline here, and it was the only copy, which was fine while
1453
+ // `auth()` was the only thing that had to ask. It is not any more: the
1454
+ // linkage path has to ask model X's predicate about model X's records while
1455
+ // servicing a request routed to model Y, and a second inline copy of these
1456
+ // six branches would be a second authorization vocabulary -- one that can
1457
+ // drift, and that reviewers would have to notice had drifted. The branch
1458
+ // order in `interpretAccess` is this block, moved, not rewritten.
1459
+ const verdict = interpretAccess(access, methodAccessMap[request.method]);
1460
+
1461
+ if (!verdict.granted) return 403;
1462
+
1463
+ // The function return shape is the per-record hook, and `state` is the
1464
+ // whole transport for it: @stonyx/rest-server memoises one state object per
1465
+ // request and hands the same one to `auth()` and to the handler.
1466
+ if (verdict.filter) state.filter = verdict.filter;
1519
1467
 
1520
1468
  return undefined;
1521
1469
  }