@stonyx/orm 0.3.2-alpha.71 → 0.3.2-alpha.73

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,6 +69,15 @@
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
+ *
72
81
  * `record` IS NOT IN THIS CONTEXT, deliberately. `auth()` runs after route
73
82
  * matching but BEFORE any handler executes (`@stonyx/rest-server`
74
83
  * `src/request.ts:58-60`), so nothing has been fetched yet -- supplying a
@@ -158,6 +167,11 @@
158
167
  * `/archived` SUB-PATH rule is still a string match, and abofs/stonyx-orm#228 is
159
168
  * a sixth spelling that gets past it.
160
169
  *
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
+ *
161
175
  * An intermediate revision of the sample read `request.baseUrl` -- the mount
162
176
  * Express ACTUALLY MATCHED. That closed all five variants (no query string,
163
177
  * not mount-relative, unaffected by absolute-form, already carrying the
@@ -173,12 +187,26 @@
173
187
  * does not decode, so `GET /owners/%61rchived` steps past it. See the
174
188
  * normalisation paragraph below and abofs/stonyx-orm#228.
175
189
  *
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
+ *
176
198
  * ONE READ OF ARGUMENT ONE SURVIVES, AND IT MUST: `request.path`. It is
177
199
  * mount-relative and query-free, and it is for rules that distinguish SUB-PATHS
178
200
  * beneath the mount. The context names which model and which verb, NOT which
179
201
  * route, so the sample's `/archived` deny cannot be expressed from the context
180
202
  * alone and a context-ONLY rewrite would silently turn that deny into an allow.
181
203
  *
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
+ *
182
210
  * NORMALISE THE WAY THE ROUTER DOES, AND CASE-FOLDING ALONE IS NOT THAT. The
183
211
  * sample lower-cases before comparing, because a matcher stricter than the
184
212
  * case-insensitive router can be stepped around. That closes the case gap only.
@@ -188,6 +216,20 @@
188
216
  * sample and is tracked as abofs/stonyx-orm#228; the `.toLowerCase()` is not a
189
217
  * complete normalisation recipe. Compare record ids at their real case.
190
218
  *
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
+ *
191
233
  * `?? ''` is not a defence. It converts an absent request target into an empty
192
234
  * string, which matches no collection, which falls through to the permission
193
235
  * array -- a total grant. An input you cannot identify must DENY, and that
@@ -196,6 +238,12 @@
196
238
  * argument one. The sample returns `false` for an absent `model` AND for an
197
239
  * absent or non-string `request.path`, rather than falling through either way.
198
240
  *
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
+ *
199
247
  * THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
200
248
  * the operation and the record. Prefer the array shape (`['read']`) or `false`
201
249
  * until #202 lands; the function shape is what requires any matching at all.
@@ -1306,10 +1354,53 @@ export default class OrmRequest extends Request {
1306
1354
  // src/types/orm-types.ts. Nothing is fetched at this point and adding a
1307
1355
  // lookup here would put a store read in the middle of an authorization
1308
1356
  // path. The function return shape below IS the per-record hook.
1357
+ //
1358
+ // -------------------------------------------------------------------------
1359
+ // #236 -- `recordId`, the DECODED route-parameter id, for the same reason.
1360
+ //
1361
+ // WHICH RECORD is the third structural fact the framework already holds and
1362
+ // the consumer was left to re-derive, and re-deriving it failed OPEN. The
1363
+ // documented sample compared `request.path` -- the RAW, undecoded pathname
1364
+ // -- against a literal `/archived`, while the router DECODES `:id`. So
1365
+ // `GET /owners/%61rchived` walked past the deny and was dispatched as the
1366
+ // record `archived`: 200 with the record in full, and DELETE answered 204
1367
+ // with the record destroyed, unauthenticated. Four spellings measured, all
1368
+ // four through; 255 non-canonical spellings of that 8-character id decode
1369
+ // to the same key, so this was never a deny-list of one.
1370
+ //
1371
+ // TWO CONSUMER-SIDE NORMALISATIONS WERE MEASURED WRONG IN OPPOSITE
1372
+ // DIRECTIONS, which is the argument for doing it once, here.
1373
+ // `.toLowerCase()` case-folds a route-parameter VALUE on the axis that
1374
+ // governs literal SEGMENTS: with a distinct owner seeded at `ARCHIVED`,
1375
+ // `GET /owners/ARCHIVED` was a false DENY on the wrong record and
1376
+ // `GET /owners/%41RCHIVED` a false ALLOW on that same one.
1377
+ // `decodeURIComponent(request.path)` decodes THEN splits while the router
1378
+ // splits THEN decodes, so it over-denied `/owners/archived%2fx` -- 403 for
1379
+ // a genuinely distinct record. Failing closed there was luck, not design.
1380
+ //
1381
+ // `getId(request.params)` AND NOT `request.params.id`, for exactly the
1382
+ // reason `operation` is a `methodAccessMap` lookup: it is the SAME single
1383
+ // coercion the store lookup one layer down performs, so the predicate and
1384
+ // the dispatch cannot disagree about which record a request addresses.
1385
+ // The raw string would reintroduce that divergence on hex-shaped ids --
1386
+ // `GET /animals/0x2391` looks up record `9105`.
1387
+ //
1388
+ // NOTHING HERE PARSES THE REQUEST TARGET EITHER. `request.params` is what
1389
+ // the router matched, so a mount prefix, an absolute-form target, a query
1390
+ // string or a case-varied mount cannot move this value -- the same
1391
+ // guarantee `model` carries, by the same means.
1392
+ //
1393
+ // `null` and not `undefined` on a collection route, so the KEY IS ALWAYS
1394
+ // PRESENT -- the rule `operation`'s own docblock already establishes. A
1395
+ // context reaching a predicate WITHOUT the key therefore did not come from
1396
+ // here; it was hand-assembled by a caller resolving the predicate through
1397
+ // `Orm.instance.getAccess()`, and that absence stays deniable only because
1398
+ // `auth()` never produces it.
1309
1399
  // -------------------------------------------------------------------------
1310
1400
  const context = {
1311
1401
  model: this.model,
1312
1402
  operation: methodAccessMap[request.method],
1403
+ recordId: request.params && 'id' in request.params ? getId(request.params) : null,
1313
1404
  };
1314
1405
  let access;
1315
1406
  try {
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;
@@ -251,6 +251,101 @@ export interface AccessContext {
251
251
  * from one that classified the request and found nothing.
252
252
  */
253
253
  operation: AccessOperation | undefined;
254
+ /**
255
+ * The record this route was addressed to, as the store key -- or `null` on a
256
+ * collection route, which is addressed to no record (abofs/stonyx-orm#236).
257
+ *
258
+ * IT IS ALREADY DECODED, AND THAT IS THE WHOLE POINT. Express decodes route
259
+ * PARAMETERS while leaving `request.path` raw, so a consumer comparing
260
+ * `request.path` against a literal compares an undecoded string against a
261
+ * decoded dispatch. `GET /owners/%61rchived` reached such a comparison as
262
+ * `/%61rchived`, walked past a `/archived` deny, and was dispatched as the
263
+ * record `archived` -- 200 with the record in full, and `DELETE` destroyed
264
+ * it, unauthenticated. 255 non-canonical spellings of an 8-character id
265
+ * decode to the same key, so a deny-list of spellings is the wrong shape.
266
+ *
267
+ * SO DO NOT NORMALISE THIS, AND DO NOT NORMALISE ANYTHING ELSE INSTEAD:
268
+ *
269
+ * - Do NOT decode it. Express decodes exactly ONCE, which is what a route
270
+ * parameter means. `GET /owners/%2561rchived` is the legitimate id
271
+ * `%61rchived`, not a second-order spelling of `archived`; a predicate that
272
+ * decoded until stable would deny a record it was never asked about.
273
+ * - Do NOT case-fold it. A record id is a VALUE, not a literal route segment,
274
+ * and express's `case sensitive routing` governs literal segments only.
275
+ * With a distinct owner seeded at `ARCHIVED`, `.toLowerCase()` was measured
276
+ * wrong in BOTH directions at once: `GET /owners/ARCHIVED` 403 (a false
277
+ * deny, on the wrong record) and `GET /owners/%41RCHIVED` 200 (a false
278
+ * allow, on that same record).
279
+ * - Do NOT derive it from `request.path` or the request target. Decoding the
280
+ * whole path decodes THEN splits, while the router splits THEN decodes, so
281
+ * `/owners/archived%2fx` -- a genuinely distinct record whose id is
282
+ * `archived/x` -- was measured over-denied 403.
283
+ *
284
+ * IT IS `getId(request.params)`, BYTE FOR BYTE -- the same single coercion
285
+ * the store lookup uses, exactly as `operation` is the same `methodAccessMap`
286
+ * lookup the permission-array branch uses. The predicate and the dispatch
287
+ * therefore cannot disagree about which record a request addresses. Handing
288
+ * over the raw `request.params.id` instead would reintroduce that divergence
289
+ * on hex-shaped ids: `GET /animals/0x2391` looks up record `9105`.
290
+ *
291
+ * It inherits abofs/stonyx-orm#209 along with that coercion -- on a model
292
+ * declaring `id = attr('string')`, `'9107'` arrives here as the number
293
+ * `9107`. That is consistency WITH THE LOOKUP, which is the property this key
294
+ * exists to buy; it is not a defect to repair here.
295
+ *
296
+ * `null`, not `undefined`, on a collection route -- and the KEY IS ALWAYS
297
+ * PRESENT, the same rule `operation` states above. `auth()` always sets it,
298
+ * so a context arriving WITHOUT the key did not come from `auth()`: it was
299
+ * hand-assembled by a caller resolving the predicate through
300
+ * `Orm.instance.getAccess()`. That absence stays a distinguishable, deniable
301
+ * signal only because the framework never produces it.
302
+ *
303
+ * IT DISAGREES WITH THE HOOK VOCABULARY, AND NOT ONLY ON THE ABSENCE
304
+ * SPELLING. `HookContext.recordId` (`src/hooks.ts`) is an identically-named
305
+ * key on an identically-shaped context object, which is the exact
306
+ * configuration that makes `operation` fail-open shaped -- a hook sees
307
+ * `'get'` where `access()` sees `'read'`. An earlier revision of THIS
308
+ * docblock asserted the opposite ("here they AGREE... they differ in ONE way
309
+ * and it is the absence spelling"). That was measured false, in the fail-open
310
+ * direction, and it is corrected here rather than deleted.
311
+ *
312
+ * MEASURED over the live dispatch, before-hooks registered for all five
313
+ * operations on one model:
314
+ *
315
+ * before:list key ABSENT ('recordId' in context === false)
316
+ * before:get key ABSENT params={"id":"visible1"}
317
+ * before:create key ABSENT
318
+ * before:update key ABSENT params={"id":"visible2"}
319
+ * before:delete recordId="visible3"
320
+ * after:delete recordId="visible3"
321
+ *
322
+ * `_withHooks` assigns `context.recordId` at exactly TWO sites in
323
+ * `src/orm-request.ts`, and BOTH sit inside an `operation === 'delete'`
324
+ * branch. So the two keys differ in COVERAGE, on four of five operations: on
325
+ * a hook context the key is absent for get, list, create and update, while
326
+ * this key is present on every route `auth()` classifies. The absence
327
+ * spelling is the smaller half of the difference, not the whole of it.
328
+ *
329
+ * AND THAT INVERTS THE ARGUMENT ABOVE WHEN IT IS READ ACROSS THE TWO. Here,
330
+ * a missing `recordId` means "did not come from `auth()`" and is deniable.
331
+ * On a hook context it means "this is a get / list / create / update" -- an
332
+ * ordinary request. A consumer who writes the hook-side half of the same
333
+ * rule --
334
+ *
335
+ * beforeHook('update', 'owner', ctx => ctx.recordId === 'archived' ? 403 : undefined)
336
+ *
337
+ * -- gets a deny that NEVER FIRES: measured, `PATCH /owners/visible2` -> 200,
338
+ * with `ctx.recordId === undefined` while the addressed record sits in
339
+ * `ctx.params`. The hook side is abofs/stonyx-orm#242 and is deliberately not
340
+ * repaired here. A predicate must not read `undefined` here as "collection",
341
+ * and nothing in this contract makes it safe to read the two keys as one key.
342
+ *
343
+ * IT NAMES WHICH RECORD, NOT WHICH SURFACE. `GET /owners/gina`,
344
+ * `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` all
345
+ * carry `recordId: 'gina'`; the related-resource gap is abofs/stonyx-orm#196
346
+ * and is untouched by this key.
347
+ */
348
+ recordId: string | number | null;
254
349
  }
255
350
  /**
256
351
  * A consumer `access()` predicate.
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.73",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -162,7 +162,37 @@ function resolveVerdict(request: unknown, type: string): AccessVerdict {
162
162
  let access: AccessMethod;
163
163
 
164
164
  try {
165
- access = predicate(request, { model: type, operation: 'read' });
165
+ // `recordId: null`, AND NOT `request.params.id`. THE TEMPTING WRONG ANSWER
166
+ // IS RIGHT THERE, so this is pinned by assertion as well as by comment --
167
+ // test/unit/linkage-verdict-test.ts, `#234 + #241 -- recordId is null`.
168
+ //
169
+ // `AccessContext.recordId` (src/types/orm-types.ts, abofs/stonyx-orm#236 /
170
+ // #241) means "the record THIS ROUTE WAS ADDRESSED TO, as the store key of
171
+ // the model being authorised", and `null` means "addressed to no record".
172
+ // The id sitting on the request in hand names the PRIMARY record, which
173
+ // belongs to a DIFFERENT model -- `GET /owners/gina` carries
174
+ // `params.id === 'gina'`, and the ask being made HERE is about `animal` or
175
+ // `trait`. Filling this in from the request would hand the related model's
176
+ // predicate an id belonging to another model, which is byte-for-byte the
177
+ // cross-model confusion abofs/stonyx-orm#202 introduced this context to
178
+ // eliminate: the predicate would compare an owner's id against its own
179
+ // records and answer a question nobody asked. There is no record of THIS
180
+ // model addressed by this request, so `null` is the honest value -- the
181
+ // same spelling `auth()` uses for a collection route.
182
+ //
183
+ // NOR ANY RECORD'S OWN ID, WHICH IS THE SECOND-MOST TEMPTING ANSWER. This
184
+ // verdict is resolved ONCE PER TYPE and cached in `byType` below, before
185
+ // any record has been looked at; there is no per-record `AccessContext`
186
+ // built anywhere on this path. Seeding it from the first record of a type
187
+ // would let that record's identity answer for every later record of the
188
+ // same type -- the same "one record's verdict answers for another" defect
189
+ // the `decisions` raw-key argument below exists to prevent, just one level
190
+ // coarser. And it is unnecessary: `AccessContext` deliberately carries no
191
+ // `record` because auth-time and record-time are separate decision points,
192
+ // and the per-record point already receives the WHOLE record, id included,
193
+ // through `verdict.filter(record)`. A predicate that wants a record's id
194
+ // has the contract's own channel for it.
195
+ access = predicate(request, { model: type, operation: 'read', recordId: null });
166
196
  } catch (error) {
167
197
  log.error?.(`[@stonyx/orm] access() threw while resolving linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
168
198
 
@@ -206,8 +236,42 @@ function resolveVerdict(request: unknown, type: string): AccessVerdict {
206
236
  * SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
207
237
  * it -- a verdict cached across requests would answer a second caller with the
208
238
  * first caller's authorization.
239
+ *
240
+ * A REQUEST IS REQUIRED, AND ITS ABSENCE IS CHECKED HERE RATHER THAN DELEGATED.
241
+ * This function is EXPORTED (src/index.ts), and the README's Consumer Contracts
242
+ * section points consumers at exactly the contexts that have no live request --
243
+ * a queue payload, a websocket frame, a custom route. Without one there is no
244
+ * caller to authorise against, and this file's header already says so: the
245
+ * shipped sample reads `request.path` and fail-closes when it is absent, so
246
+ * `getAccess('owner')(undefined, ...)` is `false`, while
247
+ * `getAccess('animal')(undefined, ...)` returns a per-record predicate and
248
+ * GRANTS. Measured on this repo's own fixture before this guard existed:
249
+ *
250
+ * createLinkageFilter(undefined | null | {} | 'x' | 0)
251
+ * -> owner=false animal=TRUE trait=TRUE category=TRUE phone-number=TRUE
252
+ *
253
+ * Four of five claimed models granted, with no log, because whether an absent
254
+ * request fails closed was left ENTIRELY to consumer predicates -- and a
255
+ * predicate that ignores its request cannot fail closed on one that is missing.
256
+ * A nullish or primitive `request` therefore denies every model outright and
257
+ * says so once, at construction, so the signal exists even for a caller that
258
+ * goes on to serialize nothing.
259
+ *
260
+ * WHAT THIS CANNOT CHECK: `{}` is an object and passes. There is no request
261
+ * contract this module owns -- `auth()` reads `.method`, the shipped sample
262
+ * reads `.path`, a consumer's reads whatever it likes -- so anything past
263
+ * "is it an object" would be this module inventing a shape for someone else's
264
+ * framework. The residual is documented in the README under Consumer Contracts.
209
265
  */
210
266
  export function createLinkageFilter(request: unknown): LinkageFilter {
267
+ if (typeof request !== 'object' || request === null) {
268
+ 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.`);
269
+
270
+ return function isLinkable(_type: string, _record: unknown): boolean {
271
+ return false;
272
+ };
273
+ }
274
+
211
275
  const byType = new Map<string, { verdict: AccessVerdict; decisions: Map<unknown, boolean> }>();
212
276
 
213
277
  return function isLinkable(type: string, record: unknown): boolean {
package/src/hooks.ts CHANGED
@@ -37,7 +37,21 @@ export interface HookContext {
37
37
  state?: Record<string, unknown>;
38
38
  /** Previous record state (available in update hooks). */
39
39
  oldState?: unknown;
40
- /** Target record ID for single-record operations. */
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
+ */
41
55
  recordId?: string | number;
42
56
  /** Response data (available in after hooks). */
43
57
  response?: unknown;