@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 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 closes all four; that is
318
- > not the same as being safe. The real fix is
319
- > [#202](https://github.com/abofs/stonyx-orm/issues/202) `access()` should
320
- > receive the model, the operation and the record, so there is no URL to parse.
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. Only affects function-style `access` users. See
533
- [Filter functions](#filter-functions) for why, and let the server assign the
534
- id instead. `409`-on-duplicate is unchanged for everyone else.
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
@@ -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;
@@ -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
- if (isNaN(id))
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
- if (typeof id === 'number')
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
- if (typeof id !== 'string' || id.trim() === '')
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 isNaN(id) ? id : parseInt(id, 10);
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
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-alpha.49",
7
+ "version": "0.3.2-alpha.50",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -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 parseInt(id);
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
- if (typeof id === 'number') return id;
114
- if (typeof id !== 'string' || id.trim() === '') return id;
115
-
116
- return isNaN(id as unknown as number) ? id : parseInt(id, 10);
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;