@stonyx/orm 0.3.2-alpha.110 → 0.3.2-alpha.112

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
@@ -40,12 +40,6 @@ All properties prefixed with `__` (`__data`, `__relationships`, `__model`, `__se
40
40
  npm install @stonyx/orm
41
41
  ````
42
42
 
43
- That is the whole install for an ORM-only app. The database drivers and
44
- `@stonyx/rest-server` are **optional peer dependencies**: a default install does
45
- not put them on disk, and none of them is loaded unless your configuration asks
46
- for it. Add only the ones you actually use — see
47
- [Optional peer dependencies](#optional-peer-dependencies).
48
-
49
43
  ## Usage example
50
44
 
51
45
  This module is part of the **Stonyx framework**. To use it, first configure the `restServer` key in your `environment.js` file:
@@ -112,9 +106,7 @@ export default {
112
106
  tablePrefix: DYNAMODB_TABLE_PREFIX, // optional table name prefix
113
107
  } : undefined,
114
108
  restServer: {
115
- // 'true' requires @stonyx/rest-server to be installed — see
116
- // "Optional peer dependencies" below.
117
- enabled: ORM_USE_REST_SERVER ?? 'false',
109
+ enabled: ORM_USE_REST_SERVER ?? 'true',
118
110
  route: ORM_REST_ROUTE ?? '/'
119
111
  }
120
112
  }
@@ -135,46 +127,6 @@ stonyx serve
135
127
 
136
128
  For further framework instructions, see the [Stonyx repository](https://github.com/abofs/stonyx).
137
129
 
138
- ## Optional peer dependencies
139
-
140
- `@stonyx/orm` boots with **none** of its optional peers installed. Each one is
141
- imported lazily, and only when your configuration selects it:
142
-
143
- | Configuration | Package you must install |
144
- |---|---|
145
- | `orm.restServer.enabled: 'true'` | `@stonyx/rest-server` |
146
- | `orm.postgres` or `orm.timescale` | `pg` |
147
- | `orm.mysql` | `mysql2` |
148
- | `orm.dynamodb` | `@aws-sdk/client-dynamodb`, `@aws-sdk/lib-dynamodb` |
149
-
150
- If a configuration key selects one that is not on disk, the failure surfaces
151
- from `Orm.init()` while the framework boots:
152
-
153
- ```
154
- Cannot find package '@stonyx/rest-server' imported from .../@stonyx/orm/dist/orm-request.js
155
- ```
156
-
157
- **ORM-only, no REST server.** This is the shape the `environment.js` above is
158
- written for: keep `orm.restServer.enabled` at `'false'` and install nothing
159
- beyond `@stonyx/orm`. Models, relationships, serializers, transforms and hooks
160
- work unchanged; only the generated REST routes are absent.
161
-
162
- > **The module's own default is the other way round.** The `config/environment.js`
163
- > that ships inside `@stonyx/orm` defaults `restServer.enabled` to `'true'`, so
164
- > an app that omits the `restServer` key *entirely* gets REST switched on and
165
- > needs `@stonyx/rest-server` installed. Set the key explicitly, whichever way
166
- > you want it.
167
-
168
- **Turning REST on.**
169
-
170
- ```bash
171
- npm install @stonyx/rest-server
172
- ORM_USE_REST_SERVER=true stonyx serve
173
- ```
174
-
175
- See [REST Server Integration](#rest-server-integration) for access classes and
176
- route configuration.
177
-
178
130
  ## Models
179
131
 
180
132
  Define a model with attributes and relationships:
@@ -370,19 +322,23 @@ export default class OwnerAccess {
370
322
  models = ['owner'];
371
323
 
372
324
  access(request) {
373
- // `access` runs after route matching, so `request.params` is populated and
374
- // `id` has already been URL-decoded. Authorize on it, never on a URL.
375
- const { id } = request.params;
376
-
377
- // `id` is still raw client text. Normalise it the way the record lookup
378
- // does, or your predicate and the lookup disagree — see "Numeric ids" below.
379
- // No radix on parseInt: that is deliberate, and it must stay that way.
380
- const recordId = id === undefined ? undefined : (isNaN(id) ? id : parseInt(id));
325
+ // Fail closed on a build that predates abofs/stonyx-orm#270: such a build
326
+ // never attaches `recordId`, so the collection branch below would fire on
327
+ // the record route and authorize it outright. Exact no-op on a build that
328
+ // has the change — `recordId` is always assigned, `undefined` included.
329
+ if (!('recordId' in request)) return false;
330
+
331
+ // `request.recordId` is the id the ORM resolves the record by. The ORM
332
+ // computes it before `access()` runs, from `request.params.id`, using the
333
+ // same function the record lookup uses — so your predicate and the lookup
334
+ // cannot disagree. Authorize on it, never on a URL and never on raw
335
+ // `params.id`.
336
+ const { recordId } = request;
381
337
 
382
338
  // Returning false explicitly denies access to this record
383
339
  if (recordId === 'angela') return false;
384
340
 
385
- // No `id` means the collection route. Returning a function plugs it in to
341
+ // No `recordId` means the collection route. Returning a function plugs it in to
386
342
  // the response object as a filter. NOTE: a function return authorizes the
387
343
  // request outright — the operations list below is not consulted — so this
388
344
  // branch permits POST /owners as well as reads.
@@ -415,31 +371,64 @@ unrecognised spelling falls through to whatever your method returns next, so
415
371
  prefer one class per model. A class may still list several models in `models`
416
372
  when they share one rule.
417
373
 
418
- **Numeric ids: normalise before you compare.** `request.params.id` is raw text
419
- from the client. When it looks numeric the ORM coerces it — `isNaN(id) ? id :
420
- parseInt(id)` *before* it resolves the record, so `7`, `007`, `7.0`, `7.9`,
421
- `7e0`, `0x7`, `+7`, `%207` (a leading space), `%097` (a tab) and `7%0A` (a
422
- trailing newline) all address record `7`, while a `===` against the raw text
423
- matches only the one spelling you wrote down. Every other spelling falls through
424
- to whatever your method returns next — which, in the shape above, is a full CRUD
425
- grant. All of them are plain address-bar requests.
426
-
427
- Two details are load-bearing. `parseInt` is called with **no radix**, so `0x7`
428
- is `7` and not `0`; writing `parseInt(id, 10)` in your predicate re-opens the
429
- hex spelling. And the coercion applies only when the id looks numeric, so a
430
- model with string ids (like `owner` above) is unaffected which is exactly why
431
- this is easy to miss. Normalise the same way the lookup does:
374
+ **Numeric ids: authorize on `request.recordId`, not on `request.params.id`.**
375
+ `params.id` is raw text from the client. When it looks numeric the ORM coerces
376
+ it *before* it resolves the record, so `7`, `007`, `7.0`, `7.9`, `7e0`, `0x7`,
377
+ `+7`, `%207` (a leading space), `%097` (a tab) and `7%0A` (a trailing newline)
378
+ all address record `7`, while a `===` against the raw text matches only the one
379
+ spelling you wrote down. Every other spelling would fall through to whatever
380
+ your method returns next — which, in the shape above, is a full CRUD grant. All
381
+ of them are plain address-bar requests.
382
+
383
+ **The ORM does that normalisation for you and hands you the result.** `access()`
384
+ is called with `request.recordId` already set to the value the record will be
385
+ resolved by, so there is no arithmetic to copy into your predicate and nothing
386
+ to keep in sync abofs/stonyx-orm#270. It is `undefined` on collection routes,
387
+ which is how you tell a collection request from a record request. `params.id` is
388
+ left untouched, so anything that needs the raw client text still has it.
389
+
390
+ **The samples above fail *closed* on a build that predates
391
+ abofs/stonyx-orm#270.** That is what the first line of each `access()` is for. A
392
+ build without this change never attaches `recordId`, so `recordId === undefined`
393
+ — the collection branch — fires on the *record* route, and a function return
394
+ authorizes the request outright.
395
+
396
+ The same normalisation is exported for the places `access()` does not reach —
397
+ hooks, custom handlers, your own lookups:
398
+
399
+ ```javascript
400
+ import { normalizeRecordId } from '@stonyx/orm';
401
+
402
+ normalizeRecordId('0x7'); // 7 — the same value the ORM resolves by
403
+ ```
404
+
405
+ Do not re-implement it. A hand-written copy is correct only for as long as it
406
+ happens to match, and nothing holds the two together.
407
+
408
+ **`normalizeRecordId(undefined)` is `''`, not `undefined`.** The two values this
409
+ section documents side by side do not agree, and the difference is load-bearing:
410
+ `request.recordId` is `undefined` on a collection route, while
411
+ `normalizeRecordId` returns `''` for any falsy id — including `undefined` — because
412
+ `store.get(key, undefined)` returns the whole model Map rather than a record
413
+ (abofs/stonyx-orm#167). So `normalizeRecordId(context.params.id) === undefined`
414
+ is **never** true and a collection branch written that way never runs. Branch on
415
+ `request.recordId === undefined`, or compare against `''`.
432
416
 
433
417
  ```javascript
434
418
  export default class AnimalAccess {
435
419
  models = ['animal'];
436
420
 
437
421
  access(request) {
438
- const { id } = request.params;
422
+ // Fail closed on a build that predates abofs/stonyx-orm#270: such a build
423
+ // never attaches `recordId`, so the collection branch below would fire on
424
+ // the record route and authorize it outright. Exact no-op on a build that
425
+ // has the change — `recordId` is always assigned, `undefined` included.
426
+ if (!('recordId' in request)) return false;
439
427
 
440
- // Agrees with the lookup for every spelling of 7 above. Compare the
441
- // coerced value, which for a numeric-id model is a number, not a string.
442
- const recordId = id === undefined ? undefined : (isNaN(id) ? id : parseInt(id));
428
+ // Already normalised by the ORM, and it agrees with the lookup for every
429
+ // spelling of 7 above. For a numeric-id model it is a NUMBER, not a
430
+ // string, so compare it against a number.
431
+ const { recordId } = request;
443
432
 
444
433
  if (recordId === 7) return false;
445
434
 
@@ -616,7 +605,7 @@ Each hook receives a context object with comprehensive information:
616
605
  model: 'animal', // Model name
617
606
  operation: 'create', // Operation type
618
607
  request, // Express request object
619
- params, // URL params (e.g., { id: 5 })
608
+ params, // URL params, raw client text (e.g., { id: '5' })
620
609
  body, // Request body (POST/PATCH)
621
610
  query, // Query parameters
622
611
  state, // Request state object
@@ -634,6 +623,7 @@ Each hook receives a context object with comprehensive information:
634
623
  - The deep copy is created via JSON serialization (`JSON.parse(JSON.stringify())`) to ensure complete isolation
635
624
  - For `delete` operations, `recordId` is provided in after hooks since the record may no longer exist in the store
636
625
  - `oldState` is captured as a deep copy of the record's data before the operation, providing access to the previous field values
626
+ - `params.id` is the raw client text as a STRING (`/animals/05` gives `{ id: '05' }`). The id the ORM resolved the record by is `request.recordId`, and for a numeric-id model it is a NUMBER (`5` for both `/animals/5` and `/animals/05`) — abofs/stonyx-orm#270. Key lookups on `request.recordId`, never on `params.id`.
637
627
 
638
628
  ### Usage Examples
639
629
 
@@ -671,9 +661,13 @@ beforeHook('create', 'animal', (context) => {
671
661
  }
672
662
  });
673
663
 
674
- // Return an object to send a custom response
664
+ // Return an object to send a custom response.
665
+ // Look the record up by `context.request.recordId` — the value the ORM
666
+ // resolved the record by. `context.params.id` is the raw client text and is a
667
+ // different value for every alias of the same id, so a lookup keyed on it
668
+ // finds nothing on a numeric-id model (abofs/stonyx-orm#270).
675
669
  beforeHook('delete', 'animal', (context) => {
676
- const animal = store.get('animal', context.params.id);
670
+ const animal = store.get('animal', context.request.recordId);
677
671
  if (animal.protected) {
678
672
  return { errors: [{ detail: 'Cannot delete protected animals' }] };
679
673
  }
@@ -714,9 +708,12 @@ afterHook('update', 'animal', async (context) => {
714
708
  }
715
709
  });
716
710
 
717
- // Cache invalidation
711
+ // Cache invalidation.
712
+ // Keyed on `recordId`, not `params.id`: `/animals/7` and `/animals/007` are
713
+ // one record but two strings, so a raw-text key invalidates two entries and
714
+ // misses the one the write used.
718
715
  afterHook('delete', 'animal', async (context) => {
719
- await cache.invalidate(`owner:${context.params.id}:pets`);
716
+ await cache.invalidate(`owner:${context.request.recordId}:pets`);
720
717
  });
721
718
  ```
722
719
 
@@ -757,10 +754,16 @@ afterHook('delete', 'animal', async (context) => {
757
754
  #### Authorization
758
755
 
759
756
  ```javascript
760
- // Additional access control - halt with 403 if unauthorized
757
+ // Additional access control - halt with 403 if unauthorized.
758
+ //
759
+ // `context.request.recordId` is the id the ORM resolved the record by — the
760
+ // same value `access()` is handed. Authorizing on `context.params.id` instead
761
+ // looks up the raw client text: on a numeric-id model that lookup returns
762
+ // `undefined` for EVERY spelling, `animal.owner` throws, and the check never
763
+ // runs. Measured; abofs/stonyx-orm#270.
761
764
  beforeHook('delete', 'animal', (context) => {
762
765
  const user = context.state.currentUser;
763
- const animal = store.get('animal', context.params.id);
766
+ const animal = store.get('animal', context.request.recordId);
764
767
 
765
768
  if (animal.owner !== user.id && !user.isAdmin) {
766
769
  return 403; // Forbidden
@@ -961,12 +964,52 @@ test('validation hook rejects negative age', async () => {
961
964
  | `afterHook` | Register an after hook for post-operation logic. |
962
965
  | `clearHook` | Clear hooks for a specific operation:model. |
963
966
  | `clearAllHooks` | Clear all registered hooks (useful for testing). |
967
+ | `normalizeRecordId` | Turn a raw URL id into the value the ORM resolves the record by. |
968
+
969
+ ### `normalizeRecordId(id)`
970
+
971
+ ```ts
972
+ normalizeRecordId(id?: string | null): string | number
973
+ ```
974
+
975
+ The **one** implementation of URL-id normalisation in the package
976
+ (abofs/stonyx-orm#270). `access()` is already handed its result as
977
+ `request.recordId`; import it for the places `access()` does not reach — hooks,
978
+ custom handlers, your own lookups. Synchronous, and it must stay synchronous:
979
+ `auth()` is invoked without `await`.
980
+
981
+ | Input | Returns | Note |
982
+ | --- | --- | --- |
983
+ | `'7'`, `'007'`, `'7.0'`, `'7.9'`, `'7e0'`, `'0x7'`, `'+7'`, `' 7'` | `7` (number) | `parseInt` with **no radix**; passing a radix of 10 would make this `0` |
984
+ | `'angela'`, `'ANGELA'` | the same string, case included | a non-numeric id is passed through untouched |
985
+ | `'0'`, `'00'`, `'-0'`, `'0x0'` | `0` (number) | falsy, and a legitimate record id |
986
+ | `' '`, `'\t'`, `'\n'`, `'\u00a0'` | `NaN` | whitespace-only ids are numeric to `isNaN` but parse to nothing |
987
+ | `''`, `null`, `undefined` | `''` (empty string) | **not** `undefined` — see the trap below |
988
+
989
+ **The trap.** `request.recordId` is `undefined` on a collection route;
990
+ `normalizeRecordId(undefined)` is `''`. They are different values and a
991
+ collection check written against the wrong one silently never fires:
992
+
993
+ ```javascript
994
+ // WRONG — never true, so this branch never runs
995
+ if (normalizeRecordId(context.params.id) === undefined) { /* … */ }
996
+
997
+ // Right — the ORM attaches undefined for "this route carries no :id"
998
+ if (context.request.recordId === undefined) { /* collection route */ }
999
+ ```
1000
+
1001
+ The `''` is deliberate: `store.get(key, undefined)` returns the whole model Map
1002
+ rather than a record (abofs/stonyx-orm#167), so the resolution path depends on
1003
+ the empty string this function returns today.
1004
+
1005
+ **A normalised id is not a promise that a record exists.** `0` and `NaN` are both
1006
+ possible returns and neither addresses a record you can rely on — `NaN` is not
1007
+ even `===` itself, so `recordId === NaN` can never be written as a guard. Treat
1008
+ `recordId` as "the key the lookup will use", not as "a record is there".
964
1009
 
965
1010
  ## Project Structure
966
1011
 
967
- For a full architectural reference, see
968
- [docs/project-structure.md](https://github.com/abofs/stonyx-orm/blob/dev/docs/project-structure.md).
969
- That file is repo-only — it is not in the published tarball, so the link is absolute.
1012
+ For a full architectural reference, see [project-structure.md](project-structure.md).
970
1013
 
971
1014
  ## License
972
1015
 
package/dist/index.d.ts CHANGED
@@ -12,4 +12,6 @@ export type { PersistErrorDetail } from './main.js';
12
12
  export { Model, View, Serializer };
13
13
  export { attr, belongsTo, hasMany, createRecord, updateRecord };
14
14
  export { count, avg, sum, min, max };
15
+ export { default as normalizeRecordId } from './normalize-record-id.js';
16
+ export type { OrmRequest$ as OrmAccessRequest } from './orm-request.js';
15
17
  export { beforeHook, afterHook, clearHook, clearAllHooks } from './hooks.js';
package/dist/index.js CHANGED
@@ -26,6 +26,7 @@ export { store, relationships } from './main.js';
26
26
  export { Model, View, Serializer }; // base classes
27
27
  export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
28
28
  export { count, avg, sum, min, max }; // aggregate helpers
29
+ export { default as normalizeRecordId } from './normalize-record-id.js'; // canonical URL-id -> record-id resolution (#270)
29
30
  export { beforeHook, afterHook, clearHook, clearAllHooks } from './hooks.js'; // middleware hooks
30
31
  // Store API:
31
32
  // store.get(model, id) -- sync, memory-only
package/dist/main.js CHANGED
@@ -19,6 +19,7 @@ import log from 'stonyx/log';
19
19
  import { forEachFileImport } from '@stonyx/utils/file';
20
20
  import { kebabCaseToPascalCase, pluralize } from '@stonyx/utils/string';
21
21
  import { registerPluralName } from './plural-registry.js';
22
+ import setupRestServer from './setup-rest-server.js';
22
23
  import baseTransforms from './transforms.js';
23
24
  import Store from './store.js';
24
25
  import Serializer from './serializer.js';
@@ -130,25 +131,6 @@ export default class Orm {
130
131
  promises.push(db.init());
131
132
  }
132
133
  if (restServer.enabled === 'true') {
133
- // MUST stay dynamic. setup-rest-server.js names the optional
134
- // '@stonyx/rest-server' peer in its own static graph — directly, and
135
- // through orm-request.ts / meta-request.ts, which import `Request` at
136
- // module scope because they extend it (correctly: an `extends` base
137
- // class cannot be awaited). Node links a module's entire static graph
138
- // before evaluating any of it, so a static import here puts that
139
- // specifier on the entry graph and `import('@stonyx/orm')` throws
140
- // ERR_MODULE_NOT_FOUND for an ORM-only consumer that never installed the
141
- // optional peer.
142
- //
143
- // NOT the same reason the SQL/DynamoDB drivers above are lazy: those
144
- // modules carry no static peer specifier that survives to `dist/`
145
- // (postgres-db.ts:15 and mysql-db.ts:17 are `import type`, erased by
146
- // tsc), so the `await import()` there is not what isolates pg / mysql2 /
147
- // @aws-sdk — that happens one layer down, in src/*/connection.ts (and
148
- // src/dynamodb/dynamodb-db.ts).
149
- // setup-rest-server.js is the only dist module whose laziness is
150
- // load-bearing for peer resolution. (#280)
151
- const { default: setupRestServer } = await import('./setup-rest-server.js');
152
134
  promises.push(setupRestServer(restServer.route, paths.access, restServer.metaRoute));
153
135
  }
154
136
  // Wire up memory resolver so store.find() can check model memory flags
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The ONE place a URL id is turned into the value a record is resolved by —
3
+ * abofs/stonyx-orm#270.
4
+ *
5
+ * Before this existed this coercion was written out seven times: twice in
6
+ * README.md, once in docs/usage-patterns.md, and four times inside the
7
+ * framework. Seven copies, not seven identical copies — the three persistence
8
+ * ones (abofs/stonyx-orm#282) omit the `if (!id) return ''` guard below, which
9
+ * is the point: nothing held them together, so they had already drifted.
10
+ * The framework's copy — `getId()`, module-private in
11
+ * orm-request.ts, unreachable through the package `exports` map — was the one
12
+ * that decided which record a request addressed, and a consumer's `access()`
13
+ * predicate had no way to obtain it. So the framework resolved the record by
14
+ * one value and asked the consumer to authorize on a different one.
15
+ *
16
+ * Measured consequence at the time of filing: with the documented predicate
17
+ * applied verbatim to a numeric-id model, `GET /animals/007`, `/7.9`, `/7e0`,
18
+ * `/0x7`, `/%207`, `/%2B7` and `/7%0A` each served the protected record, and
19
+ * `DELETE /animals/007` destroyed it — because `parseInt` folds every one of
20
+ * those onto `7` while `'007' === '7'` is false.
21
+ *
22
+ * Two things follow from this being a single exported function, and both are
23
+ * the point:
24
+ *
25
+ * 1. A permissive change here is HARMLESS, because both sides move together.
26
+ * Lowercasing string ids used to disclose and destroy `owner:angela` with
27
+ * the whole suite green; with one implementation the predicate simply sees
28
+ * the same lowercased value and still refuses.
29
+ * 2. A divergence — a second, private normaliser at the resolution site — is
30
+ * what is now dangerous, and that is what the tests pin, by observing the
31
+ * key the resolution path actually used.
32
+ *
33
+ * SEMANTICS ARE UNCHANGED FROM `getId()`, deliberately and byte-for-byte.
34
+ * Whether `7.9` / `0x7` / `%0A` *should* resolve to record 7 at all is a real
35
+ * question, and it is a behaviour change on the record-resolution path for
36
+ * every consumer rather than an authorization fix — it belongs in its own
37
+ * issue with its own compatibility argument (issue body scope item 4). #270
38
+ * preserves today's semantics exactly and makes both sides agree on them.
39
+ *
40
+ * Two details are load-bearing and are pinned by
41
+ * test/unit/normalize-record-id-test.ts:
42
+ *
43
+ * - `parseInt` is called with NO RADIX. `parseInt('0x7')` is 7;
44
+ * `parseInt('0x7', 10)` is 0. Adding the radix looks like a cleanup and
45
+ * silently changes which record every hex-spelled URL addresses.
46
+ * - The coercion applies only when the id LOOKS numeric, so a model with
47
+ * string ids is passed through untouched, case included.
48
+ *
49
+ * Must stay synchronous: `auth()` is invoked without `await` by
50
+ * @stonyx/rest-server, so a promise here would be handed to `access()` as the
51
+ * record id.
52
+ *
53
+ * FALSY AND `NaN` RETURNS ARE LOAD-BEARING ELSEWHERE — abofs/stonyx-orm#287.
54
+ * `''` is returned for any falsy id because `store.get(key, undefined)` returns
55
+ * the whole model Map rather than a record (abofs/stonyx-orm#167). But `''` is
56
+ * not the only falsy return: `'0'`, `'00'`, `'-0'` and `'0x0'` all normalise to
57
+ * `0`, and `' '`, `'\t'`, `'\n'`, `'\u00a0'` all normalise to `NaN` (their
58
+ * `Number()` is `0`, so the `isNaN` guard does not fire and `parseInt` runs).
59
+ * `store.remove(key, id)` branches on truthiness, so those spellings reach a
60
+ * fall-through this function does not own. Tracked as #287; every row is pinned
61
+ * in test/unit/normalize-record-id-test.ts so a cleanup here cannot move the
62
+ * boundary #287 is measured against.
63
+ *
64
+ * @param id the raw, already-URL-decoded id text from `request.params.id`
65
+ * @returns the value the ORM resolves the record by
66
+ */
67
+ export default function normalizeRecordId(id?: string | null): string | number;
@@ -0,0 +1,88 @@
1
+ /*
2
+ * Copyright 2025 Stone Costa
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the 'License');
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an 'AS IS' BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+ /**
17
+ * The ONE place a URL id is turned into the value a record is resolved by —
18
+ * abofs/stonyx-orm#270.
19
+ *
20
+ * Before this existed this coercion was written out seven times: twice in
21
+ * README.md, once in docs/usage-patterns.md, and four times inside the
22
+ * framework. Seven copies, not seven identical copies — the three persistence
23
+ * ones (abofs/stonyx-orm#282) omit the `if (!id) return ''` guard below, which
24
+ * is the point: nothing held them together, so they had already drifted.
25
+ * The framework's copy — `getId()`, module-private in
26
+ * orm-request.ts, unreachable through the package `exports` map — was the one
27
+ * that decided which record a request addressed, and a consumer's `access()`
28
+ * predicate had no way to obtain it. So the framework resolved the record by
29
+ * one value and asked the consumer to authorize on a different one.
30
+ *
31
+ * Measured consequence at the time of filing: with the documented predicate
32
+ * applied verbatim to a numeric-id model, `GET /animals/007`, `/7.9`, `/7e0`,
33
+ * `/0x7`, `/%207`, `/%2B7` and `/7%0A` each served the protected record, and
34
+ * `DELETE /animals/007` destroyed it — because `parseInt` folds every one of
35
+ * those onto `7` while `'007' === '7'` is false.
36
+ *
37
+ * Two things follow from this being a single exported function, and both are
38
+ * the point:
39
+ *
40
+ * 1. A permissive change here is HARMLESS, because both sides move together.
41
+ * Lowercasing string ids used to disclose and destroy `owner:angela` with
42
+ * the whole suite green; with one implementation the predicate simply sees
43
+ * the same lowercased value and still refuses.
44
+ * 2. A divergence — a second, private normaliser at the resolution site — is
45
+ * what is now dangerous, and that is what the tests pin, by observing the
46
+ * key the resolution path actually used.
47
+ *
48
+ * SEMANTICS ARE UNCHANGED FROM `getId()`, deliberately and byte-for-byte.
49
+ * Whether `7.9` / `0x7` / `%0A` *should* resolve to record 7 at all is a real
50
+ * question, and it is a behaviour change on the record-resolution path for
51
+ * every consumer rather than an authorization fix — it belongs in its own
52
+ * issue with its own compatibility argument (issue body scope item 4). #270
53
+ * preserves today's semantics exactly and makes both sides agree on them.
54
+ *
55
+ * Two details are load-bearing and are pinned by
56
+ * test/unit/normalize-record-id-test.ts:
57
+ *
58
+ * - `parseInt` is called with NO RADIX. `parseInt('0x7')` is 7;
59
+ * `parseInt('0x7', 10)` is 0. Adding the radix looks like a cleanup and
60
+ * silently changes which record every hex-spelled URL addresses.
61
+ * - The coercion applies only when the id LOOKS numeric, so a model with
62
+ * string ids is passed through untouched, case included.
63
+ *
64
+ * Must stay synchronous: `auth()` is invoked without `await` by
65
+ * @stonyx/rest-server, so a promise here would be handed to `access()` as the
66
+ * record id.
67
+ *
68
+ * FALSY AND `NaN` RETURNS ARE LOAD-BEARING ELSEWHERE — abofs/stonyx-orm#287.
69
+ * `''` is returned for any falsy id because `store.get(key, undefined)` returns
70
+ * the whole model Map rather than a record (abofs/stonyx-orm#167). But `''` is
71
+ * not the only falsy return: `'0'`, `'00'`, `'-0'` and `'0x0'` all normalise to
72
+ * `0`, and `' '`, `'\t'`, `'\n'`, `'\u00a0'` all normalise to `NaN` (their
73
+ * `Number()` is `0`, so the `isNaN` guard does not fire and `parseInt` runs).
74
+ * `store.remove(key, id)` branches on truthiness, so those spellings reach a
75
+ * fall-through this function does not own. Tracked as #287; every row is pinned
76
+ * in test/unit/normalize-record-id-test.ts so a cleanup here cannot move the
77
+ * boundary #287 is measured against.
78
+ *
79
+ * @param id the raw, already-URL-decoded id text from `request.params.id`
80
+ * @returns the value the ORM resolves the record by
81
+ */
82
+ export default function normalizeRecordId(id) {
83
+ if (!id)
84
+ return '';
85
+ if (isNaN(id))
86
+ return id;
87
+ return parseInt(id);
88
+ }
@@ -1,11 +1,26 @@
1
1
  import { Request } from '@stonyx/rest-server';
2
- interface OrmRequest$ extends Request {
2
+ /**
3
+ * The request object a consumer's `access()` predicate receives.
4
+ *
5
+ * Exported because `recordId` is public API — README's `access()` samples
6
+ * destructure it — and a public runtime field with an unreachable type asks the
7
+ * consumer to re-declare something the framework already knows, which is
8
+ * abofs/stonyx-orm#270's own defect shape one layer over into the type surface.
9
+ * `HookContext` (src/hooks.ts) is this repo's precedent for exporting the
10
+ * interface a consumer is handed. Re-exported from the root barrel as
11
+ * `OrmAccessRequest` (src/index.ts) — NOT as `OrmRequest`, which is already the
12
+ * default-exported CLASS in this file and means something else. The
13
+ * `./orm-request` subpath is not in the `exports` map, so the barrel is the
14
+ * only reachable spelling.
15
+ */
16
+ export interface OrmRequest$ extends Request {
3
17
  protocol?: string;
4
18
  baseUrl?: string;
5
19
  method: string;
6
20
  params: {
7
21
  [key: string]: string;
8
22
  };
23
+ recordId?: string | number | undefined;
9
24
  body?: {
10
25
  [key: string]: unknown;
11
26
  };
@@ -5,6 +5,7 @@ import { getPluralName } from './plural-registry.js';
5
5
  import { getBeforeHooks, getAfterHooks } from './hooks.js';
6
6
  import config from 'stonyx/config';
7
7
  import { isOrmRecord } from './utils.js';
8
+ import normalizeRecordId from './normalize-record-id.js';
8
9
  const methodAccessMap = {
9
10
  GET: 'read',
10
11
  POST: 'create',
@@ -65,13 +66,23 @@ function getBaseUrl(request, pluralizedModel) {
65
66
  const prefix = mountPath.endsWith(modelSegment) ? mountPath.slice(0, -modelSegment.length) : '';
66
67
  return `${protocol}://${host}${prefix}`;
67
68
  }
69
+ // Kept as a name because twelve `getId(...)` call sites read it. It is now a
70
+ // thin delegate: there is exactly ONE normalisation of a URL id in the repo,
71
+ // and it is the exported one a consumer can import.
72
+ //
73
+ // "of a URL id" is the load-bearing qualifier, and it is the same one
74
+ // src/normalize-record-id.ts:18 carries. Three copies of the coercion survive
75
+ // at this head — src/orm-request.ts (the create-response path),
76
+ // src/postgres/postgres-db.ts and src/mysql/mysql-db.ts — but each normalises a
77
+ // RESPONSE id (`response?.data?.id`), not a URL id, and each omits this
78
+ // function's `if (!id) return ''` guard. They are tracked as
79
+ // abofs/stonyx-orm#282 and enumerated by name in AC-5's allowlist
80
+ // (test/integration/readme-sample-test.ts).
81
+ //
82
+ // A second implementation of the URL-id normalisation here is the defect
83
+ // abofs/stonyx-orm#270 exists to remove — see src/normalize-record-id.ts.
68
84
  function getId(params) {
69
- const id = params.id;
70
- if (!id)
71
- return '';
72
- if (isNaN(id))
73
- return id;
74
- return parseInt(id);
85
+ return normalizeRecordId(params.id);
75
86
  }
76
87
  function buildResponse(data, includeParam, recordOrRecords, options = {}) {
77
88
  const { links, baseUrl } = options;
@@ -481,6 +492,35 @@ export default class OrmRequest extends Request {
481
492
  return routes;
482
493
  }
483
494
  auth(request, state) {
495
+ // abofs/stonyx-orm#270 — hand the predicate the value the record is
496
+ // ACTUALLY resolved by, rather than the raw text it was parsed from.
497
+ //
498
+ // This is the default path precisely because it requires the consumer to
499
+ // remember nothing. Exporting `normalizeRecordId` alone would leave the
500
+ // framework owning resolution while asking every consumer to call one
501
+ // function, with no signal when they forget — the same silent fail-open,
502
+ // one step over. The export is the escape hatch for hooks and custom
503
+ // handlers; this line is the contract.
504
+ //
505
+ // `request.params` is deliberately NOT mutated. Twelve `getId(...)` call
506
+ // sites read it, and `_withHooks` assigns `params: request.params` onto the
507
+ // hook context, so every consumer hook reads the same object. Changing
508
+ // `params.id` from string to number underneath them is a silent behaviour
509
+ // change on paths this issue is not about.
510
+ //
511
+ // Synchronous by necessity: @stonyx/rest-server calls auth() without
512
+ // awaiting it.
513
+ //
514
+ // The `undefined` branch is a PRESENCE check, not a second normalisation:
515
+ // it answers "does this route carry an :id at all", which is how both
516
+ // documented samples tell a collection request from a record request. It
517
+ // cannot be folded into normalizeRecordId, because that function must keep
518
+ // returning '' for a falsy id — `store.get(key, undefined)` returns the
519
+ // whole model Map rather than a record (abofs/stonyx-orm#167, pinned by
520
+ // test/unit/store-get-falsy-id-test.ts), so the resolution path depends on
521
+ // the '' it returns today.
522
+ const rawId = request.params?.id;
523
+ request.recordId = rawId === undefined ? undefined : normalizeRecordId(rawId);
484
524
  const access = this.access(request);
485
525
  if (!access)
486
526
  return 403;
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-alpha.110",
7
+ "version": "0.3.2-alpha.112",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -106,7 +106,7 @@
106
106
  "test": "pnpm build && NODE_ENV=test node --import tsx/esm --import ./test/setup.ts node_modules/qunit/bin/qunit.js 'test/**/*-test.ts' && ORM_TEST_ROUTE=/ pnpm test:mounted && ORM_TEST_ROUTE=/api pnpm test:mounted && ORM_TEST_ROUTE=api pnpm test:mounted && ORM_TEST_ROUTE=/api/v1 pnpm test:mounted && ORM_TEST_ROUTE=/api/ pnpm test:mounted && pnpm test:readme && pnpm test:reference",
107
107
  "test:mounted": "node --import tsx/esm --import ./test/integration/mounted-route/setup.ts node_modules/qunit/bin/qunit.js 'test/integration/mounted-route/links-mounted.ts' 'test/zz-exit-test.ts'",
108
108
  "test:readme": "pnpm build && node --import tsx/esm --import ./test/integration/readme-access/setup.ts node_modules/qunit/bin/qunit.js 'test/integration/readme-access/readme-sample.ts' 'test/zz-exit-test.ts'",
109
- "test:reference": "pnpm build && node --import tsx/esm --import ./test/integration/reference-access/setup.ts node_modules/qunit/bin/qunit.js 'test/integration/reference-access/reference-sample.ts' 'test/zz-exit-test.ts'",
109
+ "test:reference": "pnpm build && node --import tsx/esm --import ./test/integration/reference-access/setup.ts node_modules/qunit/bin/qunit.js 'test/integration/reference-access/reference-sample.ts' 'test/integration/reference-access/record-id-resolution.ts' 'test/zz-exit-test.ts'",
110
110
  "test:dynamodb": "pnpm build && node --import tsx/esm --import ./test/integration/dynamodb/setup.ts node_modules/qunit/bin/qunit.js 'test/integration/dynamodb/**/*-test.ts'"
111
111
  }
112
112
  }
package/src/index.ts CHANGED
@@ -30,6 +30,8 @@ export type { PersistErrorDetail } from './main.js';
30
30
  export { Model, View, Serializer }; // base classes
31
31
  export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
32
32
  export { count, avg, sum, min, max }; // aggregate helpers
33
+ export { default as normalizeRecordId } from './normalize-record-id.js'; // canonical URL-id -> record-id resolution (#270)
34
+ export type { OrmRequest$ as OrmAccessRequest } from './orm-request.js'; // the request access() is handed, incl. recordId (#270)
33
35
  export { beforeHook, afterHook, clearHook, clearAllHooks } from './hooks.js'; // middleware hooks
34
36
 
35
37
  // Store API:
package/src/main.ts CHANGED
@@ -20,6 +20,7 @@ import log from 'stonyx/log';
20
20
  import { forEachFileImport } from '@stonyx/utils/file';
21
21
  import { kebabCaseToPascalCase, pluralize } from '@stonyx/utils/string';
22
22
  import { registerPluralName } from './plural-registry.js';
23
+ import setupRestServer from './setup-rest-server.js';
23
24
  import baseTransforms from './transforms.js';
24
25
  import Store from './store.js';
25
26
  import Serializer from './serializer.js';
@@ -176,25 +177,6 @@ export default class Orm {
176
177
  }
177
178
 
178
179
  if (restServer.enabled === 'true') {
179
- // MUST stay dynamic. setup-rest-server.js names the optional
180
- // '@stonyx/rest-server' peer in its own static graph — directly, and
181
- // through orm-request.ts / meta-request.ts, which import `Request` at
182
- // module scope because they extend it (correctly: an `extends` base
183
- // class cannot be awaited). Node links a module's entire static graph
184
- // before evaluating any of it, so a static import here puts that
185
- // specifier on the entry graph and `import('@stonyx/orm')` throws
186
- // ERR_MODULE_NOT_FOUND for an ORM-only consumer that never installed the
187
- // optional peer.
188
- //
189
- // NOT the same reason the SQL/DynamoDB drivers above are lazy: those
190
- // modules carry no static peer specifier that survives to `dist/`
191
- // (postgres-db.ts:15 and mysql-db.ts:17 are `import type`, erased by
192
- // tsc), so the `await import()` there is not what isolates pg / mysql2 /
193
- // @aws-sdk — that happens one layer down, in src/*/connection.ts (and
194
- // src/dynamodb/dynamodb-db.ts).
195
- // setup-rest-server.js is the only dist module whose laziness is
196
- // load-bearing for peer resolution. (#280)
197
- const { default: setupRestServer } = await import('./setup-rest-server.js');
198
180
  promises.push(setupRestServer(restServer.route, paths.access, restServer.metaRoute));
199
181
  }
200
182
 
@@ -0,0 +1,88 @@
1
+ /*
2
+ * Copyright 2025 Stone Costa
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the 'License');
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an 'AS IS' BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * The ONE place a URL id is turned into the value a record is resolved by —
19
+ * abofs/stonyx-orm#270.
20
+ *
21
+ * Before this existed this coercion was written out seven times: twice in
22
+ * README.md, once in docs/usage-patterns.md, and four times inside the
23
+ * framework. Seven copies, not seven identical copies — the three persistence
24
+ * ones (abofs/stonyx-orm#282) omit the `if (!id) return ''` guard below, which
25
+ * is the point: nothing held them together, so they had already drifted.
26
+ * The framework's copy — `getId()`, module-private in
27
+ * orm-request.ts, unreachable through the package `exports` map — was the one
28
+ * that decided which record a request addressed, and a consumer's `access()`
29
+ * predicate had no way to obtain it. So the framework resolved the record by
30
+ * one value and asked the consumer to authorize on a different one.
31
+ *
32
+ * Measured consequence at the time of filing: with the documented predicate
33
+ * applied verbatim to a numeric-id model, `GET /animals/007`, `/7.9`, `/7e0`,
34
+ * `/0x7`, `/%207`, `/%2B7` and `/7%0A` each served the protected record, and
35
+ * `DELETE /animals/007` destroyed it — because `parseInt` folds every one of
36
+ * those onto `7` while `'007' === '7'` is false.
37
+ *
38
+ * Two things follow from this being a single exported function, and both are
39
+ * the point:
40
+ *
41
+ * 1. A permissive change here is HARMLESS, because both sides move together.
42
+ * Lowercasing string ids used to disclose and destroy `owner:angela` with
43
+ * the whole suite green; with one implementation the predicate simply sees
44
+ * the same lowercased value and still refuses.
45
+ * 2. A divergence — a second, private normaliser at the resolution site — is
46
+ * what is now dangerous, and that is what the tests pin, by observing the
47
+ * key the resolution path actually used.
48
+ *
49
+ * SEMANTICS ARE UNCHANGED FROM `getId()`, deliberately and byte-for-byte.
50
+ * Whether `7.9` / `0x7` / `%0A` *should* resolve to record 7 at all is a real
51
+ * question, and it is a behaviour change on the record-resolution path for
52
+ * every consumer rather than an authorization fix — it belongs in its own
53
+ * issue with its own compatibility argument (issue body scope item 4). #270
54
+ * preserves today's semantics exactly and makes both sides agree on them.
55
+ *
56
+ * Two details are load-bearing and are pinned by
57
+ * test/unit/normalize-record-id-test.ts:
58
+ *
59
+ * - `parseInt` is called with NO RADIX. `parseInt('0x7')` is 7;
60
+ * `parseInt('0x7', 10)` is 0. Adding the radix looks like a cleanup and
61
+ * silently changes which record every hex-spelled URL addresses.
62
+ * - The coercion applies only when the id LOOKS numeric, so a model with
63
+ * string ids is passed through untouched, case included.
64
+ *
65
+ * Must stay synchronous: `auth()` is invoked without `await` by
66
+ * @stonyx/rest-server, so a promise here would be handed to `access()` as the
67
+ * record id.
68
+ *
69
+ * FALSY AND `NaN` RETURNS ARE LOAD-BEARING ELSEWHERE — abofs/stonyx-orm#287.
70
+ * `''` is returned for any falsy id because `store.get(key, undefined)` returns
71
+ * the whole model Map rather than a record (abofs/stonyx-orm#167). But `''` is
72
+ * not the only falsy return: `'0'`, `'00'`, `'-0'` and `'0x0'` all normalise to
73
+ * `0`, and `' '`, `'\t'`, `'\n'`, `'\u00a0'` all normalise to `NaN` (their
74
+ * `Number()` is `0`, so the `isNaN` guard does not fire and `parseInt` runs).
75
+ * `store.remove(key, id)` branches on truthiness, so those spellings reach a
76
+ * fall-through this function does not own. Tracked as #287; every row is pinned
77
+ * in test/unit/normalize-record-id-test.ts so a cleanup here cannot move the
78
+ * boundary #287 is measured against.
79
+ *
80
+ * @param id the raw, already-URL-decoded id text from `request.params.id`
81
+ * @returns the value the ORM resolves the record by
82
+ */
83
+ export default function normalizeRecordId(id?: string | null): string | number {
84
+ if (!id) return '';
85
+ if (isNaN(id as unknown as number)) return id;
86
+
87
+ return parseInt(id);
88
+ }
@@ -7,8 +7,23 @@ import type { HookContext } from './hooks.js';
7
7
  import config from 'stonyx/config';
8
8
  import type { OrmRecord } from './types/orm-types.js';
9
9
  import { isOrmRecord } from './utils.js';
10
+ import normalizeRecordId from './normalize-record-id.js';
10
11
 
11
- interface OrmRequest$ extends Request {
12
+ /**
13
+ * The request object a consumer's `access()` predicate receives.
14
+ *
15
+ * Exported because `recordId` is public API — README's `access()` samples
16
+ * destructure it — and a public runtime field with an unreachable type asks the
17
+ * consumer to re-declare something the framework already knows, which is
18
+ * abofs/stonyx-orm#270's own defect shape one layer over into the type surface.
19
+ * `HookContext` (src/hooks.ts) is this repo's precedent for exporting the
20
+ * interface a consumer is handed. Re-exported from the root barrel as
21
+ * `OrmAccessRequest` (src/index.ts) — NOT as `OrmRequest`, which is already the
22
+ * default-exported CLASS in this file and means something else. The
23
+ * `./orm-request` subpath is not in the `exports` map, so the barrel is the
24
+ * only reachable spelling.
25
+ */
26
+ export interface OrmRequest$ extends Request {
12
27
  protocol?: string;
13
28
  // Express sets this to the path the router was mounted at, e.g. '/api/animals'
14
29
  // when orm.restServer.route is '/api'. Optional because non-Express callers
@@ -16,6 +31,14 @@ interface OrmRequest$ extends Request {
16
31
  baseUrl?: string;
17
32
  method: string;
18
33
  params: { [key: string]: string };
34
+ // Attached by auth() before access() runs — abofs/stonyx-orm#270. This is the
35
+ // value the ORM resolves the record by; `params.id` remains the raw client
36
+ // text it was parsed from.
37
+ //
38
+ // `undefined` is part of the contract, not an absence: it is how a collection
39
+ // route is told from a record route, and both documented samples branch on
40
+ // it. Spelled out rather than left to `?:` for that reason.
41
+ recordId?: string | number | undefined;
19
42
  body?: { [key: string]: unknown };
20
43
  query?: { [key: string]: string };
21
44
  get(header: string): string;
@@ -107,12 +130,23 @@ function getBaseUrl(request: OrmRequest$, pluralizedModel: string): string {
107
130
  return `${protocol}://${host}${prefix}`;
108
131
  }
109
132
 
133
+ // Kept as a name because twelve `getId(...)` call sites read it. It is now a
134
+ // thin delegate: there is exactly ONE normalisation of a URL id in the repo,
135
+ // and it is the exported one a consumer can import.
136
+ //
137
+ // "of a URL id" is the load-bearing qualifier, and it is the same one
138
+ // src/normalize-record-id.ts:18 carries. Three copies of the coercion survive
139
+ // at this head — src/orm-request.ts (the create-response path),
140
+ // src/postgres/postgres-db.ts and src/mysql/mysql-db.ts — but each normalises a
141
+ // RESPONSE id (`response?.data?.id`), not a URL id, and each omits this
142
+ // function's `if (!id) return ''` guard. They are tracked as
143
+ // abofs/stonyx-orm#282 and enumerated by name in AC-5's allowlist
144
+ // (test/integration/readme-sample-test.ts).
145
+ //
146
+ // A second implementation of the URL-id normalisation here is the defect
147
+ // abofs/stonyx-orm#270 exists to remove — see src/normalize-record-id.ts.
110
148
  function getId(params: { id?: string; [key: string]: unknown }): string | number {
111
- const id = params.id;
112
- if (!id) return '';
113
- if (isNaN(id as unknown as number)) return id;
114
-
115
- return parseInt(id);
149
+ return normalizeRecordId(params.id);
116
150
  }
117
151
 
118
152
  function buildResponse(
@@ -598,6 +632,37 @@ export default class OrmRequest extends Request {
598
632
  }
599
633
 
600
634
  auth(request: OrmRequest$, state: { [key: string]: unknown }): number | undefined {
635
+ // abofs/stonyx-orm#270 — hand the predicate the value the record is
636
+ // ACTUALLY resolved by, rather than the raw text it was parsed from.
637
+ //
638
+ // This is the default path precisely because it requires the consumer to
639
+ // remember nothing. Exporting `normalizeRecordId` alone would leave the
640
+ // framework owning resolution while asking every consumer to call one
641
+ // function, with no signal when they forget — the same silent fail-open,
642
+ // one step over. The export is the escape hatch for hooks and custom
643
+ // handlers; this line is the contract.
644
+ //
645
+ // `request.params` is deliberately NOT mutated. Twelve `getId(...)` call
646
+ // sites read it, and `_withHooks` assigns `params: request.params` onto the
647
+ // hook context, so every consumer hook reads the same object. Changing
648
+ // `params.id` from string to number underneath them is a silent behaviour
649
+ // change on paths this issue is not about.
650
+ //
651
+ // Synchronous by necessity: @stonyx/rest-server calls auth() without
652
+ // awaiting it.
653
+ //
654
+ // The `undefined` branch is a PRESENCE check, not a second normalisation:
655
+ // it answers "does this route carry an :id at all", which is how both
656
+ // documented samples tell a collection request from a record request. It
657
+ // cannot be folded into normalizeRecordId, because that function must keep
658
+ // returning '' for a falsy id — `store.get(key, undefined)` returns the
659
+ // whole model Map rather than a record (abofs/stonyx-orm#167, pinned by
660
+ // test/unit/store-get-falsy-id-test.ts), so the resolution path depends on
661
+ // the '' it returns today.
662
+ const rawId = request.params?.id;
663
+
664
+ request.recordId = rawId === undefined ? undefined : normalizeRecordId(rawId);
665
+
601
666
  const access = this.access(request);
602
667
 
603
668
  if (!access) return 403;