@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 +129 -86
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/main.js +1 -19
- package/dist/normalize-record-id.d.ts +67 -0
- package/dist/normalize-record-id.js +88 -0
- package/dist/orm-request.d.ts +16 -1
- package/dist/orm-request.js +46 -6
- package/package.json +2 -2
- package/src/index.ts +2 -0
- package/src/main.ts +1 -19
- package/src/normalize-record-id.ts +88 -0
- package/src/orm-request.ts +71 -6
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
|
-
|
|
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
|
-
//
|
|
374
|
-
// `
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
//
|
|
380
|
-
|
|
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 `
|
|
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:
|
|
419
|
-
from the client. When it looks numeric the ORM coerces
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
is `
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
441
|
-
//
|
|
442
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/dist/orm-request.d.ts
CHANGED
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
import { Request } from '@stonyx/rest-server';
|
|
2
|
-
|
|
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
|
};
|
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,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
|
-
|
|
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.
|
|
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
|
+
}
|
package/src/orm-request.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|