@stonyx/orm 0.3.2-beta.155 → 0.3.2-beta.156

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,15 +315,28 @@ 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. The sample below does
319
- > not parse anything: it reads `request.baseUrl`, the mount Express actually
320
- > matched.
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 variants 1, 2, 4 and 5 are **unconstructible** against
322
+ > it rather than merely handled. **Variant 3 survives.** It is the general shape
323
+ > "a hand-written matcher normalises differently from the router", and the
324
+ > migrated sample still runs one string comparison — the `/archived` sub-path
325
+ > deny — which folds case but does not decode, so `GET /owners/%61rchived` steps
326
+ > past it ([#228](https://github.com/abofs/stonyx-orm/issues/228)).
321
327
  >
322
328
  > That is still a stopgap. **The real fix is
323
329
  > [#202](https://github.com/abofs/stonyx-orm/issues/202)** — `access()` should
324
330
  > receive the model, the operation and the record, so there is nothing to
325
331
  > identify. Until it lands, prefer the array shape (`['read']`) or `false` where
326
332
  > you can: the **function** shape is the one that requires any matching at all.
333
+ >
334
+ > The one read of argument **one** that survives is `request.path`, for the
335
+ > `/archived` sub-path deny — and it has to. The context names which model and
336
+ > which verb, not which route, so that deny **cannot be expressed from the
337
+ > context alone** and a context-only rewrite would silently turn it into an
338
+ > allow.
339
+ >
327
340
  > The same warning is repeated at the top of `src/orm-request.ts`, which ships;
328
341
  > the longer write-up in `docs/usage-patterns.md` does **not** ship, so this
329
342
  > README and that source header are the two copies a consumer sees.
@@ -337,27 +350,65 @@ Access classes define models and provide custom filtering/authorization logic.
337
350
  export default class GlobalAccess {
338
351
  models = ['owner', 'animal'];
339
352
 
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();
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();
353
+ access(request, { model, operation }) {
354
+ // `model` is the model this route was mounted for. It is assigned once, at
355
+ // mount time, and no request can influence it — not a mount prefix, not a
356
+ // query string, not a case-varied path, not an absolute-form request
357
+ // target. Nothing below parses anything, so variants 1, 2, 4 and 5 are not
358
+ // constructible against this predicate any more — they are history, not
359
+ // rules to follow. VARIANT 3 IS THE EXCEPTION AND THE CLAIM IS NARROWER
360
+ // THAN IT WAS: a matcher stricter than the router can still be stepped
361
+ // around, because the sub-path rule below is still a string comparison.
362
+ // Case is handled; percent-encoding is not abofs/stonyx-orm#228.
363
+ //
364
+ // `operation` is destructured to name the whole contract at the point of
365
+ // use. This sample's rules are per-model and per-sub-path rather than
366
+ // per-verb, so it does not branch on it; the permission array at the bottom
367
+ // is where the verb is answered.
368
+
369
+ // FAIL CLOSED ON ARGUMENT TWO. `model` is absent for any caller that
370
+ // resolved this predicate without supplying the context, and a request this
371
+ // function cannot identify DENIES rather than falling through to the CRUD
372
+ // grant at the bottom. An unidentifiable input must never be the permissive
373
+ // path. Argument ONE is guarded at its own read, below — this guard does
374
+ // not cover it.
375
+ if (typeof model !== 'string' || model === '') return false;
376
+
377
+ if (model === 'owner') {
378
+ // The context names WHICH MODEL and WHICH VERB — not which route. Six
379
+ // distinct owner surfaces produce one identical context, so a rule that
380
+ // depends on the SUB-PATH still needs argument one. `request.path` is
381
+ // mount-relative and query-free, and it is the one read of the raw
382
+ // request the README sanctions. false → 403 for the whole request.
383
+ //
384
+ // THIS DENY CANNOT BE EXPRESSED FROM THE CONTEXT ALONE. Migrating it away
385
+ // does not remove a rule, it turns a deny into an ALLOW, silently.
386
+ //
387
+ // FAIL CLOSED ON ARGUMENT ONE TOO. The guard above covers the context;
388
+ // this one covers the request, and since #202 they are two different
389
+ // objects. A caller that resolves this predicate through the documented
390
+ // `Orm.instance.getAccess()` path and hand-assembles a request can supply
391
+ // a perfectly valid context with no usable `path` — and
392
+ // `String(request.path ?? '')` is then `''`, which matches no sub-path
393
+ // rule and falls straight through to the per-record filter below. That is
394
+ // a DENY becoming an ALLOW. An input this function cannot identify DENIES,
395
+ // whichever ARGUMENT it arrived on — which is also why the `?? ''` this
396
+ // file's header condemns does not appear below.
397
+ if (typeof request?.path !== 'string' || request.path === '') return false;
398
+
399
+ // Lower-cased because the router matched case-insensitively, so a
400
+ // case-sensitive rule here would be stricter than the router and could be
401
+ // stepped around.
402
+ //
403
+ // CASE-FOLDING ALONE IS NOT A SUFFICIENT NORMALISATION, and this line is
404
+ // not a recipe for one. Express sets `request.path` from the RAW pathname
405
+ // while the router DECODES `:id`, so `GET /owners/%61rchived` reaches this
406
+ // comparison as `/%61rchived`, walks past the deny, and is dispatched as
407
+ // the record `archived` — abofs/stonyx-orm#228. A matcher must normalise
408
+ // the way the router that dispatched the request does. Record ids are
409
+ // case-sensitive and must be compared at their real case.
410
+ const path = request.path.toLowerCase();
359
411
 
360
- if (collection.endsWith('/owners')) {
361
412
  if (path === '/archived' || path.startsWith('/archived/')) return false;
362
413
 
363
414
  // Returning a function plugs it in as a per-record filter, and it is
@@ -373,7 +424,7 @@ export default class GlobalAccess {
373
424
  // inert. Deliberately NO `?? record.owner` fallback: accepting the raw
374
425
  // shape as well as the resolved one would absorb a resolution regression
375
426
  // silently, which is exactly what blinded this fixture before.
376
- if (collection.endsWith('/animals')) return record => record.owner?.id !== 'restricted';
427
+ if (model === 'animal') return record => record.owner?.id !== 'restricted';
377
428
 
378
429
  // Allows full access to all calls that don't match any of the above conditions
379
430
  return ['read', 'create', 'update', 'delete'];
@@ -523,9 +574,10 @@ sample, `getAccess('owner') === getAccess('animal')`.
523
574
  #### Passing the context makes a model-correct answer *possible*
524
575
 
525
576
  It does not make the answer model-correct on its own. **The resolved predicate
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**:
577
+ has to read the context.** Against a predicate that ignores it the failure is
578
+ measurable. On a request Express dispatched to `GET /owners/angela`, asked about
579
+ **animals**, the sample as it shipped before
580
+ [#222](https://github.com/abofs/stonyx-orm/issues/222) answered:
529
581
 
530
582
  ```
531
583
  getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
@@ -533,18 +585,26 @@ getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
533
585
  ```
534
586
 
535
587
  That is the **owners** filter, and it returns `true` for animal 21 — the record
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).
588
+ hidden on every animal surface. Under a mount such a predicate recognizes
589
+ neither way it is worse still: it falls through to
590
+ `['read', 'create', 'update', 'delete']`, a full CRUD grant. Either way the
591
+ context was supplied and the answer is not the animal answer, and it is wrong in
592
+ the direction that **grants** because that predicate was single-argument and
593
+ identified its collection from the request, so it answered about the collection
594
+ the request was *addressed to* while being asked about another one.
595
+
596
+ The sample shipped with this repo has since been migrated to read the context,
597
+ and the same call now answers with the **animal** filter:
598
+
599
+ ```
600
+ getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
601
+ -> record => record.owner?.id !== 'restricted'
602
+ ```
603
+
604
+ A single-argument predicate remains the default in every consumer tree, and a
605
+ caller has no supported way to tell which kind it resolved. The boot-time arity
606
+ warning that surfaces one is
607
+ [#221](https://github.com/abofs/stonyx-orm/issues/221).
548
608
 
549
609
  So: pass the context, and do not treat a resolved predicate's answer as
550
610
  model-specific until that predicate has been migrated to read it.
@@ -676,9 +736,17 @@ no hidden records to disclose.
676
736
 
677
737
  ### Identifying the collection
678
738
 
679
- **Do not reconstruct the request path.** Every version of this sample that tried
680
- to has failed **open**, and each variant was found only after the previous one
681
- was fixed:
739
+ **Do not reconstruct the request path and since
740
+ [#202](https://github.com/abofs/stonyx-orm/issues/202) you do not have to
741
+ identify the collection at all.** Read `model` from
742
+ [the access context](#the-access-context-second-argument): it is fixed at mount
743
+ time, no request can influence it, and there is nothing left to parse.
744
+
745
+ **Everything below is the record of what happened when this sample did parse
746
+ it.** It is kept as history, not as a recipe — none of these matching strategies
747
+ should be written into a new predicate. Every version of this sample that tried
748
+ to identify the collection from the request target failed **open**, and each
749
+ variant was found only after the previous one was fixed:
682
750
 
683
751
  | # | Variant | Why it fails open |
684
752
  |---|---|---|
@@ -688,13 +756,27 @@ was fixed:
688
756
  | 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. |
689
757
  | 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. |
690
758
 
691
- **The fix is not a sixth rule.** It is to stop parsing:
692
-
693
- **Use `request.baseUrl`.** It is the mount Express *actually matched* when it
694
- dispatched the request. It carries no query string (variant 2), it is not
695
- mount-relative (variant 1), it already contains the configured `ORM_REST_ROUTE`
696
- prefix (variant 4 there is nothing left to derive, so `/apiowners` is
697
- unconstructible), and it is unaffected by an absolute-form target (variant 5).
759
+ **The fix is not a sixth rule, and it is not a better string to match.** It is
760
+ to stop identifying the collection at all. That is a statement about
761
+ **identifying the collection**, and it is not a statement about the sample as a
762
+ whole: the `/archived` sub-path rule *is* still a string match, and
763
+ [#228](https://github.com/abofs/stonyx-orm/issues/228) is a sixth spelling that
764
+ gets past it. Sub-path rules are the residue this fix does not cover, which is
765
+ why they must normalise the way the router does.
766
+
767
+ An intermediate revision read **`request.baseUrl`** — the mount Express
768
+ *actually matched*. That closed all five variants: it carries no query string
769
+ (variant 2), it is not mount-relative (variant 1), it already contains the
770
+ configured `ORM_REST_ROUTE` prefix (variant 4 — there is nothing left to derive,
771
+ so `/apiowners` is unconstructible), and it is unaffected by an absolute-form
772
+ target (variant 5). It was still a transport artifact standing in for a
773
+ structural fact, and it is **no longer what the sample does**: the sample reads
774
+ `model`, so variants 1, 2, 4 and 5 are unconstructible against it rather than
775
+ handled. **Variant 3 survives**, in the one string comparison the migration
776
+ leaves behind: the `/archived` sub-path deny folds case but does not decode
777
+ ([#228](https://github.com/abofs/stonyx-orm/issues/228)). The table below is
778
+ retained as the measured evidence behind the five variants, not because any of
779
+ these values should be matched on:
698
780
 
699
781
  | request | `request.url` | `request.originalUrl` | `request.baseUrl` | `request.path` |
700
782
  |---|---|---|---|---|
@@ -705,21 +787,35 @@ unconstructible), and it is unaffected by an absolute-form target (variant 5).
705
787
  | `GET http://anything.example/owners/angela` | `http://anything.example/angela` | `http://anything.example/owners/angela` | `/owners` | `/angela` |
706
788
  | `GET /api/animals/22` (`ORM_REST_ROUTE=/api`) | `/22` | `/api/animals/22` | `/api/animals` | `/22` |
707
789
 
708
- Two rules remain, and they are the whole list:
709
-
710
- **1. Compare lower-cased.** `baseUrl` is the text the caller sent, not the
711
- registered mount `GET /OwNeRs/angela` yields `/OwNeRs`. The router matched it
712
- case-insensitively, so a case-sensitive comparison here is stricter than the
713
- router and can be walked past. Lower-case the **mount and path only**; record ids
714
- are case-sensitive and must be compared at their real case.
715
-
716
- **2. Fail closed when `baseUrl` is absent.** `String(request.originalUrl ?? '')`
717
- was added to stop a `TypeError`, and it traded fail-closed for fail-**open**: an
718
- empty string matches no collection, so `access()` fell through to the permission
719
- array and granted full CRUD. An input you cannot identify must **deny**.
720
-
721
- Use `request.path` mount-relative and query-free if you need to distinguish
722
- sub-paths beneath the mount, as the `/archived` deny above does.
790
+ **One read of argument one survives, and it must: `request.path`.** It is
791
+ mount-relative and query-free, and it is for rules that distinguish **sub-paths**
792
+ beneath the mount as the `/archived` deny in the sample above does. The context
793
+ names which model and which verb, **not which route**, so that deny *cannot be
794
+ expressed from the context alone*, and a context-only rewrite would silently turn
795
+ it into an allow.
796
+
797
+ **Normalise the way the router that dispatched the request does — and
798
+ case-folding alone does not.** A matcher stricter than the router can be stepped
799
+ around, so the sample lower-cases before comparing (the router matched
800
+ case-insensitively). That closes the case gap and **it is not the whole rule**:
801
+ Express sets `request.path` from the **raw, undecoded** pathname while the router
802
+ **decodes** `:id`, so `GET /owners/%61rchived` reaches a `path === '/archived'`
803
+ comparison as `/%61rchived`, walks past the deny, and is dispatched as the record
804
+ `archived`. That gap is live in the sample above and is tracked as
805
+ [#228](https://github.com/abofs/stonyx-orm/issues/228) — **do not read the
806
+ `.toLowerCase()` there as a complete normalisation recipe.** Record ids are
807
+ case-sensitive and must be compared at their real case.
808
+
809
+ **Fail closed on anything you cannot identify — on *either* argument.**
810
+ `String(request.originalUrl ?? '')` was once added here to stop a `TypeError`,
811
+ and it traded fail-closed for fail-**open**: an empty string matched no
812
+ collection, so `access()` fell through to the permission array and granted full
813
+ CRUD. The same rule applies to the context — the sample returns `false` for an
814
+ absent `model` rather than falling through. Since #202 the guard and the read can
815
+ sit on **different objects**, and a guard on argument two does not protect a read
816
+ of argument one: the sample therefore also returns `false` when `request.path` is
817
+ absent or is not a string, rather than letting `?? ''` fall through to the
818
+ per-record filter. An input you cannot identify must **deny**.
723
819
 
724
820
  ### Known limitations
725
821
 
@@ -742,11 +838,14 @@ sub-paths beneath the mount, as the `/archived` deny above does.
742
838
  see [The access context](#the-access-context-second-argument):
743
839
  `Orm.instance.getAccess(modelName)` makes another model's predicate
744
840
  **reachable**, and `context.model` makes a **model-correct answer possible** —
745
- possible, not guaranteed: the resolved predicate has to read the context, and
746
- every predicate in tree is still single-argument
747
- ([#213](https://github.com/abofs/stonyx-orm/issues/213)), so today it answers
748
- about the collection the request is addressed to. **The mechanism exists; the
749
- ORM does not yet use it on this path.** The re-parenting write above is still
841
+ possible, not guaranteed: the resolved predicate has to read the context. The
842
+ sample shipped with this repo now does
843
+ ([#222](https://github.com/abofs/stonyx-orm/issues/222)), so
844
+ `getAccess('animal')` answers with the animal filter; a predicate that ignores
845
+ the second argument still answers about the collection the request is
846
+ addressed to, and the boot-time warning that surfaces one is
847
+ [#221](https://github.com/abofs/stonyx-orm/issues/221). **The mechanism
848
+ exists; the ORM does not yet use it on this path.** The re-parenting write above is still
750
849
  **not refused** — that enforcement is
751
850
  [#196](https://github.com/abofs/stonyx-orm/issues/196) and
752
851
  [#207](https://github.com/abofs/stonyx-orm/issues/207), which were blocked on
@@ -98,8 +98,8 @@
98
98
  *
99
99
  * PASSING THE CONTEXT MAKES A MODEL-CORRECT ANSWER POSSIBLE. It does not make
100
100
  * the answer model-correct on its own -- the resolved predicate has to READ it.
101
- * Measured against this repo's own shipped access class, on a request express
102
- * dispatched to `GET /owners/angela`, asked about ANIMALS:
101
+ * Measured against an ARITY-1 predicate, on a request express dispatched to
102
+ * `GET /owners/angela`, asked about ANIMALS:
103
103
  *
104
104
  * getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
105
105
  * -> record => record.id !== 'angela' && record.id !== 'restricted'
@@ -110,23 +110,30 @@
110
110
  * `['read', 'create', 'update', 'delete']`, a full CRUD grant. Either way the
111
111
  * context was supplied and the answer is not the animal answer, and it is wrong
112
112
  * in the GRANTING direction, because that predicate is arity-1 and identifies
113
- * its collection from the request. (The first of these is asserted on a live
114
- * dispatch by AC9 in test/integration/orm-test.ts.)
115
- *
116
- * Every predicate in this repo and in every consumer tree is arity-1 on the day
117
- * this ships, and the caller has no supported way to tell which kind it got --
118
- * the boot-time arity warning that would surface it is abofs/stonyx-orm#213.
113
+ * its collection from the request. (Asserted on a live dispatch by AC9 in
114
+ * test/integration/orm-test.ts, against a deliberately arity-1 predicate.)
115
+ *
116
+ * This repo's own sample access class has since been MIGRATED to read the
117
+ * context (abofs/stonyx-orm#222), so `getAccess('animal')` here now answers
118
+ * with the animal filter. That is not true of a consumer tree: an arity-1
119
+ * predicate keeps working -- the second argument is additive -- and the caller
120
+ * has no supported way to tell which kind it got. The boot-time arity warning
121
+ * that surfaces one is abofs/stonyx-orm#221.
119
122
  * So: pass the context, and do not treat a resolved predicate's answer as
120
123
  * model-specific until that predicate has been migrated to read the context.
121
124
  *
122
125
  * ---------------------------------------------------------------------------
123
126
  * DO NOT RECONSTRUCT THE REQUEST PATH INSIDE `access()`.
124
127
  * ---------------------------------------------------------------------------
125
- * `auth()` below hands your `access(request)` a raw transport artifact and asks
126
- * you to work out which collection it addresses. Every attempt to do that by
127
- * parsing the request target has failed OPEN. Five distinct variants of the
128
- * same three-line example have now been found, each after the previous was
129
- * fixed, by five different people:
128
+ * You do not have to. `auth()` below hands your predicate the ACCESS CONTEXT as
129
+ * argument two, and `context.model` already names the collection -- see the
130
+ * contract section above. Argument ONE is still the raw transport artifact, and
131
+ * everything from here to the end of this banner is the record of what happened
132
+ * when predicates worked the collection out from it. IT IS HISTORY, NOT
133
+ * GUIDANCE: do not write any of it into a new predicate. Every attempt to
134
+ * identify the collection by parsing the request target has failed OPEN. Five
135
+ * distinct variants of the same three-line example have now been found, each
136
+ * after the previous was fixed, by five different people:
130
137
  *
131
138
  * 1. `request.url` is mount-relative under `RestServer.mountRoute`, so a
132
139
  * prefix match against it is ALWAYS false.
@@ -145,22 +152,50 @@
145
152
  * last, and the record comes back in full. It walks past a hard
146
153
  * `return false` deny the same way.
147
154
  *
148
- * The fix is not a sixth rule. It is to stop parsing:
149
- *
150
- * `request.baseUrl` is the mount Express ACTUALLY MATCHED when it dispatched
151
- * the request. It carries no query string, it is not mount-relative, it is
152
- * unaffected by absolute-form, and it already includes the configured
153
- * `ORM_REST_ROUTE` prefix -- so there is nothing to derive and nothing to
154
- * join. Compare it lower-cased (the router matched case-insensitively) and
155
- * fail CLOSED when it is absent. Use `request.path` -- mount-relative and
156
- * query-free -- if you need to distinguish sub-paths.
155
+ * The fix is not a sixth rule, and it is not a better string to match. It is to
156
+ * stop identifying the collection at all: read `context.model`. That is a claim
157
+ * about IDENTIFYING THE COLLECTION, not about the sample as a whole -- the
158
+ * `/archived` SUB-PATH rule is still a string match, and abofs/stonyx-orm#228 is
159
+ * a sixth spelling that gets past it.
160
+ *
161
+ * An intermediate revision of the sample read `request.baseUrl` -- the mount
162
+ * Express ACTUALLY MATCHED. That closed all five variants (no query string,
163
+ * not mount-relative, unaffected by absolute-form, already carrying the
164
+ * configured `ORM_REST_ROUTE` prefix), but it was a transport artifact
165
+ * standing in for a structural fact and the sample no longer does it.
166
+ * `context.model` IS the structural fact, so variants 1, 2, 4 and 5 are
167
+ * unconstructible against a migrated predicate rather than handled.
168
+ *
169
+ * VARIANT 3 SURVIVES, and is deliberately not in that list. It is the general
170
+ * shape "a hand-written matcher normalises differently from the router", and a
171
+ * migrated predicate still runs one string comparison for any SUB-PATH rule --
172
+ * in the shipped sample, the `/archived` deny. That comparison folds case but
173
+ * does not decode, so `GET /owners/%61rchived` steps past it. See the
174
+ * normalisation paragraph below and abofs/stonyx-orm#228.
175
+ *
176
+ * ONE READ OF ARGUMENT ONE SURVIVES, AND IT MUST: `request.path`. It is
177
+ * mount-relative and query-free, and it is for rules that distinguish SUB-PATHS
178
+ * beneath the mount. The context names which model and which verb, NOT which
179
+ * route, so the sample's `/archived` deny cannot be expressed from the context
180
+ * alone and a context-ONLY rewrite would silently turn that deny into an allow.
181
+ *
182
+ * NORMALISE THE WAY THE ROUTER DOES, AND CASE-FOLDING ALONE IS NOT THAT. The
183
+ * sample lower-cases before comparing, because a matcher stricter than the
184
+ * case-insensitive router can be stepped around. That closes the case gap only.
185
+ * Express sets `request.path` from the RAW, UNDECODED pathname while the router
186
+ * DECODES `:id`, so `GET /owners/%61rchived` reaches a `path === '/archived'`
187
+ * comparison as `/%61rchived` and walks past the deny. That gap is live in the
188
+ * sample and is tracked as abofs/stonyx-orm#228; the `.toLowerCase()` is not a
189
+ * complete normalisation recipe. Compare record ids at their real case.
157
190
  *
158
191
  * `?? ''` is not a defence. It converts an absent request target into an empty
159
192
  * string, which matches no collection, which falls through to the permission
160
- * array -- a total grant. An input you cannot identify must DENY.
193
+ * array -- a total grant. An input you cannot identify must DENY, and that
194
+ * applies to BOTH arguments: since #202 the guard and the read can sit on
195
+ * different objects, and a guard on argument two does not protect a read of
196
+ * argument one. The sample returns `false` for an absent `model` AND for an
197
+ * absent or non-string `request.path`, rather than falling through either way.
161
198
  *
162
- * THAT IS STILL A STOPGAP. `baseUrl` closes all five variants, but it is a
163
- * transport artifact being asked to stand in for a structural fact.
164
199
  * THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
165
200
  * the operation and the record. Prefer the array shape (`['read']`) or `false`
166
201
  * until #202 lands; the function shape is what requires any matching at all.
@@ -98,8 +98,8 @@
98
98
  *
99
99
  * PASSING THE CONTEXT MAKES A MODEL-CORRECT ANSWER POSSIBLE. It does not make
100
100
  * the answer model-correct on its own -- the resolved predicate has to READ it.
101
- * Measured against this repo's own shipped access class, on a request express
102
- * dispatched to `GET /owners/angela`, asked about ANIMALS:
101
+ * Measured against an ARITY-1 predicate, on a request express dispatched to
102
+ * `GET /owners/angela`, asked about ANIMALS:
103
103
  *
104
104
  * getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
105
105
  * -> record => record.id !== 'angela' && record.id !== 'restricted'
@@ -110,23 +110,30 @@
110
110
  * `['read', 'create', 'update', 'delete']`, a full CRUD grant. Either way the
111
111
  * context was supplied and the answer is not the animal answer, and it is wrong
112
112
  * in the GRANTING direction, because that predicate is arity-1 and identifies
113
- * its collection from the request. (The first of these is asserted on a live
114
- * dispatch by AC9 in test/integration/orm-test.ts.)
113
+ * its collection from the request. (Asserted on a live dispatch by AC9 in
114
+ * test/integration/orm-test.ts, against a deliberately arity-1 predicate.)
115
115
  *
116
- * Every predicate in this repo and in every consumer tree is arity-1 on the day
117
- * this ships, and the caller has no supported way to tell which kind it got --
118
- * the boot-time arity warning that would surface it is abofs/stonyx-orm#213.
116
+ * This repo's own sample access class has since been MIGRATED to read the
117
+ * context (abofs/stonyx-orm#222), so `getAccess('animal')` here now answers
118
+ * with the animal filter. That is not true of a consumer tree: an arity-1
119
+ * predicate keeps working -- the second argument is additive -- and the caller
120
+ * has no supported way to tell which kind it got. The boot-time arity warning
121
+ * that surfaces one is abofs/stonyx-orm#221.
119
122
  * So: pass the context, and do not treat a resolved predicate's answer as
120
123
  * model-specific until that predicate has been migrated to read the context.
121
124
  *
122
125
  * ---------------------------------------------------------------------------
123
126
  * DO NOT RECONSTRUCT THE REQUEST PATH INSIDE `access()`.
124
127
  * ---------------------------------------------------------------------------
125
- * `auth()` below hands your `access(request)` a raw transport artifact and asks
126
- * you to work out which collection it addresses. Every attempt to do that by
127
- * parsing the request target has failed OPEN. Five distinct variants of the
128
- * same three-line example have now been found, each after the previous was
129
- * fixed, by five different people:
128
+ * You do not have to. `auth()` below hands your predicate the ACCESS CONTEXT as
129
+ * argument two, and `context.model` already names the collection -- see the
130
+ * contract section above. Argument ONE is still the raw transport artifact, and
131
+ * everything from here to the end of this banner is the record of what happened
132
+ * when predicates worked the collection out from it. IT IS HISTORY, NOT
133
+ * GUIDANCE: do not write any of it into a new predicate. Every attempt to
134
+ * identify the collection by parsing the request target has failed OPEN. Five
135
+ * distinct variants of the same three-line example have now been found, each
136
+ * after the previous was fixed, by five different people:
130
137
  *
131
138
  * 1. `request.url` is mount-relative under `RestServer.mountRoute`, so a
132
139
  * prefix match against it is ALWAYS false.
@@ -145,22 +152,50 @@
145
152
  * last, and the record comes back in full. It walks past a hard
146
153
  * `return false` deny the same way.
147
154
  *
148
- * The fix is not a sixth rule. It is to stop parsing:
155
+ * The fix is not a sixth rule, and it is not a better string to match. It is to
156
+ * stop identifying the collection at all: read `context.model`. That is a claim
157
+ * about IDENTIFYING THE COLLECTION, not about the sample as a whole -- the
158
+ * `/archived` SUB-PATH rule is still a string match, and abofs/stonyx-orm#228 is
159
+ * a sixth spelling that gets past it.
149
160
  *
150
- * `request.baseUrl` is the mount Express ACTUALLY MATCHED when it dispatched
151
- * the request. It carries no query string, it is not mount-relative, it is
152
- * unaffected by absolute-form, and it already includes the configured
153
- * `ORM_REST_ROUTE` prefix -- so there is nothing to derive and nothing to
154
- * join. Compare it lower-cased (the router matched case-insensitively) and
155
- * fail CLOSED when it is absent. Use `request.path` -- mount-relative and
156
- * query-free -- if you need to distinguish sub-paths.
161
+ * An intermediate revision of the sample read `request.baseUrl` -- the mount
162
+ * Express ACTUALLY MATCHED. That closed all five variants (no query string,
163
+ * not mount-relative, unaffected by absolute-form, already carrying the
164
+ * configured `ORM_REST_ROUTE` prefix), but it was a transport artifact
165
+ * standing in for a structural fact and the sample no longer does it.
166
+ * `context.model` IS the structural fact, so variants 1, 2, 4 and 5 are
167
+ * unconstructible against a migrated predicate rather than handled.
168
+ *
169
+ * VARIANT 3 SURVIVES, and is deliberately not in that list. It is the general
170
+ * shape "a hand-written matcher normalises differently from the router", and a
171
+ * migrated predicate still runs one string comparison for any SUB-PATH rule --
172
+ * in the shipped sample, the `/archived` deny. That comparison folds case but
173
+ * does not decode, so `GET /owners/%61rchived` steps past it. See the
174
+ * normalisation paragraph below and abofs/stonyx-orm#228.
175
+ *
176
+ * ONE READ OF ARGUMENT ONE SURVIVES, AND IT MUST: `request.path`. It is
177
+ * mount-relative and query-free, and it is for rules that distinguish SUB-PATHS
178
+ * beneath the mount. The context names which model and which verb, NOT which
179
+ * route, so the sample's `/archived` deny cannot be expressed from the context
180
+ * alone and a context-ONLY rewrite would silently turn that deny into an allow.
181
+ *
182
+ * NORMALISE THE WAY THE ROUTER DOES, AND CASE-FOLDING ALONE IS NOT THAT. The
183
+ * sample lower-cases before comparing, because a matcher stricter than the
184
+ * case-insensitive router can be stepped around. That closes the case gap only.
185
+ * Express sets `request.path` from the RAW, UNDECODED pathname while the router
186
+ * DECODES `:id`, so `GET /owners/%61rchived` reaches a `path === '/archived'`
187
+ * comparison as `/%61rchived` and walks past the deny. That gap is live in the
188
+ * sample and is tracked as abofs/stonyx-orm#228; the `.toLowerCase()` is not a
189
+ * complete normalisation recipe. Compare record ids at their real case.
157
190
  *
158
191
  * `?? ''` is not a defence. It converts an absent request target into an empty
159
192
  * string, which matches no collection, which falls through to the permission
160
- * array -- a total grant. An input you cannot identify must DENY.
193
+ * array -- a total grant. An input you cannot identify must DENY, and that
194
+ * applies to BOTH arguments: since #202 the guard and the read can sit on
195
+ * different objects, and a guard on argument two does not protect a read of
196
+ * argument one. The sample returns `false` for an absent `model` AND for an
197
+ * absent or non-string `request.path`, rather than falling through either way.
161
198
  *
162
- * THAT IS STILL A STOPGAP. `baseUrl` closes all five variants, but it is a
163
- * transport artifact being asked to stand in for a structural fact.
164
199
  * THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
165
200
  * the operation and the record. Prefer the array shape (`['read']`) or `false`
166
201
  * until #202 lands; the function shape is what requires any matching at all.
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-beta.155",
7
+ "version": "0.3.2-beta.156",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -98,8 +98,8 @@
98
98
  *
99
99
  * PASSING THE CONTEXT MAKES A MODEL-CORRECT ANSWER POSSIBLE. It does not make
100
100
  * the answer model-correct on its own -- the resolved predicate has to READ it.
101
- * Measured against this repo's own shipped access class, on a request express
102
- * dispatched to `GET /owners/angela`, asked about ANIMALS:
101
+ * Measured against an ARITY-1 predicate, on a request express dispatched to
102
+ * `GET /owners/angela`, asked about ANIMALS:
103
103
  *
104
104
  * getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
105
105
  * -> record => record.id !== 'angela' && record.id !== 'restricted'
@@ -110,23 +110,30 @@
110
110
  * `['read', 'create', 'update', 'delete']`, a full CRUD grant. Either way the
111
111
  * context was supplied and the answer is not the animal answer, and it is wrong
112
112
  * in the GRANTING direction, because that predicate is arity-1 and identifies
113
- * its collection from the request. (The first of these is asserted on a live
114
- * dispatch by AC9 in test/integration/orm-test.ts.)
113
+ * its collection from the request. (Asserted on a live dispatch by AC9 in
114
+ * test/integration/orm-test.ts, against a deliberately arity-1 predicate.)
115
115
  *
116
- * Every predicate in this repo and in every consumer tree is arity-1 on the day
117
- * this ships, and the caller has no supported way to tell which kind it got --
118
- * the boot-time arity warning that would surface it is abofs/stonyx-orm#213.
116
+ * This repo's own sample access class has since been MIGRATED to read the
117
+ * context (abofs/stonyx-orm#222), so `getAccess('animal')` here now answers
118
+ * with the animal filter. That is not true of a consumer tree: an arity-1
119
+ * predicate keeps working -- the second argument is additive -- and the caller
120
+ * has no supported way to tell which kind it got. The boot-time arity warning
121
+ * that surfaces one is abofs/stonyx-orm#221.
119
122
  * So: pass the context, and do not treat a resolved predicate's answer as
120
123
  * model-specific until that predicate has been migrated to read the context.
121
124
  *
122
125
  * ---------------------------------------------------------------------------
123
126
  * DO NOT RECONSTRUCT THE REQUEST PATH INSIDE `access()`.
124
127
  * ---------------------------------------------------------------------------
125
- * `auth()` below hands your `access(request)` a raw transport artifact and asks
126
- * you to work out which collection it addresses. Every attempt to do that by
127
- * parsing the request target has failed OPEN. Five distinct variants of the
128
- * same three-line example have now been found, each after the previous was
129
- * fixed, by five different people:
128
+ * You do not have to. `auth()` below hands your predicate the ACCESS CONTEXT as
129
+ * argument two, and `context.model` already names the collection -- see the
130
+ * contract section above. Argument ONE is still the raw transport artifact, and
131
+ * everything from here to the end of this banner is the record of what happened
132
+ * when predicates worked the collection out from it. IT IS HISTORY, NOT
133
+ * GUIDANCE: do not write any of it into a new predicate. Every attempt to
134
+ * identify the collection by parsing the request target has failed OPEN. Five
135
+ * distinct variants of the same three-line example have now been found, each
136
+ * after the previous was fixed, by five different people:
130
137
  *
131
138
  * 1. `request.url` is mount-relative under `RestServer.mountRoute`, so a
132
139
  * prefix match against it is ALWAYS false.
@@ -145,22 +152,50 @@
145
152
  * last, and the record comes back in full. It walks past a hard
146
153
  * `return false` deny the same way.
147
154
  *
148
- * The fix is not a sixth rule. It is to stop parsing:
155
+ * The fix is not a sixth rule, and it is not a better string to match. It is to
156
+ * stop identifying the collection at all: read `context.model`. That is a claim
157
+ * about IDENTIFYING THE COLLECTION, not about the sample as a whole -- the
158
+ * `/archived` SUB-PATH rule is still a string match, and abofs/stonyx-orm#228 is
159
+ * a sixth spelling that gets past it.
149
160
  *
150
- * `request.baseUrl` is the mount Express ACTUALLY MATCHED when it dispatched
151
- * the request. It carries no query string, it is not mount-relative, it is
152
- * unaffected by absolute-form, and it already includes the configured
153
- * `ORM_REST_ROUTE` prefix -- so there is nothing to derive and nothing to
154
- * join. Compare it lower-cased (the router matched case-insensitively) and
155
- * fail CLOSED when it is absent. Use `request.path` -- mount-relative and
156
- * query-free -- if you need to distinguish sub-paths.
161
+ * An intermediate revision of the sample read `request.baseUrl` -- the mount
162
+ * Express ACTUALLY MATCHED. That closed all five variants (no query string,
163
+ * not mount-relative, unaffected by absolute-form, already carrying the
164
+ * configured `ORM_REST_ROUTE` prefix), but it was a transport artifact
165
+ * standing in for a structural fact and the sample no longer does it.
166
+ * `context.model` IS the structural fact, so variants 1, 2, 4 and 5 are
167
+ * unconstructible against a migrated predicate rather than handled.
168
+ *
169
+ * VARIANT 3 SURVIVES, and is deliberately not in that list. It is the general
170
+ * shape "a hand-written matcher normalises differently from the router", and a
171
+ * migrated predicate still runs one string comparison for any SUB-PATH rule --
172
+ * in the shipped sample, the `/archived` deny. That comparison folds case but
173
+ * does not decode, so `GET /owners/%61rchived` steps past it. See the
174
+ * normalisation paragraph below and abofs/stonyx-orm#228.
175
+ *
176
+ * ONE READ OF ARGUMENT ONE SURVIVES, AND IT MUST: `request.path`. It is
177
+ * mount-relative and query-free, and it is for rules that distinguish SUB-PATHS
178
+ * beneath the mount. The context names which model and which verb, NOT which
179
+ * route, so the sample's `/archived` deny cannot be expressed from the context
180
+ * alone and a context-ONLY rewrite would silently turn that deny into an allow.
181
+ *
182
+ * NORMALISE THE WAY THE ROUTER DOES, AND CASE-FOLDING ALONE IS NOT THAT. The
183
+ * sample lower-cases before comparing, because a matcher stricter than the
184
+ * case-insensitive router can be stepped around. That closes the case gap only.
185
+ * Express sets `request.path` from the RAW, UNDECODED pathname while the router
186
+ * DECODES `:id`, so `GET /owners/%61rchived` reaches a `path === '/archived'`
187
+ * comparison as `/%61rchived` and walks past the deny. That gap is live in the
188
+ * sample and is tracked as abofs/stonyx-orm#228; the `.toLowerCase()` is not a
189
+ * complete normalisation recipe. Compare record ids at their real case.
157
190
  *
158
191
  * `?? ''` is not a defence. It converts an absent request target into an empty
159
192
  * string, which matches no collection, which falls through to the permission
160
- * array -- a total grant. An input you cannot identify must DENY.
193
+ * array -- a total grant. An input you cannot identify must DENY, and that
194
+ * applies to BOTH arguments: since #202 the guard and the read can sit on
195
+ * different objects, and a guard on argument two does not protect a read of
196
+ * argument one. The sample returns `false` for an absent `model` AND for an
197
+ * absent or non-string `request.path`, rather than falling through either way.
161
198
  *
162
- * THAT IS STILL A STOPGAP. `baseUrl` closes all five variants, but it is a
163
- * transport artifact being asked to stand in for a structural fact.
164
199
  * THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
165
200
  * the operation and the record. Prefer the array shape (`['read']`) or `false`
166
201
  * until #202 lands; the function shape is what requires any matching at all.