@stonyx/orm 0.3.2-alpha.50 → 0.3.2-alpha.52

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
@@ -311,57 +311,71 @@ await setupRestServer('/', './access');
311
311
 
312
312
  Access classes define models and provide custom filtering/authorization logic.
313
313
 
314
- > **The URL-matching in this example is a stopgap. Read
315
- > [Matching the url](#matching-the-url) before copying it.** The same three-line
316
- > example has failed **open** in four distinct ways during one review, each found
317
- > only after the previous was fixed, by four different people. The sample below
318
- > closes all four; that is not the same as being safe it is safe against the
319
- > four variants that happen to have been found, and there is no reason to believe
320
- > the list is complete.
314
+ > **Do not reconstruct the request path inside `access()`. Read
315
+ > [Identifying the collection](#identifying-the-collection) before copying this.**
316
+ > Every attempt to identify the collection by parsing the request target has
317
+ > failed **open** five distinct variants of this same example, each found only
318
+ > after the previous was fixed, by five different people. The sample below does
319
+ > not parse anything: it reads `request.baseUrl`, the mount Express actually
320
+ > matched.
321
321
  >
322
- > **The real fix is
322
+ > That is still a stopgap. **The real fix is
323
323
  > [#202](https://github.com/abofs/stonyx-orm/issues/202)** — `access()` should
324
- > receive the model, the operation and the record, so there is no URL to parse
325
- > and no variant to miss. Until it lands, prefer the array shape (`['read']`) or
326
- > `false` where you can: the **function** shape is the one that requires URL
327
- > matching. The same warning is repeated at the top of `src/orm-request.ts`,
328
- > which ships; the longer write-up in `docs/usage-patterns.md` does **not** ship,
329
- > so this README and that source header are the two copies a consumer sees.
324
+ > receive the model, the operation and the record, so there is nothing to
325
+ > identify. Until it lands, prefer the array shape (`['read']`) or `false` where
326
+ > you can: the **function** shape is the one that requires any matching at all.
327
+ > The same warning is repeated at the top of `src/orm-request.ts`, which ships;
328
+ > the longer write-up in `docs/usage-patterns.md` does **not** ship, so this
329
+ > README and that source header are the two copies a consumer sees.
330
+ >
331
+ > This sample is the same code as the shipped test fixture, and a test asserts
332
+ > the two `access()` bodies are identical line for line. For four rounds they
333
+ > were two independently written copies — and the fifth fail-open variant was
334
+ > found in the one nothing was mutating.
330
335
 
331
336
  ```js
332
- import config from 'stonyx/config';
333
-
334
- // Build the mount prefix from the SAME value the ORM mounts under, and compare
335
- // lower-cased. Both are load-bearing — see "Matching the url".
336
- function collectionPrefix(name) {
337
- const route = config.orm.restServer.route ?? '/';
338
- const trimmed = String(route).replace(/^\/+|\/+$/g, '');
339
-
340
- return `${trimmed === '' ? '' : `/${trimmed}`}/${name}`.toLowerCase();
341
- }
342
-
343
337
  export default class GlobalAccess {
344
338
  models = ['owner', 'animal'];
345
339
 
346
340
  access(request) {
347
- // `originalUrl`, not `url` `url` is mount-relative, so a prefix match
348
- // against it is ALWAYS false. Query string stripped, because `originalUrl`
349
- // carries it. Lower-cased, because the router matched case-insensitively
350
- // and a matcher stricter than the router can be walked past. Every one of
351
- // those three omissions fails OPEN.
352
- const path = String(request.originalUrl ?? '').split('?')[0].toLowerCase();
353
- const owners = collectionPrefix('owners');
354
-
355
- // false 403 for the whole request
356
- if (path.startsWith(`${owners}/archived`)) return false;
357
-
358
- // A function is a per-record filter. Anchored on a `/` boundary so it
359
- // cannot also match `/owners-archive`. Rejected records are 404 on record
360
- // routes, 403 on POST.
361
- if (path === owners || path.startsWith(`${owners}/`)) {
362
- return record => record.id !== 'angela';
341
+ // `request.baseUrl` is the mount Express matched `/owners`, or
342
+ // `/api/owners` under ORM_REST_ROUTE=/api. Never parse `originalUrl`: it is
343
+ // the raw request target and can be absolute-form.
344
+ const mount = request.baseUrl;
345
+
346
+ // FAIL CLOSED. If Express did not tell us what it matched we are not behind
347
+ // the mount we think we are, and an unidentifiable request denies rather
348
+ // than falling through to the CRUD grant at the bottom.
349
+ if (typeof mount !== 'string' || mount === '') return false;
350
+
351
+ // Lower-cased because the router matched case-INSENSITIVELY, and a matcher
352
+ // stricter than the router that dispatched the request can be stepped
353
+ // around. The PATH only record ids stay at their real case below.
354
+ const collection = mount.toLowerCase();
355
+
356
+ // `request.path` is mount-relative and query-free, so sub-path rules need no
357
+ // prefix arithmetic either. false → 403 for the whole request.
358
+ const path = String(request.path ?? '').toLowerCase();
359
+
360
+ if (collection.endsWith('/owners')) {
361
+ if (path === '/archived' || path.startsWith('/archived/')) return false;
362
+
363
+ // Returning a function plugs it in as a per-record filter, and it is
364
+ // enforced on every surface addressed to one of these records:
365
+ // /owners, /owners/:id, /owners/:id/pets, /owners/:id/relationships/pets
366
+ // A rejected record is 404 on record routes — the same status as a record
367
+ // that does not exist — so the filter is not an existence oracle.
368
+ return record => record.id !== 'angela' && record.id !== 'restricted';
363
369
  }
364
370
 
371
+ // `record.owner` resolves to an OrmRecord, not to the owner's id string —
372
+ // comparing it directly against a string is the bug that made this predicate
373
+ // inert. Deliberately NO `?? record.owner` fallback: accepting the raw
374
+ // shape as well as the resolved one would absorb a resolution regression
375
+ // silently, which is exactly what blinded this fixture before.
376
+ if (collection.endsWith('/animals')) return record => record.owner?.id !== 'restricted';
377
+
378
+ // Allows full access to all calls that don't match any of the above conditions
365
379
  return ['read', 'create', 'update', 'delete'];
366
380
  }
367
381
  }
@@ -383,7 +397,13 @@ A `throw` inside `access()` is a **denial**, not a 500.
383
397
  ### Filter functions
384
398
 
385
399
  A function return value is a **per-record predicate**, and it is enforced on
386
- every endpoint that is addressed to a record — not only on the collection:
400
+ every endpoint that is addressed to a record — not only on the collection.
401
+
402
+ It is evaluated against the record the route is *addressed to*, **on that model
403
+ only**. It is not a guarantee that a hidden record cannot be reached or modified:
404
+ a write to a *different* collection can still re-parent one. See
405
+ [Known limitations](#known-limitations) and
406
+ [#207](https://github.com/abofs/stonyx-orm/issues/207).
387
407
 
388
408
  | Endpoint | A record the predicate rejects |
389
409
  |---|---|
@@ -393,7 +413,7 @@ every endpoint that is addressed to a record — not only on the collection:
393
413
  | `GET /:models/:id/relationships/{relationship}` | `404` — same |
394
414
  | `PATCH /:models/:id` | `404`, no attribute is applied |
395
415
  | `DELETE /:models/:id` | `404`, the record is not removed and no SQL `DELETE` is issued |
396
- | `POST /:models` | `403`, and nothing is left in the store |
416
+ | `POST /:models` | `403`, and a record **this request inserted** is rolled back — see [Known limitations](#known-limitations) for the case where it did not insert one |
397
417
 
398
418
  **Denied record-level requests return 404, not 403.** This is deliberate and it
399
419
  is the property most easily "improved" away. 403 would confirm that the record
@@ -431,72 +451,81 @@ Let the server assign the id and read it back from the response. Callers with no
431
451
  function-style filter are unaffected: `409` on a duplicate id and `200` on a free
432
452
  one both behave exactly as before.
433
453
 
434
- ### Matching the url
435
-
436
- **This section describes a pattern the framework should not be asking you to
437
- implement.** It has produced four separate fail-open defects, listed below,
438
- each found only after the previous was fixed, and there is no reason to believe
439
- the list is complete. [#202](https://github.com/abofs/stonyx-orm/issues/202)
440
- replaces it. Until then, all four rules apply and each one, omitted, fails
441
- **open**.
454
+ ### Identifying the collection
442
455
 
443
- **1. Match `request.originalUrl`, never `request.url`.**
444
- `RestServer.mountRoute` mounts each model as an Express **sub-app**, so by the
445
- time `access()` runs the mount path has been stripped from `request.url`:
456
+ **Do not reconstruct the request path.** Every version of this sample that tried
457
+ to has failed **open**, and each variant was found only after the previous one
458
+ was fixed:
446
459
 
447
- | request | `request.url` | `request.originalUrl` |
460
+ | # | Variant | Why it fails open |
448
461
  |---|---|---|
449
- | `GET /owners` | `/` | `/owners` |
450
- | `GET /owners/angela` | `/angela` | `/owners/angela` |
451
- | `GET /owners/angela/pets` | `/angela/pets` | `/owners/angela/pets` |
452
-
453
- `request.url.startsWith('/owners')` is therefore **always false**: the branch
454
- never fires, `access()` falls through to whatever it returns last, and a filter
455
- that looks correct enforces nothing on any surface.
456
-
457
- **2. Strip the query string, and match the prefix rather than the exact url.**
458
- The predicate has to be returned for record routes too, so
459
- `url.endsWith('/owners')` leaves `/owners/angela` unguarded and `originalUrl`
460
- carries the query string, so a bare `=== '/owners'` misses
461
- `/owners?filter[age]=30` and lets a filtered collection through unfiltered.
462
- Anchoring on the path portion covers both without also matching
463
- `/owners-archive`.
464
-
465
- **3. Compare lower-cased.** `RestServer` mounts with a bare `express()`, whose
466
- default is `caseSensitive: false`, while `originalUrl` preserves the caller's
467
- case. A case-sensitive matcher is stricter than the router that dispatched the
468
- request, so it can simply be stepped around:
469
-
470
- ```
471
- GET /owners/angela -> 404 GET /OwNeRs/angela -> 200, angela in full
472
- GET /owners -> filtered GET /OWNERS -> unfiltered
473
- DELETE /animals/22 -> 404 DELETE /ANIMALS/22 -> 204, record destroyed
474
- ```
475
-
476
- Lower-case the **path** only. Record ids are case-sensitive and must be compared
477
- at their real case. The router-side fix is tracked as
478
- [stonyx-rest-server#47](https://github.com/abofs/stonyx-rest-server/issues/47).
479
-
480
- **4. Build the prefix from the configured mount route.** With
481
- `ORM_REST_ROUTE=/api` the urls above become `/api/owners/...`, and a sample
482
- hard-coded to `/owners` matches nothing environment-specifically, which is
483
- harder to notice than failing everywhere.
484
-
485
- Note what this must *not* be. An earlier version of this document suggested
486
- `` `${config.orm.restServer.route}owners` ``. For the default route that is
487
- `/owners` and looks correct; for `ORM_REST_ROUTE=/api` it evaluates to
488
- **`/apiowners`**, so a reader who followed the correction exactly still failed
489
- open and believed they had handled it. Join on `/` and collapse the duplicate,
490
- as `collectionPrefix()` above does.
462
+ | 1 | match `request.url` | `RestServer.mountRoute` mounts each model as an Express **sub-app**, so `url` is mount-relative — `GET /owners/angela` arrives as `/angela`. A `/owners` prefix match is **always false**, so the branch never fires and `access()` falls through to whatever it returns last. |
463
+ | 2 | anchored match on a raw `request.originalUrl` | `originalUrl` carries the query string, so `=== '/owners'` misses `/owners?filter[age]=30` and that collection comes back unfiltered. `endsWith('/owners')` is the older half of the same trap: it leaves every record route unguarded. |
464
+ | 3 | case-sensitive matcher | `RestServer` mounts with a bare `express()`, whose default is `caseSensitive: false`. A matcher stricter than the router that dispatched the request can simply be stepped around: `GET /owners/angela` 404 but `GET /OwNeRs/angela` 200 in full, and `DELETE /ANIMALS/22` destroyed a hidden record. Router-side fix: [stonyx-rest-server#47](https://github.com/abofs/stonyx-rest-server/issues/47). |
465
+ | 4 | hard-coded `/owners` | With `ORM_REST_ROUTE=/api` every url becomes `/api/owners/...` and the sample matches nothing — environment-specifically, which is harder to notice than failing everywhere. The remediation this document used to give was itself broken: `` `${config.orm.restServer.route}owners` `` evaluates to **`/apiowners`**, so a reader who followed the correction exactly still failed open and believed they had handled it. |
466
+ | 5 | any match on `originalUrl` at all | HTTP/1.1 permits an **absolute-form** request-target. Express routes on `parseurl(req).pathname`, so the request dispatches normally — but `originalUrl` is the raw target. `GET http://anything.example/owners/angela` yields `originalUrl === 'http://anything.example/owners/angela'`, which has no `/owners` prefix. Measured: the record came back in full, `DELETE` succeeded, and it walked past a hard `return false` deny the same way. |
467
+
468
+ **The fix is not a sixth rule.** It is to stop parsing:
469
+
470
+ **Use `request.baseUrl`.** It is the mount Express *actually matched* when it
471
+ dispatched the request. It carries no query string (variant 2), it is not
472
+ mount-relative (variant 1), it already contains the configured `ORM_REST_ROUTE`
473
+ prefix (variant 4 there is nothing left to derive, so `/apiowners` is
474
+ unconstructible), and it is unaffected by an absolute-form target (variant 5).
475
+
476
+ | request | `request.url` | `request.originalUrl` | `request.baseUrl` | `request.path` |
477
+ |---|---|---|---|---|
478
+ | `GET /owners` | `/` | `/owners` | `/owners` | `/` |
479
+ | `GET /owners/angela` | `/angela` | `/owners/angela` | `/owners` | `/angela` |
480
+ | `GET /owners/angela?filter[age]=30` | `/angela?filter[age]=30` | `/owners/angela?filter[age]=30` | `/owners` | `/angela` |
481
+ | `GET /OwNeRs/angela` | `/angela` | `/OwNeRs/angela` | `/OwNeRs` | `/angela` |
482
+ | `GET http://anything.example/owners/angela` | `http://anything.example/angela` | `http://anything.example/owners/angela` | `/owners` | `/angela` |
483
+ | `GET /api/animals/22` (`ORM_REST_ROUTE=/api`) | `/22` | `/api/animals/22` | `/api/animals` | `/22` |
484
+
485
+ Two rules remain, and they are the whole list:
486
+
487
+ **1. Compare lower-cased.** `baseUrl` is the text the caller sent, not the
488
+ registered mount — `GET /OwNeRs/angela` yields `/OwNeRs`. The router matched it
489
+ case-insensitively, so a case-sensitive comparison here is stricter than the
490
+ router and can be walked past. Lower-case the **mount and path only**; record ids
491
+ are case-sensitive and must be compared at their real case.
492
+
493
+ **2. Fail closed when `baseUrl` is absent.** `String(request.originalUrl ?? '')`
494
+ was added to stop a `TypeError`, and it traded fail-closed for fail-**open**: an
495
+ empty string matches no collection, so `access()` fell through to the permission
496
+ array and granted full CRUD. An input you cannot identify must **deny**.
497
+
498
+ Use `request.path` mount-relative and query-free if you need to distinguish
499
+ sub-paths beneath the mount, as the `/archived` deny above does.
491
500
 
492
501
  ### Known limitations
493
502
 
494
- - **Authorization by URL matching is a consumer-side reconstruction of
495
- information the framework already holds.** `access()` receives a transport
496
- artifact and is asked to re-derive, correctly and defensively, which model,
497
- which operation and which record the request addresses. The four rules above
498
- are the four ways that reconstruction has been observed to fail open so far.
499
- Tracked as [#202](https://github.com/abofs/stonyx-orm/issues/202).
503
+ - **A function-style filter is not a guarantee that a hidden record cannot be
504
+ modified.** A write to a *different* collection can re-parent one and de-hide
505
+ it: `POST /owners` (or `PATCH /owners/{id}`) carrying
506
+ `relationships: { pets: { data: { id: 21 } } }` — or
507
+ `attributes: { pets: [21, 22] } `, which never enters the relationships loop at
508
+ all re-parents animal 21 onto an owner the caller is permitted to write. The
509
+ animal's `owner` is the field the `/animals` predicate reads, so the record
510
+ stops being rejected: it becomes readable through `GET /animals/21` and
511
+ deletable through `DELETE /animals/21`. **Reachable unauthenticated** wherever
512
+ one collection is writable and another is filtered on a field the first can
513
+ set. Blocking it requires checking animal 21 against the **animal** model's
514
+ predicate while servicing an **owners** route — cross-model access resolution,
515
+ which the current contract cannot express: `access()` never receives the model
516
+ structurally ([#202](https://github.com/abofs/stonyx-orm/issues/202)) and
517
+ `setup-rest-server.ts` discards the model→predicate map at boot
518
+ ([#196](https://github.com/abofs/stonyx-orm/issues/196)). Tracked as
519
+ [#207](https://github.com/abofs/stonyx-orm/issues/207), blocked on that chain
520
+ (#202 → #196 → #207). Until it lands, do not rely on a filter to keep a record
521
+ unmodifiable; keep the *writable* collections' predicates as tight as the
522
+ hidden ones.
523
+ - **Authorization by identifying the collection is a consumer-side
524
+ reconstruction of information the framework already holds.** `access()`
525
+ receives a transport artifact and is asked to work out which model, which
526
+ operation and which record the request addresses. The five variants above are
527
+ the five ways that has been observed to fail open so far. Tracked as
528
+ [#202](https://github.com/abofs/stonyx-orm/issues/202).
500
529
  - **Related and included records are not filtered.** The predicate is evaluated
501
530
  against the record the route is *addressed to*. `GET /animals/1/owner`,
502
531
  `GET /animals/1/relationships/owner` and `?include=owner` all serialize the
@@ -526,19 +555,34 @@ as `collectionPrefix()` above does.
526
555
  `updateRecord`. `id` is stripped in both handlers because it defeats breaking
527
556
  change 3 above; the general form is tracked as
528
557
  [#204](https://github.com/abofs/stonyx-orm/issues/204).
529
- - **A partially numeric `id` in a `POST` body can overwrite a different record
530
- on an unfiltered collection.** The duplicate check rejects `"9105h"` as a
531
- string, correctly, but the model's id transform truncates it to `9105` and the
532
- create lands there. Filtered collections are unaffected breaking change 3
533
- refuses any client-supplied id so this reaches consumers with **no**
534
- function-style filter. Tracked as
558
+ - **A `POST` body `id` that the duplicate check cannot resolve can still
559
+ overwrite a different record on an unfiltered collection.** The lookup is
560
+ correct and deliberately does not coerce: `"9105h"` is rejected as a string
561
+ rather than truncated, and `9105.5`, `[9105]` and `true` are passed through as
562
+ themselves. The model's id transform then coerces anyway a bare `parseInt`
563
+ with no such guard — so the create lands on `9105` (or on `NaN`) and
564
+ overwrites whatever is there. **This is not string-only**: any body id whose
565
+ transform output differs from its lookup key is the same defect. Filtered
566
+ collections are unaffected — breaking change 3 refuses any client-supplied id
567
+ — so this reaches consumers with **no** function-style filter. Tracked as
535
568
  [#205](https://github.com/abofs/stonyx-orm/issues/205), alongside
536
569
  [#203](https://github.com/abofs/stonyx-orm/issues/203), which is the other way
537
570
  a create can land on an id nobody named.
538
-
539
- ### Breaking changes in 0.4.0
540
-
541
- There is no changelog or release-notes channel yet
571
+ - **A denied `POST` rolls back only a record it *inserted*.** The rollback
572
+ requires the store to have grown, because removing by id alone is a write
573
+ primitive keyed by a caller-supplied value. When `assignRecordId` lands a
574
+ **server-assigned** id on an occupied slot
575
+ ([#203](https://github.com/abofs/stonyx-orm/issues/203) — it returns
576
+ last-*inserted* + 1, not max + 1, so a store whose insertion order is not
577
+ ascending collides), `createRecord` updates that record **in place**: the map
578
+ does not grow, the rollback correctly declines to remove a record this request
579
+ did not create, and the `403` leaves the caller's attributes on someone else's
580
+ record. Narrow — it needs a non-ascending insertion order — but it is the
581
+ reachability condition, so it is stated rather than implied.
582
+
583
+ ### Breaking changes
584
+
585
+ These land in the next published build. There is no changelog or release-notes channel yet
542
586
  ([stonyx-workflows#17](https://github.com/abofs/stonyx-workflows/issues/17)), so
543
587
  they are recorded here.
544
588
 
@@ -548,8 +592,10 @@ they are recorded here.
548
592
  `models: '*'` mounts every model by default. It is not optional: if a denied
549
593
  delete returned 404 while a missing one returned 204, the pair would be a
550
594
  perfect existence oracle and the filter would be worthless.
551
- 2. **After-hooks no longer fire for a write that failed** — denied, missing,
552
- `400` or `409`. Previously `afterHook('delete', ...)` ran with a populated
595
+ 2. **After-hooks no longer fire for a request that failed** — denied, missing,
596
+ `400` or `409`. The gate is on the handler's status, not on the operation, so
597
+ it covers reads as well: a `GET /:id` that answers 404 runs no after-`get`
598
+ hook either. Previously `afterHook('delete', ...)` ran with a populated
553
599
  `context.recordId` on a request that deleted nothing, so a consumer cascade
554
600
  destroyed children behind a 404.
555
601
  3. **`POST` with a client-supplied `id` returns `403` when a function-style
@@ -572,6 +618,11 @@ they are recorded here.
572
618
  previously reachable by id despite being filtered from the collection now
573
619
  return 404. Only affects function-style `access` users, for whom the old
574
620
  behaviour was the bypass.
621
+
622
+ "Seven surfaces" means the seven endpoints of **the filtered model**. A write
623
+ to another collection can still reach one of its records through a
624
+ relationship — [#207](https://github.com/abofs/stonyx-orm/issues/207), which
625
+ is **not** closed here.
575
626
  5. **A predicate that throws is treated as a denial** rather than propagating to
576
627
  Express's default 500 handler. So is an `access()` that throws.
577
628
  6. **`access()` returning a bare string is one permission, not full access.**
@@ -579,6 +630,14 @@ they are recorded here.
579
630
  branch and granted all four operations — `return 'read'` allowed `DELETE`.
580
631
  It is now equivalent to `['read']`. Any other unrecognised shape (an object,
581
632
  a number) now returns `403` rather than granting full access.
633
+ 7. **`POST` with a client-supplied `id` normalises the body id before the
634
+ duplicate check**, so an id shape that previously *missed* the store's key
635
+ now finds it. `POST {"id":"0x2391"}` and `POST {"id":" 21 "}` answer `409`
636
+ where they answered `200`, and the `200` was not a success: the lookup missed,
637
+ the duplicate check was skipped, and `createRecord` overwrote the colliding
638
+ record in place. **This one reaches consumers with no filter at all** — the
639
+ population breaking changes 3 and 4 explicitly exempt. If you were relying on
640
+ a hex-shaped or whitespace-padded id creating a second record, it never did.
582
641
 
583
642
  ### Include Parameter (Sideloading Relationships)
584
643
 
@@ -853,7 +912,19 @@ afterHook('delete', 'animal', async (context) => {
853
912
  // Additional access control - halt with 403 if unauthorized
854
913
  beforeHook('delete', 'animal', (context) => {
855
914
  const user = context.state.currentUser;
856
- const animal = store.get('animal', context.params.id);
915
+
916
+ // `context.oldState` — NOT a store lookup. For `update` and `delete` the ORM
917
+ // has already fetched the record (and already applied the access filter to
918
+ // it) before this hook runs, so re-fetching it here is a fourth id coercion
919
+ // that has to agree with three others.
920
+ //
921
+ // And it would not agree. `context.params.id` is the raw url segment, always
922
+ // a string, while the store keys numeric-id models by NUMBER — so
923
+ // `store.get('animal', '21')` misses the record held under `21`, and so does
924
+ // `store.find('animal', '21')`: neither coerces. The miss reads as "no such
925
+ // record", which in an authorization hook fails whichever way your code
926
+ // happens to handle a null.
927
+ const animal = context.oldState;
857
928
 
858
929
  if (animal.owner !== user.id && !user.isAdmin) {
859
930
  return 403; // Forbidden
@@ -861,6 +932,10 @@ beforeHook('delete', 'animal', (context) => {
861
932
  });
862
933
  ```
863
934
 
935
+ > If you do need a lookup for some *other* model inside a hook, coerce the id
936
+ > yourself to the type that model's `id` attribute declares — the store is a
937
+ > `Map` and `'21'` and `21` are different keys.
938
+
864
939
  #### Auditing
865
940
 
866
941
  ```javascript
@@ -994,11 +1069,29 @@ beforeHook('create', 'post', (context) => {
994
1069
 
995
1070
  ### Hook Execution Order
996
1071
 
997
- 1. **Before hooks** fire first (sequentially, in registration order)
998
- 2. **Main operation** executes (if no before hook halted)
999
- 3. **After hooks** fire last (sequentially, in registration order)
1000
-
1001
- Before hooks can halt the operation by returning a value. After hooks run after completion and cannot halt.
1072
+ 1. **Authorization is evaluated first for `update` and `delete`.** A record the
1073
+ access filter rejects returns `404` **before any before-hook runs**, so a
1074
+ hook never sees a record or a `context.oldState` — that the caller is not
1075
+ allowed to read. `create` is the exception: there is no record to test until
1076
+ the handler has built one, so `beforeHook('create', ...)` **does** fire for a
1077
+ `POST` that goes on to answer `403`.
1078
+ 2. **Before hooks** fire next (sequentially, in registration order).
1079
+ 3. **Main operation** executes (if no before hook halted).
1080
+ 4. **After hooks** fire last (sequentially, in registration order) — **only if
1081
+ the request succeeded.**
1082
+
1083
+ Before hooks can halt the operation by returning a value, and that value becomes
1084
+ the response.
1085
+
1086
+ **After hooks do not run for a failed request.** Any status `>= 400` — denied,
1087
+ missing, `400`, `409` — skips the entire after-hook pipeline, along with SQL
1088
+ persistence and `onUpdate` autosave. This is a behaviour change; see
1089
+ [Breaking changes](#breaking-changes). It applies to the samples above: the
1090
+ `afterHook('delete', ...)` auditing hook writes **no** audit row for a `DELETE`
1091
+ that answered `404`, and the `afterHook('update', ...)` change-tracking hook
1092
+ writes none for a `PATCH` that answered `404` or `400`. If you need a record of
1093
+ refused requests, log them from a before-hook or from your own middleware —
1094
+ `after<operation>` fires only for an operation that actually happened.
1002
1095
 
1003
1096
  ### Best Practices
1004
1097
 
@@ -2,14 +2,13 @@
2
2
  * REST request handling and access enforcement for @stonyx/orm.
3
3
  *
4
4
  * ---------------------------------------------------------------------------
5
- * THE DOCUMENTED `access()` PATTERN IS A STOPGAP. READ THIS BEFORE RELYING ON IT.
5
+ * DO NOT RECONSTRUCT THE REQUEST PATH INSIDE `access()`.
6
6
  * ---------------------------------------------------------------------------
7
7
  * `auth()` below hands your `access(request)` a raw transport artifact and asks
8
- * you to re-derive from a URL string what this module already holds
9
- * structurally: which model, which operation, which record. The three-line
10
- * URL-matching example in README has failed **open** in four distinct ways
11
- * during the review of a single change, each found only after the previous was
12
- * fixed, by four different people:
8
+ * you to work out which collection it addresses. Every attempt to do that by
9
+ * parsing the request target has failed OPEN. Five distinct variants of the
10
+ * same three-line example have now been found, each after the previous was
11
+ * fixed, by five different people:
13
12
  *
14
13
  * 1. `request.url` is mount-relative under `RestServer.mountRoute`, so a
15
14
  * prefix match against it is ALWAYS false.
@@ -20,20 +19,44 @@
20
19
  * past it. Router-side: abofs/stonyx-rest-server#47.
21
20
  * 4. Under a configured `ORM_REST_ROUTE` a hard-coded `/owners` matches
22
21
  * nothing -- environment-specifically, which is worse.
22
+ * 5. HTTP/1.1 permits an ABSOLUTE-FORM request-target. Express routes on
23
+ * `parseurl(req).pathname`, but `originalUrl` is the raw target, so
24
+ * `GET http://anything.example/owners/angela` reaches the handler with
25
+ * `originalUrl === 'http://anything.example/owners/angela'`. A `/owners`
26
+ * prefix match is false, `access()` falls through to whatever it returns
27
+ * last, and the record comes back in full. It walks past a hard
28
+ * `return false` deny the same way.
23
29
  *
24
- * The README sample closes all four. That is NOT the same as being safe; it is
25
- * safe against the four variants we happen to have found, and there is no
26
- * reason to believe the list is complete.
30
+ * The fix is not a sixth rule. It is to stop parsing:
27
31
  *
28
- * THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model, the
29
- * operation and the record, so there is no URL to parse and no variant to miss.
30
- * Prefer the array shape (`['read']`) or `false` until #202 lands; the
31
- * function shape is what requires the URL matching.
32
+ * `request.baseUrl` is the mount Express ACTUALLY MATCHED when it dispatched
33
+ * the request. It carries no query string, it is not mount-relative, it is
34
+ * unaffected by absolute-form, and it already includes the configured
35
+ * `ORM_REST_ROUTE` prefix -- so there is nothing to derive and nothing to
36
+ * join. Compare it lower-cased (the router matched case-insensitively) and
37
+ * fail CLOSED when it is absent. Use `request.path` -- mount-relative and
38
+ * query-free -- if you need to distinguish sub-paths.
39
+ *
40
+ * `?? ''` is not a defence. It converts an absent request target into an empty
41
+ * string, which matches no collection, which falls through to the permission
42
+ * array -- a total grant. An input you cannot identify must DENY.
43
+ *
44
+ * THAT IS STILL A STOPGAP. `baseUrl` closes all five variants, but it is a
45
+ * transport artifact being asked to stand in for a structural fact.
46
+ * THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
47
+ * the operation and the record. Prefer the array shape (`['read']`) or `false`
48
+ * until #202 lands; the function shape is what requires any matching at all.
32
49
  *
33
50
  * The enforcement gates in this file (GATE 0/1/2, the create rollback, the
34
51
  * per-handler `isDenied` re-checks) are correct independently of that -- they
35
52
  * enforce whatever predicate you return. The stopgap is the part where YOU have
36
53
  * to work out which predicate to return.
54
+ *
55
+ * AND A PREDICATE IS NOT A GUARANTEE THAT A HIDDEN RECORD CANNOT BE MODIFIED.
56
+ * It is evaluated against the record the route is ADDRESSED TO, on that model
57
+ * only. A write to a DIFFERENT collection can still re-parent a hidden record
58
+ * and de-hide it -- abofs/stonyx-orm#207, which is blocked on #202 and #196.
59
+ * See `### Known limitations` in README.
37
60
  */
38
61
  import { Request } from '@stonyx/rest-server';
39
62
  interface OrmRequest$ extends Request {
@@ -2,14 +2,13 @@
2
2
  * REST request handling and access enforcement for @stonyx/orm.
3
3
  *
4
4
  * ---------------------------------------------------------------------------
5
- * THE DOCUMENTED `access()` PATTERN IS A STOPGAP. READ THIS BEFORE RELYING ON IT.
5
+ * DO NOT RECONSTRUCT THE REQUEST PATH INSIDE `access()`.
6
6
  * ---------------------------------------------------------------------------
7
7
  * `auth()` below hands your `access(request)` a raw transport artifact and asks
8
- * you to re-derive from a URL string what this module already holds
9
- * structurally: which model, which operation, which record. The three-line
10
- * URL-matching example in README has failed **open** in four distinct ways
11
- * during the review of a single change, each found only after the previous was
12
- * fixed, by four different people:
8
+ * you to work out which collection it addresses. Every attempt to do that by
9
+ * parsing the request target has failed OPEN. Five distinct variants of the
10
+ * same three-line example have now been found, each after the previous was
11
+ * fixed, by five different people:
13
12
  *
14
13
  * 1. `request.url` is mount-relative under `RestServer.mountRoute`, so a
15
14
  * prefix match against it is ALWAYS false.
@@ -20,20 +19,44 @@
20
19
  * past it. Router-side: abofs/stonyx-rest-server#47.
21
20
  * 4. Under a configured `ORM_REST_ROUTE` a hard-coded `/owners` matches
22
21
  * nothing -- environment-specifically, which is worse.
22
+ * 5. HTTP/1.1 permits an ABSOLUTE-FORM request-target. Express routes on
23
+ * `parseurl(req).pathname`, but `originalUrl` is the raw target, so
24
+ * `GET http://anything.example/owners/angela` reaches the handler with
25
+ * `originalUrl === 'http://anything.example/owners/angela'`. A `/owners`
26
+ * prefix match is false, `access()` falls through to whatever it returns
27
+ * last, and the record comes back in full. It walks past a hard
28
+ * `return false` deny the same way.
23
29
  *
24
- * The README sample closes all four. That is NOT the same as being safe; it is
25
- * safe against the four variants we happen to have found, and there is no
26
- * reason to believe the list is complete.
30
+ * The fix is not a sixth rule. It is to stop parsing:
27
31
  *
28
- * THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model, the
29
- * operation and the record, so there is no URL to parse and no variant to miss.
30
- * Prefer the array shape (`['read']`) or `false` until #202 lands; the
31
- * function shape is what requires the URL matching.
32
+ * `request.baseUrl` is the mount Express ACTUALLY MATCHED when it dispatched
33
+ * the request. It carries no query string, it is not mount-relative, it is
34
+ * unaffected by absolute-form, and it already includes the configured
35
+ * `ORM_REST_ROUTE` prefix -- so there is nothing to derive and nothing to
36
+ * join. Compare it lower-cased (the router matched case-insensitively) and
37
+ * fail CLOSED when it is absent. Use `request.path` -- mount-relative and
38
+ * query-free -- if you need to distinguish sub-paths.
39
+ *
40
+ * `?? ''` is not a defence. It converts an absent request target into an empty
41
+ * string, which matches no collection, which falls through to the permission
42
+ * array -- a total grant. An input you cannot identify must DENY.
43
+ *
44
+ * THAT IS STILL A STOPGAP. `baseUrl` closes all five variants, but it is a
45
+ * transport artifact being asked to stand in for a structural fact.
46
+ * THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
47
+ * the operation and the record. Prefer the array shape (`['read']`) or `false`
48
+ * until #202 lands; the function shape is what requires any matching at all.
32
49
  *
33
50
  * The enforcement gates in this file (GATE 0/1/2, the create rollback, the
34
51
  * per-handler `isDenied` re-checks) are correct independently of that -- they
35
52
  * enforce whatever predicate you return. The stopgap is the part where YOU have
36
53
  * to work out which predicate to return.
54
+ *
55
+ * AND A PREDICATE IS NOT A GUARANTEE THAT A HIDDEN RECORD CANNOT BE MODIFIED.
56
+ * It is evaluated against the record the route is ADDRESSED TO, on that model
57
+ * only. A write to a DIFFERENT collection can still re-parent a hidden record
58
+ * and de-hide it -- abofs/stonyx-orm#207, which is blocked on #202 and #196.
59
+ * See `### Known limitations` in README.
37
60
  */
38
61
  import { Request } from '@stonyx/rest-server';
39
62
  import Orm, { store, createRecord, updateRecord } from '@stonyx/orm';
@@ -41,6 +64,7 @@ import { camelCaseToKebabCase } from '@stonyx/utils/string';
41
64
  import { getPluralName } from './plural-registry.js';
42
65
  import { getBeforeHooks, getAfterHooks } from './hooks.js';
43
66
  import config from 'stonyx/config';
67
+ import log from 'stonyx/log';
44
68
  import { isOrmRecord } from './utils.js';
45
69
  const methodAccessMap = {
46
70
  GET: 'read',
@@ -86,28 +110,47 @@ function getBaseUrl(request) {
86
110
  return `${protocol}://${host}`;
87
111
  }
88
112
  /**
89
- * The ONE coercion from a caller-supplied id string to the key the store holds
90
- * it under. Both id-bearing surfaces go through this, and neither has a copy.
113
+ * The ONE coercion from a caller-supplied id to the key the store holds it
114
+ * under. Every id-bearing surface in this file goes through it, and none has a
115
+ * copy: `getId()` (URL params), `normalizeBodyId()` (JSON body), and the
116
+ * post-create `context.record` lookup in `_withHooks`.
91
117
  *
92
- * WHY IT IS SHARED RATHER THAN DUPLICATED. `getId()` (URL) and
93
- * `normalizeBodyId()` (JSON body) each had their own arithmetic, and they
94
- * disagreed: `parseInt(id)` versus `parseInt(id, 10)`. On a hex-shaped id that
95
- * is a two-record difference --
118
+ * WHY IT IS SHARED RATHER THAN DUPLICATED. `getId()` and `normalizeBodyId()`
119
+ * each had their own arithmetic, and they disagreed: `parseInt(id)` versus
120
+ * `parseInt(id, 10)`. On a hex-shaped id that is a two-record difference --
96
121
  *
97
122
  * GET /animals/0x2391 -> record 9105 (getId -> parseInt('0x2391') = 9105)
98
123
  * POST /animals {"id":"0x2391"} -> lookup under 0 (normalize -> parseInt('0x2391',10) = 0)
99
124
  * -> a MISS, so the duplicate check was skipped and
100
125
  * createRecord OVERWROTE 9105 in place, answering 200
101
126
  *
102
- * -- which is the raw-versus-normalised divergence that produced the round-3
103
- * blocker, in a narrower form, reintroduced by the fix for it. Two coercions
104
- * that must agree cannot be kept in agreement by review; they have to be one
105
- * function. Pinned by assertion 43.
127
+ * -- a narrower form of the raw-versus-normalised divergence that the body-id
128
+ * normalisation was added to close, reintroduced by the fix for it. Two
129
+ * coercions that must agree cannot be kept in agreement by review; they have to
130
+ * be one function. Pinned by assertion 43.
131
+ *
132
+ * The third copy was found later and in a quieter place: `_withHooks` populated
133
+ * `context.record` for `create` with `isNaN(id) ? id : parseInt(id)` -- this
134
+ * function's body, inlined verbatim, feeding `store.get`. It was equivalent on
135
+ * every input reachable there, which is exactly what the two that DID diverge
136
+ * looked like until someone tried a hex id.
137
+ *
138
+ * `parseInt` and not `Number`, deliberately, and the anchor is NOT `getId`.
139
+ * It is `src/transforms.ts:7` -- `number: (value) => parseInt(value as string)`,
140
+ * also radix-less -- because that transform is what actually produces the store
141
+ * KEY a record is filed under. `getId` merely agrees with it. They differ from
142
+ * `Number` on `'1e3'` (1 vs 1000) and `'9105.5'` (9105 vs 9105.5), so switching
143
+ * this function to `Number` would make the lookup key disagree with the landing
144
+ * key on those shapes.
145
+ *
146
+ * THE CHANGE THAT WOULD BREAK THIS: adding a radix to `transforms.number`
147
+ * (`parseInt(value, 10)`). That is a one-word edit in a file with no connection
148
+ * to authorization, it would silently reopen the hex divergence in the other
149
+ * direction, and this comment would still read as correct. Assertion 45 pins
150
+ * the transform's radix-less shape directly, so that edit turns a test red
151
+ * rather than shipping.
106
152
  *
107
- * `parseInt` and not `Number`, deliberately. They differ on `'1e3'` (1 vs 1000)
108
- * and `'9105.5'` (9105 vs 9105.5), and `getId` -- which decides which record an
109
- * id ADDRESSES -- is the reference, so `Number` would trade one divergence for
110
- * two. The reason `parseInt` is safe here is the `isNaN` gate in front of it:
153
+ * The reason `parseInt` is safe here is the `isNaN` gate in front of it:
111
154
  * `parseInt('9105h')` is `9105`, and truncating a partially-valid id into a
112
155
  * DIFFERENT VALID key is exactly how a collision lookup gets skipped. The gate
113
156
  * rejects it as a string instead, so nothing is ever truncated. That gate, not
@@ -322,7 +365,13 @@ function isDenied(filter, record) {
322
365
  try {
323
366
  return !filter(record);
324
367
  }
325
- catch {
368
+ catch (error) {
369
+ // Denied, but not silently. A consumer predicate that throws on every
370
+ // record turns the whole collection into a 404 wall, and with no
371
+ // diagnostic that is indistinguishable from an empty database. `stonyx/log`
372
+ // is the module convention (see setup-rest-server.ts); optional-call
373
+ // because a consumer may not have configured the log types.
374
+ log.error?.(`[@stonyx/orm] access filter threw for model -- denying. ${error instanceof Error ? error.message : String(error)}`);
326
375
  return true;
327
376
  }
328
377
  }
@@ -426,9 +475,24 @@ export default class OrmRequest extends Request {
426
475
  // the oracle exists for: with no per-record filter there are no hidden
427
476
  // records, and 409 discloses nothing GET /:id does not already.
428
477
  //
429
- // RESIDUAL, stated rather than implied: a caller can still learn that a
430
- // collection HAS a per-record filter (403 rather than 409/200 for an
431
- // id-bearing POST). That discloses a configuration fact, not a record.
478
+ // RESIDUALS, stated rather than implied.
479
+ //
480
+ // - a caller can still learn that a collection HAS a per-record filter
481
+ // (403 rather than 409/200 for an id-bearing POST). That discloses a
482
+ // configuration fact, not a record.
483
+ // - this gate is about ids arriving on THIS model's create route. It
484
+ // says nothing about a write to ANOTHER collection: a `POST /owners`
485
+ // carrying `relationships: {pets: {data: {id: 21}}}` -- or
486
+ // `attributes: {pets: [21, 22]}`, which never enters the
487
+ // relationships loop at all -- re-parents hidden animal 21 onto an
488
+ // owner the caller may write, which changes the very field the
489
+ // animals predicate reads and DE-HIDES it. Blocking that needs animal
490
+ // 21 checked against the ANIMAL model's predicate while servicing an
491
+ // OWNERS route, i.e. cross-model access resolution: abofs/stonyx-orm
492
+ // #207, blocked on #202 (`access` receives the model structurally)
493
+ // and #196 (setup-rest-server discards the model->predicate map at
494
+ // boot). NOT closed here, and no comment in this file may say it is.
495
+ //
432
496
  // See README `### Known limitations`.
433
497
  if (id !== undefined) {
434
498
  if (typeof filter === 'function')
@@ -457,8 +521,11 @@ export default class OrmRequest extends Request {
457
521
  // on the create surface. Pinned by assertion 39.
458
522
  //
459
523
  // The `id` member of the resource object is now the ONLY channel a caller
460
- // id can arrive on, which is what makes GATE 0's guarantee checkable
461
- // rather than merely asserted. INHERITED from `dev`, which carries this
524
+ // id can arrive on FOR THIS MODEL'S OWN CREATE ROUTE, which is what makes
525
+ // GATE 0's guarantee checkable rather than merely asserted. It is not a
526
+ // statement about the record's reachability in general -- a relationship
527
+ // write on another collection reaches it without ever touching this
528
+ // handler (abofs/stonyx-orm#207). INHERITED from `dev`, which carries this
462
529
  // loop verbatim; the general form -- the loop accepts any key, not just
463
530
  // `id`, so a body key that is not a declared relationship is still
464
531
  // mass-assigned -- is abofs/stonyx-orm#204.
@@ -530,9 +597,11 @@ export default class OrmRequest extends Request {
530
597
  // silently re-armed when that code moves.
531
598
  // SO IT BECOMES REACHABLE IF AN `await` IS
532
599
  // INTRODUCED HERE, which is the change a future
533
- // editor would actually make. See the
534
- // guards-redundant-by-construction table in
535
- // docs/project-structure.md.
600
+ // editor would actually make. Stated here rather
601
+ // than by reference: `docs/` is not in `files`, so
602
+ // a pointer into it resolves to nothing for anyone
603
+ // who installed this package. README carries the
604
+ // consumer-facing half.
536
605
  if (createdNewSlot && store.get(model, record.id) === record) {
537
606
  store.remove(model, record.id, { _skipAutoPersist: true });
538
607
  }
@@ -600,7 +669,12 @@ export default class OrmRequest extends Request {
600
669
  return { data: record.toJSON?.() };
601
670
  };
602
671
  const deleteHandler = async ({ params }, { filter }) => {
603
- const record = await store.find(model, getId(params));
672
+ // Coerced ONCE. `getId(params)` was evaluated twice here -- once to find
673
+ // the record and once to remove it -- and a coercion evaluated repeatedly
674
+ // is a coercion that can be edited in one place and not the other, which
675
+ // is the defect `coerceId` exists to prevent.
676
+ const recordId = getId(params);
677
+ const record = await store.find(model, recordId);
604
678
  // BEHAVIOUR CHANGE (#190): a DELETE of a record that never existed
605
679
  // returned 204 before this change. It now returns 404, matching the
606
680
  // denied case below. This is deliberate and load-bearing -- if a denied
@@ -615,7 +689,10 @@ export default class OrmRequest extends Request {
615
689
  // turns a 404 into a destroyed record.
616
690
  if (isDenied(filter, record))
617
691
  return 404;
618
- store.remove(model, getId(params), { _skipAutoPersist: true });
692
+ // Removed by the id of the record actually fetched, not by re-deriving it
693
+ // from the params a second time: the record the filter tested and the
694
+ // record removed are then provably the same one.
695
+ store.remove(model, record.id, { _skipAutoPersist: true });
619
696
  return 204;
620
697
  };
621
698
  // Wrap handlers with hooks
@@ -669,7 +746,34 @@ export default class OrmRequest extends Request {
669
746
  // ===========================================================================
670
747
  _withHooks(operation, handler) {
671
748
  return async (request, state) => {
672
- const { filter } = (state || {});
749
+ // `|| {}` so this function behaves like the relationship routes below,
750
+ // which declare `state` with a `= {}` default. It is unkillable through
751
+ // the rest-server dispatcher, which always passes `getState(req)`; it is
752
+ // listed as such in the guards-redundant-by-construction table rather
753
+ // than left silently unkillable, and it defends the WHOLE function (the
754
+ // context, the snapshot and the handler call all read `callState`) rather
755
+ // than one destructure that the next line would throw past anyway.
756
+ const callState = (state || {});
757
+ // ---------------------------------------------------------------------
758
+ // THE AUTHORIZATION SNAPSHOT. Read ONCE, here, before any consumer code
759
+ // can run.
760
+ //
761
+ // `callState` is the object `auth()` planted the filter in, and it is
762
+ // also handed to every before-hook as `context.state` -- a published,
763
+ // WRITABLE extension point. So `state.filter` is an INPUT to the
764
+ // authorization decision, not only an output channel, and re-reading it
765
+ // after the hook loop lets a consumer hook disarm the filter:
766
+ //
767
+ // beforeHook('get', 'animal', ctx => { delete ctx.state.filter })
768
+ // -> GET /animals/21 turned 404 into 200
769
+ // -> GET /animals turned 20 records into 22
770
+ //
771
+ // GATE 1 already used this snapshot, so writes held; the READ handlers
772
+ // re-destructured `filter` from the live bag and did not. Everything
773
+ // downstream now reads `filter` from here, and the handler is handed
774
+ // `handlerState` below -- never `callState`.
775
+ // ---------------------------------------------------------------------
776
+ const { filter } = callState;
673
777
  // Build context object for hooks
674
778
  const context = {
675
779
  model: this.model,
@@ -678,7 +782,11 @@ export default class OrmRequest extends Request {
678
782
  params: request.params,
679
783
  body: request.body,
680
784
  query: request.query,
681
- state,
785
+ // Deliberately the LIVE object: `redirect` and `pipe` are read back off
786
+ // it by @stonyx/rest-server after the handler returns, so hooks must be
787
+ // able to write to it. What must not happen is the authorization
788
+ // decision reading it back, which is what the snapshot above prevents.
789
+ state: callState,
682
790
  };
683
791
  // Capture old state for operations that modify data
684
792
  if (operation === 'update' || operation === 'delete') {
@@ -720,7 +828,14 @@ export default class OrmRequest extends Request {
720
828
  }
721
829
  }
722
830
  // Execute main handler
723
- const response = await handler(request, state);
831
+ // The handler receives the SNAPSHOT, never the live bag. `filter` is
832
+ // assigned LAST so it wins over anything a before-hook wrote to
833
+ // `callState.filter` -- including a `delete`, which the spread would
834
+ // otherwise carry through as an absent key. Every other key a hook adds
835
+ // is still visible to the handler; only the authorization input is
836
+ // pinned.
837
+ const handlerState = { ...callState, filter };
838
+ const response = await handler(request, handlerState);
724
839
  // Set context.record for update BEFORE persist so SQL drivers can read it
725
840
  if (operation === 'update' && response?.data) {
726
841
  context.record = store.get(this.model, getId(request.params));
@@ -732,6 +847,16 @@ export default class OrmRequest extends Request {
732
847
  // authorization ones: a 400 (POST with no `type`) and a 409 (duplicate id)
733
848
  // are equally requests in which nothing happened, and a persist or a
734
849
  // cascade hook for one of them is just as wrong.
850
+ // `Number.isInteger` is a TYPE guard, not a behaviour guard, and it is
851
+ // unkillable TODAY: the only non-integer a handler in this file can
852
+ // return is a `{ data, links }` object, and `{} >= 400` is `false` by JS
853
+ // coercion, so dropping it changes no reachable outcome. It is kept
854
+ // because `>=` coerces rather than rejects, and the shapes it coerces
855
+ // are not obvious -- `[500] >= 400` is TRUE, so a handler that one day
856
+ // returned an array would have every response read as a denial. Listed
857
+ // as an equivalent mutant rather than left to read as coverage; it
858
+ // becomes killable the moment a handler returns anything array-like or
859
+ // numeric-string-like.
735
860
  const denied = Number.isInteger(response) && response >= 400;
736
861
  // EXECUTOR 1 -- SQL persistence, for all write operations.
737
862
  //
@@ -759,8 +884,16 @@ export default class OrmRequest extends Request {
759
884
  else if (operation === 'create' && response?.data && (response.data.id)) {
760
885
  // For create, get the record from store using the ID from the response
761
886
  const responseData = response.data;
762
- const recordId = isNaN(responseData.id) ? responseData.id : parseInt(responseData.id);
763
- context.record = store.get(this.model, recordId);
887
+ // `normalizeBodyId`, not a copy of its body. This line WAS
888
+ // `isNaN(id) ? id : parseInt(id)` -- `coerceId` inlined verbatim, a
889
+ // third coercion feeding a store lookup, sitting under a docblock that
890
+ // said neither surface had a copy. Equivalent on every input that can
891
+ // reach this truthy-guarded branch (`21`->`21`, `'21'`->`21`,
892
+ // `'angela'`->`'angela'`; `''` and `0` cannot reach it), so this is a
893
+ // de-duplication rather than a behaviour change -- and that is the
894
+ // point: the two that disagreed were equivalent on every input anyone
895
+ // checked, too.
896
+ context.record = store.get(this.model, normalizeBodyId(responseData.id));
764
897
  }
765
898
  else if (operation === 'delete') {
766
899
  // For delete, the record may no longer exist, but we have oldState
@@ -897,7 +1030,11 @@ export default class OrmRequest extends Request {
897
1030
  try {
898
1031
  access = this.access(request);
899
1032
  }
900
- catch {
1033
+ catch (error) {
1034
+ // Same reasoning as `isDenied`: fail closed, but say so. An `access()`
1035
+ // that throws denies EVERY request to the collection, and a silent 403
1036
+ // wall is the hardest possible thing to diagnose from the outside.
1037
+ log.error?.(`[@stonyx/orm] access() threw for model "${this.model}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
901
1038
  return 403; // Forbidden
902
1039
  }
903
1040
  if (!access)
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-alpha.50",
7
+ "version": "0.3.2-alpha.52",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -2,14 +2,13 @@
2
2
  * REST request handling and access enforcement for @stonyx/orm.
3
3
  *
4
4
  * ---------------------------------------------------------------------------
5
- * THE DOCUMENTED `access()` PATTERN IS A STOPGAP. READ THIS BEFORE RELYING ON IT.
5
+ * DO NOT RECONSTRUCT THE REQUEST PATH INSIDE `access()`.
6
6
  * ---------------------------------------------------------------------------
7
7
  * `auth()` below hands your `access(request)` a raw transport artifact and asks
8
- * you to re-derive from a URL string what this module already holds
9
- * structurally: which model, which operation, which record. The three-line
10
- * URL-matching example in README has failed **open** in four distinct ways
11
- * during the review of a single change, each found only after the previous was
12
- * fixed, by four different people:
8
+ * you to work out which collection it addresses. Every attempt to do that by
9
+ * parsing the request target has failed OPEN. Five distinct variants of the
10
+ * same three-line example have now been found, each after the previous was
11
+ * fixed, by five different people:
13
12
  *
14
13
  * 1. `request.url` is mount-relative under `RestServer.mountRoute`, so a
15
14
  * prefix match against it is ALWAYS false.
@@ -20,20 +19,44 @@
20
19
  * past it. Router-side: abofs/stonyx-rest-server#47.
21
20
  * 4. Under a configured `ORM_REST_ROUTE` a hard-coded `/owners` matches
22
21
  * nothing -- environment-specifically, which is worse.
22
+ * 5. HTTP/1.1 permits an ABSOLUTE-FORM request-target. Express routes on
23
+ * `parseurl(req).pathname`, but `originalUrl` is the raw target, so
24
+ * `GET http://anything.example/owners/angela` reaches the handler with
25
+ * `originalUrl === 'http://anything.example/owners/angela'`. A `/owners`
26
+ * prefix match is false, `access()` falls through to whatever it returns
27
+ * last, and the record comes back in full. It walks past a hard
28
+ * `return false` deny the same way.
23
29
  *
24
- * The README sample closes all four. That is NOT the same as being safe; it is
25
- * safe against the four variants we happen to have found, and there is no
26
- * reason to believe the list is complete.
30
+ * The fix is not a sixth rule. It is to stop parsing:
27
31
  *
28
- * THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model, the
29
- * operation and the record, so there is no URL to parse and no variant to miss.
30
- * Prefer the array shape (`['read']`) or `false` until #202 lands; the
31
- * function shape is what requires the URL matching.
32
+ * `request.baseUrl` is the mount Express ACTUALLY MATCHED when it dispatched
33
+ * the request. It carries no query string, it is not mount-relative, it is
34
+ * unaffected by absolute-form, and it already includes the configured
35
+ * `ORM_REST_ROUTE` prefix -- so there is nothing to derive and nothing to
36
+ * join. Compare it lower-cased (the router matched case-insensitively) and
37
+ * fail CLOSED when it is absent. Use `request.path` -- mount-relative and
38
+ * query-free -- if you need to distinguish sub-paths.
39
+ *
40
+ * `?? ''` is not a defence. It converts an absent request target into an empty
41
+ * string, which matches no collection, which falls through to the permission
42
+ * array -- a total grant. An input you cannot identify must DENY.
43
+ *
44
+ * THAT IS STILL A STOPGAP. `baseUrl` closes all five variants, but it is a
45
+ * transport artifact being asked to stand in for a structural fact.
46
+ * THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
47
+ * the operation and the record. Prefer the array shape (`['read']`) or `false`
48
+ * until #202 lands; the function shape is what requires any matching at all.
32
49
  *
33
50
  * The enforcement gates in this file (GATE 0/1/2, the create rollback, the
34
51
  * per-handler `isDenied` re-checks) are correct independently of that -- they
35
52
  * enforce whatever predicate you return. The stopgap is the part where YOU have
36
53
  * to work out which predicate to return.
54
+ *
55
+ * AND A PREDICATE IS NOT A GUARANTEE THAT A HIDDEN RECORD CANNOT BE MODIFIED.
56
+ * It is evaluated against the record the route is ADDRESSED TO, on that model
57
+ * only. A write to a DIFFERENT collection can still re-parent a hidden record
58
+ * and de-hide it -- abofs/stonyx-orm#207, which is blocked on #202 and #196.
59
+ * See `### Known limitations` in README.
37
60
  */
38
61
  import { Request } from '@stonyx/rest-server';
39
62
  import Orm, { store, createRecord, updateRecord } from '@stonyx/orm';
@@ -42,6 +65,7 @@ import { getPluralName } from './plural-registry.js';
42
65
  import { getBeforeHooks, getAfterHooks } from './hooks.js';
43
66
  import type { HookContext } from './hooks.js';
44
67
  import config from 'stonyx/config';
68
+ import log from 'stonyx/log';
45
69
  import type { OrmRecord } from './types/orm-types.js';
46
70
  import { isOrmRecord } from './utils.js';
47
71
 
@@ -122,28 +146,47 @@ function getBaseUrl(request: OrmRequest$): string {
122
146
  }
123
147
 
124
148
  /**
125
- * The ONE coercion from a caller-supplied id string to the key the store holds
126
- * it under. Both id-bearing surfaces go through this, and neither has a copy.
149
+ * The ONE coercion from a caller-supplied id to the key the store holds it
150
+ * under. Every id-bearing surface in this file goes through it, and none has a
151
+ * copy: `getId()` (URL params), `normalizeBodyId()` (JSON body), and the
152
+ * post-create `context.record` lookup in `_withHooks`.
127
153
  *
128
- * WHY IT IS SHARED RATHER THAN DUPLICATED. `getId()` (URL) and
129
- * `normalizeBodyId()` (JSON body) each had their own arithmetic, and they
130
- * disagreed: `parseInt(id)` versus `parseInt(id, 10)`. On a hex-shaped id that
131
- * is a two-record difference --
154
+ * WHY IT IS SHARED RATHER THAN DUPLICATED. `getId()` and `normalizeBodyId()`
155
+ * each had their own arithmetic, and they disagreed: `parseInt(id)` versus
156
+ * `parseInt(id, 10)`. On a hex-shaped id that is a two-record difference --
132
157
  *
133
158
  * GET /animals/0x2391 -> record 9105 (getId -> parseInt('0x2391') = 9105)
134
159
  * POST /animals {"id":"0x2391"} -> lookup under 0 (normalize -> parseInt('0x2391',10) = 0)
135
160
  * -> a MISS, so the duplicate check was skipped and
136
161
  * createRecord OVERWROTE 9105 in place, answering 200
137
162
  *
138
- * -- which is the raw-versus-normalised divergence that produced the round-3
139
- * blocker, in a narrower form, reintroduced by the fix for it. Two coercions
140
- * that must agree cannot be kept in agreement by review; they have to be one
141
- * function. Pinned by assertion 43.
163
+ * -- a narrower form of the raw-versus-normalised divergence that the body-id
164
+ * normalisation was added to close, reintroduced by the fix for it. Two
165
+ * coercions that must agree cannot be kept in agreement by review; they have to
166
+ * be one function. Pinned by assertion 43.
167
+ *
168
+ * The third copy was found later and in a quieter place: `_withHooks` populated
169
+ * `context.record` for `create` with `isNaN(id) ? id : parseInt(id)` -- this
170
+ * function's body, inlined verbatim, feeding `store.get`. It was equivalent on
171
+ * every input reachable there, which is exactly what the two that DID diverge
172
+ * looked like until someone tried a hex id.
142
173
  *
143
- * `parseInt` and not `Number`, deliberately. They differ on `'1e3'` (1 vs 1000)
144
- * and `'9105.5'` (9105 vs 9105.5), and `getId` -- which decides which record an
145
- * id ADDRESSES -- is the reference, so `Number` would trade one divergence for
146
- * two. The reason `parseInt` is safe here is the `isNaN` gate in front of it:
174
+ * `parseInt` and not `Number`, deliberately, and the anchor is NOT `getId`.
175
+ * It is `src/transforms.ts:7` -- `number: (value) => parseInt(value as string)`,
176
+ * also radix-less -- because that transform is what actually produces the store
177
+ * KEY a record is filed under. `getId` merely agrees with it. They differ from
178
+ * `Number` on `'1e3'` (1 vs 1000) and `'9105.5'` (9105 vs 9105.5), so switching
179
+ * this function to `Number` would make the lookup key disagree with the landing
180
+ * key on those shapes.
181
+ *
182
+ * THE CHANGE THAT WOULD BREAK THIS: adding a radix to `transforms.number`
183
+ * (`parseInt(value, 10)`). That is a one-word edit in a file with no connection
184
+ * to authorization, it would silently reopen the hex divergence in the other
185
+ * direction, and this comment would still read as correct. Assertion 45 pins
186
+ * the transform's radix-less shape directly, so that edit turns a test red
187
+ * rather than shipping.
188
+ *
189
+ * The reason `parseInt` is safe here is the `isNaN` gate in front of it:
147
190
  * `parseInt('9105h')` is `9105`, and truncating a partially-valid id into a
148
191
  * DIFFERENT VALID key is exactly how a collision lookup gets skipped. The gate
149
192
  * rejects it as a string instead, so nothing is ever truncated. That gate, not
@@ -388,7 +431,14 @@ function isDenied(filter: unknown, record: unknown): boolean {
388
431
  // hands back the oracle this whole change exists to close.
389
432
  try {
390
433
  return !(filter as (record: unknown) => boolean)(record);
391
- } catch {
434
+ } catch (error) {
435
+ // Denied, but not silently. A consumer predicate that throws on every
436
+ // record turns the whole collection into a 404 wall, and with no
437
+ // diagnostic that is indistinguishable from an empty database. `stonyx/log`
438
+ // is the module convention (see setup-rest-server.ts); optional-call
439
+ // because a consumer may not have configured the log types.
440
+ log.error?.(`[@stonyx/orm] access filter threw for model -- denying. ${error instanceof Error ? error.message : String(error)}`);
441
+
392
442
  return true;
393
443
  }
394
444
  }
@@ -508,9 +558,24 @@ export default class OrmRequest extends Request {
508
558
  // the oracle exists for: with no per-record filter there are no hidden
509
559
  // records, and 409 discloses nothing GET /:id does not already.
510
560
  //
511
- // RESIDUAL, stated rather than implied: a caller can still learn that a
512
- // collection HAS a per-record filter (403 rather than 409/200 for an
513
- // id-bearing POST). That discloses a configuration fact, not a record.
561
+ // RESIDUALS, stated rather than implied.
562
+ //
563
+ // - a caller can still learn that a collection HAS a per-record filter
564
+ // (403 rather than 409/200 for an id-bearing POST). That discloses a
565
+ // configuration fact, not a record.
566
+ // - this gate is about ids arriving on THIS model's create route. It
567
+ // says nothing about a write to ANOTHER collection: a `POST /owners`
568
+ // carrying `relationships: {pets: {data: {id: 21}}}` -- or
569
+ // `attributes: {pets: [21, 22]}`, which never enters the
570
+ // relationships loop at all -- re-parents hidden animal 21 onto an
571
+ // owner the caller may write, which changes the very field the
572
+ // animals predicate reads and DE-HIDES it. Blocking that needs animal
573
+ // 21 checked against the ANIMAL model's predicate while servicing an
574
+ // OWNERS route, i.e. cross-model access resolution: abofs/stonyx-orm
575
+ // #207, blocked on #202 (`access` receives the model structurally)
576
+ // and #196 (setup-rest-server discards the model->predicate map at
577
+ // boot). NOT closed here, and no comment in this file may say it is.
578
+ //
514
579
  // See README `### Known limitations`.
515
580
  if (id !== undefined) {
516
581
  if (typeof filter === 'function') return 403; // Forbidden
@@ -540,8 +605,11 @@ export default class OrmRequest extends Request {
540
605
  // on the create surface. Pinned by assertion 39.
541
606
  //
542
607
  // The `id` member of the resource object is now the ONLY channel a caller
543
- // id can arrive on, which is what makes GATE 0's guarantee checkable
544
- // rather than merely asserted. INHERITED from `dev`, which carries this
608
+ // id can arrive on FOR THIS MODEL'S OWN CREATE ROUTE, which is what makes
609
+ // GATE 0's guarantee checkable rather than merely asserted. It is not a
610
+ // statement about the record's reachability in general -- a relationship
611
+ // write on another collection reaches it without ever touching this
612
+ // handler (abofs/stonyx-orm#207). INHERITED from `dev`, which carries this
545
613
  // loop verbatim; the general form -- the loop accepts any key, not just
546
614
  // `id`, so a body key that is not a declared relationship is still
547
615
  // mass-assigned -- is abofs/stonyx-orm#204.
@@ -565,13 +633,13 @@ export default class OrmRequest extends Request {
565
633
  // mutates the existing OrmRecord IN PLACE, so `store.get(...) === record`
566
634
  // is true for a record the request did not create. The map's size is the
567
635
  // only O(1) signal that distinguishes an insert from an overwrite.
568
- const slotsBefore = (store.get(model) as Map<string | number, unknown> | undefined)?.size ?? 0;
636
+ const slotsBefore = store.get(model)?.size ?? 0;
569
637
 
570
638
  const created = createRecord(model, recordAttributes as { [key: string]: unknown }, { serialize: false, _skipAutoPersist: true });
571
639
  const record = isOrmRecord(created) ? created : null;
572
640
  if (!record) return 500;
573
641
 
574
- const createdNewSlot = ((store.get(model) as Map<string | number, unknown> | undefined)?.size ?? 0) > slotsBefore;
642
+ const createdNewSlot = (store.get(model)?.size ?? 0) > slotsBefore;
575
643
 
576
644
  // 403 here, NOT 404. The oracle argument does not apply to create: there
577
645
  // is no pre-existing record whose existence could leak, the caller
@@ -617,9 +685,11 @@ export default class OrmRequest extends Request {
617
685
  // silently re-armed when that code moves.
618
686
  // SO IT BECOMES REACHABLE IF AN `await` IS
619
687
  // INTRODUCED HERE, which is the change a future
620
- // editor would actually make. See the
621
- // guards-redundant-by-construction table in
622
- // docs/project-structure.md.
688
+ // editor would actually make. Stated here rather
689
+ // than by reference: `docs/` is not in `files`, so
690
+ // a pointer into it resolves to nothing for anyone
691
+ // who installed this package. README carries the
692
+ // consumer-facing half.
623
693
  if (createdNewSlot && store.get(model, record.id as string | number) === record) {
624
694
  store.remove(model, record.id as string | number, { _skipAutoPersist: true });
625
695
  }
@@ -693,7 +763,12 @@ export default class OrmRequest extends Request {
693
763
  };
694
764
 
695
765
  const deleteHandler: HandlerFn = async ({ params }, { filter }) => {
696
- const record = await store.find(model, getId(params));
766
+ // Coerced ONCE. `getId(params)` was evaluated twice here -- once to find
767
+ // the record and once to remove it -- and a coercion evaluated repeatedly
768
+ // is a coercion that can be edited in one place and not the other, which
769
+ // is the defect `coerceId` exists to prevent.
770
+ const recordId = getId(params);
771
+ const record = await store.find(model, recordId) as OrmRecord | undefined;
697
772
 
698
773
  // BEHAVIOUR CHANGE (#190): a DELETE of a record that never existed
699
774
  // returned 204 before this change. It now returns 404, matching the
@@ -708,7 +783,10 @@ export default class OrmRequest extends Request {
708
783
  // turns a 404 into a destroyed record.
709
784
  if (isDenied(filter, record)) return 404;
710
785
 
711
- store.remove(model, getId(params), { _skipAutoPersist: true });
786
+ // Removed by the id of the record actually fetched, not by re-deriving it
787
+ // from the params a second time: the record the filter tested and the
788
+ // record removed are then provably the same one.
789
+ store.remove(model, record.id as string | number, { _skipAutoPersist: true });
712
790
  return 204;
713
791
  };
714
792
 
@@ -766,7 +844,35 @@ export default class OrmRequest extends Request {
766
844
  // ===========================================================================
767
845
  private _withHooks(operation: string, handler: HandlerFn): HandlerFn {
768
846
  return async (request: OrmRequest$, state: { [key: string]: unknown }) => {
769
- const { filter } = (state || {}) as { filter?: unknown };
847
+ // `|| {}` so this function behaves like the relationship routes below,
848
+ // which declare `state` with a `= {}` default. It is unkillable through
849
+ // the rest-server dispatcher, which always passes `getState(req)`; it is
850
+ // listed as such in the guards-redundant-by-construction table rather
851
+ // than left silently unkillable, and it defends the WHOLE function (the
852
+ // context, the snapshot and the handler call all read `callState`) rather
853
+ // than one destructure that the next line would throw past anyway.
854
+ const callState = (state || {}) as { [key: string]: unknown };
855
+
856
+ // ---------------------------------------------------------------------
857
+ // THE AUTHORIZATION SNAPSHOT. Read ONCE, here, before any consumer code
858
+ // can run.
859
+ //
860
+ // `callState` is the object `auth()` planted the filter in, and it is
861
+ // also handed to every before-hook as `context.state` -- a published,
862
+ // WRITABLE extension point. So `state.filter` is an INPUT to the
863
+ // authorization decision, not only an output channel, and re-reading it
864
+ // after the hook loop lets a consumer hook disarm the filter:
865
+ //
866
+ // beforeHook('get', 'animal', ctx => { delete ctx.state.filter })
867
+ // -> GET /animals/21 turned 404 into 200
868
+ // -> GET /animals turned 20 records into 22
869
+ //
870
+ // GATE 1 already used this snapshot, so writes held; the READ handlers
871
+ // re-destructured `filter` from the live bag and did not. Everything
872
+ // downstream now reads `filter` from here, and the handler is handed
873
+ // `handlerState` below -- never `callState`.
874
+ // ---------------------------------------------------------------------
875
+ const { filter } = callState as { filter?: unknown };
770
876
 
771
877
  // Build context object for hooks
772
878
  const context: HookContext = {
@@ -776,7 +882,11 @@ export default class OrmRequest extends Request {
776
882
  params: request.params,
777
883
  body: request.body,
778
884
  query: request.query,
779
- state,
885
+ // Deliberately the LIVE object: `redirect` and `pipe` are read back off
886
+ // it by @stonyx/rest-server after the handler returns, so hooks must be
887
+ // able to write to it. What must not happen is the authorization
888
+ // decision reading it back, which is what the snapshot above prevents.
889
+ state: callState,
780
890
  };
781
891
 
782
892
  // Capture old state for operations that modify data
@@ -822,7 +932,14 @@ export default class OrmRequest extends Request {
822
932
  }
823
933
 
824
934
  // Execute main handler
825
- const response = await handler(request, state);
935
+ // The handler receives the SNAPSHOT, never the live bag. `filter` is
936
+ // assigned LAST so it wins over anything a before-hook wrote to
937
+ // `callState.filter` -- including a `delete`, which the spread would
938
+ // otherwise carry through as an absent key. Every other key a hook adds
939
+ // is still visible to the handler; only the authorization input is
940
+ // pinned.
941
+ const handlerState = { ...callState, filter };
942
+ const response = await handler(request, handlerState);
826
943
 
827
944
  // Set context.record for update BEFORE persist so SQL drivers can read it
828
945
  if (operation === 'update' && (response as JsonApiResponse)?.data) {
@@ -836,6 +953,16 @@ export default class OrmRequest extends Request {
836
953
  // authorization ones: a 400 (POST with no `type`) and a 409 (duplicate id)
837
954
  // are equally requests in which nothing happened, and a persist or a
838
955
  // cascade hook for one of them is just as wrong.
956
+ // `Number.isInteger` is a TYPE guard, not a behaviour guard, and it is
957
+ // unkillable TODAY: the only non-integer a handler in this file can
958
+ // return is a `{ data, links }` object, and `{} >= 400` is `false` by JS
959
+ // coercion, so dropping it changes no reachable outcome. It is kept
960
+ // because `>=` coerces rather than rejects, and the shapes it coerces
961
+ // are not obvious -- `[500] >= 400` is TRUE, so a handler that one day
962
+ // returned an array would have every response read as a denial. Listed
963
+ // as an equivalent mutant rather than left to read as coverage; it
964
+ // becomes killable the moment a handler returns anything array-like or
965
+ // numeric-string-like.
839
966
  const denied = Number.isInteger(response) && (response as number) >= 400;
840
967
 
841
968
  // EXECUTOR 1 -- SQL persistence, for all write operations.
@@ -864,8 +991,16 @@ export default class OrmRequest extends Request {
864
991
  } else if (operation === 'create' && (response as JsonApiResponse)?.data && ((response as { data: { id?: unknown } }).data.id)) {
865
992
  // For create, get the record from store using the ID from the response
866
993
  const responseData = (response as { data: { id: string | number } }).data;
867
- const recordId = isNaN(responseData.id as unknown as number) ? responseData.id : parseInt(responseData.id as string);
868
- context.record = store.get(this.model, recordId);
994
+ // `normalizeBodyId`, not a copy of its body. This line WAS
995
+ // `isNaN(id) ? id : parseInt(id)` -- `coerceId` inlined verbatim, a
996
+ // third coercion feeding a store lookup, sitting under a docblock that
997
+ // said neither surface had a copy. Equivalent on every input that can
998
+ // reach this truthy-guarded branch (`21`->`21`, `'21'`->`21`,
999
+ // `'angela'`->`'angela'`; `''` and `0` cannot reach it), so this is a
1000
+ // de-duplication rather than a behaviour change -- and that is the
1001
+ // point: the two that disagreed were equivalent on every input anyone
1002
+ // checked, too.
1003
+ context.record = store.get(this.model, normalizeBodyId(responseData.id) as string | number);
869
1004
  } else if (operation === 'delete') {
870
1005
  // For delete, the record may no longer exist, but we have oldState
871
1006
  context.recordId = getId(request.params);
@@ -1013,7 +1148,12 @@ export default class OrmRequest extends Request {
1013
1148
  let access: AccessMethod;
1014
1149
  try {
1015
1150
  access = this.access(request);
1016
- } catch {
1151
+ } catch (error) {
1152
+ // Same reasoning as `isDenied`: fail closed, but say so. An `access()`
1153
+ // that throws denies EVERY request to the collection, and a silent 403
1154
+ // wall is the hardest possible thing to diagnose from the outside.
1155
+ log.error?.(`[@stonyx/orm] access() threw for model "${this.model}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
1156
+
1017
1157
  return 403; // Forbidden
1018
1158
  }
1019
1159