@stonyx/orm 0.3.2-alpha.105 → 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 +38 -28
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/main.js +1 -7
- package/dist/normalize-record-id.d.ts +53 -0
- package/dist/normalize-record-id.js +74 -0
- package/dist/orm-request.d.ts +1 -0
- package/dist/orm-request.js +36 -6
- package/package.json +2 -2
- package/src/index.ts +1 -0
- package/src/main.ts +1 -7
- package/src/normalize-record-id.ts +74 -0
- package/src/orm-request.ts +42 -5
package/README.md
CHANGED
|
@@ -322,19 +322,17 @@ export default class OwnerAccess {
|
|
|
322
322
|
models = ['owner'];
|
|
323
323
|
|
|
324
324
|
access(request) {
|
|
325
|
-
// `
|
|
326
|
-
//
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
// `id
|
|
330
|
-
|
|
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 `
|
|
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:
|
|
371
|
-
from the client. When it looks numeric the ORM coerces
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
is `
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
//
|
|
393
|
-
|
|
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
|
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,13 +131,6 @@ export default class Orm {
|
|
|
130
131
|
promises.push(db.init());
|
|
131
132
|
}
|
|
132
133
|
if (restServer.enabled === 'true') {
|
|
133
|
-
// Lazy-imported so the optional `@stonyx/rest-server` peer is only resolved
|
|
134
|
-
// when the consumer has actually enabled REST. A static import here pulls
|
|
135
|
-
// setup-rest-server -> orm-request/meta-request -> '@stonyx/rest-server'
|
|
136
|
-
// into the entry graph, so `import('@stonyx/orm')` throws
|
|
137
|
-
// ERR_MODULE_NOT_FOUND for an ORM-only consumer that never installed the
|
|
138
|
-
// optional peer. Same reason the SQL/DynamoDB drivers above are lazy. (#280)
|
|
139
|
-
const { default: setupRestServer } = await import('./setup-rest-server.js');
|
|
140
134
|
promises.push(setupRestServer(restServer.route, paths.access, restServer.metaRoute));
|
|
141
135
|
}
|
|
142
136
|
// Wire up memory resolver so store.find() can check model memory flags
|
|
@@ -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
|
+
}
|
package/dist/orm-request.d.ts
CHANGED
package/dist/orm-request.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
7
|
+
"version": "0.3.2-alpha.106",
|
|
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,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:
|
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,13 +177,6 @@ export default class Orm {
|
|
|
176
177
|
}
|
|
177
178
|
|
|
178
179
|
if (restServer.enabled === 'true') {
|
|
179
|
-
// Lazy-imported so the optional `@stonyx/rest-server` peer is only resolved
|
|
180
|
-
// when the consumer has actually enabled REST. A static import here pulls
|
|
181
|
-
// setup-rest-server -> orm-request/meta-request -> '@stonyx/rest-server'
|
|
182
|
-
// into the entry graph, so `import('@stonyx/orm')` throws
|
|
183
|
-
// ERR_MODULE_NOT_FOUND for an ORM-only consumer that never installed the
|
|
184
|
-
// optional peer. Same reason the SQL/DynamoDB drivers above are lazy. (#280)
|
|
185
|
-
const { default: setupRestServer } = await import('./setup-rest-server.js');
|
|
186
180
|
promises.push(setupRestServer(restServer.route, paths.access, restServer.metaRoute));
|
|
187
181
|
}
|
|
188
182
|
|
|
@@ -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
|
+
}
|
package/src/orm-request.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|