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

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
@@ -325,6 +325,19 @@ Access classes define models and provide custom filtering/authorization logic.
325
325
  > deny — which folds case but does not decode, so `GET /owners/%61rchived` steps
326
326
  > past it ([#228](https://github.com/abofs/stonyx-orm/issues/228)).
327
327
  >
328
+ > **Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
329
+ > [#237](https://github.com/abofs/stonyx-orm/issues/237), and left standing rather
330
+ > than rewritten.** Both claims above are now false: `#228` is **closed**, and the
331
+ > one string comparison variant 3 lived in is gone — the sample below compares the
332
+ > **decoded `recordId`** the access context supplies, so there is no comparison
333
+ > left to step around. The paragraph about `request.path` further down is
334
+ > superseded the same way. Nothing here is deleted because the same "variant 3
335
+ > survives" wording sits at four sites (this file twice, `src/orm-request.ts`, and
336
+ > the test fixture) and retiring one of four leaves the shipped copies
337
+ > contradicting each other; retiring all four **with the measurement that retires
338
+ > them** is [#238](https://github.com/abofs/stonyx-orm/issues/238), which also owns
339
+ > this blockquote and the reference section below.
340
+ >
328
341
  > That is still a stopgap. **The real fix is
329
342
  > [#202](https://github.com/abofs/stonyx-orm/issues/202)** — `access()` should
330
343
  > receive the model, the operation and the record, so there is nothing to
@@ -337,6 +350,14 @@ Access classes define models and provide custom filtering/authorization logic.
337
350
  > context alone** and a context-only rewrite would silently turn it into an
338
351
  > allow.
339
352
  >
353
+ > **Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
354
+ > [#237](https://github.com/abofs/stonyx-orm/issues/237)** — see the dated note
355
+ > above. No read of argument **one** survives in the sample below: the context
356
+ > carries `recordId`, the decoded route-parameter id, so the `/archived` deny **is**
357
+ > expressible from the context alone. It still must not be dropped — expressible is
358
+ > not optional. Retirement of this wording:
359
+ > [#238](https://github.com/abofs/stonyx-orm/issues/238).
360
+ >
340
361
  > The same warning is repeated at the top of `src/orm-request.ts`, which ships;
341
362
  > the longer write-up in `docs/usage-patterns.md` does **not** ship, so this
342
363
  > README and that source header are the two copies a consumer sees.
@@ -499,6 +520,16 @@ access class shipped with this repo has such a rule: its `/archived` deny
499
520
  **cannot be expressed from the context alone**, and a predicate migrated to
500
521
  context-only would silently drop it — a deny becoming an allow.
501
522
 
523
+ **Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
524
+ [#237](https://github.com/abofs/stonyx-orm/issues/237).** The context also carries
525
+ `recordId` — the record this route was addressed to, already decoded — so the
526
+ `/archived` deny **is** expressible from the context alone, and the shipped sample
527
+ no longer reads `request.path`. The full contract is `AccessContext.recordId` in
528
+ `src/types/orm-types.ts`, which ships. This section — the signature, the key table
529
+ and this paragraph — is corrected by
530
+ [#238](https://github.com/abofs/stonyx-orm/issues/238); the pointer is here because
531
+ what it currently says is an instruction, and the instruction is wrong.
532
+
502
533
  Note also that the related-resource and `?include=` surfaces serve *another
503
534
  model's* records under `model: 'owner'`, and the context gives a predicate no
504
535
  signal that it is authorizing a related-resource route. That is
@@ -794,6 +825,17 @@ these values should be matched on:
794
825
  | `GET http://anything.example/owners/angela` | `http://anything.example/angela` | `http://anything.example/owners/angela` | `/owners` | `/angela` |
795
826
  | `GET /api/animals/22` (`ORM_REST_ROUTE=/api`) | `/22` | `/api/animals/22` | `/api/animals` | `/22` |
796
827
 
828
+ **Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
829
+ [#237](https://github.com/abofs/stonyx-orm/issues/237) — "Variant 3 survives" above,
830
+ and the two paragraphs below, are no longer true.** The access context now carries
831
+ `recordId`, the **decoded** route-parameter id, and the sample compares against it:
832
+ the one string comparison variant 3 lived in is gone, `#228` is **closed**, and no
833
+ read of argument one survives in the sample. The wording is left standing rather
834
+ than deleted because it appears at four sites (this file twice,
835
+ `src/orm-request.ts`, and the test fixture) and retiring one of four leaves the
836
+ shipped copies contradicting each other; retiring all four **with the measurement
837
+ that retires them** is [#238](https://github.com/abofs/stonyx-orm/issues/238).
838
+
797
839
  **One read of argument one survives, and it must: `request.path`.** It is
798
840
  mount-relative and query-free, and it is for rules that distinguish **sub-paths**
799
841
  beneath the mount — as the `/archived` deny in the sample above does. The context
@@ -813,6 +855,20 @@ comparison as `/%61rchived`, walks past the deny, and is dispatched as the recor
813
855
  `.toLowerCase()` there as a complete normalisation recipe.** Record ids are
814
856
  case-sensitive and must be compared at their real case.
815
857
 
858
+ **Do not follow the two paragraphs above — superseded 2026-09-01 by
859
+ [#236](https://github.com/abofs/stonyx-orm/issues/236) /
860
+ [#237](https://github.com/abofs/stonyx-orm/issues/237).** They are *instructions*,
861
+ not merely stale observations, which is why this note is louder than a date. The
862
+ sample no longer reads `request.path` and no longer calls `.toLowerCase()` on
863
+ anything it compares: `.toLowerCase()` was measured wrong in **both directions at
864
+ once** — with a distinct owner seeded at `ARCHIVED`, `GET /owners/ARCHIVED` was a
865
+ false **deny** on the wrong record and `GET /owners/%41RCHIVED` a false **allow**
866
+ on that same record. Compare `recordId` **as it arrives**: do not case-fold it, do
867
+ not decode it, do not derive it from `request.path`. The contract is
868
+ `AccessContext.recordId` in `src/types/orm-types.ts`, which ships and says "Do NOT
869
+ case-fold it". Retirement of this wording, with its measurement:
870
+ [#238](https://github.com/abofs/stonyx-orm/issues/238).
871
+
816
872
  **Fail closed on anything you cannot identify — on *either* argument.**
817
873
  `String(request.originalUrl ?? '')` was once added here to stop a `TypeError`,
818
874
  and it traded fail-closed for fail-**open**: an empty string matched no
@@ -1193,6 +1249,16 @@ Each hook receives a context object with comprehensive information:
1193
1249
  - It contains a deep copy of the record's state **before** the operation executes (captured before the `before` hook fires)
1194
1250
  - The deep copy is created via JSON serialization (`JSON.parse(JSON.stringify())`) to ensure complete isolation
1195
1251
  - For `delete` operations, `recordId` is provided in after hooks since the record may no longer exist in the store
1252
+ - **`context.recordId` here is NOT `AccessContext.recordId`.** Same name, same-shaped
1253
+ object, different coverage: `_withHooks` sets this key **only** under
1254
+ `operation === 'delete'`, so on `get` / `list` / `create` / `update` the key is
1255
+ **absent** — `beforeHook('update', 'owner', ctx => ctx.recordId === 'archived' ? 403 : undefined)`
1256
+ never fires (measured: `PATCH /owners/{id}` → 200 with `ctx.recordId === undefined`
1257
+ and the id sitting in `ctx.params`). The access context, by contrast, carries
1258
+ `recordId` on every route it classifies and spells absence as `null`, never
1259
+ `undefined`. Tracked as
1260
+ [#242](https://github.com/abofs/stonyx-orm/issues/242); see
1261
+ `AccessContext.recordId` in `src/types/orm-types.ts` for the other side.
1196
1262
  - `oldState` is captured as a deep copy of the record's data before the operation, providing access to the previous field values
1197
1263
 
1198
1264
  ### Usage Examples
package/dist/hooks.d.ts CHANGED
@@ -20,7 +20,21 @@ export interface HookContext {
20
20
  state?: Record<string, unknown>;
21
21
  /** Previous record state (available in update hooks). */
22
22
  oldState?: unknown;
23
- /** Target record ID for single-record operations. */
23
+ /**
24
+ * Target record ID for single-record operations.
25
+ *
26
+ * SET ONLY UNDER `delete`. `_withHooks` assigns this key in the two
27
+ * `operation === 'delete'` branches and nowhere else, so on `get`, `list`,
28
+ * `create` and `update` the key is ABSENT -- not `undefined`-valued, absent.
29
+ * A hook rule written as `ctx.recordId === '<id>'` never fires on an update;
30
+ * the addressed id is in `ctx.params`. Tracked as abofs/stonyx-orm#242.
31
+ *
32
+ * @see AccessContext.recordId in ./types/orm-types.ts -- an identically-named
33
+ * key on an identically-shaped context object, and NOT interchangeable with
34
+ * this one: it is present on every route `auth()` classifies, and spells
35
+ * absence as `null` rather than `undefined`. They differ in coverage on four
36
+ * of five operations, not only in the absence spelling.
37
+ */
24
38
  recordId?: string | number;
25
39
  /** Response data (available in after hooks). */
26
40
  response?: unknown;
@@ -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.
@@ -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.
@@ -291,16 +291,45 @@ export interface AccessContext {
291
291
  * `Orm.instance.getAccess()`. That absence stays a distinguishable, deniable
292
292
  * signal only because the framework never produces it.
293
293
  *
294
- * IT IS THE ONE KEY THE HOOK VOCABULARY DOES *NOT* DISAGREE WITH.
295
- * `HookContext.recordId` (`src/hooks.ts`) is an identically-named key on an
296
- * identically-shaped context object, which is the exact configuration that
297
- * makes `operation` fail-open shaped -- a hook sees `'get'` where `access()`
298
- * sees `'read'`. Here they AGREE, and not by coincidence: `_withHooks` sets
299
- * `context.recordId = getId(request.params)`, the same single coercion this
300
- * key is built from. They differ in ONE way and it is the absence spelling --
301
- * `HookContext.recordId` is optional and `undefined` when unset, while this
302
- * key is always present and `null` on a collection route. A predicate must
303
- * not read `undefined` here as "collection".
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.
304
333
  *
305
334
  * IT NAMES WHICH RECORD, NOT WHICH SURFACE. `GET /owners/gina`,
306
335
  * `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` all
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-alpha.69",
7
+ "version": "0.3.2-alpha.70",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
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;
@@ -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.
@@ -302,16 +302,45 @@ export interface AccessContext {
302
302
  * `Orm.instance.getAccess()`. That absence stays a distinguishable, deniable
303
303
  * signal only because the framework never produces it.
304
304
  *
305
- * IT IS THE ONE KEY THE HOOK VOCABULARY DOES *NOT* DISAGREE WITH.
306
- * `HookContext.recordId` (`src/hooks.ts`) is an identically-named key on an
307
- * identically-shaped context object, which is the exact configuration that
308
- * makes `operation` fail-open shaped -- a hook sees `'get'` where `access()`
309
- * sees `'read'`. Here they AGREE, and not by coincidence: `_withHooks` sets
310
- * `context.recordId = getId(request.params)`, the same single coercion this
311
- * key is built from. They differ in ONE way and it is the absence spelling --
312
- * `HookContext.recordId` is optional and `undefined` when unset, while this
313
- * key is always present and `null` on a collection route. A predicate must
314
- * not read `undefined` here as "collection".
305
+ * IT DISAGREES WITH THE HOOK VOCABULARY, AND NOT ONLY ON THE ABSENCE
306
+ * SPELLING. `HookContext.recordId` (`src/hooks.ts`) is an identically-named
307
+ * key on an identically-shaped context object, which is the exact
308
+ * configuration that makes `operation` fail-open shaped -- a hook sees
309
+ * `'get'` where `access()` sees `'read'`. An earlier revision of THIS
310
+ * docblock asserted the opposite ("here they AGREE... they differ in ONE way
311
+ * and it is the absence spelling"). That was measured false, in the fail-open
312
+ * direction, and it is corrected here rather than deleted.
313
+ *
314
+ * MEASURED over the live dispatch, before-hooks registered for all five
315
+ * operations on one model:
316
+ *
317
+ * before:list key ABSENT ('recordId' in context === false)
318
+ * before:get key ABSENT params={"id":"visible1"}
319
+ * before:create key ABSENT
320
+ * before:update key ABSENT params={"id":"visible2"}
321
+ * before:delete recordId="visible3"
322
+ * after:delete recordId="visible3"
323
+ *
324
+ * `_withHooks` assigns `context.recordId` at exactly TWO sites in
325
+ * `src/orm-request.ts`, and BOTH sit inside an `operation === 'delete'`
326
+ * branch. So the two keys differ in COVERAGE, on four of five operations: on
327
+ * a hook context the key is absent for get, list, create and update, while
328
+ * this key is present on every route `auth()` classifies. The absence
329
+ * spelling is the smaller half of the difference, not the whole of it.
330
+ *
331
+ * AND THAT INVERTS THE ARGUMENT ABOVE WHEN IT IS READ ACROSS THE TWO. Here,
332
+ * a missing `recordId` means "did not come from `auth()`" and is deniable.
333
+ * On a hook context it means "this is a get / list / create / update" -- an
334
+ * ordinary request. A consumer who writes the hook-side half of the same
335
+ * rule --
336
+ *
337
+ * beforeHook('update', 'owner', ctx => ctx.recordId === 'archived' ? 403 : undefined)
338
+ *
339
+ * -- gets a deny that NEVER FIRES: measured, `PATCH /owners/visible2` -> 200,
340
+ * with `ctx.recordId === undefined` while the addressed record sits in
341
+ * `ctx.params`. The hook side is abofs/stonyx-orm#242 and is deliberately not
342
+ * repaired here. A predicate must not read `undefined` here as "collection",
343
+ * and nothing in this contract makes it safe to read the two keys as one key.
315
344
  *
316
345
  * IT NAMES WHICH RECORD, NOT WHICH SURFACE. `GET /owners/gina`,
317
346
  * `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` all