@stonyx/orm 0.3.2-alpha.71 → 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.
package/README.md CHANGED
@@ -1061,12 +1061,42 @@ Not this:
1061
1061
  const linkage = (type, r) => Orm.instance.getAccess(type)?.(request)?.(r) ?? true;
1062
1062
  ```
1063
1063
 
1064
- **`linkage` is validated, and an unusable value DENIES.** `undefined` means "no
1065
- verdict supplied" and emits today's document. Any other non-function`null`,
1066
- `0`, `false`, `''`, `true`, a string, an object drops **all** linkage on that
1067
- document and logs. This matters because `null` is the natural return of a
1068
- resolver that could not resolve a session: it used to be read as "absent" and
1069
- emit the full document silently.
1064
+ **`createLinkageFilter` requires a live request, and there is no safe call
1065
+ without one.** `request` is the only authorization input the filter has it is
1066
+ handed straight to your `access()` predicates, and a predicate that does not
1067
+ *read* it cannot fail closed when it is missing. Passing `undefined`, `null` or
1068
+ any non-object therefore denies **all** linkage and logs, once, at construction.
1069
+ Measured before that guard existed, `createLinkageFilter(undefined)` granted
1070
+ four of the five models in this repository's own fixture, silently.
1071
+
1072
+ **This is the catch for the request-less contexts named above.** In a queue
1073
+ consumer or a websocket handler there is no live request, so there is nothing to
1074
+ authorize against and nothing this package can resolve for you. Either carry the
1075
+ originating request through to the point of serialization, or publish no linkage
1076
+ at all — `record.toJSON({ linkage: () => false })` emits the document with every
1077
+ relationship empty. A stand-in is **not** a substitute: `{}` is an object
1078
+ and passes the guard, and any predicate that ignores its request will grant.
1079
+
1080
+ **`linkage` itself is validated, and an unusable value DENIES.** `undefined`
1081
+ means "no verdict supplied" and emits today's document. Anything else must be a
1082
+ **synchronous function that answers with a boolean**. Each of the following
1083
+ drops **all** linkage on that document and logs once:
1084
+
1085
+ - **A non-function** — `null`, `0`, `false`, `''`, `true`, a string, an object.
1086
+ `null` is the natural return of a resolver that could not resolve a session:
1087
+ it used to be read as "absent" and emit the full document silently.
1088
+ - **An `async` function, a generator function, or any predicate that returns a
1089
+ promise or thenable.** `toJSON` is the `JSON.stringify` hook and cannot await
1090
+ a verdict, and **an `async` resolver returns a promise, a promise is
1091
+ truthy**, so every related id was published, silently, exactly as if this fix
1092
+ were not here. If your
1093
+ authorization lookup is asynchronous, `await` it *before* you serialize and
1094
+ close over the result.
1095
+ - **Any answer that is not a boolean** — `{}`, `'no'`, `1`, `undefined`. A
1096
+ non-boolean is a resolver that did not answer, and a truthy one granted.
1097
+ - **A predicate that throws**, including a `class` passed by mistake. It is
1098
+ caught and denied; it used to escape the enclosing `JSON.stringify` and take
1099
+ the rest of that serialization down with it.
1070
1100
 
1071
1101
  #### A predicate that ignores `context.model` makes cross-model resolution GRANT
1072
1102
 
@@ -55,5 +55,31 @@ export declare function interpretAccess(access: AccessMethod, operation: AccessO
55
55
  * SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
56
56
  * it -- a verdict cached across requests would answer a second caller with the
57
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.
58
84
  */
59
85
  export declare function createLinkageFilter(request: unknown): LinkageFilter;
@@ -186,8 +186,40 @@ function resolveVerdict(request, type) {
186
186
  * SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
187
187
  * it -- a verdict cached across requests would answer a second caller with the
188
188
  * first caller's authorization.
189
+ *
190
+ * A REQUEST IS REQUIRED, AND ITS ABSENCE IS CHECKED HERE RATHER THAN DELEGATED.
191
+ * This function is EXPORTED (src/index.ts), and the README's Consumer Contracts
192
+ * section points consumers at exactly the contexts that have no live request --
193
+ * a queue payload, a websocket frame, a custom route. Without one there is no
194
+ * caller to authorise against, and this file's header already says so: the
195
+ * shipped sample reads `request.path` and fail-closes when it is absent, so
196
+ * `getAccess('owner')(undefined, ...)` is `false`, while
197
+ * `getAccess('animal')(undefined, ...)` returns a per-record predicate and
198
+ * GRANTS. Measured on this repo's own fixture before this guard existed:
199
+ *
200
+ * createLinkageFilter(undefined | null | {} | 'x' | 0)
201
+ * -> owner=false animal=TRUE trait=TRUE category=TRUE phone-number=TRUE
202
+ *
203
+ * Four of five claimed models granted, with no log, because whether an absent
204
+ * request fails closed was left ENTIRELY to consumer predicates -- and a
205
+ * predicate that ignores its request cannot fail closed on one that is missing.
206
+ * A nullish or primitive `request` therefore denies every model outright and
207
+ * says so once, at construction, so the signal exists even for a caller that
208
+ * goes on to serialize nothing.
209
+ *
210
+ * WHAT THIS CANNOT CHECK: `{}` is an object and passes. There is no request
211
+ * contract this module owns -- `auth()` reads `.method`, the shipped sample
212
+ * reads `.path`, a consumer's reads whatever it likes -- so anything past
213
+ * "is it an object" would be this module inventing a shape for someone else's
214
+ * framework. The residual is documented in the README under Consumer Contracts.
189
215
  */
190
216
  export function createLinkageFilter(request) {
217
+ if (typeof request !== 'object' || request === null) {
218
+ 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.`);
219
+ return function isLinkable(_type, _record) {
220
+ return false;
221
+ };
222
+ }
191
223
  const byType = new Map();
192
224
  return function isLinkable(type, record) {
193
225
  let entry = byType.get(type);
package/dist/record.js CHANGED
@@ -3,6 +3,24 @@ import log from 'stonyx/log';
3
3
  import { getComputedProperties } from "./serializer.js";
4
4
  import { camelCaseToKebabCase } from '@stonyx/utils/string';
5
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
+ }
6
24
  export default class Record {
7
25
  /** @private */
8
26
  __data = {};
@@ -93,54 +111,128 @@ export default class Record {
93
111
  }
94
112
  // `linkage` is a PUBLIC option -- it is on `OrmRecord.toJSON`
95
113
  // (src/types/orm-types.ts) and the README tells consumers to pass one -- so
96
- // it arrives from outside this package and may be ANY value. Three
97
- // readings, and the difference between the second and the third is a
98
- // security decision:
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".
99
133
  //
100
- // ABSENT (`undefined`). No verdict was supplied. Emit today's document.
101
- // Load-bearing and asserted (AC5/AC5b): `toJSON` is also the
102
- // `JSON.stringify` hook, so the implicit caller arrives as
103
- // `toJSON('data')` -- a STRING, which destructures to `undefined` here
104
- // (abofs/stonyx-orm#230).
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.
105
139
  //
106
- // A FUNCTION. Apply it per related record.
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.
107
150
  //
108
- // ANYTHING ELSE -- `null`, `0`, `false`, `''`, `true`, a string, an
109
- // object. DENY, and say so. Neither of the two obvious alternatives is
110
- // available. Reading it as absent is what `!linkage ||` did, and a
111
- // resolver returning `null` because it could not resolve a session is the
112
- // natural shape of that value and the fail-closed INTENT -- measured,
113
- // `toJSON({ linkage: null })` emitted the full pre-#234 linkage with no
114
- // signal, byte-identical to unpatched dev. Reading it as a function
115
- // raises `TypeError: linkage is not a function` out of the enclosing
116
- // `JSON.stringify` -- measured on `true`, `'x'` and `{}` -- which is
117
- // exactly the outcome the comment below promises cannot happen.
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.
118
169
  //
119
170
  // Logged once per DOCUMENT, not once per relationship key or per related
120
171
  // record: an emptied relationship is deliberately indistinguishable from a
121
- // genuinely empty one on the wire, so the log is the only signal a consumer
122
- // whose resolver silently returned `null` will ever get.
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.
123
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
+ }
124
200
  const linkageVerdict = !linkageSupplied
125
201
  ? undefined
126
- : typeof linkage === 'function' ? linkage : () => false;
127
- if (linkageSupplied && typeof linkage !== 'function') {
128
- log.error?.(`[@stonyx/orm] toJSON() received a \`linkage\` option of type ${linkage === null ? 'null' : typeof linkage} -- it must be a function, so ALL relationship linkage on this \`${modelName}\` document is denied.`);
129
- }
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
+ };
130
233
  for (const [key, childRecord] of Object.entries(this.__relationships)) {
131
234
  if (fields && !fields.has(key))
132
235
  continue;
133
- // The linkage decision is applied HERE, alongside the existing
134
- // `__model` liveness check, and it produces exactly the shapes that
135
- // check already produces: a dropped hasMany member leaves `data: []`,
136
- // a dropped belongsTo leaves `data: null`. Both already ship -- a
137
- // genuinely-empty hasMany emits `data: []` with links, and a cleaned
138
- // belongsTo emits `data: null` -- so a filtered relationship is
139
- // BYTE-IDENTICAL to an empty one and there is no new wire shape and no
140
- // oracle. It never throws: a throw here escapes the enclosing
141
- // `JSON.stringify` and takes `console.log` and `Orm.db.save()`'s
142
- // neighbours with it, which is a far worse failure mode than a status.
143
- const isLinkable = (r) => !linkageVerdict || linkageVerdict(r.__model.__name, r);
144
236
  const relationshipData = Array.isArray(childRecord)
145
237
  ? childRecord.filter((r) => r?.__model).filter(isLinkable).map((r) => ({ type: r.__model.__name, id: r.id }))
146
238
  : (childRecord && childRecord.__model && isLinkable(childRecord)) ? { type: childRecord.__model.__name, id: childRecord.id } : null;
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-alpha.71",
7
+ "version": "0.3.2-alpha.72",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -206,8 +206,42 @@ function resolveVerdict(request: unknown, type: string): AccessVerdict {
206
206
  * SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
207
207
  * it -- a verdict cached across requests would answer a second caller with the
208
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.
209
235
  */
210
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
+
211
245
  const byType = new Map<string, { verdict: AccessVerdict; decisions: Map<unknown, boolean> }>();
212
246
 
213
247
  return function isLinkable(type: string, record: unknown): boolean {
package/src/record.ts CHANGED
@@ -55,6 +55,25 @@ interface JSONAPIResult {
55
55
  links?: { self: string };
56
56
  }
57
57
 
58
+
59
+ /**
60
+ * Name a non-boolean `linkage` return for the one log line that reports it.
61
+ *
62
+ * A thenable is called out BY NAME because it is the shape a consumer produces
63
+ * by accident -- an `async` resolver, or one that returns the promise of an
64
+ * authorization lookup -- and the one whose truthiness silently GRANTED every
65
+ * relationship before the ANSWER was checked (abofs/stonyx-orm#234).
66
+ */
67
+ function describeNonVerdict(verdict: unknown): string {
68
+ if (verdict === null) return 'null';
69
+ if (Array.isArray(verdict)) return 'an array';
70
+
71
+ if ((typeof verdict === 'object' || typeof verdict === 'function')
72
+ && typeof (verdict as { then?: unknown }).then === 'function') return 'a Promise (or other thenable)';
73
+
74
+ return `a value of type ${typeof verdict}`;
75
+ }
76
+
58
77
  export default class Record {
59
78
  /** @private */
60
79
  __data: { [key: string]: unknown } = {};
@@ -160,57 +179,138 @@ export default class Record {
160
179
 
161
180
  // `linkage` is a PUBLIC option -- it is on `OrmRecord.toJSON`
162
181
  // (src/types/orm-types.ts) and the README tells consumers to pass one -- so
163
- // it arrives from outside this package and may be ANY value. Three
164
- // readings, and the difference between the second and the third is a
165
- // security decision:
182
+ // it arrives from outside this package, may be ANY value, and whatever it
183
+ // is, it gets INVOKED here. That makes this the trust boundary, and it was
184
+ // the LAX side of one: the internal `createLinkageFilter` coerces and
185
+ // try/catches the consumer predicate it wraps, while this -- the site that
186
+ // consumes the PUBLIC option -- did neither.
187
+ //
188
+ // THREE QUESTIONS. Every wrong answer below was measured, on a two-
189
+ // relationship record, emitting the full pre-#234 document or throwing out
190
+ // of `JSON.stringify`.
191
+ //
192
+ // 1. IS IT SUPPLIED? ABSENT (`undefined`) means no verdict was supplied:
193
+ // emit today's document. Load-bearing and asserted (AC5/AC5b) --
194
+ // `toJSON` is also the `JSON.stringify` hook, so the implicit caller
195
+ // arrives as `toJSON('data')`, a STRING, which destructures to
196
+ // `undefined` here (abofs/stonyx-orm#230).
197
+ //
198
+ // 2. IS ITS SHAPE USABLE? `[object Function]` only, because
199
+ // `typeof x === 'function'` is NOT the question "can this answer a
200
+ // synchronous boolean".
166
201
  //
167
- // ABSENT (`undefined`). No verdict was supplied. Emit today's document.
168
- // Load-bearing and asserted (AC5/AC5b): `toJSON` is also the
169
- // `JSON.stringify` hook, so the implicit caller arrives as
170
- // `toJSON('data')` -- a STRING, which destructures to `undefined` here
171
- // (abofs/stonyx-orm#230).
202
+ // A NON-FUNCTION denies. Reading it as absent is what `!linkage ||`
203
+ // did, and a resolver returning `null` because it could not resolve a
204
+ // session is the natural shape of that value and the fail-closed
205
+ // INTENT -- measured, `toJSON({ linkage: null })` emitted the full
206
+ // pre-#234 linkage with no signal, byte-identical to unpatched dev.
172
207
  //
173
- // A FUNCTION. Apply it per related record.
208
+ // AN `AsyncFunction`, `GeneratorFunction` or `AsyncGeneratorFunction`
209
+ // denies for that SAME reason, one branch over -- and a `typeof`-only
210
+ // check left the whole defect standing there. `async (type, r) =>
211
+ // false` returns a PROMISE, a promise is TRUTHY, so every relationship
212
+ // was emitted in full with ZERO log, again byte-identical to unpatched
213
+ // dev. An awaited authorization lookup is at least as natural a
214
+ // resolver as a nullish one -- the README's own Consumer Contracts
215
+ // section points consumers at queue payloads and websocket frames,
216
+ // where lookups are routinely awaited -- and it landed on the GRANT
217
+ // side of the same branch the `null` reading closed.
174
218
  //
175
- // ANYTHING ELSE -- `null`, `0`, `false`, `''`, `true`, a string, an
176
- // object. DENY, and say so. Neither of the two obvious alternatives is
177
- // available. Reading it as absent is what `!linkage ||` did, and a
178
- // resolver returning `null` because it could not resolve a session is the
179
- // natural shape of that value and the fail-closed INTENT -- measured,
180
- // `toJSON({ linkage: null })` emitted the full pre-#234 linkage with no
181
- // signal, byte-identical to unpatched dev. Reading it as a function
182
- // raises `TypeError: linkage is not a function` out of the enclosing
183
- // `JSON.stringify` -- measured on `true`, `'x'` and `{}` -- which is
184
- // exactly the outcome the comment below promises cannot happen.
219
+ // 3. IS ITS ANSWER A VERDICT? It must BE a boolean, not merely coerce to
220
+ // one. `Boolean(...)` -- the coercion `createLinkageFilter` applies to
221
+ // a consumer `access()` predicate, whose truthy contract predates this
222
+ // option and is deliberately NOT changed -- is not enough here, and
223
+ // was measured not to be: with `Boolean(...)` plus a try/catch in
224
+ // place, `async () => false`, `function* () {}`,
225
+ // `() => Promise.resolve(false)`, `() => ({})` and `() => 'no'` ALL
226
+ // still emitted the full pre-#234 linkage with no log, because
227
+ // truthiness is what they already had. A non-boolean is a resolver
228
+ // that did not answer, and the only safe reading of a non-answer is a
229
+ // denial.
230
+ //
231
+ // AND IT NEVER THROWS -- which is now true rather than only written down.
232
+ // A throw here escapes the enclosing `JSON.stringify` and takes
233
+ // `console.log` and `Orm.db.save()`'s neighbours with it, a far worse
234
+ // failure mode than a status. `class Klass {}`, `Klass.bind(null)` and any
235
+ // predicate that dereferences something undefined were all measured raising
236
+ // out of the `stringify`; all three are caught and denied.
185
237
  //
186
238
  // Logged once per DOCUMENT, not once per relationship key or per related
187
239
  // record: an emptied relationship is deliberately indistinguishable from a
188
- // genuinely empty one on the wire, so the log is the only signal a consumer
189
- // whose resolver silently returned `null` will ever get.
240
+ // genuinely empty one on the wire, so the log is the ONLY signal a consumer
241
+ // whose resolver quietly returned `null`, or a promise, will ever get.
190
242
  const linkageSupplied = linkage !== undefined;
243
+
244
+ // Read the tag DEFENSIVELY. `Object.prototype.toString` consults
245
+ // `Symbol.toStringTag`, so a Proxy with a throwing `get` trap would throw
246
+ // out of the validation whose entire job is that nothing throws.
247
+ let linkageShape = 'a non-function';
248
+
249
+ if (typeof linkage === 'function') {
250
+ try {
251
+ linkageShape = Object.prototype.toString.call(linkage);
252
+ } catch {
253
+ linkageShape = '[object Unreadable]';
254
+ }
255
+ }
256
+
257
+ const linkageUsable = linkageShape === '[object Function]';
258
+
259
+ let linkageReported = false;
260
+
261
+ const denyAllLinkage = (reason: string) => {
262
+ if (linkageReported) return;
263
+ linkageReported = true;
264
+
265
+ log.error?.(`[@stonyx/orm] toJSON() received an unusable \`linkage\` option -- ${reason}, so ALL relationship linkage on this \`${modelName}\` document is denied.`);
266
+ };
267
+
268
+ if (linkageSupplied && !linkageUsable) {
269
+ denyAllLinkage(typeof linkage !== 'function'
270
+ ? `it is of type ${linkage === null ? 'null' : typeof linkage} and it must be a function`
271
+ : `it is ${linkageShape} and it must be a SYNCHRONOUS function -- \`toJSON\` is the \`JSON.stringify\` hook and cannot await a verdict`);
272
+ }
273
+
191
274
  const linkageVerdict: LinkageFilter | undefined = !linkageSupplied
192
275
  ? undefined
193
- : typeof linkage === 'function' ? linkage : () => false;
276
+ : linkageUsable ? linkage as LinkageFilter : () => false;
277
+
278
+ // Applied per related record, alongside the existing `__model` liveness
279
+ // check, and producing exactly the shapes that check already produces: a
280
+ // dropped hasMany member leaves `data: []`, a dropped belongsTo leaves
281
+ // `data: null`. Both already ship -- a genuinely-empty hasMany emits
282
+ // `data: []` with links, and a cleaned belongsTo emits `data: null` -- so a
283
+ // filtered relationship is BYTE-IDENTICAL to an empty one and there is no
284
+ // new wire shape and no oracle.
285
+ const isLinkable = (r: Record): boolean => {
286
+ if (!linkageVerdict) return true;
287
+
288
+ try {
289
+ const verdict = linkageVerdict(r.__model.__name, r);
290
+
291
+ if (typeof verdict === 'boolean') return verdict;
292
+
293
+ denyAllLinkage(`it answered with ${describeNonVerdict(verdict)} rather than a boolean`);
294
+ } catch (error) {
295
+ // Building the report is itself a throw site -- `throw Symbol('x')`
296
+ // makes `String(error)` throw, and a getter on `.message` can throw --
297
+ // and a throw from the reporter would escape the catch that exists so
298
+ // that nothing escapes.
299
+ let detail = 'a value that could not be described';
300
+
301
+ try {
302
+ detail = error instanceof Error ? error.message : String(error);
303
+ } catch { /* keep the fallback -- the denial matters, the text does not */ }
304
+
305
+ denyAllLinkage(`it threw (${detail})`);
306
+ }
194
307
 
195
- if (linkageSupplied && typeof linkage !== 'function') {
196
- log.error?.(`[@stonyx/orm] toJSON() received a \`linkage\` option of type ${linkage === null ? 'null' : typeof linkage} -- it must be a function, so ALL relationship linkage on this \`${modelName}\` document is denied.`);
197
- }
308
+ return false;
309
+ };
198
310
 
199
311
  for (const [key, childRecord] of Object.entries(this.__relationships)) {
200
312
  if (fields && !fields.has(key)) continue;
201
313
 
202
- // The linkage decision is applied HERE, alongside the existing
203
- // `__model` liveness check, and it produces exactly the shapes that
204
- // check already produces: a dropped hasMany member leaves `data: []`,
205
- // a dropped belongsTo leaves `data: null`. Both already ship -- a
206
- // genuinely-empty hasMany emits `data: []` with links, and a cleaned
207
- // belongsTo emits `data: null` -- so a filtered relationship is
208
- // BYTE-IDENTICAL to an empty one and there is no new wire shape and no
209
- // oracle. It never throws: a throw here escapes the enclosing
210
- // `JSON.stringify` and takes `console.log` and `Orm.db.save()`'s
211
- // neighbours with it, which is a far worse failure mode than a status.
212
- const isLinkable = (r: Record) => !linkageVerdict || linkageVerdict(r.__model.__name, r);
213
-
214
314
  const relationshipData = Array.isArray(childRecord)
215
315
  ? childRecord.filter((r: Record) => r?.__model).filter(isLinkable).map((r: Record) => ({ type: r.__model.__name, id: r.id }))
216
316
  : (childRecord && (childRecord as Record).__model && isLinkable(childRecord as Record)) ? { type: (childRecord as Record).__model.__name, id: (childRecord as Record).id } : null;