@stonyx/orm 0.3.2-alpha.62 → 0.3.2-alpha.63

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
@@ -315,24 +315,15 @@ Access classes define models and provide custom filtering/authorization logic.
315
315
  > [Identifying the collection](#identifying-the-collection) before copying this.**
316
316
  > Every attempt to identify the collection by parsing the request target has
317
317
  > failed **open** — five distinct variants of this same example, each found only
318
- > after the previous was fixed, by five different people. That section is now a
319
- > record of what not to do, not a matching recipe: the sample below reads `model`
320
- > from [the access context](#the-access-context-second-argument) and never looks
321
- > at the mount at all, so all five variants are **unconstructible** against it
322
- > rather than merely handled.
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.
323
321
  >
324
322
  > That is still a stopgap. **The real fix is
325
323
  > [#202](https://github.com/abofs/stonyx-orm/issues/202)** — `access()` should
326
324
  > receive the model, the operation and the record, so there is nothing to
327
325
  > identify. Until it lands, prefer the array shape (`['read']`) or `false` where
328
326
  > you can: the **function** shape is the one that requires any matching at all.
329
- >
330
- > The one read of argument **one** that survives is `request.path`, for the
331
- > `/archived` sub-path deny — and it has to. The context names which model and
332
- > which verb, not which route, so that deny **cannot be expressed from the
333
- > context alone** and a context-only rewrite would silently turn it into an
334
- > allow.
335
- >
336
327
  > The same warning is repeated at the top of `src/orm-request.ts`, which ships;
337
328
  > the longer write-up in `docs/usage-patterns.md` does **not** ship, so this
338
329
  > README and that source header are the two copies a consumer sees.
@@ -346,39 +337,27 @@ Access classes define models and provide custom filtering/authorization logic.
346
337
  export default class GlobalAccess {
347
338
  models = ['owner', 'animal'];
348
339
 
349
- access(request, { model, operation }) {
350
- // `model` is the model this route was mounted for. It is assigned once, at
351
- // mount time, and no request can influence it — not a mount prefix, not a
352
- // query string, not a case-varied path, not an absolute-form request
353
- // target. Nothing below parses anything, so none of the five fail-open
354
- // variants recorded in the header is constructible against this predicate
355
- // any more; they are history, not rules to follow.
356
- //
357
- // `operation` is destructured to name the whole contract at the point of
358
- // use. This sample's rules are per-model and per-sub-path rather than
359
- // per-verb, so it does not branch on it; the permission array at the bottom
360
- // is where the verb is answered.
361
-
362
- // FAIL CLOSED. `model` is absent for any caller that resolved this
363
- // predicate without supplying the context, and a request this function
364
- // cannot identify DENIES rather than falling through to the CRUD grant at
365
- // the bottom. An unidentifiable input must never be the permissive path.
366
- if (typeof model !== 'string' || model === '') return false;
367
-
368
- if (model === 'owner') {
369
- // The context names WHICH MODEL and WHICH VERB — not which route. Six
370
- // distinct owner surfaces produce one identical context, so a rule that
371
- // depends on the SUB-PATH still needs argument one. `request.path` is
372
- // mount-relative and query-free, and it is the one read of the raw
373
- // request the README sanctions. Lower-cased because the router matched
374
- // case-insensitively, so a case-sensitive rule here would be stricter
375
- // than the router and could be stepped around. false → 403 for the whole
376
- // request.
377
- //
378
- // THIS DENY CANNOT BE EXPRESSED FROM THE CONTEXT ALONE. Migrating it away
379
- // does not remove a rule, it turns a deny into an ALLOW, silently.
380
- const path = String(request.path ?? '').toLowerCase();
340
+ access(request) {
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();
381
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')) {
382
361
  if (path === '/archived' || path.startsWith('/archived/')) return false;
383
362
 
384
363
  // Returning a function plugs it in as a per-record filter, and it is
@@ -394,7 +373,7 @@ export default class GlobalAccess {
394
373
  // inert. Deliberately NO `?? record.owner` fallback: accepting the raw
395
374
  // shape as well as the resolved one would absorb a resolution regression
396
375
  // silently, which is exactly what blinded this fixture before.
397
- if (model === 'animal') return record => record.owner?.id !== 'restricted';
376
+ if (collection.endsWith('/animals')) return record => record.owner?.id !== 'restricted';
398
377
 
399
378
  // Allows full access to all calls that don't match any of the above conditions
400
379
  return ['read', 'create', 'update', 'delete'];
@@ -544,10 +523,9 @@ sample, `getAccess('owner') === getAccess('animal')`.
544
523
  #### Passing the context makes a model-correct answer *possible*
545
524
 
546
525
  It does not make the answer model-correct on its own. **The resolved predicate
547
- has to read the context.** Against a predicate that ignores it the failure is
548
- measurable. On a request Express dispatched to `GET /owners/angela`, asked about
549
- **animals**, the sample as it shipped before
550
- [#222](https://github.com/abofs/stonyx-orm/issues/222) answered:
526
+ has to read the context.** Measured against the access class shipped with this
527
+ repo, on a request Express dispatched to `GET /owners/angela`, asked about
528
+ **animals**:
551
529
 
552
530
  ```
553
531
  getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
@@ -555,26 +533,18 @@ getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
555
533
  ```
556
534
 
557
535
  That is the **owners** filter, and it returns `true` for animal 21 — the record
558
- hidden on every animal surface. Under a mount such a predicate recognizes
559
- neither way it is worse still: it falls through to
560
- `['read', 'create', 'update', 'delete']`, a full CRUD grant. Either way the
561
- context was supplied and the answer is not the animal answer, and it is wrong in
562
- the direction that **grants** because that predicate was single-argument and
563
- identified its collection from the request, so it answered about the collection
564
- the request was *addressed to* while being asked about another one.
565
-
566
- The sample shipped with this repo has since been migrated to read the context,
567
- and the same call now answers with the **animal** filter:
568
-
569
- ```
570
- getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
571
- -> record => record.owner?.id !== 'restricted'
572
- ```
573
-
574
- A single-argument predicate remains the default in every consumer tree, and a
575
- caller has no supported way to tell which kind it resolved. The boot-time arity
576
- warning that surfaces one is
577
- [#221](https://github.com/abofs/stonyx-orm/issues/221).
536
+ hidden on every animal surface. Under a mount that predicate recognizes neither
537
+ way it is worse still: it falls through to
538
+ `['read', 'create', 'update', 'delete']`, a full CRUD grant.
539
+
540
+ Either way the context was supplied and the answer is not the animal answer, and
541
+ it is wrong in the direction that **grants**. That predicate is single-argument
542
+ and identifies its collection from the request, so it answered about the
543
+ collection the request is *addressed to* while being asked about another one.
544
+ Every predicate in this repo, and in every consumer tree, is single-argument on
545
+ the day this ships, and a caller has no supported way to tell which kind it
546
+ resolved. The boot-time arity warning that would surface it is
547
+ [#213](https://github.com/abofs/stonyx-orm/issues/213).
578
548
 
579
549
  So: pass the context, and do not treat a resolved predicate's answer as
580
550
  model-specific until that predicate has been migrated to read it.
@@ -611,7 +581,7 @@ a write to a *different* collection can still re-parent one. See
611
581
  | `GET /:models/:id/relationships/{relationship}` | `404` — same |
612
582
  | `PATCH /:models/:id` | `404`, no attribute is applied |
613
583
  | `DELETE /:models/:id` | `404`, the record is not removed and no SQL `DELETE` is issued |
614
- | `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 |
584
+ | `POST /:models` | `403`, and a record **this request inserted** is rolled back — see [Known limitations](#known-limitations) for why the rollback is conditional |
615
585
 
616
586
  **Denied record-level requests return 404, not 403.** This is deliberate and it
617
587
  is the property most easily "improved" away. 403 would confirm that the record
@@ -645,23 +615,35 @@ chooses the id and learns whether the create succeeded — so under a filter the
645
615
  caller does not choose the id. The refusal happens before any store lookup, so
646
616
  neither the status nor the response time depends on whether the id exists.
647
617
 
648
- Let the server assign the id and read it back from the response. Callers with no
649
- function-style filter are unaffected: `409` on a duplicate id and `200` on a free
650
- one both behave exactly as before.
618
+ Let the server assign the id and read it back from the response and read it
619
+ back rather than predicting it, because the value it returns is documented but
620
+ not stable across model kinds: a numeric-id model gets `max + 1` (or, at the
621
+ numeric ceiling, the lowest free integer), and a string-id model gets
622
+ `<model>-<n>`. See breaking change 8.
623
+
624
+ **What a server-assigned id is not.** It is not a secret. On a string-id
625
+ collection it is dense and enumerable from `1`, where previously it inherited
626
+ whatever entropy the last-inserted id happened to carry — a UUID-seeded store
627
+ answered a UUID-derived key. If a collection has **no** `access` config its
628
+ record-level routes are ungated, so the id was the only thing standing between
629
+ an unauthenticated caller and `GET`/`PATCH`/`DELETE` on a record. That was never
630
+ a control and must not become one; configure `access`.
631
+
632
+ **And the id itself is an occupancy signal.** `assignRecordId` reads the whole
633
+ store, not the caller's filtered view — it never sees `state.filter` — so the id
634
+ it returns is a function of records the caller may not be permitted to read. On
635
+ a string-id collection the assigned `n` is the smallest positive integer whose
636
+ key is free, which tells the caller that every key below it is taken, hidden or
637
+ not. Closing that requires the assignment to be filter-aware, which is a change
638
+ to the `access` contract rather than a fix; it is stated here rather than left
639
+ to be discovered. Callers with no function-style filter are unaffected — there
640
+ are no hidden records to disclose.
651
641
 
652
642
  ### Identifying the collection
653
643
 
654
- **Do not reconstruct the request path and since
655
- [#202](https://github.com/abofs/stonyx-orm/issues/202) you do not have to
656
- identify the collection at all.** Read `model` from
657
- [the access context](#the-access-context-second-argument): it is fixed at mount
658
- time, no request can influence it, and there is nothing left to parse.
659
-
660
- **Everything below is the record of what happened when this sample did parse
661
- it.** It is kept as history, not as a recipe — none of these matching strategies
662
- should be written into a new predicate. Every version of this sample that tried
663
- to identify the collection from the request target failed **open**, and each
664
- variant was found only after the previous one was fixed:
644
+ **Do not reconstruct the request path.** Every version of this sample that tried
645
+ to has failed **open**, and each variant was found only after the previous one
646
+ was fixed:
665
647
 
666
648
  | # | Variant | Why it fails open |
667
649
  |---|---|---|
@@ -671,19 +653,13 @@ variant was found only after the previous one was fixed:
671
653
  | 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. |
672
654
  | 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. |
673
655
 
674
- **The fix is not a sixth rule, and it is not a better string to match.** It is
675
- to stop identifying the collection at all.
656
+ **The fix is not a sixth rule.** It is to stop parsing:
676
657
 
677
- An intermediate revision read **`request.baseUrl`** the mount Express
678
- *actually matched*. That closed all five variants: it carries no query string
679
- (variant 2), it is not mount-relative (variant 1), it already contains the
680
- configured `ORM_REST_ROUTE` prefix (variant 4 — there is nothing left to derive,
681
- so `/apiowners` is unconstructible), and it is unaffected by an absolute-form
682
- target (variant 5). It was still a transport artifact standing in for a
683
- structural fact, and it is **no longer what the sample does**: the sample reads
684
- `model`, so all five variants are unconstructible against it rather than
685
- handled. The table below is retained as the measured evidence behind the five
686
- variants, not because any of these values should be matched on:
658
+ **Use `request.baseUrl`.** It is the mount Express *actually matched* when it
659
+ dispatched the request. It carries no query string (variant 2), it is not
660
+ mount-relative (variant 1), it already contains the configured `ORM_REST_ROUTE`
661
+ prefix (variant 4 — there is nothing left to derive, so `/apiowners` is
662
+ unconstructible), and it is unaffected by an absolute-form target (variant 5).
687
663
 
688
664
  | request | `request.url` | `request.originalUrl` | `request.baseUrl` | `request.path` |
689
665
  |---|---|---|---|---|
@@ -694,23 +670,21 @@ variants, not because any of these values should be matched on:
694
670
  | `GET http://anything.example/owners/angela` | `http://anything.example/angela` | `http://anything.example/owners/angela` | `/owners` | `/angela` |
695
671
  | `GET /api/animals/22` (`ORM_REST_ROUTE=/api`) | `/22` | `/api/animals/22` | `/api/animals` | `/22` |
696
672
 
697
- **One read of argument one survives, and it must: `request.path`.** It is
698
- mount-relative and query-free, and it is for rules that distinguish **sub-paths**
699
- beneath the mount as the `/archived` deny in the sample above does. The context
700
- names which model and which verb, **not which route**, so that deny *cannot be
701
- expressed from the context alone*, and a context-only rewrite would silently turn
702
- it into an allow. Lower-case it before comparing: the router matched
703
- case-insensitively, so a case-sensitive sub-path rule is stricter than the router
704
- that dispatched the request and can be stepped around. Record ids are
705
- case-sensitive and must be compared at their real case.
706
-
707
- **Fail closed on anything you cannot identify.**
708
- `String(request.originalUrl ?? '')` was once added here to stop a `TypeError`,
709
- and it traded fail-closed for fail-**open**: an empty string matched no
710
- collection, so `access()` fell through to the permission array and granted full
711
- CRUD. The same rule now applies to the context the sample returns `false` for
712
- an absent `model` rather than falling through. An input you cannot identify must
713
- **deny**.
673
+ Two rules remain, and they are the whole list:
674
+
675
+ **1. Compare lower-cased.** `baseUrl` is the text the caller sent, not the
676
+ registered mount `GET /OwNeRs/angela` yields `/OwNeRs`. The router matched it
677
+ case-insensitively, so a case-sensitive comparison here is stricter than the
678
+ router and can be walked past. Lower-case the **mount and path only**; record ids
679
+ are case-sensitive and must be compared at their real case.
680
+
681
+ **2. Fail closed when `baseUrl` is absent.** `String(request.originalUrl ?? '')`
682
+ was added to stop a `TypeError`, and it traded fail-closed for fail-**open**: an
683
+ empty string matches no collection, so `access()` fell through to the permission
684
+ array and granted full CRUD. An input you cannot identify must **deny**.
685
+
686
+ Use `request.path` mount-relative and query-free if you need to distinguish
687
+ sub-paths beneath the mount, as the `/archived` deny above does.
714
688
 
715
689
  ### Known limitations
716
690
 
@@ -733,14 +707,11 @@ an absent `model` rather than falling through. An input you cannot identify must
733
707
  see [The access context](#the-access-context-second-argument):
734
708
  `Orm.instance.getAccess(modelName)` makes another model's predicate
735
709
  **reachable**, and `context.model` makes a **model-correct answer possible** —
736
- possible, not guaranteed: the resolved predicate has to read the context. The
737
- sample shipped with this repo now does
738
- ([#222](https://github.com/abofs/stonyx-orm/issues/222)), so
739
- `getAccess('animal')` answers with the animal filter; a predicate that ignores
740
- the second argument still answers about the collection the request is
741
- addressed to, and the boot-time warning that surfaces one is
742
- [#221](https://github.com/abofs/stonyx-orm/issues/221). **The mechanism
743
- exists; the ORM does not yet use it on this path.** The re-parenting write above is still
710
+ possible, not guaranteed: the resolved predicate has to read the context, and
711
+ every predicate in tree is still single-argument
712
+ ([#213](https://github.com/abofs/stonyx-orm/issues/213)), so today it answers
713
+ about the collection the request is addressed to. **The mechanism exists; the
714
+ ORM does not yet use it on this path.** The re-parenting write above is still
744
715
  **not refused** — that enforcement is
745
716
  [#196](https://github.com/abofs/stonyx-orm/issues/196) and
746
717
  [#207](https://github.com/abofs/stonyx-orm/issues/207), which were blocked on
@@ -792,9 +763,10 @@ an absent `model` rather than falling through. An input you cannot identify must
792
763
  transform output differs from its lookup key is the same defect. Filtered
793
764
  collections are unaffected — breaking change 3 refuses any client-supplied id
794
765
  — so this reaches consumers with **no** function-style filter. Tracked as
795
- [#205](https://github.com/abofs/stonyx-orm/issues/205), alongside
796
- [#203](https://github.com/abofs/stonyx-orm/issues/203), which is the other way
797
- a create can land on an id nobody named.
766
+ [#205](https://github.com/abofs/stonyx-orm/issues/205). Its sibling
767
+ [#203](https://github.com/abofs/stonyx-orm/issues/203) a **server-assigned**
768
+ id landing on an occupied slot — is **fixed**; see breaking change 8. #205 is
769
+ the client-supplied half and is still open.
798
770
  - **`context.record` is `undefined` for an after-`create` hook when a string-id
799
771
  model is given a numeric-looking id.** The post-create lookup uses the same id
800
772
  coercion as every other surface, which resolves `'9107'` to the number `9107`,
@@ -804,15 +776,18 @@ an absent `model` rather than falling through. An input you cannot identify must
804
776
  [#209](https://github.com/abofs/stonyx-orm/issues/209).
805
777
  - **A denied `POST` rolls back only a record it *inserted*.** The rollback
806
778
  requires the store to have grown, because removing by id alone is a write
807
- primitive keyed by a caller-supplied value. When `assignRecordId` lands a
808
- **server-assigned** id on an occupied slot
809
- ([#203](https://github.com/abofs/stonyx-orm/issues/203) — it returns
810
- last-*inserted* + 1, not max + 1, so a store whose insertion order is not
811
- ascending collides), `createRecord` updates that record **in place**: the map
812
- does not grow, the rollback correctly declines to remove a record this request
813
- did not create, and the `403` leaves the caller's attributes on someone else's
814
- record. Narrow it needs a non-ascending insertion order — but it is the
815
- reachability condition, so it is stated rather than implied.
779
+ primitive keyed by a caller-supplied value. **The reachability condition this
780
+ bullet used to state is gone**: it was
781
+ [#203](https://github.com/abofs/stonyx-orm/issues/203) — `assignRecordId`
782
+ returned last-*inserted* + 1, so a server-assigned id could land on an
783
+ occupied slot and `createRecord` would update it in place and #203 is fixed
784
+ (breaking change 8). A server-assigned create can no longer overwrite, so on a
785
+ collection whose only id channel is `createHandler` this guard has no
786
+ observable effect today. It is kept because a caller-supplied id reaching
787
+ `createRecord` from another route a relationship write,
788
+ [#207](https://github.com/abofs/stonyx-orm/issues/207) — puts the condition
789
+ back, and without the guard a denied `403` would delete a record the request
790
+ did not create.
816
791
 
817
792
  ### Breaking changes
818
793
 
@@ -873,6 +848,39 @@ they are recorded here.
873
848
  population breaking changes 3 and 4 explicitly exempt. If you were relying on
874
849
  a hex-shaped or whitespace-padded id creating a second record, it never did.
875
850
 
851
+ 8. **Server-assigned ids change value on string-id models, and the create route
852
+ gains a `409`.** Two consumer-visible changes from
853
+ [#203](https://github.com/abofs/stonyx-orm/issues/203).
854
+
855
+ **The value.** A `POST` with no `id` against a model declaring
856
+ `id = attr('string')` previously produced the *last-inserted* id with `1`
857
+ concatenated onto it — an owner store holding `['gina', 'bob']` answered
858
+ `'bob1'`. It now answers `'owner-1'`: the model name, a hyphen, and the
859
+ lowest positive integer whose landing key is free. **No test in this repo
860
+ pinned the old value**, so a consumer relying on it gets no failing test, no
861
+ deprecation and no other signal — which is why it is recorded here. Numeric
862
+ id models (`id = attr('number')`, the default) are unaffected in shape: they
863
+ still get an integer, but it is now the **maximum** existing id plus one
864
+ rather than the last-inserted id plus one, which is the defect #203 is about.
865
+
866
+ The value is deliberately **not** numeric-looking, and that is not cosmetic.
867
+ Every id-bearing surface resolves a numeric-looking string id to a **number**
868
+ (`GET /owners/1` looks up `1`), while a string-id model files its records
869
+ under the **string** key `'1'`. A server-assigned `'1'` would therefore be a
870
+ record that was created successfully and could not be fetched, updated or
871
+ deleted by id, and whose after-`create` hook received
872
+ `context.record === undefined`
873
+ ([#209](https://github.com/abofs/stonyx-orm/issues/209)).
874
+
875
+ **The status.** `POST /{collection}` can now answer `409` for a reason other
876
+ than a duplicate id: the server could not derive a free id. That requires a
877
+ **non-injective** id transform — one that maps distinct candidates onto the
878
+ same store key, such as `boolean`, or anything you registered on
879
+ `Orm.instance.transforms` and named as an id type. It is a configuration
880
+ fault rather than a request fault; the message is logged through
881
+ `stonyx/log`. Previously this case threw out of the handler and express
882
+ answered `500` with a stack trace.
883
+
876
884
  ### Include Parameter (Sideloading Relationships)
877
885
 
878
886
  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.