@stonyx/orm 0.3.2-beta.145 → 0.3.2-beta.147

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
@@ -309,19 +309,343 @@ import setupRestServer from '@stonyx/orm/setup-rest-server';
309
309
  await setupRestServer('/', './access');
310
310
  ```
311
311
 
312
- Access classes define models and provide custom filtering/authorization logic:
312
+ Access classes define models and provide custom filtering/authorization logic.
313
+
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
+ >
322
+ > That is still a stopgap. **The real fix is
323
+ > [#202](https://github.com/abofs/stonyx-orm/issues/202)** — `access()` should
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.
313
335
 
314
336
  ```js
315
337
  export default class GlobalAccess {
316
338
  models = ['owner', 'animal'];
317
339
 
318
340
  access(request) {
319
- if (request.url.endsWith('/owner/angela')) return false;
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';
369
+ }
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
320
379
  return ['read', 'create', 'update', 'delete'];
321
380
  }
322
381
  }
323
382
  ```
324
383
 
384
+ ### Return values
385
+
386
+ | `access()` returns | Effect |
387
+ |---|---|
388
+ | `false` (or any falsy value) | `403` for the whole request |
389
+ | `true` | full access, no filter |
390
+ | `['read', 'create', 'update', 'delete']` | the listed operations only; anything else is `403` |
391
+ | `'read'` (a bare string) | **one** permission, equivalent to `['read']` |
392
+ | a function | a per-record filter — see below |
393
+ | anything else | `403` — unknown shapes fail **closed** |
394
+
395
+ A `throw` inside `access()` is a **denial**, not a 500.
396
+
397
+ ### Filter functions
398
+
399
+ A function return value is a **per-record predicate**, and it is enforced on
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).
407
+
408
+ | Endpoint | A record the predicate rejects |
409
+ |---|---|
410
+ | `GET /:models` | omitted from the collection |
411
+ | `GET /:models/:id` | `404` |
412
+ | `GET /:models/:id/{relationship}` | `404` — the **addressed** record is filtered, not the related one |
413
+ | `GET /:models/:id/relationships/{relationship}` | `404` — same |
414
+ | `PATCH /:models/:id` | `404`, no attribute is applied |
415
+ | `DELETE /:models/:id` | `404`, the record is not removed and no SQL `DELETE` is issued |
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 |
417
+
418
+ **Denied record-level requests return 404, not 403.** This is deliberate and it
419
+ is the property most easily "improved" away. 403 would confirm that the record
420
+ exists to a caller who is not allowed to know that, which turns the filter into
421
+ an existence oracle: `404` means "no such record", `403` means "there is one and
422
+ it is not yours". Every status on a record route must therefore be identical for
423
+ "filtered out" and "does not exist" — including `DELETE`, which is why deleting
424
+ a record that never existed also returns 404 rather than 204.
425
+
426
+ `POST` is the one exception and returns **403**, because 404 on a mounted
427
+ collection route is indistinguishable from "model not mounted" — a genuinely
428
+ different failure a developer needs to diagnose.
429
+
430
+ **A client-supplied `id` on `POST` is refused with `403` whenever a function
431
+ filter is in force.** This is the part that keeps `POST` from being an
432
+ enumeration oracle, and it is worth understanding rather than working around.
433
+ The duplicate-id check has to run before the filter, and it sees records the
434
+ filter hides, so the *status* of a `POST` otherwise leaks whether an id is
435
+ taken:
436
+
437
+ | `POST /animals` with a payload the caller may create | before | now |
438
+ |---|---|---|
439
+ | an id held by a record the filter **hides** | `403` | `403` |
440
+ | an id that is **free** | `200` | `403` |
441
+ | an id held by a record the caller **can see** | `409` | `403` |
442
+
443
+ Three outcomes, one request per id, the whole id space. Filtering only the
444
+ *collision* status narrows that to callers who cannot create a record they are
445
+ allowed to see; it does not close it. It cannot be closed while a caller both
446
+ chooses the id and learns whether the create succeeded — so under a filter the
447
+ caller does not choose the id. The refusal happens before any store lookup, so
448
+ neither the status nor the response time depends on whether the id exists.
449
+
450
+ Let the server assign the id and read it back from the response. Callers with no
451
+ function-style filter are unaffected: `409` on a duplicate id and `200` on a free
452
+ one both behave exactly as before.
453
+
454
+ ### Identifying the collection
455
+
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:
459
+
460
+ | # | Variant | Why it fails open |
461
+ |---|---|---|
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.
500
+
501
+ ### Known limitations
502
+
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).
529
+ - **Related and included records are not filtered.** The predicate is evaluated
530
+ against the record the route is *addressed to*. `GET /animals/1/owner`,
531
+ `GET /animals/1/relationships/owner` and `?include=owner` all serialize the
532
+ related record without resolving that model's own access class, so a filter on
533
+ `/owners` does not hide an owner reached through `/animals`. Tracked as
534
+ [#196](https://github.com/abofs/stonyx-orm/issues/196), which covers
535
+ `include=`, related-resource routes and relationship-linkage routes.
536
+ - **A before-hook that returns a value short-circuits the request.** On write
537
+ operations addressed to a record the filter is consulted first, so a hook
538
+ cannot answer for a record the caller may not see. On reads it is not, so a
539
+ `beforeHook('get', ...)` read-through cache can answer past the filter.
540
+ - **`beforeHook('create', ...)` still runs for a `POST` that is denied.** For
541
+ `create` there is no record to test until the handler has built one, so the
542
+ denial is not knowable in time. Every *after*-hook is gated, and every
543
+ before-hook on `update` and `delete` is gated; before-`create` is the one
544
+ exception. A before-`create` hook must not assume the create will succeed.
545
+ - **A caller can still learn that a collection *has* a per-record filter**, by
546
+ observing `403` rather than `409`/`200` for an id-bearing `POST`. That
547
+ discloses a configuration fact, not the existence of any record.
548
+ - **Enforcement is post-fetch.** The record is loaded and then tested, which
549
+ leaves a small timing difference between a hidden record and one that never
550
+ existed. Tracked as [#197](https://github.com/abofs/stonyx-orm/issues/197).
551
+ - **A `relationships` key that is not a declared relationship is still applied
552
+ to the record.** The key comes verbatim from the request body and is checked
553
+ against nothing except `id`, which is stripped. On a `POST` that makes an
554
+ undeclared key a mass-assigned attribute; on a `PATCH` it is passed to
555
+ `updateRecord`. `id` is stripped in both handlers because it defeats breaking
556
+ change 3 above; the general form is tracked as
557
+ [#204](https://github.com/abofs/stonyx-orm/issues/204).
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
568
+ [#205](https://github.com/abofs/stonyx-orm/issues/205), alongside
569
+ [#203](https://github.com/abofs/stonyx-orm/issues/203), which is the other way
570
+ a create can land on an id nobody named.
571
+ - **`context.record` is `undefined` for an after-`create` hook when a string-id
572
+ model is given a numeric-looking id.** The post-create lookup uses the same id
573
+ coercion as every other surface, which resolves `'9107'` to the number `9107`,
574
+ while a model declaring `id = attr('string')` files the record under the string
575
+ key. The create itself succeeds and `context.response.data` is correct; only
576
+ the hook's view of the record is wrong, and it is wrong *silently*. Tracked as
577
+ [#209](https://github.com/abofs/stonyx-orm/issues/209).
578
+ - **A denied `POST` rolls back only a record it *inserted*.** The rollback
579
+ requires the store to have grown, because removing by id alone is a write
580
+ primitive keyed by a caller-supplied value. When `assignRecordId` lands a
581
+ **server-assigned** id on an occupied slot
582
+ ([#203](https://github.com/abofs/stonyx-orm/issues/203) — it returns
583
+ last-*inserted* + 1, not max + 1, so a store whose insertion order is not
584
+ ascending collides), `createRecord` updates that record **in place**: the map
585
+ does not grow, the rollback correctly declines to remove a record this request
586
+ did not create, and the `403` leaves the caller's attributes on someone else's
587
+ record. Narrow — it needs a non-ascending insertion order — but it is the
588
+ reachability condition, so it is stated rather than implied.
589
+
590
+ ### Breaking changes
591
+
592
+ These land in the next published build. There is no changelog or release-notes channel yet
593
+ ([stonyx-workflows#17](https://github.com/abofs/stonyx-workflows/issues/17)), so
594
+ they are recorded here.
595
+
596
+ 1. **`DELETE /{collection}/{id}` on a record that does not exist returns `404`
597
+ instead of `204`.** This affects **every** consumer issuing a DELETE against
598
+ any mounted collection, whether or not an access filter is configured, and
599
+ `models: '*'` mounts every model by default. It is not optional: if a denied
600
+ delete returned 404 while a missing one returned 204, the pair would be a
601
+ perfect existence oracle and the filter would be worthless.
602
+ 2. **After-hooks no longer fire for a request that failed** — denied, missing,
603
+ `400` or `409`. The gate is on the handler's status, not on the operation, so
604
+ it covers reads as well: a `GET /:id` that answers 404 runs no after-`get`
605
+ hook either. Previously `afterHook('delete', ...)` ran with a populated
606
+ `context.recordId` on a request that deleted nothing, so a consumer cascade
607
+ destroyed children behind a 404.
608
+ 3. **`POST` with a client-supplied `id` returns `403` when a function-style
609
+ `access` filter is in force**, whatever the payload and whether or not the id
610
+ exists, and *before* any store lookup — so neither the status nor the lookup
611
+ cost can depend on whether that id exists. Only affects function-style
612
+ `access` users. See [Filter functions](#filter-functions) for why, and let the
613
+ server assign the id instead. `409`-on-duplicate is unchanged for everyone
614
+ else.
615
+
616
+ "Whatever the payload" is a statement about the **`id` member of the resource
617
+ object**, and it holds only because that is the sole channel a caller id can
618
+ arrive on. It was not always: a caller id moved into
619
+ `relationships: {"id": {"data": {"id": 21}}}` reached `createRecord` past this
620
+ refusal and overwrote a hidden record in place. Both strips — `attributes.id`
621
+ and `relationships.id` — are part of this behaviour, not tidiness. A future
622
+ change that adds a third channel without stripping it re-opens the oracle;
623
+ see [#204](https://github.com/abofs/stonyx-orm/issues/204).
624
+ 4. **Function-style `access` is now enforced on all seven surfaces.** Records
625
+ previously reachable by id despite being filtered from the collection now
626
+ return 404. Only affects function-style `access` users, for whom the old
627
+ behaviour was the bypass.
628
+
629
+ "Seven surfaces" means the seven endpoints of **the filtered model**. A write
630
+ to another collection can still reach one of its records through a
631
+ relationship — [#207](https://github.com/abofs/stonyx-orm/issues/207), which
632
+ is **not** closed here.
633
+ 5. **A predicate that throws is treated as a denial** rather than propagating to
634
+ Express's default 500 handler. So is an `access()` that throws.
635
+ 6. **`access()` returning a bare string is one permission, not full access.**
636
+ `AccessMethod` declares `string` legal, and it previously fell through every
637
+ branch and granted all four operations — `return 'read'` allowed `DELETE`.
638
+ It is now equivalent to `['read']`. Any other unrecognised shape (an object,
639
+ a number) now returns `403` rather than granting full access.
640
+ 7. **`POST` with a client-supplied `id` normalises the body id before the
641
+ duplicate check**, so an id shape that previously *missed* the store's key
642
+ now finds it. `POST {"id":"0x2391"}` and `POST {"id":" 21 "}` answer `409`
643
+ where they answered `200`, and the `200` was not a success: the lookup missed,
644
+ the duplicate check was skipped, and `createRecord` overwrote the colliding
645
+ record in place. **This one reaches consumers with no filter at all** — the
646
+ population breaking changes 3 and 4 explicitly exempt. If you were relying on
647
+ a hex-shaped or whitespace-padded id creating a second record, it never did.
648
+
325
649
  ### Include Parameter (Sideloading Relationships)
326
650
 
327
651
  The ORM supports JSON API-compliant relationship sideloading via the `include` query parameter. This reduces the need for multiple API requests by embedding related records in a single response.
@@ -595,7 +919,19 @@ afterHook('delete', 'animal', async (context) => {
595
919
  // Additional access control - halt with 403 if unauthorized
596
920
  beforeHook('delete', 'animal', (context) => {
597
921
  const user = context.state.currentUser;
598
- const animal = store.get('animal', context.params.id);
922
+
923
+ // `context.oldState` — NOT a store lookup. For `update` and `delete` the ORM
924
+ // has already fetched the record (and already applied the access filter to
925
+ // it) before this hook runs, so re-fetching it here is a fourth id coercion
926
+ // that has to agree with three others.
927
+ //
928
+ // And it would not agree. `context.params.id` is the raw url segment, always
929
+ // a string, while the store keys numeric-id models by NUMBER — so
930
+ // `store.get('animal', '21')` misses the record held under `21`, and so does
931
+ // `store.find('animal', '21')`: neither coerces. The miss reads as "no such
932
+ // record", which in an authorization hook fails whichever way your code
933
+ // happens to handle a null.
934
+ const animal = context.oldState;
599
935
 
600
936
  if (animal.owner !== user.id && !user.isAdmin) {
601
937
  return 403; // Forbidden
@@ -603,6 +939,10 @@ beforeHook('delete', 'animal', (context) => {
603
939
  });
604
940
  ```
605
941
 
942
+ > If you do need a lookup for some *other* model inside a hook, coerce the id
943
+ > yourself to the type that model's `id` attribute declares — the store is a
944
+ > `Map` and `'21'` and `21` are different keys.
945
+
606
946
  #### Auditing
607
947
 
608
948
  ```javascript
@@ -736,11 +1076,29 @@ beforeHook('create', 'post', (context) => {
736
1076
 
737
1077
  ### Hook Execution Order
738
1078
 
739
- 1. **Before hooks** fire first (sequentially, in registration order)
740
- 2. **Main operation** executes (if no before hook halted)
741
- 3. **After hooks** fire last (sequentially, in registration order)
742
-
743
- Before hooks can halt the operation by returning a value. After hooks run after completion and cannot halt.
1079
+ 1. **Authorization is evaluated first for `update` and `delete`.** A record the
1080
+ access filter rejects returns `404` **before any before-hook runs**, so a
1081
+ hook never sees a record or a `context.oldState` — that the caller is not
1082
+ allowed to read. `create` is the exception: there is no record to test until
1083
+ the handler has built one, so `beforeHook('create', ...)` **does** fire for a
1084
+ `POST` that goes on to answer `403`.
1085
+ 2. **Before hooks** fire next (sequentially, in registration order).
1086
+ 3. **Main operation** executes (if no before hook halted).
1087
+ 4. **After hooks** fire last (sequentially, in registration order) — **only if
1088
+ the request succeeded.**
1089
+
1090
+ Before hooks can halt the operation by returning a value, and that value becomes
1091
+ the response.
1092
+
1093
+ **After hooks do not run for a failed request.** Any status `>= 400` — denied,
1094
+ missing, `400`, `409` — skips the entire after-hook pipeline, along with SQL
1095
+ persistence and `onUpdate` autosave. This is a behaviour change; see
1096
+ [Breaking changes](#breaking-changes). It applies to the samples above: the
1097
+ `afterHook('delete', ...)` auditing hook writes **no** audit row for a `DELETE`
1098
+ that answered `404`, and the `afterHook('update', ...)` change-tracking hook
1099
+ writes none for a `PATCH` that answered `404` or `400`. If you need a record of
1100
+ refused requests, log them from a before-hook or from your own middleware —
1101
+ `after<operation>` fires only for an operation that actually happened.
744
1102
 
745
1103
  ### Best Practices
746
1104
 
@@ -1,3 +1,63 @@
1
+ /**
2
+ * REST request handling and access enforcement for @stonyx/orm.
3
+ *
4
+ * ---------------------------------------------------------------------------
5
+ * DO NOT RECONSTRUCT THE REQUEST PATH INSIDE `access()`.
6
+ * ---------------------------------------------------------------------------
7
+ * `auth()` below hands your `access(request)` a raw transport artifact and asks
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:
12
+ *
13
+ * 1. `request.url` is mount-relative under `RestServer.mountRoute`, so a
14
+ * prefix match against it is ALWAYS false.
15
+ * 2. `request.originalUrl` carries the query string, so an anchored equality
16
+ * check misses `/owners?filter[age]=30`.
17
+ * 3. The router is a bare `express()` (`caseSensitive: false`) while a
18
+ * hand-written matcher is case-SENSITIVE, so `GET /OwNeRs/angela` walks
19
+ * past it. Router-side: abofs/stonyx-rest-server#47.
20
+ * 4. Under a configured `ORM_REST_ROUTE` a hard-coded `/owners` matches
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.
29
+ *
30
+ * The fix is not a sixth rule. It is to stop parsing:
31
+ *
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.
49
+ *
50
+ * The enforcement gates in this file (GATE 0/1/2, the create rollback, the
51
+ * per-handler `isDenied` re-checks) are correct independently of that -- they
52
+ * enforce whatever predicate you return. The stopgap is the part where YOU have
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.
60
+ */
1
61
  import { Request } from '@stonyx/rest-server';
2
62
  interface OrmRequest$ extends Request {
3
63
  protocol?: string;