@stonyx/orm 0.3.2-alpha.104 → 0.3.2-alpha.106

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
@@ -322,19 +322,17 @@ export default class OwnerAccess {
322
322
  models = ['owner'];
323
323
 
324
324
  access(request) {
325
- // `access` runs after route matching, so `request.params` is populated and
326
- // `id` has already been URL-decoded. Authorize on it, never on a URL.
327
- const { id } = request.params;
328
-
329
- // `id` is still raw client text. Normalise it the way the record lookup
330
- // does, or your predicate and the lookup disagree — see "Numeric ids" below.
331
- // No radix on parseInt: that is deliberate, and it must stay that way.
332
- const recordId = id === undefined ? undefined : (isNaN(id) ? id : parseInt(id));
325
+ // `request.recordId` is the id the ORM resolves the record by. The ORM
326
+ // computes it before `access()` runs, from `request.params.id`, using the
327
+ // same function the record lookup uses — so your predicate and the lookup
328
+ // cannot disagree. Authorize on it, never on a URL and never on raw
329
+ // `params.id`.
330
+ const { recordId } = request;
333
331
 
334
332
  // Returning false explicitly denies access to this record
335
333
  if (recordId === 'angela') return false;
336
334
 
337
- // No `id` means the collection route. Returning a function plugs it in to
335
+ // No `recordId` means the collection route. Returning a function plugs it in to
338
336
  // the response object as a filter. NOTE: a function return authorizes the
339
337
  // request outright — the operations list below is not consulted — so this
340
338
  // branch permits POST /owners as well as reads.
@@ -367,31 +365,43 @@ unrecognised spelling falls through to whatever your method returns next, so
367
365
  prefer one class per model. A class may still list several models in `models`
368
366
  when they share one rule.
369
367
 
370
- **Numeric ids: normalise before you compare.** `request.params.id` is raw text
371
- from the client. When it looks numeric the ORM coerces it — `isNaN(id) ? id :
372
- parseInt(id)` *before* it resolves the record, so `7`, `007`, `7.0`, `7.9`,
373
- `7e0`, `0x7`, `+7`, `%207` (a leading space), `%097` (a tab) and `7%0A` (a
374
- trailing newline) all address record `7`, while a `===` against the raw text
375
- matches only the one spelling you wrote down. Every other spelling falls through
376
- to whatever your method returns next — which, in the shape above, is a full CRUD
377
- grant. All of them are plain address-bar requests.
378
-
379
- Two details are load-bearing. `parseInt` is called with **no radix**, so `0x7`
380
- is `7` and not `0`; writing `parseInt(id, 10)` in your predicate re-opens the
381
- hex spelling. And the coercion applies only when the id looks numeric, so a
382
- model with string ids (like `owner` above) is unaffected which is exactly why
383
- this is easy to miss. Normalise the same way the lookup does:
368
+ **Numeric ids: authorize on `request.recordId`, not on `request.params.id`.**
369
+ `params.id` is raw text from the client. When it looks numeric the ORM coerces
370
+ it *before* it resolves the record, so `7`, `007`, `7.0`, `7.9`, `7e0`, `0x7`,
371
+ `+7`, `%207` (a leading space), `%097` (a tab) and `7%0A` (a trailing newline)
372
+ all address record `7`, while a `===` against the raw text matches only the one
373
+ spelling you wrote down. Every other spelling would fall through to whatever
374
+ your method returns next — which, in the shape above, is a full CRUD grant. All
375
+ of them are plain address-bar requests.
376
+
377
+ **The ORM does that normalisation for you and hands you the result.** `access()`
378
+ is called with `request.recordId` already set to the value the record will be
379
+ resolved by, so there is no arithmetic to copy into your predicate and nothing
380
+ to keep in sync abofs/stonyx-orm#270. It is `undefined` on collection routes,
381
+ which is how you tell a collection request from a record request. `params.id` is
382
+ left untouched, so anything that needs the raw client text still has it.
383
+
384
+ The same function is exported for the places `access()` does not reach — hooks,
385
+ custom handlers, your own lookups:
386
+
387
+ ```javascript
388
+ import { normalizeRecordId } from '@stonyx/orm';
389
+
390
+ normalizeRecordId('0x7'); // 7 — the same value the ORM resolves by
391
+ ```
392
+
393
+ Do not re-implement it. A hand-written copy is correct only for as long as it
394
+ happens to match, and nothing holds the two together.
384
395
 
385
396
  ```javascript
386
397
  export default class AnimalAccess {
387
398
  models = ['animal'];
388
399
 
389
400
  access(request) {
390
- const { id } = request.params;
391
-
392
- // Agrees with the lookup for every spelling of 7 above. Compare the
393
- // coerced value, which for a numeric-id model is a number, not a string.
394
- const recordId = id === undefined ? undefined : (isNaN(id) ? id : parseInt(id));
401
+ // Already normalised by the ORM, and it agrees with the lookup for every
402
+ // spelling of 7 above. For a numeric-id model it is a NUMBER, not a
403
+ // string, so compare it against a number.
404
+ const { recordId } = request;
395
405
 
396
406
  if (recordId === 7) return false;
397
407
 
package/dist/index.d.ts CHANGED
@@ -12,4 +12,5 @@ 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';
15
16
  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
@@ -0,0 +1,53 @@
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 the same expression was written out seven times: twice in
6
+ * README.md, once in docs/usage-patterns.md, and four times inside the
7
+ * framework. The framework's copy — `getId()`, module-private in
8
+ * orm-request.ts, unreachable through the package `exports` map — was the one
9
+ * that decided which record a request addressed, and a consumer's `access()`
10
+ * predicate had no way to obtain it. So the framework resolved the record by
11
+ * one value and asked the consumer to authorize on a different one.
12
+ *
13
+ * Measured consequence at the time of filing: with the documented predicate
14
+ * applied verbatim to a numeric-id model, `GET /animals/007`, `/7.9`, `/7e0`,
15
+ * `/0x7`, `/%207`, `/%2B7` and `/7%0A` each served the protected record, and
16
+ * `DELETE /animals/007` destroyed it — because `parseInt` folds every one of
17
+ * those onto `7` while `'007' === '7'` is false.
18
+ *
19
+ * Two things follow from this being a single exported function, and both are
20
+ * the point:
21
+ *
22
+ * 1. A permissive change here is HARMLESS, because both sides move together.
23
+ * Lowercasing string ids used to disclose and destroy `owner:angela` with
24
+ * the whole suite green; with one implementation the predicate simply sees
25
+ * the same lowercased value and still refuses.
26
+ * 2. A divergence — a second, private normaliser at the resolution site — is
27
+ * what is now dangerous, and that is what the tests pin, by observing the
28
+ * key the resolution path actually used.
29
+ *
30
+ * SEMANTICS ARE UNCHANGED FROM `getId()`, deliberately and byte-for-byte.
31
+ * Whether `7.9` / `0x7` / `%0A` *should* resolve to record 7 at all is a real
32
+ * question, and it is a behaviour change on the record-resolution path for
33
+ * every consumer rather than an authorization fix — it belongs in its own
34
+ * issue with its own compatibility argument (issue body scope item 4). #270
35
+ * preserves today's semantics exactly and makes both sides agree on them.
36
+ *
37
+ * Two details are load-bearing and are pinned by
38
+ * test/unit/normalize-record-id-test.ts:
39
+ *
40
+ * - `parseInt` is called with NO RADIX. `parseInt('0x7')` is 7;
41
+ * `parseInt('0x7', 10)` is 0. Adding the radix looks like a cleanup and
42
+ * silently changes which record every hex-spelled URL addresses.
43
+ * - The coercion applies only when the id LOOKS numeric, so a model with
44
+ * string ids is passed through untouched, case included.
45
+ *
46
+ * Must stay synchronous: `auth()` is invoked without `await` by
47
+ * @stonyx/rest-server, so a promise here would be handed to `access()` as the
48
+ * record id.
49
+ *
50
+ * @param id the raw, already-URL-decoded id text from `request.params.id`
51
+ * @returns the value the ORM resolves the record by
52
+ */
53
+ export default function normalizeRecordId(id?: string | null): string | number;
@@ -0,0 +1,74 @@
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 the same expression was written out seven times: twice in
21
+ * README.md, once in docs/usage-patterns.md, and four times inside the
22
+ * framework. The framework's copy — `getId()`, module-private in
23
+ * orm-request.ts, unreachable through the package `exports` map — was the one
24
+ * that decided which record a request addressed, and a consumer's `access()`
25
+ * predicate had no way to obtain it. So the framework resolved the record by
26
+ * one value and asked the consumer to authorize on a different one.
27
+ *
28
+ * Measured consequence at the time of filing: with the documented predicate
29
+ * applied verbatim to a numeric-id model, `GET /animals/007`, `/7.9`, `/7e0`,
30
+ * `/0x7`, `/%207`, `/%2B7` and `/7%0A` each served the protected record, and
31
+ * `DELETE /animals/007` destroyed it — because `parseInt` folds every one of
32
+ * those onto `7` while `'007' === '7'` is false.
33
+ *
34
+ * Two things follow from this being a single exported function, and both are
35
+ * the point:
36
+ *
37
+ * 1. A permissive change here is HARMLESS, because both sides move together.
38
+ * Lowercasing string ids used to disclose and destroy `owner:angela` with
39
+ * the whole suite green; with one implementation the predicate simply sees
40
+ * the same lowercased value and still refuses.
41
+ * 2. A divergence — a second, private normaliser at the resolution site — is
42
+ * what is now dangerous, and that is what the tests pin, by observing the
43
+ * key the resolution path actually used.
44
+ *
45
+ * SEMANTICS ARE UNCHANGED FROM `getId()`, deliberately and byte-for-byte.
46
+ * Whether `7.9` / `0x7` / `%0A` *should* resolve to record 7 at all is a real
47
+ * question, and it is a behaviour change on the record-resolution path for
48
+ * every consumer rather than an authorization fix — it belongs in its own
49
+ * issue with its own compatibility argument (issue body scope item 4). #270
50
+ * preserves today's semantics exactly and makes both sides agree on them.
51
+ *
52
+ * Two details are load-bearing and are pinned by
53
+ * test/unit/normalize-record-id-test.ts:
54
+ *
55
+ * - `parseInt` is called with NO RADIX. `parseInt('0x7')` is 7;
56
+ * `parseInt('0x7', 10)` is 0. Adding the radix looks like a cleanup and
57
+ * silently changes which record every hex-spelled URL addresses.
58
+ * - The coercion applies only when the id LOOKS numeric, so a model with
59
+ * string ids is passed through untouched, case included.
60
+ *
61
+ * Must stay synchronous: `auth()` is invoked without `await` by
62
+ * @stonyx/rest-server, so a promise here would be handed to `access()` as the
63
+ * record id.
64
+ *
65
+ * @param id the raw, already-URL-decoded id text from `request.params.id`
66
+ * @returns the value the ORM resolves the record by
67
+ */
68
+ export default function normalizeRecordId(id) {
69
+ if (!id)
70
+ return '';
71
+ if (isNaN(id))
72
+ return id;
73
+ return parseInt(id);
74
+ }
@@ -6,6 +6,7 @@ interface OrmRequest$ extends Request {
6
6
  params: {
7
7
  [key: string]: string;
8
8
  };
9
+ recordId?: string | number;
9
10
  body?: {
10
11
  [key: string]: unknown;
11
12
  };
@@ -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,13 @@ 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 call sites read `getId(request.params)`, and
70
+ // docs/hooks.md documents that spelling. It is now a thin delegate: there is
71
+ // exactly ONE normalisation in the repo, and it is the exported one a consumer
72
+ // can import. A second implementation here is the defect abofs/stonyx-orm#270
73
+ // exists to remove — see src/normalize-record-id.ts.
68
74
  function getId(params) {
69
- const id = params.id;
70
- if (!id)
71
- return '';
72
- if (isNaN(id))
73
- return id;
74
- return parseInt(id);
75
+ return normalizeRecordId(params.id);
75
76
  }
76
77
  function buildResponse(data, includeParam, recordOrRecords, options = {}) {
77
78
  const { links, baseUrl } = options;
@@ -481,6 +482,35 @@ export default class OrmRequest extends Request {
481
482
  return routes;
482
483
  }
483
484
  auth(request, state) {
485
+ // abofs/stonyx-orm#270 — hand the predicate the value the record is
486
+ // ACTUALLY resolved by, rather than the raw text it was parsed from.
487
+ //
488
+ // This is the default path precisely because it requires the consumer to
489
+ // remember nothing. Exporting `normalizeRecordId` alone would leave the
490
+ // framework owning resolution while asking every consumer to call one
491
+ // function, with no signal when they forget — the same silent fail-open,
492
+ // one step over. The export is the escape hatch for hooks and custom
493
+ // handlers; this line is the contract.
494
+ //
495
+ // `request.params` is deliberately NOT mutated. Twelve `getId(...)` call
496
+ // sites, the serializer, and consumer hooks (docs/hooks.md documents
497
+ // `getId(request.params)`) all read it, and changing `params.id` from
498
+ // string to number underneath them is a silent behaviour change on paths
499
+ // this issue is not about.
500
+ //
501
+ // Synchronous by necessity: @stonyx/rest-server calls auth() without
502
+ // awaiting it.
503
+ //
504
+ // The `undefined` branch is a PRESENCE check, not a second normalisation:
505
+ // it answers "does this route carry an :id at all", which is how both
506
+ // documented samples tell a collection request from a record request. It
507
+ // cannot be folded into normalizeRecordId, because that function must keep
508
+ // returning '' for a falsy id — `store.get(key, undefined)` returns the
509
+ // whole model Map rather than a record (abofs/stonyx-orm#167, pinned by
510
+ // test/unit/store-get-falsy-id-test.ts), so the resolution path depends on
511
+ // the '' it returns today.
512
+ const rawId = request.params?.id;
513
+ request.recordId = rawId === undefined ? undefined : normalizeRecordId(rawId);
484
514
  const access = this.access(request);
485
515
  if (!access)
486
516
  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.104",
7
+ "version": "0.3.2-alpha.106",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -61,10 +61,10 @@
61
61
  },
62
62
  "homepage": "https://github.com/abofs/stonyx-orm#readme",
63
63
  "dependencies": {
64
- "@stonyx/cron": "0.2.1-beta.98",
65
- "@stonyx/events": "0.1.1-beta.54",
66
- "@stonyx/utils": "0.2.3-beta.26",
67
- "stonyx": "0.2.3-beta.81"
64
+ "@stonyx/cron": "0.2.1-beta.122",
65
+ "@stonyx/events": "0.1.1-beta.64",
66
+ "@stonyx/utils": "0.2.3-beta.27",
67
+ "stonyx": "0.2.3-beta.94"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "@aws-sdk/client-dynamodb": "^3.0.0",
@@ -91,7 +91,7 @@
91
91
  }
92
92
  },
93
93
  "devDependencies": {
94
- "@stonyx/rest-server": "0.2.1-beta.100",
94
+ "@stonyx/rest-server": "0.2.1-beta.123",
95
95
  "@types/node": "^25.6.0",
96
96
  "mysql2": "^3.20.0",
97
97
  "pg": "^8.20.0",
@@ -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,7 @@ 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)
33
34
  export { beforeHook, afterHook, clearHook, clearAllHooks } from './hooks.js'; // middleware hooks
34
35
 
35
36
  // Store API:
@@ -0,0 +1,74 @@
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 the same expression was written out seven times: twice in
22
+ * README.md, once in docs/usage-patterns.md, and four times inside the
23
+ * framework. The framework's copy — `getId()`, module-private in
24
+ * orm-request.ts, unreachable through the package `exports` map — was the one
25
+ * that decided which record a request addressed, and a consumer's `access()`
26
+ * predicate had no way to obtain it. So the framework resolved the record by
27
+ * one value and asked the consumer to authorize on a different one.
28
+ *
29
+ * Measured consequence at the time of filing: with the documented predicate
30
+ * applied verbatim to a numeric-id model, `GET /animals/007`, `/7.9`, `/7e0`,
31
+ * `/0x7`, `/%207`, `/%2B7` and `/7%0A` each served the protected record, and
32
+ * `DELETE /animals/007` destroyed it — because `parseInt` folds every one of
33
+ * those onto `7` while `'007' === '7'` is false.
34
+ *
35
+ * Two things follow from this being a single exported function, and both are
36
+ * the point:
37
+ *
38
+ * 1. A permissive change here is HARMLESS, because both sides move together.
39
+ * Lowercasing string ids used to disclose and destroy `owner:angela` with
40
+ * the whole suite green; with one implementation the predicate simply sees
41
+ * the same lowercased value and still refuses.
42
+ * 2. A divergence — a second, private normaliser at the resolution site — is
43
+ * what is now dangerous, and that is what the tests pin, by observing the
44
+ * key the resolution path actually used.
45
+ *
46
+ * SEMANTICS ARE UNCHANGED FROM `getId()`, deliberately and byte-for-byte.
47
+ * Whether `7.9` / `0x7` / `%0A` *should* resolve to record 7 at all is a real
48
+ * question, and it is a behaviour change on the record-resolution path for
49
+ * every consumer rather than an authorization fix — it belongs in its own
50
+ * issue with its own compatibility argument (issue body scope item 4). #270
51
+ * preserves today's semantics exactly and makes both sides agree on them.
52
+ *
53
+ * Two details are load-bearing and are pinned by
54
+ * test/unit/normalize-record-id-test.ts:
55
+ *
56
+ * - `parseInt` is called with NO RADIX. `parseInt('0x7')` is 7;
57
+ * `parseInt('0x7', 10)` is 0. Adding the radix looks like a cleanup and
58
+ * silently changes which record every hex-spelled URL addresses.
59
+ * - The coercion applies only when the id LOOKS numeric, so a model with
60
+ * string ids is passed through untouched, case included.
61
+ *
62
+ * Must stay synchronous: `auth()` is invoked without `await` by
63
+ * @stonyx/rest-server, so a promise here would be handed to `access()` as the
64
+ * record id.
65
+ *
66
+ * @param id the raw, already-URL-decoded id text from `request.params.id`
67
+ * @returns the value the ORM resolves the record by
68
+ */
69
+ export default function normalizeRecordId(id?: string | null): string | number {
70
+ if (!id) return '';
71
+ if (isNaN(id as unknown as number)) return id;
72
+
73
+ return parseInt(id);
74
+ }
@@ -7,6 +7,7 @@ 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
12
  interface OrmRequest$ extends Request {
12
13
  protocol?: string;
@@ -16,6 +17,10 @@ interface OrmRequest$ extends Request {
16
17
  baseUrl?: string;
17
18
  method: string;
18
19
  params: { [key: string]: string };
20
+ // Attached by auth() before access() runs — abofs/stonyx-orm#270. This is the
21
+ // value the ORM resolves the record by; `params.id` remains the raw client
22
+ // text it was parsed from.
23
+ recordId?: string | number;
19
24
  body?: { [key: string]: unknown };
20
25
  query?: { [key: string]: string };
21
26
  get(header: string): string;
@@ -107,12 +112,13 @@ function getBaseUrl(request: OrmRequest$, pluralizedModel: string): string {
107
112
  return `${protocol}://${host}${prefix}`;
108
113
  }
109
114
 
115
+ // Kept as a name because twelve call sites read `getId(request.params)`, and
116
+ // docs/hooks.md documents that spelling. It is now a thin delegate: there is
117
+ // exactly ONE normalisation in the repo, and it is the exported one a consumer
118
+ // can import. A second implementation here is the defect abofs/stonyx-orm#270
119
+ // exists to remove — see src/normalize-record-id.ts.
110
120
  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);
121
+ return normalizeRecordId(params.id);
116
122
  }
117
123
 
118
124
  function buildResponse(
@@ -598,6 +604,37 @@ export default class OrmRequest extends Request {
598
604
  }
599
605
 
600
606
  auth(request: OrmRequest$, state: { [key: string]: unknown }): number | undefined {
607
+ // abofs/stonyx-orm#270 — hand the predicate the value the record is
608
+ // ACTUALLY resolved by, rather than the raw text it was parsed from.
609
+ //
610
+ // This is the default path precisely because it requires the consumer to
611
+ // remember nothing. Exporting `normalizeRecordId` alone would leave the
612
+ // framework owning resolution while asking every consumer to call one
613
+ // function, with no signal when they forget — the same silent fail-open,
614
+ // one step over. The export is the escape hatch for hooks and custom
615
+ // handlers; this line is the contract.
616
+ //
617
+ // `request.params` is deliberately NOT mutated. Twelve `getId(...)` call
618
+ // sites, the serializer, and consumer hooks (docs/hooks.md documents
619
+ // `getId(request.params)`) all read it, and changing `params.id` from
620
+ // string to number underneath them is a silent behaviour change on paths
621
+ // this issue is not about.
622
+ //
623
+ // Synchronous by necessity: @stonyx/rest-server calls auth() without
624
+ // awaiting it.
625
+ //
626
+ // The `undefined` branch is a PRESENCE check, not a second normalisation:
627
+ // it answers "does this route carry an :id at all", which is how both
628
+ // documented samples tell a collection request from a record request. It
629
+ // cannot be folded into normalizeRecordId, because that function must keep
630
+ // returning '' for a falsy id — `store.get(key, undefined)` returns the
631
+ // whole model Map rather than a record (abofs/stonyx-orm#167, pinned by
632
+ // test/unit/store-get-falsy-id-test.ts), so the resolution path depends on
633
+ // the '' it returns today.
634
+ const rawId = request.params?.id;
635
+
636
+ request.recordId = rawId === undefined ? undefined : normalizeRecordId(rawId);
637
+
601
638
  const access = this.access(request);
602
639
 
603
640
  if (!access) return 403;