@stonyx/orm 0.3.2-alpha.70 → 0.3.2-alpha.72
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 +248 -120
- package/dist/access-verdict.d.ts +85 -0
- package/dist/access-verdict.js +254 -0
- package/dist/hooks.d.ts +1 -15
- package/dist/index.d.ts +2 -0
- package/dist/index.js +8 -0
- package/dist/orm-request.d.ts +0 -48
- package/dist/orm-request.js +57 -114
- package/dist/record.d.ts +16 -0
- package/dist/record.js +149 -3
- package/dist/types/orm-types.d.ts +27 -95
- package/package.json +1 -1
- package/src/access-verdict.ts +282 -0
- package/src/hooks.ts +1 -15
- package/src/index.ts +9 -0
- package/src/orm-request.ts +61 -113
- package/src/record.ts +176 -3
- package/src/types/orm-types.ts +28 -97
package/README.md
CHANGED
|
@@ -325,19 +325,6 @@ 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
|
-
>
|
|
341
328
|
> That is still a stopgap. **The real fix is
|
|
342
329
|
> [#202](https://github.com/abofs/stonyx-orm/issues/202)** — `access()` should
|
|
343
330
|
> receive the model, the operation and the record, so there is nothing to
|
|
@@ -350,14 +337,6 @@ Access classes define models and provide custom filtering/authorization logic.
|
|
|
350
337
|
> context alone** and a context-only rewrite would silently turn it into an
|
|
351
338
|
> allow.
|
|
352
339
|
>
|
|
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
|
-
>
|
|
361
340
|
> The same warning is repeated at the top of `src/orm-request.ts`, which ships;
|
|
362
341
|
> the longer write-up in `docs/usage-patterns.md` does **not** ship, so this
|
|
363
342
|
> README and that source header are the two copies a consumer sees.
|
|
@@ -371,73 +350,66 @@ Access classes define models and provide custom filtering/authorization logic.
|
|
|
371
350
|
export default class GlobalAccess {
|
|
372
351
|
models = ['owner', 'animal'];
|
|
373
352
|
|
|
374
|
-
access(request, { model, operation
|
|
353
|
+
access(request, { model, operation }) {
|
|
375
354
|
// `model` is the model this route was mounted for. It is assigned once, at
|
|
376
355
|
// mount time, and no request can influence it — not a mount prefix, not a
|
|
377
356
|
// query string, not a case-varied path, not an absolute-form request
|
|
378
|
-
// target.
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
// the sub-path
|
|
383
|
-
//
|
|
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.
|
|
357
|
+
// target. Nothing below parses anything, so variants 1, 2, 4 and 5 are not
|
|
358
|
+
// constructible against this predicate any more — they are history, not
|
|
359
|
+
// rules to follow. VARIANT 3 IS THE EXCEPTION AND THE CLAIM IS NARROWER
|
|
360
|
+
// THAN IT WAS: a matcher stricter than the router can still be stepped
|
|
361
|
+
// around, because the sub-path rule below is still a string comparison.
|
|
362
|
+
// Case is handled; percent-encoding is not — abofs/stonyx-orm#228.
|
|
387
363
|
//
|
|
388
364
|
// `operation` is destructured to name the whole contract at the point of
|
|
389
|
-
// use. This sample's rules are per-model and per-
|
|
365
|
+
// use. This sample's rules are per-model and per-sub-path rather than
|
|
390
366
|
// per-verb, so it does not branch on it; the permission array at the bottom
|
|
391
367
|
// is where the verb is answered.
|
|
392
368
|
|
|
393
|
-
// FAIL CLOSED ON
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
369
|
+
// FAIL CLOSED ON ARGUMENT TWO. `model` is absent for any caller that
|
|
370
|
+
// resolved this predicate without supplying the context, and a request this
|
|
371
|
+
// function cannot identify DENIES rather than falling through to the CRUD
|
|
372
|
+
// grant at the bottom. An unidentifiable input must never be the permissive
|
|
373
|
+
// path. Argument ONE is guarded at its own read, below — this guard does
|
|
374
|
+
// not cover it.
|
|
398
375
|
if (typeof model !== 'string' || model === '') return false;
|
|
399
376
|
|
|
400
377
|
if (model === 'owner') {
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
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.
|
|
415
|
-
//
|
|
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.
|
|
378
|
+
// The context names WHICH MODEL and WHICH VERB — not which route. Six
|
|
379
|
+
// distinct owner surfaces produce one identical context, so a rule that
|
|
380
|
+
// depends on the SUB-PATH still needs argument one. `request.path` is
|
|
381
|
+
// mount-relative and query-free, and it is the one read of the raw
|
|
382
|
+
// request the README sanctions. false → 403 for the whole request.
|
|
423
383
|
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
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.
|
|
384
|
+
// THIS DENY CANNOT BE EXPRESSED FROM THE CONTEXT ALONE. Migrating it away
|
|
385
|
+
// does not remove a rule, it turns a deny into an ALLOW, silently.
|
|
429
386
|
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
387
|
+
// FAIL CLOSED ON ARGUMENT ONE TOO. The guard above covers the context;
|
|
388
|
+
// this one covers the request, and since #202 they are two different
|
|
389
|
+
// objects. A caller that resolves this predicate through the documented
|
|
390
|
+
// `Orm.instance.getAccess()` path and hand-assembles a request can supply
|
|
391
|
+
// a perfectly valid context with no usable `path` — and
|
|
392
|
+
// `String(request.path ?? '')` is then `''`, which matches no sub-path
|
|
393
|
+
// rule and falls straight through to the per-record filter below. That is
|
|
394
|
+
// a DENY becoming an ALLOW. An input this function cannot identify DENIES,
|
|
395
|
+
// whichever ARGUMENT it arrived on — which is also why the `?? ''` this
|
|
396
|
+
// file's header condemns does not appear below.
|
|
397
|
+
if (typeof request?.path !== 'string' || request.path === '') return false;
|
|
398
|
+
|
|
399
|
+
// Lower-cased because the router matched case-insensitively, so a
|
|
400
|
+
// case-sensitive rule here would be stricter than the router and could be
|
|
401
|
+
// stepped around.
|
|
436
402
|
//
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
|
|
403
|
+
// CASE-FOLDING ALONE IS NOT A SUFFICIENT NORMALISATION, and this line is
|
|
404
|
+
// not a recipe for one. Express sets `request.path` from the RAW pathname
|
|
405
|
+
// while the router DECODES `:id`, so `GET /owners/%61rchived` reaches this
|
|
406
|
+
// comparison as `/%61rchived`, walks past the deny, and is dispatched as
|
|
407
|
+
// the record `archived` — abofs/stonyx-orm#228. A matcher must normalise
|
|
408
|
+
// the way the router that dispatched the request does. Record ids are
|
|
409
|
+
// case-sensitive and must be compared at their real case.
|
|
410
|
+
const path = request.path.toLowerCase();
|
|
411
|
+
|
|
412
|
+
if (path === '/archived' || path.startsWith('/archived/')) return false;
|
|
441
413
|
|
|
442
414
|
// Returning a function plugs it in as a per-record filter, and it is
|
|
443
415
|
// enforced on every surface addressed to one of these records:
|
|
@@ -520,16 +492,6 @@ access class shipped with this repo has such a rule: its `/archived` deny
|
|
|
520
492
|
**cannot be expressed from the context alone**, and a predicate migrated to
|
|
521
493
|
context-only would silently drop it — a deny becoming an allow.
|
|
522
494
|
|
|
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
|
-
|
|
533
495
|
Note also that the related-resource and `?include=` surfaces serve *another
|
|
534
496
|
model's* records under `model: 'owner'`, and the context gives a predicate no
|
|
535
497
|
signal that it is authorizing a related-resource route. That is
|
|
@@ -825,17 +787,6 @@ these values should be matched on:
|
|
|
825
787
|
| `GET http://anything.example/owners/angela` | `http://anything.example/angela` | `http://anything.example/owners/angela` | `/owners` | `/angela` |
|
|
826
788
|
| `GET /api/animals/22` (`ORM_REST_ROUTE=/api`) | `/22` | `/api/animals/22` | `/api/animals` | `/22` |
|
|
827
789
|
|
|
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
|
-
|
|
839
790
|
**One read of argument one survives, and it must: `request.path`.** It is
|
|
840
791
|
mount-relative and query-free, and it is for rules that distinguish **sub-paths**
|
|
841
792
|
beneath the mount — as the `/archived` deny in the sample above does. The context
|
|
@@ -855,20 +806,6 @@ comparison as `/%61rchived`, walks past the deny, and is dispatched as the recor
|
|
|
855
806
|
`.toLowerCase()` there as a complete normalisation recipe.** Record ids are
|
|
856
807
|
case-sensitive and must be compared at their real case.
|
|
857
808
|
|
|
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
|
-
|
|
872
809
|
**Fail closed on anything you cannot identify — on *either* argument.**
|
|
873
810
|
`String(request.originalUrl ?? '')` was once added here to stop a `TypeError`,
|
|
874
811
|
and it traded fail-closed for fail-**open**: an empty string matched no
|
|
@@ -927,7 +864,97 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
927
864
|
related record without resolving that model's own access class, so a filter on
|
|
928
865
|
`/owners` does not hide an owner reached through `/animals`. Tracked as
|
|
929
866
|
[#196](https://github.com/abofs/stonyx-orm/issues/196), which covers
|
|
930
|
-
`include=`, related-resource routes and relationship-linkage routes.
|
|
867
|
+
`include=`, related-resource routes and relationship-linkage routes. This is
|
|
868
|
+
**membership** — whether the related resource is served at all — and it is a
|
|
869
|
+
different question from which ids a document may *name*, immediately below.
|
|
870
|
+
- **Relationship linkage is filtered on the four request-bound read surfaces,
|
|
871
|
+
and only there.** A document's `relationships.*.data` used to publish the id
|
|
872
|
+
of every related record unconditionally, so a record hidden on every one of
|
|
873
|
+
its own surfaces was still named inside another model's document — with no
|
|
874
|
+
`include=`, no relationship route and no query string
|
|
875
|
+
([#234](https://github.com/abofs/stonyx-orm/issues/234)). The ORM now resolves
|
|
876
|
+
the **related** model's own access class on `GET /:models`, `GET /:models/:id`
|
|
877
|
+
and both `GET /:models/:id/{relationship}` shapes, and asks it
|
|
878
|
+
`{ model: <related>, operation: 'read' }`. An unresolvable class
|
|
879
|
+
(`getAccess()` → `undefined`) and a predicate that throws both **deny**. A
|
|
880
|
+
filtered-out relationship is **indistinguishable from a genuinely empty one** —
|
|
881
|
+
an emptied `hasMany` is `data: []` and an emptied `belongsTo` is `data: null`,
|
|
882
|
+
both **keeping their `links`**, which are built from the serialized record's
|
|
883
|
+
own id and never from the related one. Nothing errors and no status changes,
|
|
884
|
+
because throwing here would be an existence oracle *and* would throw out of
|
|
885
|
+
the enclosing `JSON.stringify`.
|
|
886
|
+
|
|
887
|
+
**That resolves the right class; it does not guarantee a model-correct
|
|
888
|
+
answer, and the failure direction is not the safe one.** Only a predicate that
|
|
889
|
+
*reads* `context.model` can answer about the model it was asked about — see
|
|
890
|
+
[Passing the context makes a model-correct answer *possible*](#passing-the-context-makes-a-model-correct-answer-possible)
|
|
891
|
+
above. A **single-argument predicate remains the default in every consumer
|
|
892
|
+
tree**, it identifies its collection from the request, and asked about
|
|
893
|
+
`owner` on a request dispatched to `/animals` it answers about **animals**.
|
|
894
|
+
Measured against this repo's own fixture with an arity-1 predicate registered
|
|
895
|
+
for `owner`: `GET /owners` correctly returns `["gina","michael","bob"]` while
|
|
896
|
+
`GET /animals/1` returns `owner.data {"type":"owner","id":"angela"}` — the
|
|
897
|
+
#234 defect, on the #234 surface, after the #234 fix. This is not a
|
|
898
|
+
regression (the id was published unconditionally before), it cannot be fixed
|
|
899
|
+
from this side, and the signal that surfaces such a predicate is
|
|
900
|
+
[#221](https://github.com/abofs/stonyx-orm/issues/221) /
|
|
901
|
+
[#213](https://github.com/abofs/stonyx-orm/issues/213). **Migrate your
|
|
902
|
+
predicates to read the context before relying on this filter.** A migrated,
|
|
903
|
+
context-reading predicate degrades the other way — it can over-deny a
|
|
904
|
+
*permitted* related record, which is recorded in the release notes as a
|
|
905
|
+
breaking change.
|
|
906
|
+
|
|
907
|
+
**Not yet covered, and each one still publishes ids the surfaces above
|
|
908
|
+
withhold:**
|
|
909
|
+
|
|
910
|
+
- **`included`** — [#235](https://github.com/abofs/stonyx-orm/issues/235). A
|
|
911
|
+
permitted record sideloaded by `?include=` emits its **own**
|
|
912
|
+
`relationships.*.data` unfiltered, so `GET /animals/1?include=owner,owner.pets`
|
|
913
|
+
returns `owner.data: null` on the primary document and then names angela in
|
|
914
|
+
`included`, along with eight permitted animals that each name
|
|
915
|
+
`{"type":"owner","id":"angela"}`. Separately,
|
|
916
|
+
[#233](https://github.com/abofs/stonyx-orm/issues/233) owns whether a
|
|
917
|
+
resource appears in `included` **at all** — that is membership, a different
|
|
918
|
+
question, and following it will not lead you to this residual.
|
|
919
|
+
- **The `POST`/`PATCH` response documents** —
|
|
920
|
+
[#235](https://github.com/abofs/stonyx-orm/issues/235). `createHandler` and
|
|
921
|
+
`updateHandler` destructure the request rather than binding it, so wiring
|
|
922
|
+
them needs a signature change rather than an argument. Until then **one HTTP
|
|
923
|
+
verb defeats the filter on the same record**: measured, `GET /animals/1`
|
|
924
|
+
returns `owner.data: null` and `PATCH /animals/1` returns **200 naming
|
|
925
|
+
angela**, seconds apart, with no query string and no relationship route. Any
|
|
926
|
+
caller who can read a record can also write it and be handed the id the read
|
|
927
|
+
withheld.
|
|
928
|
+
- **`GET /:models/:id/relationships/{relationship}`**, whose *primary data* is
|
|
929
|
+
linkage, so filtering it is a **membership** decision —
|
|
930
|
+
[#232](https://github.com/abofs/stonyx-orm/issues/232), the filed child of
|
|
931
|
+
[#196](https://github.com/abofs/stonyx-orm/issues/196).
|
|
932
|
+
- **A bare `toJSON()` still emits unfiltered linkage, and that is deliberate.**
|
|
933
|
+
`Record.toJSON()` **applies** a verdict; it never **resolves** one. It has no
|
|
934
|
+
request, and the documented `access()` contract permits a predicate to read
|
|
935
|
+
one — the sample in this README does, for its sub-path rule — so a filter
|
|
936
|
+
resolved inside `toJSON()` denies *permitted* records rather than hidden ones
|
|
937
|
+
(measured: 967 → 964, all three failures over-denials). `toJSON` is also the
|
|
938
|
+
`JSON.stringify` hook, so `JSON.stringify(record)`, `res.json(record)` and
|
|
939
|
+
`console.log(JSON.stringify(record))` reach it with a **string** in the
|
|
940
|
+
options slot and have no syntactic place to pass a verdict. The no-argument
|
|
941
|
+
call therefore returns the pre-#234 document unchanged. Fail-closed by default
|
|
942
|
+
is not available either: `Orm.instance.accessFunctions` is `{}` in any process
|
|
943
|
+
that never ran `setup-rest-server` — a CLI, an SQL-only process, a test — so
|
|
944
|
+
it would empty every relationship on every document in processes with no REST
|
|
945
|
+
surface to protect. Closing the residual means moving JSON:API serialization
|
|
946
|
+
**off** the `toJSON` name, tracked as
|
|
947
|
+
[#230](https://github.com/abofs/stonyx-orm/issues/230). If you hand a `Record`
|
|
948
|
+
to an untrusted consumer, serialize it through the REST layer, or resolve a
|
|
949
|
+
verdict with the **exported** `createLinkageFilter(request)` and pass it as
|
|
950
|
+
the `linkage` option — do not write your own reading of `access()`. This is a
|
|
951
|
+
consumer obligation with no signal when it lapses; it is stated once, in full,
|
|
952
|
+
under [Consumer Contracts](#consumer-contracts) below.
|
|
953
|
+
- **`format()` and `serialize()` are deliberately not filtered, and must stay
|
|
954
|
+
that way.** `format()` is the **persistence** path — its output is what
|
|
955
|
+
`Orm.db.save()` writes to disk — so applying an access filter there would
|
|
956
|
+
write a truncated database. That is **data loss**, not disclosure prevention.
|
|
957
|
+
Neither method appears anywhere in the REST response path.
|
|
931
958
|
- **A before-hook that returns a value short-circuits the request.** On write
|
|
932
959
|
operations addressed to a record the filter is consulted first, so a hook
|
|
933
960
|
cannot answer for a record the caller may not see. On reads it is not, so a
|
|
@@ -986,6 +1013,110 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
986
1013
|
back, and without the guard a denied `403` would delete a record the request
|
|
987
1014
|
did not create.
|
|
988
1015
|
|
|
1016
|
+
### Consumer Contracts
|
|
1017
|
+
|
|
1018
|
+
Obligations this package **cannot enforce**, where nothing fails, warns or
|
|
1019
|
+
changes shape when a consumer omits them. One place, findable, per
|
|
1020
|
+
`quality.md` rule 2 — if you are relying on `@stonyx/orm` for access control,
|
|
1021
|
+
read all of these.
|
|
1022
|
+
|
|
1023
|
+
#### `Record.toJSON()` does not filter relationship linkage unless you pass a verdict
|
|
1024
|
+
|
|
1025
|
+
**The framework owns this on four surfaces. You own it everywhere else.**
|
|
1026
|
+
|
|
1027
|
+
`GET /:models`, `GET /:models/:id` and both `GET /:models/:id/{relationship}`
|
|
1028
|
+
shapes resolve a linkage verdict and pass it to `toJSON()` for you. Any other
|
|
1029
|
+
path to a document — `JSON.stringify(record)`, `res.json(record)`,
|
|
1030
|
+
`console.log(record)`, a custom route, a queue payload, a websocket frame —
|
|
1031
|
+
calls `toJSON()` with no verdict, and **the no-verdict document names every
|
|
1032
|
+
related id, including records hidden on every one of their own surfaces**
|
|
1033
|
+
([#234](https://github.com/abofs/stonyx-orm/issues/234)). That default is
|
|
1034
|
+
deliberate and cannot be inverted; the reasons are in
|
|
1035
|
+
[Known limitations](#known-limitations) above.
|
|
1036
|
+
|
|
1037
|
+
**There is no signal when you omit it.** `linkage` is optional, absent is the
|
|
1038
|
+
default, the default is the unfiltered document, and a filtered relationship is
|
|
1039
|
+
byte-identical to a genuinely empty one — so nothing on the wire distinguishes
|
|
1040
|
+
"filtered" from "forgotten".
|
|
1041
|
+
|
|
1042
|
+
Do this:
|
|
1043
|
+
|
|
1044
|
+
```js
|
|
1045
|
+
import { createLinkageFilter } from '@stonyx/orm';
|
|
1046
|
+
|
|
1047
|
+
// `request` is the live request the caller was authorised against. The verdict
|
|
1048
|
+
// is REQUEST-SCOPED: build one per request and never cache it across requests,
|
|
1049
|
+
// or a second caller is answered with the first caller's authorization.
|
|
1050
|
+
const linkage = createLinkageFilter(request);
|
|
1051
|
+
|
|
1052
|
+
res.json({ data: record.toJSON({ baseUrl, linkage }) });
|
|
1053
|
+
```
|
|
1054
|
+
|
|
1055
|
+
Not this:
|
|
1056
|
+
|
|
1057
|
+
```js
|
|
1058
|
+
// A second, unreviewed reading of access(). It will drift from the one in
|
|
1059
|
+
// src/access-verdict.ts, and it will drift in consumer code where no reviewer
|
|
1060
|
+
// of this repository will ever see it.
|
|
1061
|
+
const linkage = (type, r) => Orm.instance.getAccess(type)?.(request)?.(r) ?? true;
|
|
1062
|
+
```
|
|
1063
|
+
|
|
1064
|
+
**`createLinkageFilter` requires a live request, and there is no safe call
|
|
1065
|
+
without one.** `request` is the only authorization input the filter has — it is
|
|
1066
|
+
handed straight to your `access()` predicates, and a predicate that does not
|
|
1067
|
+
*read* it cannot fail closed when it is missing. Passing `undefined`, `null` or
|
|
1068
|
+
any non-object therefore denies **all** linkage and logs, once, at construction.
|
|
1069
|
+
Measured before that guard existed, `createLinkageFilter(undefined)` granted
|
|
1070
|
+
four of the five models in this repository's own fixture, silently.
|
|
1071
|
+
|
|
1072
|
+
**This is the catch for the request-less contexts named above.** In a queue
|
|
1073
|
+
consumer or a websocket handler there is no live request, so there is nothing to
|
|
1074
|
+
authorize against and nothing this package can resolve for you. Either carry the
|
|
1075
|
+
originating request through to the point of serialization, or publish no linkage
|
|
1076
|
+
at all — `record.toJSON({ linkage: () => false })` emits the document with every
|
|
1077
|
+
relationship empty. A stand-in is **not** a substitute: `{}` is an object
|
|
1078
|
+
and passes the guard, and any predicate that ignores its request will grant.
|
|
1079
|
+
|
|
1080
|
+
**`linkage` itself is validated, and an unusable value DENIES.** `undefined`
|
|
1081
|
+
means "no verdict supplied" and emits today's document. Anything else must be a
|
|
1082
|
+
**synchronous function that answers with a boolean**. Each of the following
|
|
1083
|
+
drops **all** linkage on that document and logs once:
|
|
1084
|
+
|
|
1085
|
+
- **A non-function** — `null`, `0`, `false`, `''`, `true`, a string, an object.
|
|
1086
|
+
`null` is the natural return of a resolver that could not resolve a session:
|
|
1087
|
+
it used to be read as "absent" and emit the full document silently.
|
|
1088
|
+
- **An `async` function, a generator function, or any predicate that returns a
|
|
1089
|
+
promise or thenable.** `toJSON` is the `JSON.stringify` hook and cannot await
|
|
1090
|
+
a verdict, and **an `async` resolver returns a promise, a promise is
|
|
1091
|
+
truthy**, so every related id was published, silently, exactly as if this fix
|
|
1092
|
+
were not here. If your
|
|
1093
|
+
authorization lookup is asynchronous, `await` it *before* you serialize and
|
|
1094
|
+
close over the result.
|
|
1095
|
+
- **Any answer that is not a boolean** — `{}`, `'no'`, `1`, `undefined`. A
|
|
1096
|
+
non-boolean is a resolver that did not answer, and a truthy one granted.
|
|
1097
|
+
- **A predicate that throws**, including a `class` passed by mistake. It is
|
|
1098
|
+
caught and denied; it used to escape the enclosing `JSON.stringify` and take
|
|
1099
|
+
the rest of that serialization down with it.
|
|
1100
|
+
|
|
1101
|
+
#### A predicate that ignores `context.model` makes cross-model resolution GRANT
|
|
1102
|
+
|
|
1103
|
+
The linkage filter above asks the **related** model's access class the
|
|
1104
|
+
model-correct question, but only a predicate that *reads*
|
|
1105
|
+
[`context.model`](#the-access-context-second-argument) can give a model-correct
|
|
1106
|
+
answer. A single-argument predicate identifies its collection from the request
|
|
1107
|
+
and therefore answers about the collection the request was *addressed to* —
|
|
1108
|
+
which is the direction that **grants**. Measured, and worked through in
|
|
1109
|
+
[Known limitations](#known-limitations). There is no boot-time warning yet
|
|
1110
|
+
([#221](https://github.com/abofs/stonyx-orm/issues/221)). **Migrate your
|
|
1111
|
+
predicates to the two-argument contract.**
|
|
1112
|
+
|
|
1113
|
+
#### `format()` and `serialize()` are never filtered, by design
|
|
1114
|
+
|
|
1115
|
+
They are the persistence path. Do not hand their output to an untrusted
|
|
1116
|
+
consumer, and do not add a filter to them — `Orm.db.save()` writes `format()`
|
|
1117
|
+
output to disk, so filtering there is data loss rather than disclosure
|
|
1118
|
+
prevention.
|
|
1119
|
+
|
|
989
1120
|
### Breaking changes
|
|
990
1121
|
|
|
991
1122
|
These land in the next published build. There is no changelog or release-notes channel yet
|
|
@@ -1195,6 +1326,13 @@ GET /animals/1
|
|
|
1195
1326
|
#### Limitations
|
|
1196
1327
|
|
|
1197
1328
|
- Only available on GET endpoints (not POST/PATCH)
|
|
1329
|
+
- **`included` records are not access-filtered, on either question.** Whether a
|
|
1330
|
+
resource appears in `included` at all is
|
|
1331
|
+
[#233](https://github.com/abofs/stonyx-orm/issues/233); a record that *is*
|
|
1332
|
+
permitted still emits its **own** `relationships.*.data` unfiltered, so
|
|
1333
|
+
`?include=` republishes ids the primary document withholds
|
|
1334
|
+
([#235](https://github.com/abofs/stonyx-orm/issues/235)). See
|
|
1335
|
+
[Consumer Contracts](#consumer-contracts).
|
|
1198
1336
|
|
|
1199
1337
|
## Lifecycle Hooks
|
|
1200
1338
|
|
|
@@ -1249,16 +1387,6 @@ Each hook receives a context object with comprehensive information:
|
|
|
1249
1387
|
- It contains a deep copy of the record's state **before** the operation executes (captured before the `before` hook fires)
|
|
1250
1388
|
- The deep copy is created via JSON serialization (`JSON.parse(JSON.stringify())`) to ensure complete isolation
|
|
1251
1389
|
- 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.
|
|
1262
1390
|
- `oldState` is captured as a deep copy of the record's data before the operation, providing access to the previous field values
|
|
1263
1391
|
|
|
1264
1392
|
### Usage Examples
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { AccessMethod, AccessOperation, LinkageFilter } from './types/orm-types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The classified reading of one `access()` return value.
|
|
4
|
+
*
|
|
5
|
+
* `granted: false` is a total denial. `granted: true` with no `filter` is an
|
|
6
|
+
* unconditional grant. `granted: true` WITH a filter means "grant, subject to
|
|
7
|
+
* this per-record predicate" -- the function return shape, which is the
|
|
8
|
+
* per-record hook `AccessContext` deliberately does not provide.
|
|
9
|
+
*/
|
|
10
|
+
export interface AccessVerdict {
|
|
11
|
+
granted: boolean;
|
|
12
|
+
filter?: (record: unknown) => boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Classify one `access()` return value. Extracted verbatim from `auth()`, which
|
|
16
|
+
* now calls this; the branch ORDER is load-bearing and is preserved exactly.
|
|
17
|
+
*
|
|
18
|
+
* `operation` is the verb being authorised. `undefined` -- reachable, because
|
|
19
|
+
* express delivers HEAD to the GET handler and `methodAccessMap` has no entry
|
|
20
|
+
* for it -- falls through `permitted.includes(undefined)` to a denial, which is
|
|
21
|
+
* the same answer `auth()` gave before the extraction.
|
|
22
|
+
*/
|
|
23
|
+
export declare function interpretAccess(access: AccessMethod, operation: AccessOperation | undefined): AccessVerdict;
|
|
24
|
+
/**
|
|
25
|
+
* Build a request-scoped linkage filter.
|
|
26
|
+
*
|
|
27
|
+
* TWO CACHES, AND BOTH ARE LOAD-BEARING RATHER THAN AN OPTIMISATION:
|
|
28
|
+
*
|
|
29
|
+
* - one verdict per TYPE. Resolving means CALLING the consumer's `access()`,
|
|
30
|
+
* which is arbitrary code with arbitrary cost and which the module has
|
|
31
|
+
* already had to guard for throwing.
|
|
32
|
+
* - one decision per `(type, id)`. `included` is deduplicated by
|
|
33
|
+
* `buildResponse`; LINKAGE is not deduplicated at all, so it re-asks once
|
|
34
|
+
* per record. Measured on a bare `GET /animals` with no `include=`:
|
|
35
|
+
* 48 linkage entries -> 7 distinct `(type, id)` pairs (owner 20, trait 28),
|
|
36
|
+
* a 6.9x reduction and 41 predicate calls saved.
|
|
37
|
+
*
|
|
38
|
+
* The `(type, id)` cache is a `Map` per type keyed on the RAW id, not on a
|
|
39
|
+
* template-string composite. `Map` compares with SameValueZero, so the numeric
|
|
40
|
+
* id `1` and the string id `'1'` stay DISTINCT, where `` `${type}:${id}` `` --
|
|
41
|
+
* or a bare `String(id)` -- collapses them onto one entry and answers the second
|
|
42
|
+
* record with the first record's verdict.
|
|
43
|
+
*
|
|
44
|
+
* WHAT THAT DOES AND DOES NOT PROTECT. It cannot cross MODELS. `decisions` is
|
|
45
|
+
* already partitioned per type by `byType`, so a composite key inside a per-type
|
|
46
|
+
* map is one-to-one with the raw one and no owner's verdict could ever answer
|
|
47
|
+
* for an animal -- the claim that once stood here. The real exposure is narrower
|
|
48
|
+
* and entirely WITHIN one model: two records of the same type whose ids differ
|
|
49
|
+
* only by JavaScript type, which a per-record predicate may legitimately answer
|
|
50
|
+
* differently about (an id read off a JSON body is a string; the same id
|
|
51
|
+
* assigned by the server is a number). Pinned by unit assertion, because this
|
|
52
|
+
* fixture cannot produce the collision on its own -- `owner` ids are strings and
|
|
53
|
+
* `animal` ids are numbers.
|
|
54
|
+
*
|
|
55
|
+
* SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
|
|
56
|
+
* it -- a verdict cached across requests would answer a second caller with the
|
|
57
|
+
* first caller's authorization.
|
|
58
|
+
*
|
|
59
|
+
* A REQUEST IS REQUIRED, AND ITS ABSENCE IS CHECKED HERE RATHER THAN DELEGATED.
|
|
60
|
+
* This function is EXPORTED (src/index.ts), and the README's Consumer Contracts
|
|
61
|
+
* section points consumers at exactly the contexts that have no live request --
|
|
62
|
+
* a queue payload, a websocket frame, a custom route. Without one there is no
|
|
63
|
+
* caller to authorise against, and this file's header already says so: the
|
|
64
|
+
* shipped sample reads `request.path` and fail-closes when it is absent, so
|
|
65
|
+
* `getAccess('owner')(undefined, ...)` is `false`, while
|
|
66
|
+
* `getAccess('animal')(undefined, ...)` returns a per-record predicate and
|
|
67
|
+
* GRANTS. Measured on this repo's own fixture before this guard existed:
|
|
68
|
+
*
|
|
69
|
+
* createLinkageFilter(undefined | null | {} | 'x' | 0)
|
|
70
|
+
* -> owner=false animal=TRUE trait=TRUE category=TRUE phone-number=TRUE
|
|
71
|
+
*
|
|
72
|
+
* Four of five claimed models granted, with no log, because whether an absent
|
|
73
|
+
* request fails closed was left ENTIRELY to consumer predicates -- and a
|
|
74
|
+
* predicate that ignores its request cannot fail closed on one that is missing.
|
|
75
|
+
* A nullish or primitive `request` therefore denies every model outright and
|
|
76
|
+
* says so once, at construction, so the signal exists even for a caller that
|
|
77
|
+
* goes on to serialize nothing.
|
|
78
|
+
*
|
|
79
|
+
* WHAT THIS CANNOT CHECK: `{}` is an object and passes. There is no request
|
|
80
|
+
* contract this module owns -- `auth()` reads `.method`, the shipped sample
|
|
81
|
+
* reads `.path`, a consumer's reads whatever it likes -- so anything past
|
|
82
|
+
* "is it an object" would be this module inventing a shape for someone else's
|
|
83
|
+
* framework. The residual is documented in the README under Consumer Contracts.
|
|
84
|
+
*/
|
|
85
|
+
export declare function createLinkageFilter(request: unknown): LinkageFilter;
|