@stonyx/orm 0.3.2-beta.156 → 0.3.2-beta.157
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 +119 -46
- package/dist/hooks.d.ts +15 -1
- package/dist/orm-request.d.ts +48 -0
- package/dist/orm-request.js +91 -0
- package/dist/types/orm-types.d.ts +95 -0
- package/package.json +1 -1
- package/src/hooks.ts +15 -1
- package/src/orm-request.ts +91 -0
- package/src/types/orm-types.ts +96 -0
package/README.md
CHANGED
|
@@ -325,6 +325,19 @@ Access classes define models and provide custom filtering/authorization logic.
|
|
|
325
325
|
> deny — which folds case but does not decode, so `GET /owners/%61rchived` steps
|
|
326
326
|
> past it ([#228](https://github.com/abofs/stonyx-orm/issues/228)).
|
|
327
327
|
>
|
|
328
|
+
> **Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
|
|
329
|
+
> [#237](https://github.com/abofs/stonyx-orm/issues/237), and left standing rather
|
|
330
|
+
> than rewritten.** Both claims above are now false: `#228` is **closed**, and the
|
|
331
|
+
> one string comparison variant 3 lived in is gone — the sample below compares the
|
|
332
|
+
> **decoded `recordId`** the access context supplies, so there is no comparison
|
|
333
|
+
> left to step around. The paragraph about `request.path` further down is
|
|
334
|
+
> superseded the same way. Nothing here is deleted because the same "variant 3
|
|
335
|
+
> survives" wording sits at four sites (this file twice, `src/orm-request.ts`, and
|
|
336
|
+
> the test fixture) and retiring one of four leaves the shipped copies
|
|
337
|
+
> contradicting each other; retiring all four **with the measurement that retires
|
|
338
|
+
> them** is [#238](https://github.com/abofs/stonyx-orm/issues/238), which also owns
|
|
339
|
+
> this blockquote and the reference section below.
|
|
340
|
+
>
|
|
328
341
|
> That is still a stopgap. **The real fix is
|
|
329
342
|
> [#202](https://github.com/abofs/stonyx-orm/issues/202)** — `access()` should
|
|
330
343
|
> receive the model, the operation and the record, so there is nothing to
|
|
@@ -337,6 +350,14 @@ Access classes define models and provide custom filtering/authorization logic.
|
|
|
337
350
|
> context alone** and a context-only rewrite would silently turn it into an
|
|
338
351
|
> allow.
|
|
339
352
|
>
|
|
353
|
+
> **Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
|
|
354
|
+
> [#237](https://github.com/abofs/stonyx-orm/issues/237)** — see the dated note
|
|
355
|
+
> above. No read of argument **one** survives in the sample below: the context
|
|
356
|
+
> carries `recordId`, the decoded route-parameter id, so the `/archived` deny **is**
|
|
357
|
+
> expressible from the context alone. It still must not be dropped — expressible is
|
|
358
|
+
> not optional. Retirement of this wording:
|
|
359
|
+
> [#238](https://github.com/abofs/stonyx-orm/issues/238).
|
|
360
|
+
>
|
|
340
361
|
> The same warning is repeated at the top of `src/orm-request.ts`, which ships;
|
|
341
362
|
> the longer write-up in `docs/usage-patterns.md` does **not** ship, so this
|
|
342
363
|
> README and that source header are the two copies a consumer sees.
|
|
@@ -350,66 +371,73 @@ Access classes define models and provide custom filtering/authorization logic.
|
|
|
350
371
|
export default class GlobalAccess {
|
|
351
372
|
models = ['owner', 'animal'];
|
|
352
373
|
|
|
353
|
-
access(request, { model, operation }) {
|
|
374
|
+
access(request, { model, operation, recordId }) {
|
|
354
375
|
// `model` is the model this route was mounted for. It is assigned once, at
|
|
355
376
|
// mount time, and no request can influence it — not a mount prefix, not a
|
|
356
377
|
// query string, not a case-varied path, not an absolute-form request
|
|
357
|
-
// target.
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
378
|
+
// target. `recordId` is the record this route was ADDRESSED TO, decoded by
|
|
379
|
+
// the router and coerced to the key the store lookup uses. Nothing below
|
|
380
|
+
// parses anything, and since abofs/stonyx-orm#236 nothing below reads
|
|
381
|
+
// argument one AT ALL. Variants 1, 2, 4 and 5 were already unconstructible;
|
|
382
|
+
// the sub-path STRING COMPARISON that variant 3 lived in is gone too,
|
|
383
|
+
// replaced by a comparison against the decoded id. Retiring the "variant 3
|
|
384
|
+
// survives" wording at the four sites that still carry it — with the
|
|
385
|
+
// measurement that retires it, rather than by deletion — is
|
|
386
|
+
// abofs/stonyx-orm#238.
|
|
363
387
|
//
|
|
364
388
|
// `operation` is destructured to name the whole contract at the point of
|
|
365
|
-
// use. This sample's rules are per-model and per-
|
|
389
|
+
// use. This sample's rules are per-model and per-record rather than
|
|
366
390
|
// per-verb, so it does not branch on it; the permission array at the bottom
|
|
367
391
|
// is where the verb is answered.
|
|
368
392
|
|
|
369
|
-
// FAIL CLOSED ON
|
|
370
|
-
// resolved this predicate without supplying the context, and a request
|
|
371
|
-
// function cannot identify DENIES rather than falling through to the
|
|
372
|
-
// grant at the bottom. An unidentifiable input must never be the
|
|
373
|
-
// path.
|
|
374
|
-
// not cover it.
|
|
393
|
+
// FAIL CLOSED ON AN UNIDENTIFIABLE MODEL. `model` is absent for any caller
|
|
394
|
+
// that resolved this predicate without supplying the context, and a request
|
|
395
|
+
// this function cannot identify DENIES rather than falling through to the
|
|
396
|
+
// CRUD grant at the bottom. An unidentifiable input must never be the
|
|
397
|
+
// permissive path.
|
|
375
398
|
if (typeof model !== 'string' || model === '') return false;
|
|
376
399
|
|
|
377
400
|
if (model === 'owner') {
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
//
|
|
401
|
+
// FAIL CLOSED ON AN ABSENT `recordId` TOO, AND `undefined` IS THE ONLY
|
|
402
|
+
// SPELLING OF ABSENT. `auth()` ALWAYS sets the key — `null` on a
|
|
403
|
+
// collection route, which is addressed to no record — so `undefined`
|
|
404
|
+
// means the context did not come from `auth()`: it was hand-assembled by
|
|
405
|
+
// a caller resolving this predicate through the documented
|
|
406
|
+
// `Orm.instance.getAccess()` path. Letting that through would fall
|
|
407
|
+
// straight to the per-record filter below, which is a DENY becoming an
|
|
408
|
+
// ALLOW. This is the same rule the old guard on `request.path` enforced,
|
|
409
|
+
// moved to the argument this predicate now actually reads.
|
|
410
|
+
if (recordId === undefined) return false;
|
|
411
|
+
|
|
412
|
+
// THE `/archived` DENY, EXPRESSED AGAINST THE DECODED ID. It used to be
|
|
413
|
+
// `request.path.toLowerCase()` compared against `'/archived'`, and that
|
|
414
|
+
// was wrong in both directions at once.
|
|
383
415
|
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
416
|
+
// TOO PERMISSIVE: express sets `request.path` from the RAW pathname while
|
|
417
|
+
// the router DECODES `:id`, so `GET /owners/%61rchived` reached the
|
|
418
|
+
// comparison as `/%61rchived`, walked past the deny and was dispatched as
|
|
419
|
+
// the record `archived` — 200 with the record in full, and DELETE
|
|
420
|
+
// answered 204 with the record DESTROYED, unauthenticated. 255
|
|
421
|
+
// non-canonical spellings of that 8-character id decode to the same key,
|
|
422
|
+
// so no deny-list of spellings was ever going to close it.
|
|
386
423
|
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
// `
|
|
391
|
-
//
|
|
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.
|
|
424
|
+
// TOO STRICT: a record id is a VALUE, not a literal route segment, and
|
|
425
|
+
// express's `case sensitive routing` governs literal segments only. With
|
|
426
|
+
// a distinct owner seeded at `ARCHIVED`, the `.toLowerCase()` 403'd
|
|
427
|
+
// `GET /owners/ARCHIVED` — the wrong record — while still admitting
|
|
428
|
+
// `GET /owners/%41RCHIVED`, the same record encoded.
|
|
402
429
|
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
430
|
+
// SO DO NOT NORMALISE `recordId`. It is already decoded, exactly ONCE,
|
|
431
|
+
// which is what a route parameter means: `/owners/%2561rchived` is the
|
|
432
|
+
// legitimate id `%61rchived`, and decoding until stable would deny it. Do
|
|
433
|
+
// not case-fold it. Do not rebuild it from `request.path` — decoding the
|
|
434
|
+
// whole path decodes THEN splits while the router splits THEN decodes,
|
|
435
|
+
// which over-denies the distinct record at `/owners/archived%2fx`.
|
|
436
|
+
//
|
|
437
|
+
// THE DENY IS NOW EXPRESSIBLE FROM THE CONTEXT ALONE, which is exactly
|
|
438
|
+
// what `recordId` bought — and it still must not be dropped. Deleting it
|
|
439
|
+
// does not remove a rule loudly, it turns a deny into an ALLOW, silently.
|
|
440
|
+
if (recordId === 'archived') return false;
|
|
413
441
|
|
|
414
442
|
// Returning a function plugs it in as a per-record filter, and it is
|
|
415
443
|
// enforced on every surface addressed to one of these records:
|
|
@@ -492,6 +520,16 @@ access class shipped with this repo has such a rule: its `/archived` deny
|
|
|
492
520
|
**cannot be expressed from the context alone**, and a predicate migrated to
|
|
493
521
|
context-only would silently drop it — a deny becoming an allow.
|
|
494
522
|
|
|
523
|
+
**Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
|
|
524
|
+
[#237](https://github.com/abofs/stonyx-orm/issues/237).** The context also carries
|
|
525
|
+
`recordId` — the record this route was addressed to, already decoded — so the
|
|
526
|
+
`/archived` deny **is** expressible from the context alone, and the shipped sample
|
|
527
|
+
no longer reads `request.path`. The full contract is `AccessContext.recordId` in
|
|
528
|
+
`src/types/orm-types.ts`, which ships. This section — the signature, the key table
|
|
529
|
+
and this paragraph — is corrected by
|
|
530
|
+
[#238](https://github.com/abofs/stonyx-orm/issues/238); the pointer is here because
|
|
531
|
+
what it currently says is an instruction, and the instruction is wrong.
|
|
532
|
+
|
|
495
533
|
Note also that the related-resource and `?include=` surfaces serve *another
|
|
496
534
|
model's* records under `model: 'owner'`, and the context gives a predicate no
|
|
497
535
|
signal that it is authorizing a related-resource route. That is
|
|
@@ -787,6 +825,17 @@ these values should be matched on:
|
|
|
787
825
|
| `GET http://anything.example/owners/angela` | `http://anything.example/angela` | `http://anything.example/owners/angela` | `/owners` | `/angela` |
|
|
788
826
|
| `GET /api/animals/22` (`ORM_REST_ROUTE=/api`) | `/22` | `/api/animals/22` | `/api/animals` | `/22` |
|
|
789
827
|
|
|
828
|
+
**Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
|
|
829
|
+
[#237](https://github.com/abofs/stonyx-orm/issues/237) — "Variant 3 survives" above,
|
|
830
|
+
and the two paragraphs below, are no longer true.** The access context now carries
|
|
831
|
+
`recordId`, the **decoded** route-parameter id, and the sample compares against it:
|
|
832
|
+
the one string comparison variant 3 lived in is gone, `#228` is **closed**, and no
|
|
833
|
+
read of argument one survives in the sample. The wording is left standing rather
|
|
834
|
+
than deleted because it appears at four sites (this file twice,
|
|
835
|
+
`src/orm-request.ts`, and the test fixture) and retiring one of four leaves the
|
|
836
|
+
shipped copies contradicting each other; retiring all four **with the measurement
|
|
837
|
+
that retires them** is [#238](https://github.com/abofs/stonyx-orm/issues/238).
|
|
838
|
+
|
|
790
839
|
**One read of argument one survives, and it must: `request.path`.** It is
|
|
791
840
|
mount-relative and query-free, and it is for rules that distinguish **sub-paths**
|
|
792
841
|
beneath the mount — as the `/archived` deny in the sample above does. The context
|
|
@@ -806,6 +855,20 @@ comparison as `/%61rchived`, walks past the deny, and is dispatched as the recor
|
|
|
806
855
|
`.toLowerCase()` there as a complete normalisation recipe.** Record ids are
|
|
807
856
|
case-sensitive and must be compared at their real case.
|
|
808
857
|
|
|
858
|
+
**Do not follow the two paragraphs above — superseded 2026-09-01 by
|
|
859
|
+
[#236](https://github.com/abofs/stonyx-orm/issues/236) /
|
|
860
|
+
[#237](https://github.com/abofs/stonyx-orm/issues/237).** They are *instructions*,
|
|
861
|
+
not merely stale observations, which is why this note is louder than a date. The
|
|
862
|
+
sample no longer reads `request.path` and no longer calls `.toLowerCase()` on
|
|
863
|
+
anything it compares: `.toLowerCase()` was measured wrong in **both directions at
|
|
864
|
+
once** — with a distinct owner seeded at `ARCHIVED`, `GET /owners/ARCHIVED` was a
|
|
865
|
+
false **deny** on the wrong record and `GET /owners/%41RCHIVED` a false **allow**
|
|
866
|
+
on that same record. Compare `recordId` **as it arrives**: do not case-fold it, do
|
|
867
|
+
not decode it, do not derive it from `request.path`. The contract is
|
|
868
|
+
`AccessContext.recordId` in `src/types/orm-types.ts`, which ships and says "Do NOT
|
|
869
|
+
case-fold it". Retirement of this wording, with its measurement:
|
|
870
|
+
[#238](https://github.com/abofs/stonyx-orm/issues/238).
|
|
871
|
+
|
|
809
872
|
**Fail closed on anything you cannot identify — on *either* argument.**
|
|
810
873
|
`String(request.originalUrl ?? '')` was once added here to stop a `TypeError`,
|
|
811
874
|
and it traded fail-closed for fail-**open**: an empty string matched no
|
|
@@ -1186,6 +1249,16 @@ Each hook receives a context object with comprehensive information:
|
|
|
1186
1249
|
- It contains a deep copy of the record's state **before** the operation executes (captured before the `before` hook fires)
|
|
1187
1250
|
- The deep copy is created via JSON serialization (`JSON.parse(JSON.stringify())`) to ensure complete isolation
|
|
1188
1251
|
- For `delete` operations, `recordId` is provided in after hooks since the record may no longer exist in the store
|
|
1252
|
+
- **`context.recordId` here is NOT `AccessContext.recordId`.** Same name, same-shaped
|
|
1253
|
+
object, different coverage: `_withHooks` sets this key **only** under
|
|
1254
|
+
`operation === 'delete'`, so on `get` / `list` / `create` / `update` the key is
|
|
1255
|
+
**absent** — `beforeHook('update', 'owner', ctx => ctx.recordId === 'archived' ? 403 : undefined)`
|
|
1256
|
+
never fires (measured: `PATCH /owners/{id}` → 200 with `ctx.recordId === undefined`
|
|
1257
|
+
and the id sitting in `ctx.params`). The access context, by contrast, carries
|
|
1258
|
+
`recordId` on every route it classifies and spells absence as `null`, never
|
|
1259
|
+
`undefined`. Tracked as
|
|
1260
|
+
[#242](https://github.com/abofs/stonyx-orm/issues/242); see
|
|
1261
|
+
`AccessContext.recordId` in `src/types/orm-types.ts` for the other side.
|
|
1189
1262
|
- `oldState` is captured as a deep copy of the record's data before the operation, providing access to the previous field values
|
|
1190
1263
|
|
|
1191
1264
|
### Usage Examples
|
package/dist/hooks.d.ts
CHANGED
|
@@ -20,7 +20,21 @@ export interface HookContext {
|
|
|
20
20
|
state?: Record<string, unknown>;
|
|
21
21
|
/** Previous record state (available in update hooks). */
|
|
22
22
|
oldState?: unknown;
|
|
23
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Target record ID for single-record operations.
|
|
25
|
+
*
|
|
26
|
+
* SET ONLY UNDER `delete`. `_withHooks` assigns this key in the two
|
|
27
|
+
* `operation === 'delete'` branches and nowhere else, so on `get`, `list`,
|
|
28
|
+
* `create` and `update` the key is ABSENT -- not `undefined`-valued, absent.
|
|
29
|
+
* A hook rule written as `ctx.recordId === '<id>'` never fires on an update;
|
|
30
|
+
* the addressed id is in `ctx.params`. Tracked as abofs/stonyx-orm#242.
|
|
31
|
+
*
|
|
32
|
+
* @see AccessContext.recordId in ./types/orm-types.ts -- an identically-named
|
|
33
|
+
* key on an identically-shaped context object, and NOT interchangeable with
|
|
34
|
+
* this one: it is present on every route `auth()` classifies, and spells
|
|
35
|
+
* absence as `null` rather than `undefined`. They differ in coverage on four
|
|
36
|
+
* of five operations, not only in the absence spelling.
|
|
37
|
+
*/
|
|
24
38
|
recordId?: string | number;
|
|
25
39
|
/** Response data (available in after hooks). */
|
|
26
40
|
response?: unknown;
|
package/dist/orm-request.d.ts
CHANGED
|
@@ -69,6 +69,15 @@
|
|
|
69
69
|
* records under `model: 'owner'`, and the context gives no signal of that
|
|
70
70
|
* (abofs/stonyx-orm#196).
|
|
71
71
|
*
|
|
72
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237, AND KEPT FOR THE
|
|
73
|
+
* CONSTRAINT IT STATES RATHER THAN AS A DESCRIPTION OF THE CODE. The context
|
|
74
|
+
* now also carries `recordId` -- the DECODED route-parameter id, see
|
|
75
|
+
* `AccessContext.recordId` in ./types/orm-types.ts -- so the fixture's
|
|
76
|
+
* `/archived` deny IS expressible from the context alone, and the shipped
|
|
77
|
+
* sample no longer reads `request.path` at all. Retiring this wording WITH the
|
|
78
|
+
* measurement that retires it, rather than by deletion, is
|
|
79
|
+
* abofs/stonyx-orm#238.
|
|
80
|
+
*
|
|
72
81
|
* `record` IS NOT IN THIS CONTEXT, deliberately. `auth()` runs after route
|
|
73
82
|
* matching but BEFORE any handler executes (`@stonyx/rest-server`
|
|
74
83
|
* `src/request.ts:58-60`), so nothing has been fetched yet -- supplying a
|
|
@@ -158,6 +167,11 @@
|
|
|
158
167
|
* `/archived` SUB-PATH rule is still a string match, and abofs/stonyx-orm#228 is
|
|
159
168
|
* a sixth spelling that gets past it.
|
|
160
169
|
*
|
|
170
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237: the `/archived` rule is
|
|
171
|
+
* no longer a string match against the request target -- it compares the
|
|
172
|
+
* decoded `recordId` the framework supplies -- and abofs/stonyx-orm#228 is
|
|
173
|
+
* CLOSED. Retirement of this wording: abofs/stonyx-orm#238.
|
|
174
|
+
*
|
|
161
175
|
* An intermediate revision of the sample read `request.baseUrl` -- the mount
|
|
162
176
|
* Express ACTUALLY MATCHED. That closed all five variants (no query string,
|
|
163
177
|
* not mount-relative, unaffected by absolute-form, already carrying the
|
|
@@ -173,12 +187,26 @@
|
|
|
173
187
|
* does not decode, so `GET /owners/%61rchived` steps past it. See the
|
|
174
188
|
* normalisation paragraph below and abofs/stonyx-orm#228.
|
|
175
189
|
*
|
|
190
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237. Variant 3 lived in that
|
|
191
|
+
* one string comparison, and the comparison is gone: the sample compares the
|
|
192
|
+
* decoded `recordId`. Left standing rather than edited because the same
|
|
193
|
+
* "variant 3 survives" wording sits at four sites -- this header, README.md
|
|
194
|
+
* twice, and test/sample/access/global-access.ts -- three of which SHIP, so
|
|
195
|
+
* retiring one of four leaves the shipped copies contradicting each other.
|
|
196
|
+
* Retiring all four WITH their measurement is abofs/stonyx-orm#238.
|
|
197
|
+
*
|
|
176
198
|
* ONE READ OF ARGUMENT ONE SURVIVES, AND IT MUST: `request.path`. It is
|
|
177
199
|
* mount-relative and query-free, and it is for rules that distinguish SUB-PATHS
|
|
178
200
|
* beneath the mount. The context names which model and which verb, NOT which
|
|
179
201
|
* route, so the sample's `/archived` deny cannot be expressed from the context
|
|
180
202
|
* alone and a context-ONLY rewrite would silently turn that deny into an allow.
|
|
181
203
|
*
|
|
204
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237: NO read of argument one
|
|
205
|
+
* survives in the shipped sample. `recordId` names WHICH RECORD the route was
|
|
206
|
+
* addressed to, so the `/archived` deny is expressible from the context alone
|
|
207
|
+
* -- and it still must not be dropped; expressible is not optional. Retirement
|
|
208
|
+
* of this wording: abofs/stonyx-orm#238.
|
|
209
|
+
*
|
|
182
210
|
* NORMALISE THE WAY THE ROUTER DOES, AND CASE-FOLDING ALONE IS NOT THAT. The
|
|
183
211
|
* sample lower-cases before comparing, because a matcher stricter than the
|
|
184
212
|
* case-insensitive router can be stepped around. That closes the case gap only.
|
|
@@ -188,6 +216,20 @@
|
|
|
188
216
|
* sample and is tracked as abofs/stonyx-orm#228; the `.toLowerCase()` is not a
|
|
189
217
|
* complete normalisation recipe. Compare record ids at their real case.
|
|
190
218
|
*
|
|
219
|
+
* DO NOT FOLLOW THE PARAGRAPH ABOVE. SUPERSEDED 2026-09-01 BY
|
|
220
|
+
* abofs/stonyx-orm#236/#237, and flagged here rather than merely dated because
|
|
221
|
+
* it is an INSTRUCTION, not a stale observation. `.toLowerCase()` on the access
|
|
222
|
+
* path was measured WRONG IN BOTH DIRECTIONS AT ONCE: with a distinct owner
|
|
223
|
+
* seeded at `ARCHIVED`, `GET /owners/ARCHIVED` was a false DENY on the wrong
|
|
224
|
+
* record and `GET /owners/%41RCHIVED` a false ALLOW on that same record. A
|
|
225
|
+
* record id is a VALUE, not a literal route segment, and express's
|
|
226
|
+
* `case sensitive routing` governs literal segments only. Compare
|
|
227
|
+
* `context.recordId` AS IT ARRIVES: do not case-fold it, do not decode it, do
|
|
228
|
+
* not derive it from `request.path`. `AccessContext.recordId` in
|
|
229
|
+
* ./types/orm-types.ts is the contract and says "Do NOT case-fold it"; the same
|
|
230
|
+
* published tarball ships both files, and THIS paragraph is the one that is
|
|
231
|
+
* wrong. Retiring it WITH its measurement is abofs/stonyx-orm#238.
|
|
232
|
+
*
|
|
191
233
|
* `?? ''` is not a defence. It converts an absent request target into an empty
|
|
192
234
|
* string, which matches no collection, which falls through to the permission
|
|
193
235
|
* array -- a total grant. An input you cannot identify must DENY, and that
|
|
@@ -196,6 +238,12 @@
|
|
|
196
238
|
* argument one. The sample returns `false` for an absent `model` AND for an
|
|
197
239
|
* absent or non-string `request.path`, rather than falling through either way.
|
|
198
240
|
*
|
|
241
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237 as to WHAT is guarded --
|
|
242
|
+
* the principle is unchanged. The sample no longer reads `request.path`, so it
|
|
243
|
+
* returns `false` for an absent `model` AND for an absent `recordId`
|
|
244
|
+
* (`undefined`, the one spelling `auth()` never produces). Retirement of this
|
|
245
|
+
* wording: abofs/stonyx-orm#238.
|
|
246
|
+
*
|
|
199
247
|
* THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
|
|
200
248
|
* the operation and the record. Prefer the array shape (`['read']`) or `false`
|
|
201
249
|
* until #202 lands; the function shape is what requires any matching at all.
|
package/dist/orm-request.js
CHANGED
|
@@ -69,6 +69,15 @@
|
|
|
69
69
|
* records under `model: 'owner'`, and the context gives no signal of that
|
|
70
70
|
* (abofs/stonyx-orm#196).
|
|
71
71
|
*
|
|
72
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237, AND KEPT FOR THE
|
|
73
|
+
* CONSTRAINT IT STATES RATHER THAN AS A DESCRIPTION OF THE CODE. The context
|
|
74
|
+
* now also carries `recordId` -- the DECODED route-parameter id, see
|
|
75
|
+
* `AccessContext.recordId` in ./types/orm-types.ts -- so the fixture's
|
|
76
|
+
* `/archived` deny IS expressible from the context alone, and the shipped
|
|
77
|
+
* sample no longer reads `request.path` at all. Retiring this wording WITH the
|
|
78
|
+
* measurement that retires it, rather than by deletion, is
|
|
79
|
+
* abofs/stonyx-orm#238.
|
|
80
|
+
*
|
|
72
81
|
* `record` IS NOT IN THIS CONTEXT, deliberately. `auth()` runs after route
|
|
73
82
|
* matching but BEFORE any handler executes (`@stonyx/rest-server`
|
|
74
83
|
* `src/request.ts:58-60`), so nothing has been fetched yet -- supplying a
|
|
@@ -158,6 +167,11 @@
|
|
|
158
167
|
* `/archived` SUB-PATH rule is still a string match, and abofs/stonyx-orm#228 is
|
|
159
168
|
* a sixth spelling that gets past it.
|
|
160
169
|
*
|
|
170
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237: the `/archived` rule is
|
|
171
|
+
* no longer a string match against the request target -- it compares the
|
|
172
|
+
* decoded `recordId` the framework supplies -- and abofs/stonyx-orm#228 is
|
|
173
|
+
* CLOSED. Retirement of this wording: abofs/stonyx-orm#238.
|
|
174
|
+
*
|
|
161
175
|
* An intermediate revision of the sample read `request.baseUrl` -- the mount
|
|
162
176
|
* Express ACTUALLY MATCHED. That closed all five variants (no query string,
|
|
163
177
|
* not mount-relative, unaffected by absolute-form, already carrying the
|
|
@@ -173,12 +187,26 @@
|
|
|
173
187
|
* does not decode, so `GET /owners/%61rchived` steps past it. See the
|
|
174
188
|
* normalisation paragraph below and abofs/stonyx-orm#228.
|
|
175
189
|
*
|
|
190
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237. Variant 3 lived in that
|
|
191
|
+
* one string comparison, and the comparison is gone: the sample compares the
|
|
192
|
+
* decoded `recordId`. Left standing rather than edited because the same
|
|
193
|
+
* "variant 3 survives" wording sits at four sites -- this header, README.md
|
|
194
|
+
* twice, and test/sample/access/global-access.ts -- three of which SHIP, so
|
|
195
|
+
* retiring one of four leaves the shipped copies contradicting each other.
|
|
196
|
+
* Retiring all four WITH their measurement is abofs/stonyx-orm#238.
|
|
197
|
+
*
|
|
176
198
|
* ONE READ OF ARGUMENT ONE SURVIVES, AND IT MUST: `request.path`. It is
|
|
177
199
|
* mount-relative and query-free, and it is for rules that distinguish SUB-PATHS
|
|
178
200
|
* beneath the mount. The context names which model and which verb, NOT which
|
|
179
201
|
* route, so the sample's `/archived` deny cannot be expressed from the context
|
|
180
202
|
* alone and a context-ONLY rewrite would silently turn that deny into an allow.
|
|
181
203
|
*
|
|
204
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237: NO read of argument one
|
|
205
|
+
* survives in the shipped sample. `recordId` names WHICH RECORD the route was
|
|
206
|
+
* addressed to, so the `/archived` deny is expressible from the context alone
|
|
207
|
+
* -- and it still must not be dropped; expressible is not optional. Retirement
|
|
208
|
+
* of this wording: abofs/stonyx-orm#238.
|
|
209
|
+
*
|
|
182
210
|
* NORMALISE THE WAY THE ROUTER DOES, AND CASE-FOLDING ALONE IS NOT THAT. The
|
|
183
211
|
* sample lower-cases before comparing, because a matcher stricter than the
|
|
184
212
|
* case-insensitive router can be stepped around. That closes the case gap only.
|
|
@@ -188,6 +216,20 @@
|
|
|
188
216
|
* sample and is tracked as abofs/stonyx-orm#228; the `.toLowerCase()` is not a
|
|
189
217
|
* complete normalisation recipe. Compare record ids at their real case.
|
|
190
218
|
*
|
|
219
|
+
* DO NOT FOLLOW THE PARAGRAPH ABOVE. SUPERSEDED 2026-09-01 BY
|
|
220
|
+
* abofs/stonyx-orm#236/#237, and flagged here rather than merely dated because
|
|
221
|
+
* it is an INSTRUCTION, not a stale observation. `.toLowerCase()` on the access
|
|
222
|
+
* path was measured WRONG IN BOTH DIRECTIONS AT ONCE: with a distinct owner
|
|
223
|
+
* seeded at `ARCHIVED`, `GET /owners/ARCHIVED` was a false DENY on the wrong
|
|
224
|
+
* record and `GET /owners/%41RCHIVED` a false ALLOW on that same record. A
|
|
225
|
+
* record id is a VALUE, not a literal route segment, and express's
|
|
226
|
+
* `case sensitive routing` governs literal segments only. Compare
|
|
227
|
+
* `context.recordId` AS IT ARRIVES: do not case-fold it, do not decode it, do
|
|
228
|
+
* not derive it from `request.path`. `AccessContext.recordId` in
|
|
229
|
+
* ./types/orm-types.ts is the contract and says "Do NOT case-fold it"; the same
|
|
230
|
+
* published tarball ships both files, and THIS paragraph is the one that is
|
|
231
|
+
* wrong. Retiring it WITH its measurement is abofs/stonyx-orm#238.
|
|
232
|
+
*
|
|
191
233
|
* `?? ''` is not a defence. It converts an absent request target into an empty
|
|
192
234
|
* string, which matches no collection, which falls through to the permission
|
|
193
235
|
* array -- a total grant. An input you cannot identify must DENY, and that
|
|
@@ -196,6 +238,12 @@
|
|
|
196
238
|
* argument one. The sample returns `false` for an absent `model` AND for an
|
|
197
239
|
* absent or non-string `request.path`, rather than falling through either way.
|
|
198
240
|
*
|
|
241
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237 as to WHAT is guarded --
|
|
242
|
+
* the principle is unchanged. The sample no longer reads `request.path`, so it
|
|
243
|
+
* returns `false` for an absent `model` AND for an absent `recordId`
|
|
244
|
+
* (`undefined`, the one spelling `auth()` never produces). Retirement of this
|
|
245
|
+
* wording: abofs/stonyx-orm#238.
|
|
246
|
+
*
|
|
199
247
|
* THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
|
|
200
248
|
* the operation and the record. Prefer the array shape (`['read']`) or `false`
|
|
201
249
|
* until #202 lands; the function shape is what requires any matching at all.
|
|
@@ -1269,10 +1317,53 @@ export default class OrmRequest extends Request {
|
|
|
1269
1317
|
// src/types/orm-types.ts. Nothing is fetched at this point and adding a
|
|
1270
1318
|
// lookup here would put a store read in the middle of an authorization
|
|
1271
1319
|
// path. The function return shape below IS the per-record hook.
|
|
1320
|
+
//
|
|
1321
|
+
// -------------------------------------------------------------------------
|
|
1322
|
+
// #236 -- `recordId`, the DECODED route-parameter id, for the same reason.
|
|
1323
|
+
//
|
|
1324
|
+
// WHICH RECORD is the third structural fact the framework already holds and
|
|
1325
|
+
// the consumer was left to re-derive, and re-deriving it failed OPEN. The
|
|
1326
|
+
// documented sample compared `request.path` -- the RAW, undecoded pathname
|
|
1327
|
+
// -- against a literal `/archived`, while the router DECODES `:id`. So
|
|
1328
|
+
// `GET /owners/%61rchived` walked past the deny and was dispatched as the
|
|
1329
|
+
// record `archived`: 200 with the record in full, and DELETE answered 204
|
|
1330
|
+
// with the record destroyed, unauthenticated. Four spellings measured, all
|
|
1331
|
+
// four through; 255 non-canonical spellings of that 8-character id decode
|
|
1332
|
+
// to the same key, so this was never a deny-list of one.
|
|
1333
|
+
//
|
|
1334
|
+
// TWO CONSUMER-SIDE NORMALISATIONS WERE MEASURED WRONG IN OPPOSITE
|
|
1335
|
+
// DIRECTIONS, which is the argument for doing it once, here.
|
|
1336
|
+
// `.toLowerCase()` case-folds a route-parameter VALUE on the axis that
|
|
1337
|
+
// governs literal SEGMENTS: with a distinct owner seeded at `ARCHIVED`,
|
|
1338
|
+
// `GET /owners/ARCHIVED` was a false DENY on the wrong record and
|
|
1339
|
+
// `GET /owners/%41RCHIVED` a false ALLOW on that same one.
|
|
1340
|
+
// `decodeURIComponent(request.path)` decodes THEN splits while the router
|
|
1341
|
+
// splits THEN decodes, so it over-denied `/owners/archived%2fx` -- 403 for
|
|
1342
|
+
// a genuinely distinct record. Failing closed there was luck, not design.
|
|
1343
|
+
//
|
|
1344
|
+
// `getId(request.params)` AND NOT `request.params.id`, for exactly the
|
|
1345
|
+
// reason `operation` is a `methodAccessMap` lookup: it is the SAME single
|
|
1346
|
+
// coercion the store lookup one layer down performs, so the predicate and
|
|
1347
|
+
// the dispatch cannot disagree about which record a request addresses.
|
|
1348
|
+
// The raw string would reintroduce that divergence on hex-shaped ids --
|
|
1349
|
+
// `GET /animals/0x2391` looks up record `9105`.
|
|
1350
|
+
//
|
|
1351
|
+
// NOTHING HERE PARSES THE REQUEST TARGET EITHER. `request.params` is what
|
|
1352
|
+
// the router matched, so a mount prefix, an absolute-form target, a query
|
|
1353
|
+
// string or a case-varied mount cannot move this value -- the same
|
|
1354
|
+
// guarantee `model` carries, by the same means.
|
|
1355
|
+
//
|
|
1356
|
+
// `null` and not `undefined` on a collection route, so the KEY IS ALWAYS
|
|
1357
|
+
// PRESENT -- the rule `operation`'s own docblock already establishes. A
|
|
1358
|
+
// context reaching a predicate WITHOUT the key therefore did not come from
|
|
1359
|
+
// here; it was hand-assembled by a caller resolving the predicate through
|
|
1360
|
+
// `Orm.instance.getAccess()`, and that absence stays deniable only because
|
|
1361
|
+
// `auth()` never produces it.
|
|
1272
1362
|
// -------------------------------------------------------------------------
|
|
1273
1363
|
const context = {
|
|
1274
1364
|
model: this.model,
|
|
1275
1365
|
operation: methodAccessMap[request.method],
|
|
1366
|
+
recordId: request.params && 'id' in request.params ? getId(request.params) : null,
|
|
1276
1367
|
};
|
|
1277
1368
|
let access;
|
|
1278
1369
|
try {
|
|
@@ -242,6 +242,101 @@ export interface AccessContext {
|
|
|
242
242
|
* from one that classified the request and found nothing.
|
|
243
243
|
*/
|
|
244
244
|
operation: AccessOperation | undefined;
|
|
245
|
+
/**
|
|
246
|
+
* The record this route was addressed to, as the store key -- or `null` on a
|
|
247
|
+
* collection route, which is addressed to no record (abofs/stonyx-orm#236).
|
|
248
|
+
*
|
|
249
|
+
* IT IS ALREADY DECODED, AND THAT IS THE WHOLE POINT. Express decodes route
|
|
250
|
+
* PARAMETERS while leaving `request.path` raw, so a consumer comparing
|
|
251
|
+
* `request.path` against a literal compares an undecoded string against a
|
|
252
|
+
* decoded dispatch. `GET /owners/%61rchived` reached such a comparison as
|
|
253
|
+
* `/%61rchived`, walked past a `/archived` deny, and was dispatched as the
|
|
254
|
+
* record `archived` -- 200 with the record in full, and `DELETE` destroyed
|
|
255
|
+
* it, unauthenticated. 255 non-canonical spellings of an 8-character id
|
|
256
|
+
* decode to the same key, so a deny-list of spellings is the wrong shape.
|
|
257
|
+
*
|
|
258
|
+
* SO DO NOT NORMALISE THIS, AND DO NOT NORMALISE ANYTHING ELSE INSTEAD:
|
|
259
|
+
*
|
|
260
|
+
* - Do NOT decode it. Express decodes exactly ONCE, which is what a route
|
|
261
|
+
* parameter means. `GET /owners/%2561rchived` is the legitimate id
|
|
262
|
+
* `%61rchived`, not a second-order spelling of `archived`; a predicate that
|
|
263
|
+
* decoded until stable would deny a record it was never asked about.
|
|
264
|
+
* - Do NOT case-fold it. A record id is a VALUE, not a literal route segment,
|
|
265
|
+
* and express's `case sensitive routing` governs literal segments only.
|
|
266
|
+
* With a distinct owner seeded at `ARCHIVED`, `.toLowerCase()` was measured
|
|
267
|
+
* wrong in BOTH directions at once: `GET /owners/ARCHIVED` 403 (a false
|
|
268
|
+
* deny, on the wrong record) and `GET /owners/%41RCHIVED` 200 (a false
|
|
269
|
+
* allow, on that same record).
|
|
270
|
+
* - Do NOT derive it from `request.path` or the request target. Decoding the
|
|
271
|
+
* whole path decodes THEN splits, while the router splits THEN decodes, so
|
|
272
|
+
* `/owners/archived%2fx` -- a genuinely distinct record whose id is
|
|
273
|
+
* `archived/x` -- was measured over-denied 403.
|
|
274
|
+
*
|
|
275
|
+
* IT IS `getId(request.params)`, BYTE FOR BYTE -- the same single coercion
|
|
276
|
+
* the store lookup uses, exactly as `operation` is the same `methodAccessMap`
|
|
277
|
+
* lookup the permission-array branch uses. The predicate and the dispatch
|
|
278
|
+
* therefore cannot disagree about which record a request addresses. Handing
|
|
279
|
+
* over the raw `request.params.id` instead would reintroduce that divergence
|
|
280
|
+
* on hex-shaped ids: `GET /animals/0x2391` looks up record `9105`.
|
|
281
|
+
*
|
|
282
|
+
* It inherits abofs/stonyx-orm#209 along with that coercion -- on a model
|
|
283
|
+
* declaring `id = attr('string')`, `'9107'` arrives here as the number
|
|
284
|
+
* `9107`. That is consistency WITH THE LOOKUP, which is the property this key
|
|
285
|
+
* exists to buy; it is not a defect to repair here.
|
|
286
|
+
*
|
|
287
|
+
* `null`, not `undefined`, on a collection route -- and the KEY IS ALWAYS
|
|
288
|
+
* PRESENT, the same rule `operation` states above. `auth()` always sets it,
|
|
289
|
+
* so a context arriving WITHOUT the key did not come from `auth()`: it was
|
|
290
|
+
* hand-assembled by a caller resolving the predicate through
|
|
291
|
+
* `Orm.instance.getAccess()`. That absence stays a distinguishable, deniable
|
|
292
|
+
* signal only because the framework never produces it.
|
|
293
|
+
*
|
|
294
|
+
* IT DISAGREES WITH THE HOOK VOCABULARY, AND NOT ONLY ON THE ABSENCE
|
|
295
|
+
* SPELLING. `HookContext.recordId` (`src/hooks.ts`) is an identically-named
|
|
296
|
+
* key on an identically-shaped context object, which is the exact
|
|
297
|
+
* configuration that makes `operation` fail-open shaped -- a hook sees
|
|
298
|
+
* `'get'` where `access()` sees `'read'`. An earlier revision of THIS
|
|
299
|
+
* docblock asserted the opposite ("here they AGREE... they differ in ONE way
|
|
300
|
+
* and it is the absence spelling"). That was measured false, in the fail-open
|
|
301
|
+
* direction, and it is corrected here rather than deleted.
|
|
302
|
+
*
|
|
303
|
+
* MEASURED over the live dispatch, before-hooks registered for all five
|
|
304
|
+
* operations on one model:
|
|
305
|
+
*
|
|
306
|
+
* before:list key ABSENT ('recordId' in context === false)
|
|
307
|
+
* before:get key ABSENT params={"id":"visible1"}
|
|
308
|
+
* before:create key ABSENT
|
|
309
|
+
* before:update key ABSENT params={"id":"visible2"}
|
|
310
|
+
* before:delete recordId="visible3"
|
|
311
|
+
* after:delete recordId="visible3"
|
|
312
|
+
*
|
|
313
|
+
* `_withHooks` assigns `context.recordId` at exactly TWO sites in
|
|
314
|
+
* `src/orm-request.ts`, and BOTH sit inside an `operation === 'delete'`
|
|
315
|
+
* branch. So the two keys differ in COVERAGE, on four of five operations: on
|
|
316
|
+
* a hook context the key is absent for get, list, create and update, while
|
|
317
|
+
* this key is present on every route `auth()` classifies. The absence
|
|
318
|
+
* spelling is the smaller half of the difference, not the whole of it.
|
|
319
|
+
*
|
|
320
|
+
* AND THAT INVERTS THE ARGUMENT ABOVE WHEN IT IS READ ACROSS THE TWO. Here,
|
|
321
|
+
* a missing `recordId` means "did not come from `auth()`" and is deniable.
|
|
322
|
+
* On a hook context it means "this is a get / list / create / update" -- an
|
|
323
|
+
* ordinary request. A consumer who writes the hook-side half of the same
|
|
324
|
+
* rule --
|
|
325
|
+
*
|
|
326
|
+
* beforeHook('update', 'owner', ctx => ctx.recordId === 'archived' ? 403 : undefined)
|
|
327
|
+
*
|
|
328
|
+
* -- gets a deny that NEVER FIRES: measured, `PATCH /owners/visible2` -> 200,
|
|
329
|
+
* with `ctx.recordId === undefined` while the addressed record sits in
|
|
330
|
+
* `ctx.params`. The hook side is abofs/stonyx-orm#242 and is deliberately not
|
|
331
|
+
* repaired here. A predicate must not read `undefined` here as "collection",
|
|
332
|
+
* and nothing in this contract makes it safe to read the two keys as one key.
|
|
333
|
+
*
|
|
334
|
+
* IT NAMES WHICH RECORD, NOT WHICH SURFACE. `GET /owners/gina`,
|
|
335
|
+
* `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` all
|
|
336
|
+
* carry `recordId: 'gina'`; the related-resource gap is abofs/stonyx-orm#196
|
|
337
|
+
* and is untouched by this key.
|
|
338
|
+
*/
|
|
339
|
+
recordId: string | number | null;
|
|
245
340
|
}
|
|
246
341
|
/**
|
|
247
342
|
* A consumer `access()` predicate.
|
package/package.json
CHANGED
package/src/hooks.ts
CHANGED
|
@@ -37,7 +37,21 @@ export interface HookContext {
|
|
|
37
37
|
state?: Record<string, unknown>;
|
|
38
38
|
/** Previous record state (available in update hooks). */
|
|
39
39
|
oldState?: unknown;
|
|
40
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Target record ID for single-record operations.
|
|
42
|
+
*
|
|
43
|
+
* SET ONLY UNDER `delete`. `_withHooks` assigns this key in the two
|
|
44
|
+
* `operation === 'delete'` branches and nowhere else, so on `get`, `list`,
|
|
45
|
+
* `create` and `update` the key is ABSENT -- not `undefined`-valued, absent.
|
|
46
|
+
* A hook rule written as `ctx.recordId === '<id>'` never fires on an update;
|
|
47
|
+
* the addressed id is in `ctx.params`. Tracked as abofs/stonyx-orm#242.
|
|
48
|
+
*
|
|
49
|
+
* @see AccessContext.recordId in ./types/orm-types.ts -- an identically-named
|
|
50
|
+
* key on an identically-shaped context object, and NOT interchangeable with
|
|
51
|
+
* this one: it is present on every route `auth()` classifies, and spells
|
|
52
|
+
* absence as `null` rather than `undefined`. They differ in coverage on four
|
|
53
|
+
* of five operations, not only in the absence spelling.
|
|
54
|
+
*/
|
|
41
55
|
recordId?: string | number;
|
|
42
56
|
/** Response data (available in after hooks). */
|
|
43
57
|
response?: unknown;
|
package/src/orm-request.ts
CHANGED
|
@@ -69,6 +69,15 @@
|
|
|
69
69
|
* records under `model: 'owner'`, and the context gives no signal of that
|
|
70
70
|
* (abofs/stonyx-orm#196).
|
|
71
71
|
*
|
|
72
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237, AND KEPT FOR THE
|
|
73
|
+
* CONSTRAINT IT STATES RATHER THAN AS A DESCRIPTION OF THE CODE. The context
|
|
74
|
+
* now also carries `recordId` -- the DECODED route-parameter id, see
|
|
75
|
+
* `AccessContext.recordId` in ./types/orm-types.ts -- so the fixture's
|
|
76
|
+
* `/archived` deny IS expressible from the context alone, and the shipped
|
|
77
|
+
* sample no longer reads `request.path` at all. Retiring this wording WITH the
|
|
78
|
+
* measurement that retires it, rather than by deletion, is
|
|
79
|
+
* abofs/stonyx-orm#238.
|
|
80
|
+
*
|
|
72
81
|
* `record` IS NOT IN THIS CONTEXT, deliberately. `auth()` runs after route
|
|
73
82
|
* matching but BEFORE any handler executes (`@stonyx/rest-server`
|
|
74
83
|
* `src/request.ts:58-60`), so nothing has been fetched yet -- supplying a
|
|
@@ -158,6 +167,11 @@
|
|
|
158
167
|
* `/archived` SUB-PATH rule is still a string match, and abofs/stonyx-orm#228 is
|
|
159
168
|
* a sixth spelling that gets past it.
|
|
160
169
|
*
|
|
170
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237: the `/archived` rule is
|
|
171
|
+
* no longer a string match against the request target -- it compares the
|
|
172
|
+
* decoded `recordId` the framework supplies -- and abofs/stonyx-orm#228 is
|
|
173
|
+
* CLOSED. Retirement of this wording: abofs/stonyx-orm#238.
|
|
174
|
+
*
|
|
161
175
|
* An intermediate revision of the sample read `request.baseUrl` -- the mount
|
|
162
176
|
* Express ACTUALLY MATCHED. That closed all five variants (no query string,
|
|
163
177
|
* not mount-relative, unaffected by absolute-form, already carrying the
|
|
@@ -173,12 +187,26 @@
|
|
|
173
187
|
* does not decode, so `GET /owners/%61rchived` steps past it. See the
|
|
174
188
|
* normalisation paragraph below and abofs/stonyx-orm#228.
|
|
175
189
|
*
|
|
190
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237. Variant 3 lived in that
|
|
191
|
+
* one string comparison, and the comparison is gone: the sample compares the
|
|
192
|
+
* decoded `recordId`. Left standing rather than edited because the same
|
|
193
|
+
* "variant 3 survives" wording sits at four sites -- this header, README.md
|
|
194
|
+
* twice, and test/sample/access/global-access.ts -- three of which SHIP, so
|
|
195
|
+
* retiring one of four leaves the shipped copies contradicting each other.
|
|
196
|
+
* Retiring all four WITH their measurement is abofs/stonyx-orm#238.
|
|
197
|
+
*
|
|
176
198
|
* ONE READ OF ARGUMENT ONE SURVIVES, AND IT MUST: `request.path`. It is
|
|
177
199
|
* mount-relative and query-free, and it is for rules that distinguish SUB-PATHS
|
|
178
200
|
* beneath the mount. The context names which model and which verb, NOT which
|
|
179
201
|
* route, so the sample's `/archived` deny cannot be expressed from the context
|
|
180
202
|
* alone and a context-ONLY rewrite would silently turn that deny into an allow.
|
|
181
203
|
*
|
|
204
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237: NO read of argument one
|
|
205
|
+
* survives in the shipped sample. `recordId` names WHICH RECORD the route was
|
|
206
|
+
* addressed to, so the `/archived` deny is expressible from the context alone
|
|
207
|
+
* -- and it still must not be dropped; expressible is not optional. Retirement
|
|
208
|
+
* of this wording: abofs/stonyx-orm#238.
|
|
209
|
+
*
|
|
182
210
|
* NORMALISE THE WAY THE ROUTER DOES, AND CASE-FOLDING ALONE IS NOT THAT. The
|
|
183
211
|
* sample lower-cases before comparing, because a matcher stricter than the
|
|
184
212
|
* case-insensitive router can be stepped around. That closes the case gap only.
|
|
@@ -188,6 +216,20 @@
|
|
|
188
216
|
* sample and is tracked as abofs/stonyx-orm#228; the `.toLowerCase()` is not a
|
|
189
217
|
* complete normalisation recipe. Compare record ids at their real case.
|
|
190
218
|
*
|
|
219
|
+
* DO NOT FOLLOW THE PARAGRAPH ABOVE. SUPERSEDED 2026-09-01 BY
|
|
220
|
+
* abofs/stonyx-orm#236/#237, and flagged here rather than merely dated because
|
|
221
|
+
* it is an INSTRUCTION, not a stale observation. `.toLowerCase()` on the access
|
|
222
|
+
* path was measured WRONG IN BOTH DIRECTIONS AT ONCE: with a distinct owner
|
|
223
|
+
* seeded at `ARCHIVED`, `GET /owners/ARCHIVED` was a false DENY on the wrong
|
|
224
|
+
* record and `GET /owners/%41RCHIVED` a false ALLOW on that same record. A
|
|
225
|
+
* record id is a VALUE, not a literal route segment, and express's
|
|
226
|
+
* `case sensitive routing` governs literal segments only. Compare
|
|
227
|
+
* `context.recordId` AS IT ARRIVES: do not case-fold it, do not decode it, do
|
|
228
|
+
* not derive it from `request.path`. `AccessContext.recordId` in
|
|
229
|
+
* ./types/orm-types.ts is the contract and says "Do NOT case-fold it"; the same
|
|
230
|
+
* published tarball ships both files, and THIS paragraph is the one that is
|
|
231
|
+
* wrong. Retiring it WITH its measurement is abofs/stonyx-orm#238.
|
|
232
|
+
*
|
|
191
233
|
* `?? ''` is not a defence. It converts an absent request target into an empty
|
|
192
234
|
* string, which matches no collection, which falls through to the permission
|
|
193
235
|
* array -- a total grant. An input you cannot identify must DENY, and that
|
|
@@ -196,6 +238,12 @@
|
|
|
196
238
|
* argument one. The sample returns `false` for an absent `model` AND for an
|
|
197
239
|
* absent or non-string `request.path`, rather than falling through either way.
|
|
198
240
|
*
|
|
241
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237 as to WHAT is guarded --
|
|
242
|
+
* the principle is unchanged. The sample no longer reads `request.path`, so it
|
|
243
|
+
* returns `false` for an absent `model` AND for an absent `recordId`
|
|
244
|
+
* (`undefined`, the one spelling `auth()` never produces). Retirement of this
|
|
245
|
+
* wording: abofs/stonyx-orm#238.
|
|
246
|
+
*
|
|
199
247
|
* THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
|
|
200
248
|
* the operation and the record. Prefer the array shape (`['read']`) or `false`
|
|
201
249
|
* until #202 lands; the function shape is what requires any matching at all.
|
|
@@ -1389,10 +1437,53 @@ export default class OrmRequest extends Request {
|
|
|
1389
1437
|
// src/types/orm-types.ts. Nothing is fetched at this point and adding a
|
|
1390
1438
|
// lookup here would put a store read in the middle of an authorization
|
|
1391
1439
|
// path. The function return shape below IS the per-record hook.
|
|
1440
|
+
//
|
|
1441
|
+
// -------------------------------------------------------------------------
|
|
1442
|
+
// #236 -- `recordId`, the DECODED route-parameter id, for the same reason.
|
|
1443
|
+
//
|
|
1444
|
+
// WHICH RECORD is the third structural fact the framework already holds and
|
|
1445
|
+
// the consumer was left to re-derive, and re-deriving it failed OPEN. The
|
|
1446
|
+
// documented sample compared `request.path` -- the RAW, undecoded pathname
|
|
1447
|
+
// -- against a literal `/archived`, while the router DECODES `:id`. So
|
|
1448
|
+
// `GET /owners/%61rchived` walked past the deny and was dispatched as the
|
|
1449
|
+
// record `archived`: 200 with the record in full, and DELETE answered 204
|
|
1450
|
+
// with the record destroyed, unauthenticated. Four spellings measured, all
|
|
1451
|
+
// four through; 255 non-canonical spellings of that 8-character id decode
|
|
1452
|
+
// to the same key, so this was never a deny-list of one.
|
|
1453
|
+
//
|
|
1454
|
+
// TWO CONSUMER-SIDE NORMALISATIONS WERE MEASURED WRONG IN OPPOSITE
|
|
1455
|
+
// DIRECTIONS, which is the argument for doing it once, here.
|
|
1456
|
+
// `.toLowerCase()` case-folds a route-parameter VALUE on the axis that
|
|
1457
|
+
// governs literal SEGMENTS: with a distinct owner seeded at `ARCHIVED`,
|
|
1458
|
+
// `GET /owners/ARCHIVED` was a false DENY on the wrong record and
|
|
1459
|
+
// `GET /owners/%41RCHIVED` a false ALLOW on that same one.
|
|
1460
|
+
// `decodeURIComponent(request.path)` decodes THEN splits while the router
|
|
1461
|
+
// splits THEN decodes, so it over-denied `/owners/archived%2fx` -- 403 for
|
|
1462
|
+
// a genuinely distinct record. Failing closed there was luck, not design.
|
|
1463
|
+
//
|
|
1464
|
+
// `getId(request.params)` AND NOT `request.params.id`, for exactly the
|
|
1465
|
+
// reason `operation` is a `methodAccessMap` lookup: it is the SAME single
|
|
1466
|
+
// coercion the store lookup one layer down performs, so the predicate and
|
|
1467
|
+
// the dispatch cannot disagree about which record a request addresses.
|
|
1468
|
+
// The raw string would reintroduce that divergence on hex-shaped ids --
|
|
1469
|
+
// `GET /animals/0x2391` looks up record `9105`.
|
|
1470
|
+
//
|
|
1471
|
+
// NOTHING HERE PARSES THE REQUEST TARGET EITHER. `request.params` is what
|
|
1472
|
+
// the router matched, so a mount prefix, an absolute-form target, a query
|
|
1473
|
+
// string or a case-varied mount cannot move this value -- the same
|
|
1474
|
+
// guarantee `model` carries, by the same means.
|
|
1475
|
+
//
|
|
1476
|
+
// `null` and not `undefined` on a collection route, so the KEY IS ALWAYS
|
|
1477
|
+
// PRESENT -- the rule `operation`'s own docblock already establishes. A
|
|
1478
|
+
// context reaching a predicate WITHOUT the key therefore did not come from
|
|
1479
|
+
// here; it was hand-assembled by a caller resolving the predicate through
|
|
1480
|
+
// `Orm.instance.getAccess()`, and that absence stays deniable only because
|
|
1481
|
+
// `auth()` never produces it.
|
|
1392
1482
|
// -------------------------------------------------------------------------
|
|
1393
1483
|
const context: AccessContext = {
|
|
1394
1484
|
model: this.model,
|
|
1395
1485
|
operation: methodAccessMap[request.method],
|
|
1486
|
+
recordId: request.params && 'id' in request.params ? getId(request.params) : null,
|
|
1396
1487
|
};
|
|
1397
1488
|
|
|
1398
1489
|
let access: AccessMethod;
|
package/src/types/orm-types.ts
CHANGED
|
@@ -252,6 +252,102 @@ export interface AccessContext {
|
|
|
252
252
|
* from one that classified the request and found nothing.
|
|
253
253
|
*/
|
|
254
254
|
operation: AccessOperation | undefined;
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The record this route was addressed to, as the store key -- or `null` on a
|
|
258
|
+
* collection route, which is addressed to no record (abofs/stonyx-orm#236).
|
|
259
|
+
*
|
|
260
|
+
* IT IS ALREADY DECODED, AND THAT IS THE WHOLE POINT. Express decodes route
|
|
261
|
+
* PARAMETERS while leaving `request.path` raw, so a consumer comparing
|
|
262
|
+
* `request.path` against a literal compares an undecoded string against a
|
|
263
|
+
* decoded dispatch. `GET /owners/%61rchived` reached such a comparison as
|
|
264
|
+
* `/%61rchived`, walked past a `/archived` deny, and was dispatched as the
|
|
265
|
+
* record `archived` -- 200 with the record in full, and `DELETE` destroyed
|
|
266
|
+
* it, unauthenticated. 255 non-canonical spellings of an 8-character id
|
|
267
|
+
* decode to the same key, so a deny-list of spellings is the wrong shape.
|
|
268
|
+
*
|
|
269
|
+
* SO DO NOT NORMALISE THIS, AND DO NOT NORMALISE ANYTHING ELSE INSTEAD:
|
|
270
|
+
*
|
|
271
|
+
* - Do NOT decode it. Express decodes exactly ONCE, which is what a route
|
|
272
|
+
* parameter means. `GET /owners/%2561rchived` is the legitimate id
|
|
273
|
+
* `%61rchived`, not a second-order spelling of `archived`; a predicate that
|
|
274
|
+
* decoded until stable would deny a record it was never asked about.
|
|
275
|
+
* - Do NOT case-fold it. A record id is a VALUE, not a literal route segment,
|
|
276
|
+
* and express's `case sensitive routing` governs literal segments only.
|
|
277
|
+
* With a distinct owner seeded at `ARCHIVED`, `.toLowerCase()` was measured
|
|
278
|
+
* wrong in BOTH directions at once: `GET /owners/ARCHIVED` 403 (a false
|
|
279
|
+
* deny, on the wrong record) and `GET /owners/%41RCHIVED` 200 (a false
|
|
280
|
+
* allow, on that same record).
|
|
281
|
+
* - Do NOT derive it from `request.path` or the request target. Decoding the
|
|
282
|
+
* whole path decodes THEN splits, while the router splits THEN decodes, so
|
|
283
|
+
* `/owners/archived%2fx` -- a genuinely distinct record whose id is
|
|
284
|
+
* `archived/x` -- was measured over-denied 403.
|
|
285
|
+
*
|
|
286
|
+
* IT IS `getId(request.params)`, BYTE FOR BYTE -- the same single coercion
|
|
287
|
+
* the store lookup uses, exactly as `operation` is the same `methodAccessMap`
|
|
288
|
+
* lookup the permission-array branch uses. The predicate and the dispatch
|
|
289
|
+
* therefore cannot disagree about which record a request addresses. Handing
|
|
290
|
+
* over the raw `request.params.id` instead would reintroduce that divergence
|
|
291
|
+
* on hex-shaped ids: `GET /animals/0x2391` looks up record `9105`.
|
|
292
|
+
*
|
|
293
|
+
* It inherits abofs/stonyx-orm#209 along with that coercion -- on a model
|
|
294
|
+
* declaring `id = attr('string')`, `'9107'` arrives here as the number
|
|
295
|
+
* `9107`. That is consistency WITH THE LOOKUP, which is the property this key
|
|
296
|
+
* exists to buy; it is not a defect to repair here.
|
|
297
|
+
*
|
|
298
|
+
* `null`, not `undefined`, on a collection route -- and the KEY IS ALWAYS
|
|
299
|
+
* PRESENT, the same rule `operation` states above. `auth()` always sets it,
|
|
300
|
+
* so a context arriving WITHOUT the key did not come from `auth()`: it was
|
|
301
|
+
* hand-assembled by a caller resolving the predicate through
|
|
302
|
+
* `Orm.instance.getAccess()`. That absence stays a distinguishable, deniable
|
|
303
|
+
* signal only because the framework never produces it.
|
|
304
|
+
*
|
|
305
|
+
* IT DISAGREES WITH THE HOOK VOCABULARY, AND NOT ONLY ON THE ABSENCE
|
|
306
|
+
* SPELLING. `HookContext.recordId` (`src/hooks.ts`) is an identically-named
|
|
307
|
+
* key on an identically-shaped context object, which is the exact
|
|
308
|
+
* configuration that makes `operation` fail-open shaped -- a hook sees
|
|
309
|
+
* `'get'` where `access()` sees `'read'`. An earlier revision of THIS
|
|
310
|
+
* docblock asserted the opposite ("here they AGREE... they differ in ONE way
|
|
311
|
+
* and it is the absence spelling"). That was measured false, in the fail-open
|
|
312
|
+
* direction, and it is corrected here rather than deleted.
|
|
313
|
+
*
|
|
314
|
+
* MEASURED over the live dispatch, before-hooks registered for all five
|
|
315
|
+
* operations on one model:
|
|
316
|
+
*
|
|
317
|
+
* before:list key ABSENT ('recordId' in context === false)
|
|
318
|
+
* before:get key ABSENT params={"id":"visible1"}
|
|
319
|
+
* before:create key ABSENT
|
|
320
|
+
* before:update key ABSENT params={"id":"visible2"}
|
|
321
|
+
* before:delete recordId="visible3"
|
|
322
|
+
* after:delete recordId="visible3"
|
|
323
|
+
*
|
|
324
|
+
* `_withHooks` assigns `context.recordId` at exactly TWO sites in
|
|
325
|
+
* `src/orm-request.ts`, and BOTH sit inside an `operation === 'delete'`
|
|
326
|
+
* branch. So the two keys differ in COVERAGE, on four of five operations: on
|
|
327
|
+
* a hook context the key is absent for get, list, create and update, while
|
|
328
|
+
* this key is present on every route `auth()` classifies. The absence
|
|
329
|
+
* spelling is the smaller half of the difference, not the whole of it.
|
|
330
|
+
*
|
|
331
|
+
* AND THAT INVERTS THE ARGUMENT ABOVE WHEN IT IS READ ACROSS THE TWO. Here,
|
|
332
|
+
* a missing `recordId` means "did not come from `auth()`" and is deniable.
|
|
333
|
+
* On a hook context it means "this is a get / list / create / update" -- an
|
|
334
|
+
* ordinary request. A consumer who writes the hook-side half of the same
|
|
335
|
+
* rule --
|
|
336
|
+
*
|
|
337
|
+
* beforeHook('update', 'owner', ctx => ctx.recordId === 'archived' ? 403 : undefined)
|
|
338
|
+
*
|
|
339
|
+
* -- gets a deny that NEVER FIRES: measured, `PATCH /owners/visible2` -> 200,
|
|
340
|
+
* with `ctx.recordId === undefined` while the addressed record sits in
|
|
341
|
+
* `ctx.params`. The hook side is abofs/stonyx-orm#242 and is deliberately not
|
|
342
|
+
* repaired here. A predicate must not read `undefined` here as "collection",
|
|
343
|
+
* and nothing in this contract makes it safe to read the two keys as one key.
|
|
344
|
+
*
|
|
345
|
+
* IT NAMES WHICH RECORD, NOT WHICH SURFACE. `GET /owners/gina`,
|
|
346
|
+
* `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` all
|
|
347
|
+
* carry `recordId: 'gina'`; the related-resource gap is abofs/stonyx-orm#196
|
|
348
|
+
* and is untouched by this key.
|
|
349
|
+
*/
|
|
350
|
+
recordId: string | number | null;
|
|
255
351
|
}
|
|
256
352
|
|
|
257
353
|
/**
|