@stonyx/orm 0.3.2-alpha.61 → 0.3.2-alpha.62
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 +117 -68
- package/dist/manage-record.js +8 -103
- package/dist/orm-request.d.ts +41 -25
- package/dist/orm-request.js +39 -23
- package/package.json +1 -1
- package/src/manage-record.ts +8 -113
- package/src/orm-request.ts +39 -23
package/README.md
CHANGED
|
@@ -315,15 +315,24 @@ 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.
|
|
319
|
-
> not
|
|
320
|
-
>
|
|
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.
|
|
321
323
|
>
|
|
322
324
|
> That is still a stopgap. **The real fix is
|
|
323
325
|
> [#202](https://github.com/abofs/stonyx-orm/issues/202)** — `access()` should
|
|
324
326
|
> receive the model, the operation and the record, so there is nothing to
|
|
325
327
|
> identify. Until it lands, prefer the array shape (`['read']`) or `false` where
|
|
326
328
|
> 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
|
+
>
|
|
327
336
|
> The same warning is repeated at the top of `src/orm-request.ts`, which ships;
|
|
328
337
|
> the longer write-up in `docs/usage-patterns.md` does **not** ship, so this
|
|
329
338
|
> README and that source header are the two copies a consumer sees.
|
|
@@ -337,27 +346,39 @@ Access classes define models and provide custom filtering/authorization logic.
|
|
|
337
346
|
export default class GlobalAccess {
|
|
338
347
|
models = ['owner', 'animal'];
|
|
339
348
|
|
|
340
|
-
access(request) {
|
|
341
|
-
// `
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
//
|
|
352
|
-
|
|
353
|
-
//
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
//
|
|
357
|
-
|
|
358
|
-
|
|
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();
|
|
359
381
|
|
|
360
|
-
if (collection.endsWith('/owners')) {
|
|
361
382
|
if (path === '/archived' || path.startsWith('/archived/')) return false;
|
|
362
383
|
|
|
363
384
|
// Returning a function plugs it in as a per-record filter, and it is
|
|
@@ -373,7 +394,7 @@ export default class GlobalAccess {
|
|
|
373
394
|
// inert. Deliberately NO `?? record.owner` fallback: accepting the raw
|
|
374
395
|
// shape as well as the resolved one would absorb a resolution regression
|
|
375
396
|
// silently, which is exactly what blinded this fixture before.
|
|
376
|
-
if (
|
|
397
|
+
if (model === 'animal') return record => record.owner?.id !== 'restricted';
|
|
377
398
|
|
|
378
399
|
// Allows full access to all calls that don't match any of the above conditions
|
|
379
400
|
return ['read', 'create', 'update', 'delete'];
|
|
@@ -523,9 +544,10 @@ sample, `getAccess('owner') === getAccess('animal')`.
|
|
|
523
544
|
#### Passing the context makes a model-correct answer *possible*
|
|
524
545
|
|
|
525
546
|
It does not make the answer model-correct on its own. **The resolved predicate
|
|
526
|
-
has to read the context.**
|
|
527
|
-
|
|
528
|
-
**animals
|
|
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:
|
|
529
551
|
|
|
530
552
|
```
|
|
531
553
|
getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
|
|
@@ -533,18 +555,26 @@ getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
|
|
|
533
555
|
```
|
|
534
556
|
|
|
535
557
|
That is the **owners** filter, and it returns `true` for animal 21 — the record
|
|
536
|
-
hidden on every animal surface. Under a mount
|
|
537
|
-
way it is worse still: it falls through to
|
|
538
|
-
`['read', 'create', 'update', 'delete']`, a full CRUD grant.
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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).
|
|
548
578
|
|
|
549
579
|
So: pass the context, and do not treat a resolved predicate's answer as
|
|
550
580
|
model-specific until that predicate has been migrated to read it.
|
|
@@ -621,9 +651,17 @@ one both behave exactly as before.
|
|
|
621
651
|
|
|
622
652
|
### Identifying the collection
|
|
623
653
|
|
|
624
|
-
**Do not reconstruct the request path
|
|
625
|
-
|
|
626
|
-
|
|
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:
|
|
627
665
|
|
|
628
666
|
| # | Variant | Why it fails open |
|
|
629
667
|
|---|---|---|
|
|
@@ -633,13 +671,19 @@ was fixed:
|
|
|
633
671
|
| 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. |
|
|
634
672
|
| 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. |
|
|
635
673
|
|
|
636
|
-
**The fix is not a sixth rule
|
|
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.
|
|
637
676
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
mount-relative (variant 1), it already contains the
|
|
641
|
-
prefix (variant 4 — there is nothing left to derive,
|
|
642
|
-
unconstructible), and it is unaffected by an absolute-form
|
|
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:
|
|
643
687
|
|
|
644
688
|
| request | `request.url` | `request.originalUrl` | `request.baseUrl` | `request.path` |
|
|
645
689
|
|---|---|---|---|---|
|
|
@@ -650,21 +694,23 @@ unconstructible), and it is unaffected by an absolute-form target (variant 5).
|
|
|
650
694
|
| `GET http://anything.example/owners/angela` | `http://anything.example/angela` | `http://anything.example/owners/angela` | `/owners` | `/angela` |
|
|
651
695
|
| `GET /api/animals/22` (`ORM_REST_ROUTE=/api`) | `/22` | `/api/animals/22` | `/api/animals` | `/22` |
|
|
652
696
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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**.
|
|
668
714
|
|
|
669
715
|
### Known limitations
|
|
670
716
|
|
|
@@ -687,11 +733,14 @@ sub-paths beneath the mount, as the `/archived` deny above does.
|
|
|
687
733
|
see [The access context](#the-access-context-second-argument):
|
|
688
734
|
`Orm.instance.getAccess(modelName)` makes another model's predicate
|
|
689
735
|
**reachable**, and `context.model` makes a **model-correct answer possible** —
|
|
690
|
-
possible, not guaranteed: the resolved predicate has to read the context
|
|
691
|
-
|
|
692
|
-
([#
|
|
693
|
-
|
|
694
|
-
|
|
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
|
|
695
744
|
**not refused** — that enforcement is
|
|
696
745
|
[#196](https://github.com/abofs/stonyx-orm/issues/196) and
|
|
697
746
|
[#207](https://github.com/abofs/stonyx-orm/issues/207), which were blocked on
|
package/dist/manage-record.js
CHANGED
|
@@ -153,52 +153,17 @@ export function updateRecord(record, rawData, userOptions = {}) {
|
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
155
|
/**
|
|
156
|
-
* gets the next available id
|
|
156
|
+
* gets the next available id based on last record entry.
|
|
157
157
|
*
|
|
158
158
|
* In MySQL mode with numeric IDs, assigns a temporary pending ID.
|
|
159
159
|
* MySQL's AUTO_INCREMENT provides the real ID after INSERT.
|
|
160
|
-
*
|
|
161
|
-
* ---------------------------------------------------------------------------
|
|
162
|
-
* WAS: `Array.from(storeMap.values()).at(-1).id + 1` — the LAST INSERTED id,
|
|
163
|
-
* not the maximum (abofs/stonyx-orm#203). The store is a Map, so insertion
|
|
164
|
-
* order stops being ascending the moment a record is deleted and recreated, a
|
|
165
|
-
* db.json is written out of order, a directory-mode store is read back in file
|
|
166
|
-
* order, or a caller POSTs a high id and then a low one. After that, every
|
|
167
|
-
* server-assigned id is one that is ALREADY TAKEN — and `createRecord`'s
|
|
168
|
-
* last-entry-wins branch then overwrites that record IN PLACE and answers 200.
|
|
169
|
-
* No error, no 409, and the store's size does not change. That is the whole
|
|
170
|
-
* defect, and it is reachable from a create with NO id at all, which is the
|
|
171
|
-
* most ordinary write a consumer performs.
|
|
172
|
-
*
|
|
173
|
-
* Covered by test/unit/assign-record-id-test.ts. Note for anyone changing this
|
|
174
|
-
* function: before that file existed, the whole suite scored 951/0 both on the
|
|
175
|
-
* defect and on a naive `Math.max` fix that introduced a second one. A green
|
|
176
|
-
* suite is not evidence here; those assertions are.
|
|
177
|
-
* ---------------------------------------------------------------------------
|
|
178
160
|
*/
|
|
179
161
|
function assignRecordId(modelName, rawData) {
|
|
180
|
-
|
|
181
|
-
// and `if (rawData.id) return` silently reassigned it, handing the caller back
|
|
182
|
-
// a different record than the one it named (#203).
|
|
183
|
-
//
|
|
184
|
-
// `''` is deliberately NOT honoured here and this is not an oversight: it is
|
|
185
|
-
// the one string that means "no id". `parseInt('')` is `NaN`, a record CAN be
|
|
186
|
-
// held under the key `NaN`, and orm-request.ts's body-id normalisation relies
|
|
187
|
-
// on `''` staying absent — otherwise `POST {"id":""}` answers 409 against a
|
|
188
|
-
// record it never named. Pinned by test/unit/assign-record-id-test.ts (AC6's
|
|
189
|
-
// BOUNDARY assertions) and by access-filter-enforcement-test.ts assertion 44.
|
|
190
|
-
// Widening this to `!== undefined` breaks both.
|
|
191
|
-
if (rawData.id || rawData.id === 0)
|
|
162
|
+
if (rawData.id)
|
|
192
163
|
return;
|
|
193
164
|
// In SQL mode with numeric IDs, defer to database auto-increment.
|
|
194
165
|
// Use unique negative integers — they survive the number transform (parseInt preserves negatives)
|
|
195
166
|
// and avoid NaN store-key collisions that string pending IDs caused.
|
|
196
|
-
//
|
|
197
|
-
// This early return is ABOVE the max computation on purpose: a pending
|
|
198
|
-
// negative must never be a candidate for, or be perturbed by, the max path.
|
|
199
|
-
// Pinned directly (AC5.3) rather than by asserting the max is unaffected —
|
|
200
|
-
// that assertion could not have failed, because nothing negative ever reaches
|
|
201
|
-
// the code below.
|
|
202
167
|
if (Orm.instance?.sqlDb && !isStringIdModel(modelName)) {
|
|
203
168
|
rawData.id = -(++pendingIdCounter);
|
|
204
169
|
rawData.__pendingSqlId = true;
|
|
@@ -208,73 +173,13 @@ function assignRecordId(modelName, rawData) {
|
|
|
208
173
|
if (!storeMap)
|
|
209
174
|
throw new Error(`Cannot assign record ID: model "${modelName}" not found in store`);
|
|
210
175
|
const modelStore = Array.from(storeMap.values()).filter(isOrmRecord);
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
// a store CAN hold a record under the key `NaN` (`{id: ' '}` is truthy, so
|
|
214
|
-
// it survives the guard above and NaNs in the number transform — that is the
|
|
215
|
-
// state access-filter-enforcement-test.ts assertion 44 constructs). `Math.max`
|
|
216
|
-
// returns `NaN` if any operand is `NaN`, so it would assign `NaN`, land on
|
|
217
|
-
// that slot and overwrite it — exactly the defect being fixed, in a new
|
|
218
|
-
// disguise. This reduce cannot: non-numbers are skipped, and `NaN > max` is
|
|
219
|
-
// `false`. Pinned by AC2.
|
|
220
|
-
const maxId = modelStore.reduce((max, record) => {
|
|
221
|
-
const recordId = record.id;
|
|
222
|
-
return typeof recordId === 'number' && recordId > max ? recordId : max;
|
|
223
|
-
}, 0);
|
|
224
|
-
// THE OCCUPANCY CHECK RUNS ON THE LANDING KEY, NOT ON THE RAW CANDIDATE, and
|
|
225
|
-
// the difference is a silent data loss rather than a nicety.
|
|
226
|
-
//
|
|
227
|
-
// `createRecord` looks the record up under `rawData.id` (:50) but WRITES it
|
|
228
|
-
// under `record.id` (:69) — the value after the model's declared id transform
|
|
229
|
-
// has run inside `serialize`. On a string-id model those two differ: the
|
|
230
|
-
// number `1` is looked up, the record lands under the string `'1'`. A guard
|
|
231
|
-
// written as `storeMap.has(rawData.id)` therefore checks a key the record will
|
|
232
|
-
// never occupy, misses an occupied slot and overwrites it — measured: owner
|
|
233
|
-
// '1' age 55 -> 9, store size unchanged, no error. That is abofs/stonyx-orm
|
|
234
|
-
// #205's lookup-key/landing-key divergence reappearing inside #203's own fix,
|
|
235
|
-
// which is why AC4 exists and why `rawData.id` is set to the LANDING key
|
|
236
|
-
// below: it makes :50 and :69 agree by construction.
|
|
237
|
-
//
|
|
238
|
-
// Termination: with an injective id transform at most `storeMap.size`
|
|
239
|
-
// candidates can be occupied. A NON-injective id type would otherwise spin
|
|
240
|
-
// forever, so the loop is bounded and exits with a defined error the route can
|
|
241
|
-
// report instead of hanging the request.
|
|
242
|
-
//
|
|
243
|
-
// Resolved ONCE, outside the loop: `getIdType` instantiates the model class,
|
|
244
|
-
// so resolving it per candidate would put a model construction on every
|
|
245
|
-
// iteration of a loop that exists to walk past occupied slots.
|
|
246
|
-
const toStoreKey = storeKeyDeriver(modelName);
|
|
247
|
-
let candidate = maxId + 1;
|
|
248
|
-
let landingKey = toStoreKey(candidate);
|
|
249
|
-
let attempts = 0;
|
|
250
|
-
while (storeMap.has(landingKey)) {
|
|
251
|
-
if (++attempts > storeMap.size) {
|
|
252
|
-
throw new Error(`Cannot assign record ID: no free id available for model "${modelName}"`);
|
|
253
|
-
}
|
|
254
|
-
candidate += 1;
|
|
255
|
-
landingKey = toStoreKey(candidate);
|
|
256
|
-
}
|
|
257
|
-
rawData.id = landingKey;
|
|
258
|
-
}
|
|
259
|
-
/**
|
|
260
|
-
* Returns the derivation that maps an id VALUE to the store KEY a record
|
|
261
|
-
* carrying it will actually be filed under — the model's declared id transform,
|
|
262
|
-
* the same one `serialize` runs at createRecord:68 before the `.set` at :69.
|
|
263
|
-
*/
|
|
264
|
-
function storeKeyDeriver(modelName) {
|
|
265
|
-
const idType = getIdType(modelName);
|
|
266
|
-
const transform = idType ? Orm.instance?.transforms?.[idType] : undefined;
|
|
267
|
-
if (typeof transform !== 'function')
|
|
268
|
-
return value => value;
|
|
269
|
-
return value => transform(value);
|
|
176
|
+
const lastRecord = modelStore.at(-1);
|
|
177
|
+
rawData.id = lastRecord ? lastRecord.id + 1 : 1;
|
|
270
178
|
}
|
|
271
|
-
function
|
|
272
|
-
const modelClass = Orm.instance
|
|
179
|
+
function isStringIdModel(modelName) {
|
|
180
|
+
const modelClass = Orm.instance.getRecordClasses(modelName).modelClass;
|
|
273
181
|
if (!modelClass)
|
|
274
|
-
return
|
|
182
|
+
return false;
|
|
275
183
|
const model = new modelClass(modelName);
|
|
276
|
-
return model.id?.type;
|
|
277
|
-
}
|
|
278
|
-
function isStringIdModel(modelName) {
|
|
279
|
-
return getIdType(modelName) === 'string';
|
|
184
|
+
return model.id?.type === 'string';
|
|
280
185
|
}
|
package/dist/orm-request.d.ts
CHANGED
|
@@ -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
|
|
102
|
-
*
|
|
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. (
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* the
|
|
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
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
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,31 @@
|
|
|
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
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
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`.
|
|
157
|
+
*
|
|
158
|
+
* An intermediate revision of the sample read `request.baseUrl` -- the mount
|
|
159
|
+
* Express ACTUALLY MATCHED. That closed all five variants (no query string,
|
|
160
|
+
* not mount-relative, unaffected by absolute-form, already carrying the
|
|
161
|
+
* configured `ORM_REST_ROUTE` prefix), but it was a transport artifact
|
|
162
|
+
* standing in for a structural fact and the sample no longer does it.
|
|
163
|
+
* `context.model` IS the structural fact, so all five variants are
|
|
164
|
+
* unconstructible against a migrated predicate rather than handled.
|
|
165
|
+
*
|
|
166
|
+
* ONE READ OF ARGUMENT ONE SURVIVES, AND IT MUST: `request.path`. It is
|
|
167
|
+
* mount-relative and query-free, and it is for rules that distinguish SUB-PATHS
|
|
168
|
+
* beneath the mount. The context names which model and which verb, NOT which
|
|
169
|
+
* route, so the sample's `/archived` deny cannot be expressed from the context
|
|
170
|
+
* alone and a context-ONLY rewrite would silently turn that deny into an allow.
|
|
171
|
+
* Lower-case it before comparing -- the router matched case-insensitively -- and
|
|
172
|
+
* compare record ids at their real case.
|
|
157
173
|
*
|
|
158
174
|
* `?? ''` is not a defence. It converts an absent request target into an empty
|
|
159
175
|
* string, which matches no collection, which falls through to the permission
|
|
160
|
-
* array -- a total grant. An input you cannot identify must DENY
|
|
176
|
+
* array -- a total grant. An input you cannot identify must DENY, and that
|
|
177
|
+
* applies to the context too: the sample returns `false` for an absent `model`
|
|
178
|
+
* rather than falling through.
|
|
161
179
|
*
|
|
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
180
|
* THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
|
|
165
181
|
* the operation and the record. Prefer the array shape (`['read']`) or `false`
|
|
166
182
|
* until #202 lands; the function shape is what requires any matching at all.
|
package/dist/orm-request.js
CHANGED
|
@@ -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
|
|
102
|
-
*
|
|
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. (
|
|
114
|
-
*
|
|
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
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* the
|
|
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
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
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,31 @@
|
|
|
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
|
|
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`.
|
|
149
157
|
*
|
|
150
|
-
* `request.baseUrl`
|
|
151
|
-
*
|
|
152
|
-
* unaffected by absolute-form,
|
|
153
|
-
* `ORM_REST_ROUTE` prefix
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
158
|
+
* An intermediate revision of the sample read `request.baseUrl` -- the mount
|
|
159
|
+
* Express ACTUALLY MATCHED. That closed all five variants (no query string,
|
|
160
|
+
* not mount-relative, unaffected by absolute-form, already carrying the
|
|
161
|
+
* configured `ORM_REST_ROUTE` prefix), but it was a transport artifact
|
|
162
|
+
* standing in for a structural fact and the sample no longer does it.
|
|
163
|
+
* `context.model` IS the structural fact, so all five variants are
|
|
164
|
+
* unconstructible against a migrated predicate rather than handled.
|
|
165
|
+
*
|
|
166
|
+
* ONE READ OF ARGUMENT ONE SURVIVES, AND IT MUST: `request.path`. It is
|
|
167
|
+
* mount-relative and query-free, and it is for rules that distinguish SUB-PATHS
|
|
168
|
+
* beneath the mount. The context names which model and which verb, NOT which
|
|
169
|
+
* route, so the sample's `/archived` deny cannot be expressed from the context
|
|
170
|
+
* alone and a context-ONLY rewrite would silently turn that deny into an allow.
|
|
171
|
+
* Lower-case it before comparing -- the router matched case-insensitively -- and
|
|
172
|
+
* compare record ids at their real case.
|
|
157
173
|
*
|
|
158
174
|
* `?? ''` is not a defence. It converts an absent request target into an empty
|
|
159
175
|
* string, which matches no collection, which falls through to the permission
|
|
160
|
-
* array -- a total grant. An input you cannot identify must DENY
|
|
176
|
+
* array -- a total grant. An input you cannot identify must DENY, and that
|
|
177
|
+
* applies to the context too: the sample returns `false` for an absent `model`
|
|
178
|
+
* rather than falling through.
|
|
161
179
|
*
|
|
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
180
|
* THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
|
|
165
181
|
* the operation and the record. Prefer the array shape (`['read']`) or `false`
|
|
166
182
|
* until #202 lands; the function shape is what requires any matching at all.
|
package/package.json
CHANGED
package/src/manage-record.ts
CHANGED
|
@@ -195,52 +195,17 @@ export function updateRecord(record: OrmRecord, rawData: unknown, userOptions: C
|
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
/**
|
|
198
|
-
* gets the next available id
|
|
198
|
+
* gets the next available id based on last record entry.
|
|
199
199
|
*
|
|
200
200
|
* In MySQL mode with numeric IDs, assigns a temporary pending ID.
|
|
201
201
|
* MySQL's AUTO_INCREMENT provides the real ID after INSERT.
|
|
202
|
-
*
|
|
203
|
-
* ---------------------------------------------------------------------------
|
|
204
|
-
* WAS: `Array.from(storeMap.values()).at(-1).id + 1` — the LAST INSERTED id,
|
|
205
|
-
* not the maximum (abofs/stonyx-orm#203). The store is a Map, so insertion
|
|
206
|
-
* order stops being ascending the moment a record is deleted and recreated, a
|
|
207
|
-
* db.json is written out of order, a directory-mode store is read back in file
|
|
208
|
-
* order, or a caller POSTs a high id and then a low one. After that, every
|
|
209
|
-
* server-assigned id is one that is ALREADY TAKEN — and `createRecord`'s
|
|
210
|
-
* last-entry-wins branch then overwrites that record IN PLACE and answers 200.
|
|
211
|
-
* No error, no 409, and the store's size does not change. That is the whole
|
|
212
|
-
* defect, and it is reachable from a create with NO id at all, which is the
|
|
213
|
-
* most ordinary write a consumer performs.
|
|
214
|
-
*
|
|
215
|
-
* Covered by test/unit/assign-record-id-test.ts. Note for anyone changing this
|
|
216
|
-
* function: before that file existed, the whole suite scored 951/0 both on the
|
|
217
|
-
* defect and on a naive `Math.max` fix that introduced a second one. A green
|
|
218
|
-
* suite is not evidence here; those assertions are.
|
|
219
|
-
* ---------------------------------------------------------------------------
|
|
220
202
|
*/
|
|
221
203
|
function assignRecordId(modelName: string, rawData: { [key: string]: unknown }): void {
|
|
222
|
-
|
|
223
|
-
// and `if (rawData.id) return` silently reassigned it, handing the caller back
|
|
224
|
-
// a different record than the one it named (#203).
|
|
225
|
-
//
|
|
226
|
-
// `''` is deliberately NOT honoured here and this is not an oversight: it is
|
|
227
|
-
// the one string that means "no id". `parseInt('')` is `NaN`, a record CAN be
|
|
228
|
-
// held under the key `NaN`, and orm-request.ts's body-id normalisation relies
|
|
229
|
-
// on `''` staying absent — otherwise `POST {"id":""}` answers 409 against a
|
|
230
|
-
// record it never named. Pinned by test/unit/assign-record-id-test.ts (AC6's
|
|
231
|
-
// BOUNDARY assertions) and by access-filter-enforcement-test.ts assertion 44.
|
|
232
|
-
// Widening this to `!== undefined` breaks both.
|
|
233
|
-
if (rawData.id || rawData.id === 0) return;
|
|
204
|
+
if (rawData.id) return;
|
|
234
205
|
|
|
235
206
|
// In SQL mode with numeric IDs, defer to database auto-increment.
|
|
236
207
|
// Use unique negative integers — they survive the number transform (parseInt preserves negatives)
|
|
237
208
|
// and avoid NaN store-key collisions that string pending IDs caused.
|
|
238
|
-
//
|
|
239
|
-
// This early return is ABOVE the max computation on purpose: a pending
|
|
240
|
-
// negative must never be a candidate for, or be perturbed by, the max path.
|
|
241
|
-
// Pinned directly (AC5.3) rather than by asserting the max is unaffected —
|
|
242
|
-
// that assertion could not have failed, because nothing negative ever reaches
|
|
243
|
-
// the code below.
|
|
244
209
|
if (Orm.instance?.sqlDb && !isStringIdModel(modelName)) {
|
|
245
210
|
rawData.id = -(++pendingIdCounter);
|
|
246
211
|
rawData.__pendingSqlId = true;
|
|
@@ -250,85 +215,15 @@ function assignRecordId(modelName: string, rawData: { [key: string]: unknown }):
|
|
|
250
215
|
const storeMap = store.get(modelName);
|
|
251
216
|
if (!storeMap) throw new Error(`Cannot assign record ID: model "${modelName}" not found in store`);
|
|
252
217
|
const modelStore = Array.from(storeMap.values()).filter(isOrmRecord);
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
// `Math.max(...ids)` for a reason that is measurable rather than stylistic:
|
|
256
|
-
// a store CAN hold a record under the key `NaN` (`{id: ' '}` is truthy, so
|
|
257
|
-
// it survives the guard above and NaNs in the number transform — that is the
|
|
258
|
-
// state access-filter-enforcement-test.ts assertion 44 constructs). `Math.max`
|
|
259
|
-
// returns `NaN` if any operand is `NaN`, so it would assign `NaN`, land on
|
|
260
|
-
// that slot and overwrite it — exactly the defect being fixed, in a new
|
|
261
|
-
// disguise. This reduce cannot: non-numbers are skipped, and `NaN > max` is
|
|
262
|
-
// `false`. Pinned by AC2.
|
|
263
|
-
const maxId = modelStore.reduce((max: number, record) => {
|
|
264
|
-
const recordId = record.id as unknown;
|
|
265
|
-
|
|
266
|
-
return typeof recordId === 'number' && recordId > max ? recordId : max;
|
|
267
|
-
}, 0);
|
|
268
|
-
|
|
269
|
-
// THE OCCUPANCY CHECK RUNS ON THE LANDING KEY, NOT ON THE RAW CANDIDATE, and
|
|
270
|
-
// the difference is a silent data loss rather than a nicety.
|
|
271
|
-
//
|
|
272
|
-
// `createRecord` looks the record up under `rawData.id` (:50) but WRITES it
|
|
273
|
-
// under `record.id` (:69) — the value after the model's declared id transform
|
|
274
|
-
// has run inside `serialize`. On a string-id model those two differ: the
|
|
275
|
-
// number `1` is looked up, the record lands under the string `'1'`. A guard
|
|
276
|
-
// written as `storeMap.has(rawData.id)` therefore checks a key the record will
|
|
277
|
-
// never occupy, misses an occupied slot and overwrites it — measured: owner
|
|
278
|
-
// '1' age 55 -> 9, store size unchanged, no error. That is abofs/stonyx-orm
|
|
279
|
-
// #205's lookup-key/landing-key divergence reappearing inside #203's own fix,
|
|
280
|
-
// which is why AC4 exists and why `rawData.id` is set to the LANDING key
|
|
281
|
-
// below: it makes :50 and :69 agree by construction.
|
|
282
|
-
//
|
|
283
|
-
// Termination: with an injective id transform at most `storeMap.size`
|
|
284
|
-
// candidates can be occupied. A NON-injective id type would otherwise spin
|
|
285
|
-
// forever, so the loop is bounded and exits with a defined error the route can
|
|
286
|
-
// report instead of hanging the request.
|
|
287
|
-
//
|
|
288
|
-
// Resolved ONCE, outside the loop: `getIdType` instantiates the model class,
|
|
289
|
-
// so resolving it per candidate would put a model construction on every
|
|
290
|
-
// iteration of a loop that exists to walk past occupied slots.
|
|
291
|
-
const toStoreKey = storeKeyDeriver(modelName);
|
|
292
|
-
|
|
293
|
-
let candidate = maxId + 1;
|
|
294
|
-
let landingKey = toStoreKey(candidate);
|
|
295
|
-
let attempts = 0;
|
|
296
|
-
|
|
297
|
-
while (storeMap.has(landingKey)) {
|
|
298
|
-
if (++attempts > storeMap.size) {
|
|
299
|
-
throw new Error(`Cannot assign record ID: no free id available for model "${modelName}"`);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
candidate += 1;
|
|
303
|
-
landingKey = toStoreKey(candidate);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
rawData.id = landingKey;
|
|
218
|
+
const lastRecord = modelStore.at(-1);
|
|
219
|
+
rawData.id = lastRecord ? (lastRecord.id as number) + 1 : 1;
|
|
307
220
|
}
|
|
308
221
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
* the same one `serialize` runs at createRecord:68 before the `.set` at :69.
|
|
313
|
-
*/
|
|
314
|
-
function storeKeyDeriver(modelName: string): (value: number) => number | string {
|
|
315
|
-
const idType = getIdType(modelName);
|
|
316
|
-
const transform = idType ? Orm.instance?.transforms?.[idType] : undefined;
|
|
317
|
-
|
|
318
|
-
if (typeof transform !== 'function') return value => value;
|
|
319
|
-
|
|
320
|
-
return value => transform(value) as number | string;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function getIdType(modelName: string): string | undefined {
|
|
324
|
-
const modelClass = Orm.instance?.getRecordClasses(modelName).modelClass as (new (name: string) => { [key: string]: unknown }) | undefined;
|
|
325
|
-
if (!modelClass) return undefined;
|
|
222
|
+
function isStringIdModel(modelName: string): boolean {
|
|
223
|
+
const modelClass = Orm.instance.getRecordClasses(modelName).modelClass as (new (name: string) => { [key: string]: unknown }) | undefined;
|
|
224
|
+
if (!modelClass) return false;
|
|
326
225
|
|
|
327
226
|
const model = new modelClass(modelName);
|
|
328
227
|
|
|
329
|
-
return (model.id as { type?: string } | undefined)?.type;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
function isStringIdModel(modelName: string): boolean {
|
|
333
|
-
return getIdType(modelName) === 'string';
|
|
228
|
+
return (model.id as { type?: string } | undefined)?.type === 'string';
|
|
334
229
|
}
|
package/src/orm-request.ts
CHANGED
|
@@ -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
|
|
102
|
-
*
|
|
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. (
|
|
114
|
-
*
|
|
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
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* the
|
|
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
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
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,31 @@
|
|
|
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
|
|
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`.
|
|
149
157
|
*
|
|
150
|
-
* `request.baseUrl`
|
|
151
|
-
*
|
|
152
|
-
* unaffected by absolute-form,
|
|
153
|
-
* `ORM_REST_ROUTE` prefix
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
158
|
+
* An intermediate revision of the sample read `request.baseUrl` -- the mount
|
|
159
|
+
* Express ACTUALLY MATCHED. That closed all five variants (no query string,
|
|
160
|
+
* not mount-relative, unaffected by absolute-form, already carrying the
|
|
161
|
+
* configured `ORM_REST_ROUTE` prefix), but it was a transport artifact
|
|
162
|
+
* standing in for a structural fact and the sample no longer does it.
|
|
163
|
+
* `context.model` IS the structural fact, so all five variants are
|
|
164
|
+
* unconstructible against a migrated predicate rather than handled.
|
|
165
|
+
*
|
|
166
|
+
* ONE READ OF ARGUMENT ONE SURVIVES, AND IT MUST: `request.path`. It is
|
|
167
|
+
* mount-relative and query-free, and it is for rules that distinguish SUB-PATHS
|
|
168
|
+
* beneath the mount. The context names which model and which verb, NOT which
|
|
169
|
+
* route, so the sample's `/archived` deny cannot be expressed from the context
|
|
170
|
+
* alone and a context-ONLY rewrite would silently turn that deny into an allow.
|
|
171
|
+
* Lower-case it before comparing -- the router matched case-insensitively -- and
|
|
172
|
+
* compare record ids at their real case.
|
|
157
173
|
*
|
|
158
174
|
* `?? ''` is not a defence. It converts an absent request target into an empty
|
|
159
175
|
* string, which matches no collection, which falls through to the permission
|
|
160
|
-
* array -- a total grant. An input you cannot identify must DENY
|
|
176
|
+
* array -- a total grant. An input you cannot identify must DENY, and that
|
|
177
|
+
* applies to the context too: the sample returns `false` for an absent `model`
|
|
178
|
+
* rather than falling through.
|
|
161
179
|
*
|
|
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
180
|
* THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
|
|
165
181
|
* the operation and the record. Prefer the array shape (`['read']`) or `false`
|
|
166
182
|
* until #202 lands; the function shape is what requires any matching at all.
|