@stonyx/orm 0.3.2-alpha.49 → 0.3.2-alpha.50
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 +43 -7
- package/dist/orm-request.d.ts +37 -0
- package/dist/orm-request.js +171 -8
- package/package.json +1 -1
- package/src/orm-request.ts +174 -8
package/README.md
CHANGED
|
@@ -314,10 +314,19 @@ Access classes define models and provide custom filtering/authorization logic.
|
|
|
314
314
|
> **The URL-matching in this example is a stopgap. Read
|
|
315
315
|
> [Matching the url](#matching-the-url) before copying it.** The same three-line
|
|
316
316
|
> example has failed **open** in four distinct ways during one review, each found
|
|
317
|
-
> only after the previous was fixed. The sample below
|
|
318
|
-
> not the same as being safe
|
|
319
|
-
>
|
|
320
|
-
>
|
|
317
|
+
> only after the previous was fixed, by four different people. The sample below
|
|
318
|
+
> closes all four; that is not the same as being safe — it is safe against the
|
|
319
|
+
> four variants that happen to have been found, and there is no reason to believe
|
|
320
|
+
> the list is complete.
|
|
321
|
+
>
|
|
322
|
+
> **The real fix is
|
|
323
|
+
> [#202](https://github.com/abofs/stonyx-orm/issues/202)** — `access()` should
|
|
324
|
+
> receive the model, the operation and the record, so there is no URL to parse
|
|
325
|
+
> and no variant to miss. Until it lands, prefer the array shape (`['read']`) or
|
|
326
|
+
> `false` where you can: the **function** shape is the one that requires URL
|
|
327
|
+
> matching. The same warning is repeated at the top of `src/orm-request.ts`,
|
|
328
|
+
> which ships; the longer write-up in `docs/usage-patterns.md` does **not** ship,
|
|
329
|
+
> so this README and that source header are the two copies a consumer sees.
|
|
321
330
|
|
|
322
331
|
```js
|
|
323
332
|
import config from 'stonyx/config';
|
|
@@ -510,6 +519,22 @@ as `collectionPrefix()` above does.
|
|
|
510
519
|
- **Enforcement is post-fetch.** The record is loaded and then tested, which
|
|
511
520
|
leaves a small timing difference between a hidden record and one that never
|
|
512
521
|
existed. Tracked as [#197](https://github.com/abofs/stonyx-orm/issues/197).
|
|
522
|
+
- **A `relationships` key that is not a declared relationship is still applied
|
|
523
|
+
to the record.** The key comes verbatim from the request body and is checked
|
|
524
|
+
against nothing except `id`, which is stripped. On a `POST` that makes an
|
|
525
|
+
undeclared key a mass-assigned attribute; on a `PATCH` it is passed to
|
|
526
|
+
`updateRecord`. `id` is stripped in both handlers because it defeats breaking
|
|
527
|
+
change 3 above; the general form is tracked as
|
|
528
|
+
[#204](https://github.com/abofs/stonyx-orm/issues/204).
|
|
529
|
+
- **A partially numeric `id` in a `POST` body can overwrite a different record
|
|
530
|
+
on an unfiltered collection.** The duplicate check rejects `"9105h"` as a
|
|
531
|
+
string, correctly, but the model's id transform truncates it to `9105` and the
|
|
532
|
+
create lands there. Filtered collections are unaffected — breaking change 3
|
|
533
|
+
refuses any client-supplied id — so this reaches consumers with **no**
|
|
534
|
+
function-style filter. Tracked as
|
|
535
|
+
[#205](https://github.com/abofs/stonyx-orm/issues/205), alongside
|
|
536
|
+
[#203](https://github.com/abofs/stonyx-orm/issues/203), which is the other way
|
|
537
|
+
a create can land on an id nobody named.
|
|
513
538
|
|
|
514
539
|
### Breaking changes in 0.4.0
|
|
515
540
|
|
|
@@ -529,9 +554,20 @@ they are recorded here.
|
|
|
529
554
|
destroyed children behind a 404.
|
|
530
555
|
3. **`POST` with a client-supplied `id` returns `403` when a function-style
|
|
531
556
|
`access` filter is in force**, whatever the payload and whether or not the id
|
|
532
|
-
exists
|
|
533
|
-
|
|
534
|
-
|
|
557
|
+
exists, and *before* any store lookup — so neither the status nor the lookup
|
|
558
|
+
cost can depend on whether that id exists. Only affects function-style
|
|
559
|
+
`access` users. See [Filter functions](#filter-functions) for why, and let the
|
|
560
|
+
server assign the id instead. `409`-on-duplicate is unchanged for everyone
|
|
561
|
+
else.
|
|
562
|
+
|
|
563
|
+
"Whatever the payload" is a statement about the **`id` member of the resource
|
|
564
|
+
object**, and it holds only because that is the sole channel a caller id can
|
|
565
|
+
arrive on. It was not always: a caller id moved into
|
|
566
|
+
`relationships: {"id": {"data": {"id": 21}}}` reached `createRecord` past this
|
|
567
|
+
refusal and overwrote a hidden record in place. Both strips — `attributes.id`
|
|
568
|
+
and `relationships.id` — are part of this behaviour, not tidiness. A future
|
|
569
|
+
change that adds a third channel without stripping it re-opens the oracle;
|
|
570
|
+
see [#204](https://github.com/abofs/stonyx-orm/issues/204).
|
|
535
571
|
4. **Function-style `access` is now enforced on all seven surfaces.** Records
|
|
536
572
|
previously reachable by id despite being filtered from the collection now
|
|
537
573
|
return 404. Only affects function-style `access` users, for whom the old
|
package/dist/orm-request.d.ts
CHANGED
|
@@ -1,3 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST request handling and access enforcement for @stonyx/orm.
|
|
3
|
+
*
|
|
4
|
+
* ---------------------------------------------------------------------------
|
|
5
|
+
* THE DOCUMENTED `access()` PATTERN IS A STOPGAP. READ THIS BEFORE RELYING ON IT.
|
|
6
|
+
* ---------------------------------------------------------------------------
|
|
7
|
+
* `auth()` below hands your `access(request)` a raw transport artifact and asks
|
|
8
|
+
* you to re-derive from a URL string what this module already holds
|
|
9
|
+
* structurally: which model, which operation, which record. The three-line
|
|
10
|
+
* URL-matching example in README has failed **open** in four distinct ways
|
|
11
|
+
* during the review of a single change, each found only after the previous was
|
|
12
|
+
* fixed, by four different people:
|
|
13
|
+
*
|
|
14
|
+
* 1. `request.url` is mount-relative under `RestServer.mountRoute`, so a
|
|
15
|
+
* prefix match against it is ALWAYS false.
|
|
16
|
+
* 2. `request.originalUrl` carries the query string, so an anchored equality
|
|
17
|
+
* check misses `/owners?filter[age]=30`.
|
|
18
|
+
* 3. The router is a bare `express()` (`caseSensitive: false`) while a
|
|
19
|
+
* hand-written matcher is case-SENSITIVE, so `GET /OwNeRs/angela` walks
|
|
20
|
+
* past it. Router-side: abofs/stonyx-rest-server#47.
|
|
21
|
+
* 4. Under a configured `ORM_REST_ROUTE` a hard-coded `/owners` matches
|
|
22
|
+
* nothing -- environment-specifically, which is worse.
|
|
23
|
+
*
|
|
24
|
+
* The README sample closes all four. That is NOT the same as being safe; it is
|
|
25
|
+
* safe against the four variants we happen to have found, and there is no
|
|
26
|
+
* reason to believe the list is complete.
|
|
27
|
+
*
|
|
28
|
+
* THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model, the
|
|
29
|
+
* operation and the record, so there is no URL to parse and no variant to miss.
|
|
30
|
+
* Prefer the array shape (`['read']`) or `false` until #202 lands; the
|
|
31
|
+
* function shape is what requires the URL matching.
|
|
32
|
+
*
|
|
33
|
+
* The enforcement gates in this file (GATE 0/1/2, the create rollback, the
|
|
34
|
+
* per-handler `isDenied` re-checks) are correct independently of that -- they
|
|
35
|
+
* enforce whatever predicate you return. The stopgap is the part where YOU have
|
|
36
|
+
* to work out which predicate to return.
|
|
37
|
+
*/
|
|
1
38
|
import { Request } from '@stonyx/rest-server';
|
|
2
39
|
interface OrmRequest$ extends Request {
|
|
3
40
|
protocol?: string;
|
package/dist/orm-request.js
CHANGED
|
@@ -1,3 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST request handling and access enforcement for @stonyx/orm.
|
|
3
|
+
*
|
|
4
|
+
* ---------------------------------------------------------------------------
|
|
5
|
+
* THE DOCUMENTED `access()` PATTERN IS A STOPGAP. READ THIS BEFORE RELYING ON IT.
|
|
6
|
+
* ---------------------------------------------------------------------------
|
|
7
|
+
* `auth()` below hands your `access(request)` a raw transport artifact and asks
|
|
8
|
+
* you to re-derive from a URL string what this module already holds
|
|
9
|
+
* structurally: which model, which operation, which record. The three-line
|
|
10
|
+
* URL-matching example in README has failed **open** in four distinct ways
|
|
11
|
+
* during the review of a single change, each found only after the previous was
|
|
12
|
+
* fixed, by four different people:
|
|
13
|
+
*
|
|
14
|
+
* 1. `request.url` is mount-relative under `RestServer.mountRoute`, so a
|
|
15
|
+
* prefix match against it is ALWAYS false.
|
|
16
|
+
* 2. `request.originalUrl` carries the query string, so an anchored equality
|
|
17
|
+
* check misses `/owners?filter[age]=30`.
|
|
18
|
+
* 3. The router is a bare `express()` (`caseSensitive: false`) while a
|
|
19
|
+
* hand-written matcher is case-SENSITIVE, so `GET /OwNeRs/angela` walks
|
|
20
|
+
* past it. Router-side: abofs/stonyx-rest-server#47.
|
|
21
|
+
* 4. Under a configured `ORM_REST_ROUTE` a hard-coded `/owners` matches
|
|
22
|
+
* nothing -- environment-specifically, which is worse.
|
|
23
|
+
*
|
|
24
|
+
* The README sample closes all four. That is NOT the same as being safe; it is
|
|
25
|
+
* safe against the four variants we happen to have found, and there is no
|
|
26
|
+
* reason to believe the list is complete.
|
|
27
|
+
*
|
|
28
|
+
* THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model, the
|
|
29
|
+
* operation and the record, so there is no URL to parse and no variant to miss.
|
|
30
|
+
* Prefer the array shape (`['read']`) or `false` until #202 lands; the
|
|
31
|
+
* function shape is what requires the URL matching.
|
|
32
|
+
*
|
|
33
|
+
* The enforcement gates in this file (GATE 0/1/2, the create rollback, the
|
|
34
|
+
* per-handler `isDenied` re-checks) are correct independently of that -- they
|
|
35
|
+
* enforce whatever predicate you return. The stopgap is the part where YOU have
|
|
36
|
+
* to work out which predicate to return.
|
|
37
|
+
*/
|
|
1
38
|
import { Request } from '@stonyx/rest-server';
|
|
2
39
|
import Orm, { store, createRecord, updateRecord } from '@stonyx/orm';
|
|
3
40
|
import { camelCaseToKebabCase } from '@stonyx/utils/string';
|
|
@@ -48,13 +85,44 @@ function getBaseUrl(request) {
|
|
|
48
85
|
const host = request.get('host');
|
|
49
86
|
return `${protocol}://${host}`;
|
|
50
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* The ONE coercion from a caller-supplied id string to the key the store holds
|
|
90
|
+
* it under. Both id-bearing surfaces go through this, and neither has a copy.
|
|
91
|
+
*
|
|
92
|
+
* WHY IT IS SHARED RATHER THAN DUPLICATED. `getId()` (URL) and
|
|
93
|
+
* `normalizeBodyId()` (JSON body) each had their own arithmetic, and they
|
|
94
|
+
* disagreed: `parseInt(id)` versus `parseInt(id, 10)`. On a hex-shaped id that
|
|
95
|
+
* is a two-record difference --
|
|
96
|
+
*
|
|
97
|
+
* GET /animals/0x2391 -> record 9105 (getId -> parseInt('0x2391') = 9105)
|
|
98
|
+
* POST /animals {"id":"0x2391"} -> lookup under 0 (normalize -> parseInt('0x2391',10) = 0)
|
|
99
|
+
* -> a MISS, so the duplicate check was skipped and
|
|
100
|
+
* createRecord OVERWROTE 9105 in place, answering 200
|
|
101
|
+
*
|
|
102
|
+
* -- which is the raw-versus-normalised divergence that produced the round-3
|
|
103
|
+
* blocker, in a narrower form, reintroduced by the fix for it. Two coercions
|
|
104
|
+
* that must agree cannot be kept in agreement by review; they have to be one
|
|
105
|
+
* function. Pinned by assertion 43.
|
|
106
|
+
*
|
|
107
|
+
* `parseInt` and not `Number`, deliberately. They differ on `'1e3'` (1 vs 1000)
|
|
108
|
+
* and `'9105.5'` (9105 vs 9105.5), and `getId` -- which decides which record an
|
|
109
|
+
* id ADDRESSES -- is the reference, so `Number` would trade one divergence for
|
|
110
|
+
* two. The reason `parseInt` is safe here is the `isNaN` gate in front of it:
|
|
111
|
+
* `parseInt('9105h')` is `9105`, and truncating a partially-valid id into a
|
|
112
|
+
* DIFFERENT VALID key is exactly how a collision lookup gets skipped. The gate
|
|
113
|
+
* rejects it as a string instead, so nothing is ever truncated. That gate, not
|
|
114
|
+
* the parser, is the load-bearing half -- assertion 43 pins it.
|
|
115
|
+
*/
|
|
116
|
+
function coerceId(id) {
|
|
117
|
+
if (isNaN(id))
|
|
118
|
+
return id;
|
|
119
|
+
return parseInt(id);
|
|
120
|
+
}
|
|
51
121
|
function getId(params) {
|
|
52
122
|
const id = params.id;
|
|
53
123
|
if (!id)
|
|
54
124
|
return '';
|
|
55
|
-
|
|
56
|
-
return id;
|
|
57
|
-
return parseInt(id);
|
|
125
|
+
return coerceId(id);
|
|
58
126
|
}
|
|
59
127
|
/**
|
|
60
128
|
* Normalise a caller-supplied BODY id to the key the store will hold it under.
|
|
@@ -72,13 +140,33 @@ function getId(params) {
|
|
|
72
140
|
* answered 200; combined with the denied-create rollback added for #190 it
|
|
73
141
|
* became an unauthenticated DELETE of any id. Normalising here is half of that
|
|
74
142
|
* fix -- see the rollback in createHandler for the other half.
|
|
143
|
+
*
|
|
144
|
+
* It shares `coerceId` with `getId` so the two surfaces cannot drift apart
|
|
145
|
+
* again, and differs from `getId` in exactly ONE place, below.
|
|
75
146
|
*/
|
|
76
147
|
function normalizeBodyId(id) {
|
|
77
|
-
|
|
148
|
+
// Non-strings pass through untouched: a JSON body id can arrive as a number,
|
|
149
|
+
// and `getId`'s falsy-flatten must NOT apply to it -- `0` is a legitimate id
|
|
150
|
+
// and `getId` would turn it into `''`. (assertion 30 sweeps id `0`.)
|
|
151
|
+
if (typeof id !== 'string')
|
|
78
152
|
return id;
|
|
79
|
-
|
|
153
|
+
// THE ONE DIVERGENCE FROM `getId`, and it mirrors rather than contradicts it:
|
|
154
|
+
// `getId` maps a falsy param to `''`, and `''` is the only string a body can
|
|
155
|
+
// carry that means "no id" -- `createRecord` treats it as absent and assigns a
|
|
156
|
+
// server id. Coercing it instead would make it address a real slot, because
|
|
157
|
+
// `parseInt('')` is `NaN` and a record CAN be held under `NaN` (a truthy but
|
|
158
|
+
// non-numeric id such as `' '` survives `assignRecordId`'s falsy guard and
|
|
159
|
+
// then NaNs in the id transform). So `POST {"id":""}` would answer 409 against
|
|
160
|
+
// an unrelated record it never named. Pinned by assertion 44.
|
|
161
|
+
//
|
|
162
|
+
// Note what is deliberately NOT special-cased here any more: whitespace.
|
|
163
|
+
// `id.trim() === ''` used to short-circuit `' '` as well, which made the
|
|
164
|
+
// body surface DISAGREE with the URL surface -- `getId({id:' '})` is `NaN`,
|
|
165
|
+
// so `' '` addresses the NaN slot on every other route while the collision
|
|
166
|
+
// lookup missed it. Same class of bug as the hex divergence above.
|
|
167
|
+
if (id === '')
|
|
80
168
|
return id;
|
|
81
|
-
return
|
|
169
|
+
return coerceId(id);
|
|
82
170
|
}
|
|
83
171
|
function buildResponse(data, includeParam, recordOrRecords, options = {}) {
|
|
84
172
|
const { links, baseUrl } = options;
|
|
@@ -315,6 +403,25 @@ export default class OrmRequest extends Request {
|
|
|
315
403
|
// lookup cost, can depend on whether that id exists. 403 -- the same
|
|
316
404
|
// status as a denied create -- so the two cannot be separated either.
|
|
317
405
|
//
|
|
406
|
+
// BOTH HALVES OF THAT ARE PINNED, because both were once asserted here and
|
|
407
|
+
// pinned by nothing:
|
|
408
|
+
//
|
|
409
|
+
// status -- assertion 22 sweeps payload x id x id-type, plus `null`.
|
|
410
|
+
// latency -- assertion 41 asserts NO `store.find` is issued on this
|
|
411
|
+
// path. Moving the refusal to after a lookup and returning
|
|
412
|
+
// the same 403 left the suite green while re-opening a
|
|
413
|
+
// hit-versus-miss timing difference on every id-bearing POST,
|
|
414
|
+
// which is what would turn #197 from a ~0.06ms post-fetch
|
|
415
|
+
// residual into a live timing oracle on create.
|
|
416
|
+
//
|
|
417
|
+
// AND THE GUARANTEE IS ONLY AS WIDE AS THE CHANNELS IT COVERS. It reads on
|
|
418
|
+
// the `id` member of the resource object, so it holds only while that is
|
|
419
|
+
// the ONLY way a caller id can reach `createRecord`. It was not: the
|
|
420
|
+
// relationships loop below re-admitted one under `key === "id"` and the
|
|
421
|
+
// gate never fired. Both strips -- `attributes.id` and `relationships.id`
|
|
422
|
+
// -- are therefore part of THIS gate, not tidiness, and assertion 39 pins
|
|
423
|
+
// them. Adding a third channel without a strip re-opens the oracle.
|
|
424
|
+
//
|
|
318
425
|
// Scoped to function-style `access` because that is exactly the population
|
|
319
426
|
// the oracle exists for: with no per-record filter there are no hidden
|
|
320
427
|
// records, and 409 discloses nothing GET /:id does not already.
|
|
@@ -333,9 +440,32 @@ export default class OrmRequest extends Request {
|
|
|
333
440
|
return 409; // Conflict
|
|
334
441
|
}
|
|
335
442
|
const { id: _ignoredId, ...sanitizedAttributes } = attributes || {};
|
|
336
|
-
// Extract relationship IDs from JSON:API relationships object
|
|
443
|
+
// Extract relationship IDs from JSON:API relationships object.
|
|
444
|
+
//
|
|
445
|
+
// `key` comes VERBATIM from the request body, so `id` is stripped here for
|
|
446
|
+
// exactly the same reason it is stripped from `attributes` on the line
|
|
447
|
+
// above -- and it must be, or GATE 0 is walked around by moving one field:
|
|
448
|
+
//
|
|
449
|
+
// POST /animals {"id":21, ...} -> 403 GATE 0 fires
|
|
450
|
+
// POST /animals {"relationships":{"id":{"data":{"id":21}}},
|
|
451
|
+
// "attributes":{"owner":"gina"}} -> 200 BYPASS
|
|
452
|
+
//
|
|
453
|
+
// Top-level `id` stayed `undefined`, so GATE 0 never fired and the
|
|
454
|
+
// collision lookup never ran; `createRecord` took its last-entry-wins
|
|
455
|
+
// branch, overwrote hidden record 21 in place and reset its `owner` to a
|
|
456
|
+
// value the caller chose -- de-hiding it permanently. That is #190 itself,
|
|
457
|
+
// on the create surface. Pinned by assertion 39.
|
|
458
|
+
//
|
|
459
|
+
// The `id` member of the resource object is now the ONLY channel a caller
|
|
460
|
+
// id can arrive on, which is what makes GATE 0's guarantee checkable
|
|
461
|
+
// rather than merely asserted. INHERITED from `dev`, which carries this
|
|
462
|
+
// loop verbatim; the general form -- the loop accepts any key, not just
|
|
463
|
+
// `id`, so a body key that is not a declared relationship is still
|
|
464
|
+
// mass-assigned -- is abofs/stonyx-orm#204.
|
|
337
465
|
if (rels) {
|
|
338
466
|
for (const [key, value] of Object.entries(rels)) {
|
|
467
|
+
if (key === 'id')
|
|
468
|
+
continue;
|
|
339
469
|
const relData = value?.data;
|
|
340
470
|
if (relData && relData.id !== undefined) {
|
|
341
471
|
sanitizedAttributes[key] = relData.id;
|
|
@@ -382,7 +512,27 @@ export default class OrmRequest extends Request {
|
|
|
382
512
|
// abofs/stonyx-orm#203.
|
|
383
513
|
// identity -- the slot still holds the object we just created,
|
|
384
514
|
// so nothing between createRecord and here replaced
|
|
385
|
-
// it.
|
|
515
|
+
// it. Deleting this half SURVIVES the suite, and it
|
|
516
|
+
// is kept anyway. WHY IT IS REDUNDANT: there is no
|
|
517
|
+
// `await` anywhere between `slotsBefore` and
|
|
518
|
+
// `store.remove` -- the whole window is synchronous,
|
|
519
|
+
// so it is atomic under Node's event loop; before-
|
|
520
|
+
// `create` hooks run BEFORE the handler
|
|
521
|
+
// (`_withHooks` runs its hook loop ahead of
|
|
522
|
+
// `await handler(...)`), and a consumer predicate
|
|
523
|
+
// inside `isDenied` runs AFTER `createdNewSlot` is
|
|
524
|
+
// computed and cannot flip it. That is a property of
|
|
525
|
+
// THIS function, not of GATE 0 -- an earlier note
|
|
526
|
+
// credited GATE 0, which was both wrong (a caller id
|
|
527
|
+
// reached createRecord through the relationships
|
|
528
|
+
// loop, #204) and the wrong kind of reason: a guard
|
|
529
|
+
// justified on code sixty lines upstream gets
|
|
530
|
+
// silently re-armed when that code moves.
|
|
531
|
+
// SO IT BECOMES REACHABLE IF AN `await` IS
|
|
532
|
+
// INTRODUCED HERE, which is the change a future
|
|
533
|
+
// editor would actually make. See the
|
|
534
|
+
// guards-redundant-by-construction table in
|
|
535
|
+
// docs/project-structure.md.
|
|
386
536
|
if (createdNewSlot && store.get(model, record.id) === record) {
|
|
387
537
|
store.remove(model, record.id, { _skipAutoPersist: true });
|
|
388
538
|
}
|
|
@@ -425,6 +575,19 @@ export default class OrmRequest extends Request {
|
|
|
425
575
|
if (rels) {
|
|
426
576
|
const relUpdates = {};
|
|
427
577
|
for (const [key, value] of Object.entries(rels)) {
|
|
578
|
+
// The same missing key filter as createHandler's, and as the
|
|
579
|
+
// attribute loop directly above -- which already had it, while this
|
|
580
|
+
// loop did not. A PATCH carrying
|
|
581
|
+
// `relationships:{"id":{"data":{"id":9101}}}` reached `updateRecord`
|
|
582
|
+
// and RE-KEYED the record: the object held under store key 9102 then
|
|
583
|
+
// reported id 9101, so a visible record claimed a hidden record's
|
|
584
|
+
// identity on every surface that reads `record.id` rather than the map
|
|
585
|
+
// key. Gated by GATE 1 on the addressed record, so it is store
|
|
586
|
+
// corruption rather than a filter bypass -- but it is the same one-line
|
|
587
|
+
// omission two handlers apart. Pinned by assertion 40; INHERITED from
|
|
588
|
+
// `dev`; abofs/stonyx-orm#204.
|
|
589
|
+
if (key === 'id')
|
|
590
|
+
continue;
|
|
428
591
|
const relData = value?.data;
|
|
429
592
|
if (relData && relData.id !== undefined) {
|
|
430
593
|
relUpdates[key] = relData.id;
|
package/package.json
CHANGED
package/src/orm-request.ts
CHANGED
|
@@ -1,3 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST request handling and access enforcement for @stonyx/orm.
|
|
3
|
+
*
|
|
4
|
+
* ---------------------------------------------------------------------------
|
|
5
|
+
* THE DOCUMENTED `access()` PATTERN IS A STOPGAP. READ THIS BEFORE RELYING ON IT.
|
|
6
|
+
* ---------------------------------------------------------------------------
|
|
7
|
+
* `auth()` below hands your `access(request)` a raw transport artifact and asks
|
|
8
|
+
* you to re-derive from a URL string what this module already holds
|
|
9
|
+
* structurally: which model, which operation, which record. The three-line
|
|
10
|
+
* URL-matching example in README has failed **open** in four distinct ways
|
|
11
|
+
* during the review of a single change, each found only after the previous was
|
|
12
|
+
* fixed, by four different people:
|
|
13
|
+
*
|
|
14
|
+
* 1. `request.url` is mount-relative under `RestServer.mountRoute`, so a
|
|
15
|
+
* prefix match against it is ALWAYS false.
|
|
16
|
+
* 2. `request.originalUrl` carries the query string, so an anchored equality
|
|
17
|
+
* check misses `/owners?filter[age]=30`.
|
|
18
|
+
* 3. The router is a bare `express()` (`caseSensitive: false`) while a
|
|
19
|
+
* hand-written matcher is case-SENSITIVE, so `GET /OwNeRs/angela` walks
|
|
20
|
+
* past it. Router-side: abofs/stonyx-rest-server#47.
|
|
21
|
+
* 4. Under a configured `ORM_REST_ROUTE` a hard-coded `/owners` matches
|
|
22
|
+
* nothing -- environment-specifically, which is worse.
|
|
23
|
+
*
|
|
24
|
+
* The README sample closes all four. That is NOT the same as being safe; it is
|
|
25
|
+
* safe against the four variants we happen to have found, and there is no
|
|
26
|
+
* reason to believe the list is complete.
|
|
27
|
+
*
|
|
28
|
+
* THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model, the
|
|
29
|
+
* operation and the record, so there is no URL to parse and no variant to miss.
|
|
30
|
+
* Prefer the array shape (`['read']`) or `false` until #202 lands; the
|
|
31
|
+
* function shape is what requires the URL matching.
|
|
32
|
+
*
|
|
33
|
+
* The enforcement gates in this file (GATE 0/1/2, the create rollback, the
|
|
34
|
+
* per-handler `isDenied` re-checks) are correct independently of that -- they
|
|
35
|
+
* enforce whatever predicate you return. The stopgap is the part where YOU have
|
|
36
|
+
* to work out which predicate to return.
|
|
37
|
+
*/
|
|
1
38
|
import { Request } from '@stonyx/rest-server';
|
|
2
39
|
import Orm, { store, createRecord, updateRecord } from '@stonyx/orm';
|
|
3
40
|
import { camelCaseToKebabCase } from '@stonyx/utils/string';
|
|
@@ -84,12 +121,45 @@ function getBaseUrl(request: OrmRequest$): string {
|
|
|
84
121
|
return `${protocol}://${host}`;
|
|
85
122
|
}
|
|
86
123
|
|
|
124
|
+
/**
|
|
125
|
+
* The ONE coercion from a caller-supplied id string to the key the store holds
|
|
126
|
+
* it under. Both id-bearing surfaces go through this, and neither has a copy.
|
|
127
|
+
*
|
|
128
|
+
* WHY IT IS SHARED RATHER THAN DUPLICATED. `getId()` (URL) and
|
|
129
|
+
* `normalizeBodyId()` (JSON body) each had their own arithmetic, and they
|
|
130
|
+
* disagreed: `parseInt(id)` versus `parseInt(id, 10)`. On a hex-shaped id that
|
|
131
|
+
* is a two-record difference --
|
|
132
|
+
*
|
|
133
|
+
* GET /animals/0x2391 -> record 9105 (getId -> parseInt('0x2391') = 9105)
|
|
134
|
+
* POST /animals {"id":"0x2391"} -> lookup under 0 (normalize -> parseInt('0x2391',10) = 0)
|
|
135
|
+
* -> a MISS, so the duplicate check was skipped and
|
|
136
|
+
* createRecord OVERWROTE 9105 in place, answering 200
|
|
137
|
+
*
|
|
138
|
+
* -- which is the raw-versus-normalised divergence that produced the round-3
|
|
139
|
+
* blocker, in a narrower form, reintroduced by the fix for it. Two coercions
|
|
140
|
+
* that must agree cannot be kept in agreement by review; they have to be one
|
|
141
|
+
* function. Pinned by assertion 43.
|
|
142
|
+
*
|
|
143
|
+
* `parseInt` and not `Number`, deliberately. They differ on `'1e3'` (1 vs 1000)
|
|
144
|
+
* and `'9105.5'` (9105 vs 9105.5), and `getId` -- which decides which record an
|
|
145
|
+
* id ADDRESSES -- is the reference, so `Number` would trade one divergence for
|
|
146
|
+
* two. The reason `parseInt` is safe here is the `isNaN` gate in front of it:
|
|
147
|
+
* `parseInt('9105h')` is `9105`, and truncating a partially-valid id into a
|
|
148
|
+
* DIFFERENT VALID key is exactly how a collision lookup gets skipped. The gate
|
|
149
|
+
* rejects it as a string instead, so nothing is ever truncated. That gate, not
|
|
150
|
+
* the parser, is the load-bearing half -- assertion 43 pins it.
|
|
151
|
+
*/
|
|
152
|
+
function coerceId(id: string): string | number {
|
|
153
|
+
if (isNaN(id as unknown as number)) return id;
|
|
154
|
+
|
|
155
|
+
return parseInt(id);
|
|
156
|
+
}
|
|
157
|
+
|
|
87
158
|
function getId(params: { id?: string; [key: string]: unknown }): string | number {
|
|
88
159
|
const id = params.id;
|
|
89
160
|
if (!id) return '';
|
|
90
|
-
if (isNaN(id as unknown as number)) return id;
|
|
91
161
|
|
|
92
|
-
return
|
|
162
|
+
return coerceId(id);
|
|
93
163
|
}
|
|
94
164
|
|
|
95
165
|
/**
|
|
@@ -108,12 +178,33 @@ function getId(params: { id?: string; [key: string]: unknown }): string | number
|
|
|
108
178
|
* answered 200; combined with the denied-create rollback added for #190 it
|
|
109
179
|
* became an unauthenticated DELETE of any id. Normalising here is half of that
|
|
110
180
|
* fix -- see the rollback in createHandler for the other half.
|
|
181
|
+
*
|
|
182
|
+
* It shares `coerceId` with `getId` so the two surfaces cannot drift apart
|
|
183
|
+
* again, and differs from `getId` in exactly ONE place, below.
|
|
111
184
|
*/
|
|
112
185
|
function normalizeBodyId(id: string | number): string | number {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
186
|
+
// Non-strings pass through untouched: a JSON body id can arrive as a number,
|
|
187
|
+
// and `getId`'s falsy-flatten must NOT apply to it -- `0` is a legitimate id
|
|
188
|
+
// and `getId` would turn it into `''`. (assertion 30 sweeps id `0`.)
|
|
189
|
+
if (typeof id !== 'string') return id;
|
|
190
|
+
|
|
191
|
+
// THE ONE DIVERGENCE FROM `getId`, and it mirrors rather than contradicts it:
|
|
192
|
+
// `getId` maps a falsy param to `''`, and `''` is the only string a body can
|
|
193
|
+
// carry that means "no id" -- `createRecord` treats it as absent and assigns a
|
|
194
|
+
// server id. Coercing it instead would make it address a real slot, because
|
|
195
|
+
// `parseInt('')` is `NaN` and a record CAN be held under `NaN` (a truthy but
|
|
196
|
+
// non-numeric id such as `' '` survives `assignRecordId`'s falsy guard and
|
|
197
|
+
// then NaNs in the id transform). So `POST {"id":""}` would answer 409 against
|
|
198
|
+
// an unrelated record it never named. Pinned by assertion 44.
|
|
199
|
+
//
|
|
200
|
+
// Note what is deliberately NOT special-cased here any more: whitespace.
|
|
201
|
+
// `id.trim() === ''` used to short-circuit `' '` as well, which made the
|
|
202
|
+
// body surface DISAGREE with the URL surface -- `getId({id:' '})` is `NaN`,
|
|
203
|
+
// so `' '` addresses the NaN slot on every other route while the collision
|
|
204
|
+
// lookup missed it. Same class of bug as the hex divergence above.
|
|
205
|
+
if (id === '') return id;
|
|
206
|
+
|
|
207
|
+
return coerceId(id);
|
|
117
208
|
}
|
|
118
209
|
|
|
119
210
|
function buildResponse(
|
|
@@ -394,6 +485,25 @@ export default class OrmRequest extends Request {
|
|
|
394
485
|
// lookup cost, can depend on whether that id exists. 403 -- the same
|
|
395
486
|
// status as a denied create -- so the two cannot be separated either.
|
|
396
487
|
//
|
|
488
|
+
// BOTH HALVES OF THAT ARE PINNED, because both were once asserted here and
|
|
489
|
+
// pinned by nothing:
|
|
490
|
+
//
|
|
491
|
+
// status -- assertion 22 sweeps payload x id x id-type, plus `null`.
|
|
492
|
+
// latency -- assertion 41 asserts NO `store.find` is issued on this
|
|
493
|
+
// path. Moving the refusal to after a lookup and returning
|
|
494
|
+
// the same 403 left the suite green while re-opening a
|
|
495
|
+
// hit-versus-miss timing difference on every id-bearing POST,
|
|
496
|
+
// which is what would turn #197 from a ~0.06ms post-fetch
|
|
497
|
+
// residual into a live timing oracle on create.
|
|
498
|
+
//
|
|
499
|
+
// AND THE GUARANTEE IS ONLY AS WIDE AS THE CHANNELS IT COVERS. It reads on
|
|
500
|
+
// the `id` member of the resource object, so it holds only while that is
|
|
501
|
+
// the ONLY way a caller id can reach `createRecord`. It was not: the
|
|
502
|
+
// relationships loop below re-admitted one under `key === "id"` and the
|
|
503
|
+
// gate never fired. Both strips -- `attributes.id` and `relationships.id`
|
|
504
|
+
// -- are therefore part of THIS gate, not tidiness, and assertion 39 pins
|
|
505
|
+
// them. Adding a third channel without a strip re-opens the oracle.
|
|
506
|
+
//
|
|
397
507
|
// Scoped to function-style `access` because that is exactly the population
|
|
398
508
|
// the oracle exists for: with no per-record filter there are no hidden
|
|
399
509
|
// records, and 409 discloses nothing GET /:id does not already.
|
|
@@ -413,9 +523,32 @@ export default class OrmRequest extends Request {
|
|
|
413
523
|
|
|
414
524
|
const { id: _ignoredId, ...sanitizedAttributes } = attributes || {};
|
|
415
525
|
|
|
416
|
-
// Extract relationship IDs from JSON:API relationships object
|
|
526
|
+
// Extract relationship IDs from JSON:API relationships object.
|
|
527
|
+
//
|
|
528
|
+
// `key` comes VERBATIM from the request body, so `id` is stripped here for
|
|
529
|
+
// exactly the same reason it is stripped from `attributes` on the line
|
|
530
|
+
// above -- and it must be, or GATE 0 is walked around by moving one field:
|
|
531
|
+
//
|
|
532
|
+
// POST /animals {"id":21, ...} -> 403 GATE 0 fires
|
|
533
|
+
// POST /animals {"relationships":{"id":{"data":{"id":21}}},
|
|
534
|
+
// "attributes":{"owner":"gina"}} -> 200 BYPASS
|
|
535
|
+
//
|
|
536
|
+
// Top-level `id` stayed `undefined`, so GATE 0 never fired and the
|
|
537
|
+
// collision lookup never ran; `createRecord` took its last-entry-wins
|
|
538
|
+
// branch, overwrote hidden record 21 in place and reset its `owner` to a
|
|
539
|
+
// value the caller chose -- de-hiding it permanently. That is #190 itself,
|
|
540
|
+
// on the create surface. Pinned by assertion 39.
|
|
541
|
+
//
|
|
542
|
+
// The `id` member of the resource object is now the ONLY channel a caller
|
|
543
|
+
// id can arrive on, which is what makes GATE 0's guarantee checkable
|
|
544
|
+
// rather than merely asserted. INHERITED from `dev`, which carries this
|
|
545
|
+
// loop verbatim; the general form -- the loop accepts any key, not just
|
|
546
|
+
// `id`, so a body key that is not a declared relationship is still
|
|
547
|
+
// mass-assigned -- is abofs/stonyx-orm#204.
|
|
417
548
|
if (rels) {
|
|
418
549
|
for (const [key, value] of Object.entries(rels)) {
|
|
550
|
+
if (key === 'id') continue;
|
|
551
|
+
|
|
419
552
|
const relData = value?.data;
|
|
420
553
|
if (relData && relData.id !== undefined) {
|
|
421
554
|
(sanitizedAttributes as { [key: string]: unknown })[key] = relData.id;
|
|
@@ -466,7 +599,27 @@ export default class OrmRequest extends Request {
|
|
|
466
599
|
// abofs/stonyx-orm#203.
|
|
467
600
|
// identity -- the slot still holds the object we just created,
|
|
468
601
|
// so nothing between createRecord and here replaced
|
|
469
|
-
// it.
|
|
602
|
+
// it. Deleting this half SURVIVES the suite, and it
|
|
603
|
+
// is kept anyway. WHY IT IS REDUNDANT: there is no
|
|
604
|
+
// `await` anywhere between `slotsBefore` and
|
|
605
|
+
// `store.remove` -- the whole window is synchronous,
|
|
606
|
+
// so it is atomic under Node's event loop; before-
|
|
607
|
+
// `create` hooks run BEFORE the handler
|
|
608
|
+
// (`_withHooks` runs its hook loop ahead of
|
|
609
|
+
// `await handler(...)`), and a consumer predicate
|
|
610
|
+
// inside `isDenied` runs AFTER `createdNewSlot` is
|
|
611
|
+
// computed and cannot flip it. That is a property of
|
|
612
|
+
// THIS function, not of GATE 0 -- an earlier note
|
|
613
|
+
// credited GATE 0, which was both wrong (a caller id
|
|
614
|
+
// reached createRecord through the relationships
|
|
615
|
+
// loop, #204) and the wrong kind of reason: a guard
|
|
616
|
+
// justified on code sixty lines upstream gets
|
|
617
|
+
// silently re-armed when that code moves.
|
|
618
|
+
// SO IT BECOMES REACHABLE IF AN `await` IS
|
|
619
|
+
// INTRODUCED HERE, which is the change a future
|
|
620
|
+
// editor would actually make. See the
|
|
621
|
+
// guards-redundant-by-construction table in
|
|
622
|
+
// docs/project-structure.md.
|
|
470
623
|
if (createdNewSlot && store.get(model, record.id as string | number) === record) {
|
|
471
624
|
store.remove(model, record.id as string | number, { _skipAutoPersist: true });
|
|
472
625
|
}
|
|
@@ -513,6 +666,19 @@ export default class OrmRequest extends Request {
|
|
|
513
666
|
if (rels) {
|
|
514
667
|
const relUpdates: { [key: string]: unknown } = {};
|
|
515
668
|
for (const [key, value] of Object.entries(rels)) {
|
|
669
|
+
// The same missing key filter as createHandler's, and as the
|
|
670
|
+
// attribute loop directly above -- which already had it, while this
|
|
671
|
+
// loop did not. A PATCH carrying
|
|
672
|
+
// `relationships:{"id":{"data":{"id":9101}}}` reached `updateRecord`
|
|
673
|
+
// and RE-KEYED the record: the object held under store key 9102 then
|
|
674
|
+
// reported id 9101, so a visible record claimed a hidden record's
|
|
675
|
+
// identity on every surface that reads `record.id` rather than the map
|
|
676
|
+
// key. Gated by GATE 1 on the addressed record, so it is store
|
|
677
|
+
// corruption rather than a filter bypass -- but it is the same one-line
|
|
678
|
+
// omission two handlers apart. Pinned by assertion 40; INHERITED from
|
|
679
|
+
// `dev`; abofs/stonyx-orm#204.
|
|
680
|
+
if (key === 'id') continue;
|
|
681
|
+
|
|
516
682
|
const relData = value?.data;
|
|
517
683
|
if (relData && relData.id !== undefined) {
|
|
518
684
|
relUpdates[key] = relData.id;
|