@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.
@@ -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.
@@ -267,6 +219,7 @@ import { getBeforeHooks, getAfterHooks } from './hooks.js';
267
219
  import config from 'stonyx/config';
268
220
  import log from 'stonyx/log';
269
221
  import { isOrmRecord, NO_FREE_ID_ERROR } from './utils.js';
222
+ import { interpretAccess, createLinkageFilter } from './access-verdict.js';
270
223
  const methodAccessMap = {
271
224
  GET: 'read',
272
225
  POST: 'create',
@@ -436,6 +389,13 @@ function buildResponse(data, includeParam, recordOrRecords, options = {}) {
436
389
  return response;
437
390
  const includedRecords = collectIncludedRecords(recordOrRecords, includes);
438
391
  if (includedRecords.length > 0) {
392
+ // NO `linkage` ARGUMENT, deliberately, and abofs/stonyx-orm#235 owns adding
393
+ // one. Until it does, a PERMITTED record here emits the full pre-#234
394
+ // document: `GET /animals/1?include=owner` filters the primary document's
395
+ // `owner.data` to `null` and then names `owner:angela` in `included`.
396
+ // Whether a resource reaches this array at all is a different question
397
+ // (membership, abofs/stonyx-orm#233) and closing that one does not close
398
+ // this one.
439
399
  response.included = includedRecords.map(record => record.toJSON?.({ baseUrl }));
440
400
  }
441
401
  return response;
@@ -609,7 +569,13 @@ export default class OrmRequest extends Request {
609
569
  if (queryFilterPredicate)
610
570
  recordsToReturn = recordsToReturn.filter(queryFilterPredicate);
611
571
  const baseUrl = getBaseUrl(request);
612
- const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl }));
572
+ // ONE filter per REQUEST, not one per record: it carries the per-type
573
+ // verdict cache and the per-(type, id) decision cache, and both are
574
+ // worthless if it is rebuilt inside the map. Measured on this exact
575
+ // surface with no `include=`: 48 linkage entries collapse to 7 distinct
576
+ // (type, id) pairs.
577
+ const linkage = createLinkageFilter(request);
578
+ const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl, linkage }));
613
579
  return buildResponse(data, request.query?.include, recordsToReturn, {
614
580
  links: { self: `${baseUrl}/${pluralizedModel}` },
615
581
  baseUrl
@@ -627,7 +593,24 @@ export default class OrmRequest extends Request {
627
593
  const fieldsMap = parseFields(request.query);
628
594
  const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
629
595
  const baseUrl = getBaseUrl(request);
630
- return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl }), request.query?.include, record, {
596
+ const linkage = createLinkageFilter(request);
597
+ // `buildResponse` is deliberately NOT given the linkage filter, and the
598
+ // residual that leaves is NOT the one #233 owns. Two different questions:
599
+ //
600
+ // - WHETHER A RESOURCE APPEARS in `included` at all is MEMBERSHIP ->
601
+ // abofs/stonyx-orm#233.
602
+ // - What a record already IN `included` may NAME is LINKAGE -- the same
603
+ // question #234 answers for the primary document -- and it is
604
+ // abofs/stonyx-orm#235, which also owns createHandler/updateHandler.
605
+ //
606
+ // The residual, stated so the next reader does not have to derive it:
607
+ // `buildResponse` calls `record.toJSON?.({ baseUrl })` with no `linkage`
608
+ // argument, so a PERMITTED record in `included` emits the full pre-#234
609
+ // document. Measured: `GET /animals/1?include=owner` returns
610
+ // `owner.data: null` on the primary document and `owner:angela` in
611
+ // `included`. One query parameter deep. Only the PRIMARY document's
612
+ // linkage is filtered here.
613
+ return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
631
614
  links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
632
615
  baseUrl
633
616
  });
@@ -1214,15 +1197,21 @@ export default class OrmRequest extends Request {
1214
1197
  return 404;
1215
1198
  const relatedData = record.__relationships[relationshipName];
1216
1199
  const baseUrl = getBaseUrl(request);
1200
+ // LINKAGE ONLY. This filter decides which ids the emitted documents may
1201
+ // NAME in their own `relationships.*.data`; it does NOT decide whether
1202
+ // the related records themselves are served -- that is the parent-only
1203
+ // filtering this route has done since #190, and widening it to the
1204
+ // related record is abofs/stonyx-orm#196.
1205
+ const linkage = createLinkageFilter(request);
1217
1206
  let data;
1218
1207
  if (info.isArray) {
1219
1208
  // hasMany - return array
1220
1209
  const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
1221
- data = related.map(r => r.toJSON?.({ baseUrl }));
1210
+ data = related.map(r => r.toJSON?.({ baseUrl, linkage }));
1222
1211
  }
1223
1212
  else {
1224
1213
  // belongsTo - return single or null
1225
- data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl }) : null;
1214
+ data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl, linkage }) : null;
1226
1215
  }
1227
1216
  return {
1228
1217
  links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}/${dasherizedName}` },
@@ -1317,53 +1306,10 @@ export default class OrmRequest extends Request {
1317
1306
  // src/types/orm-types.ts. Nothing is fetched at this point and adding a
1318
1307
  // lookup here would put a store read in the middle of an authorization
1319
1308
  // path. The function return shape below IS the per-record hook.
1320
- //
1321
- // -------------------------------------------------------------------------
1322
- // #236 -- `recordId`, the DECODED route-parameter id, for the same reason.
1323
- //
1324
- // WHICH RECORD is the third structural fact the framework already holds and
1325
- // the consumer was left to re-derive, and re-deriving it failed OPEN. The
1326
- // documented sample compared `request.path` -- the RAW, undecoded pathname
1327
- // -- against a literal `/archived`, while the router DECODES `:id`. So
1328
- // `GET /owners/%61rchived` walked past the deny and was dispatched as the
1329
- // record `archived`: 200 with the record in full, and DELETE answered 204
1330
- // with the record destroyed, unauthenticated. Four spellings measured, all
1331
- // four through; 255 non-canonical spellings of that 8-character id decode
1332
- // to the same key, so this was never a deny-list of one.
1333
- //
1334
- // TWO CONSUMER-SIDE NORMALISATIONS WERE MEASURED WRONG IN OPPOSITE
1335
- // DIRECTIONS, which is the argument for doing it once, here.
1336
- // `.toLowerCase()` case-folds a route-parameter VALUE on the axis that
1337
- // governs literal SEGMENTS: with a distinct owner seeded at `ARCHIVED`,
1338
- // `GET /owners/ARCHIVED` was a false DENY on the wrong record and
1339
- // `GET /owners/%41RCHIVED` a false ALLOW on that same one.
1340
- // `decodeURIComponent(request.path)` decodes THEN splits while the router
1341
- // splits THEN decodes, so it over-denied `/owners/archived%2fx` -- 403 for
1342
- // a genuinely distinct record. Failing closed there was luck, not design.
1343
- //
1344
- // `getId(request.params)` AND NOT `request.params.id`, for exactly the
1345
- // reason `operation` is a `methodAccessMap` lookup: it is the SAME single
1346
- // coercion the store lookup one layer down performs, so the predicate and
1347
- // the dispatch cannot disagree about which record a request addresses.
1348
- // The raw string would reintroduce that divergence on hex-shaped ids --
1349
- // `GET /animals/0x2391` looks up record `9105`.
1350
- //
1351
- // NOTHING HERE PARSES THE REQUEST TARGET EITHER. `request.params` is what
1352
- // the router matched, so a mount prefix, an absolute-form target, a query
1353
- // string or a case-varied mount cannot move this value -- the same
1354
- // guarantee `model` carries, by the same means.
1355
- //
1356
- // `null` and not `undefined` on a collection route, so the KEY IS ALWAYS
1357
- // PRESENT -- the rule `operation`'s own docblock already establishes. A
1358
- // context reaching a predicate WITHOUT the key therefore did not come from
1359
- // here; it was hand-assembled by a caller resolving the predicate through
1360
- // `Orm.instance.getAccess()`, and that absence stays deniable only because
1361
- // `auth()` never produces it.
1362
1309
  // -------------------------------------------------------------------------
1363
1310
  const context = {
1364
1311
  model: this.model,
1365
1312
  operation: methodAccessMap[request.method],
1366
- recordId: request.params && 'id' in request.params ? getId(request.params) : null,
1367
1313
  };
1368
1314
  let access;
1369
1315
  try {
@@ -1376,26 +1322,23 @@ export default class OrmRequest extends Request {
1376
1322
  log.error?.(`[@stonyx/orm] access() threw for model "${this.model}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
1377
1323
  return 403; // Forbidden
1378
1324
  }
1379
- if (!access)
1380
- return 403;
1381
- if (typeof access === 'function') {
1382
- state.filter = access;
1383
- return undefined;
1384
- }
1385
- if (access === true)
1386
- return undefined;
1387
- // `AccessMethod` declares `string` legal and it fell through every branch
1388
- // above, returning undefined -- i.e. FULL CRUD, no filter. `return 'read'`
1389
- // is the natural reading of a type that lists `string` first, and it
1390
- // granted DELETE. A bare string is one permission, not a grant of all four.
1391
- const permitted = typeof access === 'string' ? [access] : access;
1392
- // Anything that is not a permission array by this point -- an object, a
1393
- // number, a Symbol -- is a consumer mistake, and the only safe reading of a
1394
- // shape the contract does not define is a denial. Fail CLOSED.
1395
- if (!Array.isArray(permitted))
1396
- return 403;
1397
- if (!permitted.includes(methodAccessMap[request.method]))
1325
+ // THE READING OF THE RETURN SHAPE LIVES IN ONE PLACE (#234).
1326
+ //
1327
+ // It used to be inline here, and it was the only copy, which was fine while
1328
+ // `auth()` was the only thing that had to ask. It is not any more: the
1329
+ // linkage path has to ask model X's predicate about model X's records while
1330
+ // servicing a request routed to model Y, and a second inline copy of these
1331
+ // six branches would be a second authorization vocabulary -- one that can
1332
+ // drift, and that reviewers would have to notice had drifted. The branch
1333
+ // order in `interpretAccess` is this block, moved, not rewritten.
1334
+ const verdict = interpretAccess(access, methodAccessMap[request.method]);
1335
+ if (!verdict.granted)
1398
1336
  return 403;
1337
+ // The function return shape is the per-record hook, and `state` is the
1338
+ // whole transport for it: @stonyx/rest-server memoises one state object per
1339
+ // request and hands the same one to `auth()` and to the handler.
1340
+ if (verdict.filter)
1341
+ state.filter = verdict.filter;
1399
1342
  return undefined;
1400
1343
  }
1401
1344
  }
package/dist/record.d.ts CHANGED
@@ -1,7 +1,23 @@
1
1
  import type Serializer from './serializer.js';
2
+ import type { LinkageFilter } from './types/orm-types.js';
2
3
  interface ToJSONOptions {
3
4
  fields?: Set<string>;
4
5
  baseUrl?: string;
6
+ /**
7
+ * An ALREADY-RESOLVED linkage decision, supplied by a caller that holds the
8
+ * request (abofs/stonyx-orm#234). Returning `false` for a related record
9
+ * drops that record's `{ type, id }` from `relationships.*.data`.
10
+ *
11
+ * This method APPLIES a verdict; it never RESOLVES one -- see
12
+ * `src/access-verdict.ts` for the two measured reasons it cannot. ABSENT is
13
+ * the default and the default is TODAY'S DOCUMENT, unchanged, because
14
+ * `toJSON` is also the `JSON.stringify` hook and an implicit caller has no
15
+ * syntactic place to pass this (abofs/stonyx-orm#230).
16
+ *
17
+ * ABSENT and UNUSABLE are read differently, and the difference is a security
18
+ * decision -- see the three-way reading at the call site below.
19
+ */
20
+ linkage?: LinkageFilter;
5
21
  }
6
22
  interface SerializeOptions {
7
23
  update?: boolean;
package/dist/record.js CHANGED
@@ -1,7 +1,26 @@
1
1
  import { store } from '@stonyx/orm';
2
+ import log from 'stonyx/log';
2
3
  import { getComputedProperties } from "./serializer.js";
3
4
  import { camelCaseToKebabCase } from '@stonyx/utils/string';
4
5
  import { getPluralName } from './plural-registry.js';
6
+ /**
7
+ * Name a non-boolean `linkage` return for the one log line that reports it.
8
+ *
9
+ * A thenable is called out BY NAME because it is the shape a consumer produces
10
+ * by accident -- an `async` resolver, or one that returns the promise of an
11
+ * authorization lookup -- and the one whose truthiness silently GRANTED every
12
+ * relationship before the ANSWER was checked (abofs/stonyx-orm#234).
13
+ */
14
+ function describeNonVerdict(verdict) {
15
+ if (verdict === null)
16
+ return 'null';
17
+ if (Array.isArray(verdict))
18
+ return 'an array';
19
+ if ((typeof verdict === 'object' || typeof verdict === 'function')
20
+ && typeof verdict.then === 'function')
21
+ return 'a Promise (or other thenable)';
22
+ return `a value of type ${typeof verdict}`;
23
+ }
5
24
  export default class Record {
6
25
  /** @private */
7
26
  __data = {};
@@ -65,7 +84,13 @@ export default class Record {
65
84
  toJSON(options = {}) {
66
85
  if (!this.__serialized)
67
86
  throw new Error('Record must be serialized before being converted to JSON');
68
- const { fields, baseUrl } = options;
87
+ // DESTRUCTURED FROM A VALUE THAT IS NOT ALWAYS AN OBJECT. `toJSON` is the
88
+ // ECMAScript serialization hook, so `JSON.stringify({ data: record })`
89
+ // arrives here as `toJSON('data')` -- a STRING in the options slot.
90
+ // Destructuring a string yields `undefined` for every key, which is exactly
91
+ // the no-argument default, so the implicit path keeps working and keeps
92
+ // emitting today's document (abofs/stonyx-orm#230).
93
+ const { fields, baseUrl, linkage } = options;
69
94
  const { __data: data } = this;
70
95
  const modelName = this.__model.__name;
71
96
  const pluralizedModelName = getPluralName(modelName);
@@ -84,12 +109,133 @@ export default class Record {
84
109
  continue;
85
110
  attributes[key] = getter.call(this);
86
111
  }
112
+ // `linkage` is a PUBLIC option -- it is on `OrmRecord.toJSON`
113
+ // (src/types/orm-types.ts) and the README tells consumers to pass one -- so
114
+ // it arrives from outside this package, may be ANY value, and whatever it
115
+ // is, it gets INVOKED here. That makes this the trust boundary, and it was
116
+ // the LAX side of one: the internal `createLinkageFilter` coerces and
117
+ // try/catches the consumer predicate it wraps, while this -- the site that
118
+ // consumes the PUBLIC option -- did neither.
119
+ //
120
+ // THREE QUESTIONS. Every wrong answer below was measured, on a two-
121
+ // relationship record, emitting the full pre-#234 document or throwing out
122
+ // of `JSON.stringify`.
123
+ //
124
+ // 1. IS IT SUPPLIED? ABSENT (`undefined`) means no verdict was supplied:
125
+ // emit today's document. Load-bearing and asserted (AC5/AC5b) --
126
+ // `toJSON` is also the `JSON.stringify` hook, so the implicit caller
127
+ // arrives as `toJSON('data')`, a STRING, which destructures to
128
+ // `undefined` here (abofs/stonyx-orm#230).
129
+ //
130
+ // 2. IS ITS SHAPE USABLE? `[object Function]` only, because
131
+ // `typeof x === 'function'` is NOT the question "can this answer a
132
+ // synchronous boolean".
133
+ //
134
+ // A NON-FUNCTION denies. Reading it as absent is what `!linkage ||`
135
+ // did, and a resolver returning `null` because it could not resolve a
136
+ // session is the natural shape of that value and the fail-closed
137
+ // INTENT -- measured, `toJSON({ linkage: null })` emitted the full
138
+ // pre-#234 linkage with no signal, byte-identical to unpatched dev.
139
+ //
140
+ // AN `AsyncFunction`, `GeneratorFunction` or `AsyncGeneratorFunction`
141
+ // denies for that SAME reason, one branch over -- and a `typeof`-only
142
+ // check left the whole defect standing there. `async (type, r) =>
143
+ // false` returns a PROMISE, a promise is TRUTHY, so every relationship
144
+ // was emitted in full with ZERO log, again byte-identical to unpatched
145
+ // dev. An awaited authorization lookup is at least as natural a
146
+ // resolver as a nullish one -- the README's own Consumer Contracts
147
+ // section points consumers at queue payloads and websocket frames,
148
+ // where lookups are routinely awaited -- and it landed on the GRANT
149
+ // side of the same branch the `null` reading closed.
150
+ //
151
+ // 3. IS ITS ANSWER A VERDICT? It must BE a boolean, not merely coerce to
152
+ // one. `Boolean(...)` -- the coercion `createLinkageFilter` applies to
153
+ // a consumer `access()` predicate, whose truthy contract predates this
154
+ // option and is deliberately NOT changed -- is not enough here, and
155
+ // was measured not to be: with `Boolean(...)` plus a try/catch in
156
+ // place, `async () => false`, `function* () {}`,
157
+ // `() => Promise.resolve(false)`, `() => ({})` and `() => 'no'` ALL
158
+ // still emitted the full pre-#234 linkage with no log, because
159
+ // truthiness is what they already had. A non-boolean is a resolver
160
+ // that did not answer, and the only safe reading of a non-answer is a
161
+ // denial.
162
+ //
163
+ // AND IT NEVER THROWS -- which is now true rather than only written down.
164
+ // A throw here escapes the enclosing `JSON.stringify` and takes
165
+ // `console.log` and `Orm.db.save()`'s neighbours with it, a far worse
166
+ // failure mode than a status. `class Klass {}`, `Klass.bind(null)` and any
167
+ // predicate that dereferences something undefined were all measured raising
168
+ // out of the `stringify`; all three are caught and denied.
169
+ //
170
+ // Logged once per DOCUMENT, not once per relationship key or per related
171
+ // record: an emptied relationship is deliberately indistinguishable from a
172
+ // genuinely empty one on the wire, so the log is the ONLY signal a consumer
173
+ // whose resolver quietly returned `null`, or a promise, will ever get.
174
+ const linkageSupplied = linkage !== undefined;
175
+ // Read the tag DEFENSIVELY. `Object.prototype.toString` consults
176
+ // `Symbol.toStringTag`, so a Proxy with a throwing `get` trap would throw
177
+ // out of the validation whose entire job is that nothing throws.
178
+ let linkageShape = 'a non-function';
179
+ if (typeof linkage === 'function') {
180
+ try {
181
+ linkageShape = Object.prototype.toString.call(linkage);
182
+ }
183
+ catch {
184
+ linkageShape = '[object Unreadable]';
185
+ }
186
+ }
187
+ const linkageUsable = linkageShape === '[object Function]';
188
+ let linkageReported = false;
189
+ const denyAllLinkage = (reason) => {
190
+ if (linkageReported)
191
+ return;
192
+ linkageReported = true;
193
+ log.error?.(`[@stonyx/orm] toJSON() received an unusable \`linkage\` option -- ${reason}, so ALL relationship linkage on this \`${modelName}\` document is denied.`);
194
+ };
195
+ if (linkageSupplied && !linkageUsable) {
196
+ denyAllLinkage(typeof linkage !== 'function'
197
+ ? `it is of type ${linkage === null ? 'null' : typeof linkage} and it must be a function`
198
+ : `it is ${linkageShape} and it must be a SYNCHRONOUS function -- \`toJSON\` is the \`JSON.stringify\` hook and cannot await a verdict`);
199
+ }
200
+ const linkageVerdict = !linkageSupplied
201
+ ? undefined
202
+ : linkageUsable ? linkage : () => false;
203
+ // Applied per related record, alongside the existing `__model` liveness
204
+ // check, and producing exactly the shapes that check already produces: a
205
+ // dropped hasMany member leaves `data: []`, a dropped belongsTo leaves
206
+ // `data: null`. Both already ship -- a genuinely-empty hasMany emits
207
+ // `data: []` with links, and a cleaned belongsTo emits `data: null` -- so a
208
+ // filtered relationship is BYTE-IDENTICAL to an empty one and there is no
209
+ // new wire shape and no oracle.
210
+ const isLinkable = (r) => {
211
+ if (!linkageVerdict)
212
+ return true;
213
+ try {
214
+ const verdict = linkageVerdict(r.__model.__name, r);
215
+ if (typeof verdict === 'boolean')
216
+ return verdict;
217
+ denyAllLinkage(`it answered with ${describeNonVerdict(verdict)} rather than a boolean`);
218
+ }
219
+ catch (error) {
220
+ // Building the report is itself a throw site -- `throw Symbol('x')`
221
+ // makes `String(error)` throw, and a getter on `.message` can throw --
222
+ // and a throw from the reporter would escape the catch that exists so
223
+ // that nothing escapes.
224
+ let detail = 'a value that could not be described';
225
+ try {
226
+ detail = error instanceof Error ? error.message : String(error);
227
+ }
228
+ catch { /* keep the fallback -- the denial matters, the text does not */ }
229
+ denyAllLinkage(`it threw (${detail})`);
230
+ }
231
+ return false;
232
+ };
87
233
  for (const [key, childRecord] of Object.entries(this.__relationships)) {
88
234
  if (fields && !fields.has(key))
89
235
  continue;
90
236
  const relationshipData = Array.isArray(childRecord)
91
- ? childRecord.filter((r) => r?.__model).map((r) => ({ type: r.__model.__name, id: r.id }))
92
- : (childRecord && childRecord.__model) ? { type: childRecord.__model.__name, id: childRecord.id } : null;
237
+ ? childRecord.filter((r) => r?.__model).filter(isLinkable).map((r) => ({ type: r.__model.__name, id: r.id }))
238
+ : (childRecord && childRecord.__model && isLinkable(childRecord)) ? { type: childRecord.__model.__name, id: childRecord.id } : null;
93
239
  // Dasherize the key for URL paths (e.g., accessLinks -> access-links)
94
240
  const dasherizedKey = camelCaseToKebabCase(key);
95
241
  relationships[dasherizedKey] = { data: relationshipData };
@@ -87,9 +87,18 @@ export interface OrmRecord {
87
87
  __pendingSqlId?: boolean;
88
88
  };
89
89
  __relationships: Record<string, unknown>;
90
+ /**
91
+ * `linkage` is an ALREADY-RESOLVED decision supplied by a caller that holds
92
+ * the request (abofs/stonyx-orm#234): return `false` for a related record and
93
+ * its `{ type, id }` is dropped from `relationships.*.data`. Omitting it is
94
+ * the default, and the default is the pre-#234 document unchanged -- this
95
+ * method is also the `JSON.stringify` hook, so an implicit caller has no
96
+ * syntactic place to pass it (abofs/stonyx-orm#230).
97
+ */
90
98
  toJSON?(options?: {
91
99
  fields?: Set<string>;
92
100
  baseUrl?: string;
101
+ linkage?: LinkageFilter;
93
102
  }): Record<string, unknown>;
94
103
  [key: string]: unknown;
95
104
  }
@@ -242,101 +251,6 @@ export interface AccessContext {
242
251
  * from one that classified the request and found nothing.
243
252
  */
244
253
  operation: AccessOperation | undefined;
245
- /**
246
- * The record this route was addressed to, as the store key -- or `null` on a
247
- * collection route, which is addressed to no record (abofs/stonyx-orm#236).
248
- *
249
- * IT IS ALREADY DECODED, AND THAT IS THE WHOLE POINT. Express decodes route
250
- * PARAMETERS while leaving `request.path` raw, so a consumer comparing
251
- * `request.path` against a literal compares an undecoded string against a
252
- * decoded dispatch. `GET /owners/%61rchived` reached such a comparison as
253
- * `/%61rchived`, walked past a `/archived` deny, and was dispatched as the
254
- * record `archived` -- 200 with the record in full, and `DELETE` destroyed
255
- * it, unauthenticated. 255 non-canonical spellings of an 8-character id
256
- * decode to the same key, so a deny-list of spellings is the wrong shape.
257
- *
258
- * SO DO NOT NORMALISE THIS, AND DO NOT NORMALISE ANYTHING ELSE INSTEAD:
259
- *
260
- * - Do NOT decode it. Express decodes exactly ONCE, which is what a route
261
- * parameter means. `GET /owners/%2561rchived` is the legitimate id
262
- * `%61rchived`, not a second-order spelling of `archived`; a predicate that
263
- * decoded until stable would deny a record it was never asked about.
264
- * - Do NOT case-fold it. A record id is a VALUE, not a literal route segment,
265
- * and express's `case sensitive routing` governs literal segments only.
266
- * With a distinct owner seeded at `ARCHIVED`, `.toLowerCase()` was measured
267
- * wrong in BOTH directions at once: `GET /owners/ARCHIVED` 403 (a false
268
- * deny, on the wrong record) and `GET /owners/%41RCHIVED` 200 (a false
269
- * allow, on that same record).
270
- * - Do NOT derive it from `request.path` or the request target. Decoding the
271
- * whole path decodes THEN splits, while the router splits THEN decodes, so
272
- * `/owners/archived%2fx` -- a genuinely distinct record whose id is
273
- * `archived/x` -- was measured over-denied 403.
274
- *
275
- * IT IS `getId(request.params)`, BYTE FOR BYTE -- the same single coercion
276
- * the store lookup uses, exactly as `operation` is the same `methodAccessMap`
277
- * lookup the permission-array branch uses. The predicate and the dispatch
278
- * therefore cannot disagree about which record a request addresses. Handing
279
- * over the raw `request.params.id` instead would reintroduce that divergence
280
- * on hex-shaped ids: `GET /animals/0x2391` looks up record `9105`.
281
- *
282
- * It inherits abofs/stonyx-orm#209 along with that coercion -- on a model
283
- * declaring `id = attr('string')`, `'9107'` arrives here as the number
284
- * `9107`. That is consistency WITH THE LOOKUP, which is the property this key
285
- * exists to buy; it is not a defect to repair here.
286
- *
287
- * `null`, not `undefined`, on a collection route -- and the KEY IS ALWAYS
288
- * PRESENT, the same rule `operation` states above. `auth()` always sets it,
289
- * so a context arriving WITHOUT the key did not come from `auth()`: it was
290
- * hand-assembled by a caller resolving the predicate through
291
- * `Orm.instance.getAccess()`. That absence stays a distinguishable, deniable
292
- * signal only because the framework never produces it.
293
- *
294
- * IT DISAGREES WITH THE HOOK VOCABULARY, AND NOT ONLY ON THE ABSENCE
295
- * SPELLING. `HookContext.recordId` (`src/hooks.ts`) is an identically-named
296
- * key on an identically-shaped context object, which is the exact
297
- * configuration that makes `operation` fail-open shaped -- a hook sees
298
- * `'get'` where `access()` sees `'read'`. An earlier revision of THIS
299
- * docblock asserted the opposite ("here they AGREE... they differ in ONE way
300
- * and it is the absence spelling"). That was measured false, in the fail-open
301
- * direction, and it is corrected here rather than deleted.
302
- *
303
- * MEASURED over the live dispatch, before-hooks registered for all five
304
- * operations on one model:
305
- *
306
- * before:list key ABSENT ('recordId' in context === false)
307
- * before:get key ABSENT params={"id":"visible1"}
308
- * before:create key ABSENT
309
- * before:update key ABSENT params={"id":"visible2"}
310
- * before:delete recordId="visible3"
311
- * after:delete recordId="visible3"
312
- *
313
- * `_withHooks` assigns `context.recordId` at exactly TWO sites in
314
- * `src/orm-request.ts`, and BOTH sit inside an `operation === 'delete'`
315
- * branch. So the two keys differ in COVERAGE, on four of five operations: on
316
- * a hook context the key is absent for get, list, create and update, while
317
- * this key is present on every route `auth()` classifies. The absence
318
- * spelling is the smaller half of the difference, not the whole of it.
319
- *
320
- * AND THAT INVERTS THE ARGUMENT ABOVE WHEN IT IS READ ACROSS THE TWO. Here,
321
- * a missing `recordId` means "did not come from `auth()`" and is deniable.
322
- * On a hook context it means "this is a get / list / create / update" -- an
323
- * ordinary request. A consumer who writes the hook-side half of the same
324
- * rule --
325
- *
326
- * beforeHook('update', 'owner', ctx => ctx.recordId === 'archived' ? 403 : undefined)
327
- *
328
- * -- gets a deny that NEVER FIRES: measured, `PATCH /owners/visible2` -> 200,
329
- * with `ctx.recordId === undefined` while the addressed record sits in
330
- * `ctx.params`. The hook side is abofs/stonyx-orm#242 and is deliberately not
331
- * repaired here. A predicate must not read `undefined` here as "collection",
332
- * and nothing in this contract makes it safe to read the two keys as one key.
333
- *
334
- * IT NAMES WHICH RECORD, NOT WHICH SURFACE. `GET /owners/gina`,
335
- * `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` all
336
- * carry `recordId: 'gina'`; the related-resource gap is abofs/stonyx-orm#196
337
- * and is untouched by this key.
338
- */
339
- recordId: string | number | null;
340
254
  }
341
255
  /**
342
256
  * A consumer `access()` predicate.
@@ -358,3 +272,21 @@ export interface AccessContext {
358
272
  * context gets `TS2554: Expected 2 arguments, but got 1`.
359
273
  */
360
274
  export type AccessFunction = (request: unknown, context: AccessContext) => AccessMethod;
275
+ /**
276
+ * A resolved, request-scoped linkage decision: may `record` of model `type` be
277
+ * NAMED, by id, inside another model's document (abofs/stonyx-orm#234)?
278
+ *
279
+ * Arity is `(type, record)` and not `(type, id)` because the per-record filter
280
+ * a consumer returns is handed the RECORD -- this repo's own fixture reads
281
+ * `record.owner?.id`, not just `record.id`. The `(type, id)` pair is the CACHE
282
+ * key inside `createLinkageFilter`, not the input.
283
+ *
284
+ * DECLARED HERE, with the rest of the access vocabulary, and imported by every
285
+ * site that names it. It had three structurally-identical hand-written copies
286
+ * (`access-verdict.ts`, `record.ts`, `OrmRecord.toJSON` below) bridged to each
287
+ * other by nothing, so a drift in nullability or a widening of `type` would
288
+ * have landed on one and not the others -- which is the same "second,
289
+ * unreviewed vocabulary" failure `src/access-verdict.ts` exists to prevent, one
290
+ * level up in the type system.
291
+ */
292
+ export type LinkageFilter = (type: string, record: unknown) => boolean;
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-alpha.70",
7
+ "version": "0.3.2-alpha.72",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",