@stonyx/orm 0.3.2-alpha.108 → 0.3.2-alpha.109
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 +86 -135
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -1
- package/dist/main.js +17 -1
- package/dist/orm-request.d.ts +1 -16
- package/dist/orm-request.js +6 -46
- package/package.json +2 -2
- package/src/index.ts +0 -2
- package/src/main.ts +17 -1
- package/src/orm-request.ts +6 -71
- package/dist/normalize-record-id.d.ts +0 -67
- package/dist/normalize-record-id.js +0 -88
- package/src/normalize-record-id.ts +0 -88
package/README.md
CHANGED
|
@@ -40,6 +40,12 @@ 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
|
+
|
|
43
49
|
## Usage example
|
|
44
50
|
|
|
45
51
|
This module is part of the **Stonyx framework**. To use it, first configure the `restServer` key in your `environment.js` file:
|
|
@@ -106,7 +112,9 @@ export default {
|
|
|
106
112
|
tablePrefix: DYNAMODB_TABLE_PREFIX, // optional table name prefix
|
|
107
113
|
} : undefined,
|
|
108
114
|
restServer: {
|
|
109
|
-
|
|
115
|
+
// 'true' requires @stonyx/rest-server to be installed — see
|
|
116
|
+
// "Optional peer dependencies" below.
|
|
117
|
+
enabled: ORM_USE_REST_SERVER ?? 'false',
|
|
110
118
|
route: ORM_REST_ROUTE ?? '/'
|
|
111
119
|
}
|
|
112
120
|
}
|
|
@@ -127,6 +135,46 @@ stonyx serve
|
|
|
127
135
|
|
|
128
136
|
For further framework instructions, see the [Stonyx repository](https://github.com/abofs/stonyx).
|
|
129
137
|
|
|
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
|
+
|
|
130
178
|
## Models
|
|
131
179
|
|
|
132
180
|
Define a model with attributes and relationships:
|
|
@@ -322,17 +370,19 @@ export default class OwnerAccess {
|
|
|
322
370
|
models = ['owner'];
|
|
323
371
|
|
|
324
372
|
access(request) {
|
|
325
|
-
// `
|
|
326
|
-
//
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
// `
|
|
330
|
-
|
|
373
|
+
// `access` runs after route matching, so `request.params` is populated and
|
|
374
|
+
// `id` has already been URL-decoded. Authorize on it, never on a URL.
|
|
375
|
+
const { id } = request.params;
|
|
376
|
+
|
|
377
|
+
// `id` is still raw client text. Normalise it the way the record lookup
|
|
378
|
+
// does, or your predicate and the lookup disagree — see "Numeric ids" below.
|
|
379
|
+
// No radix on parseInt: that is deliberate, and it must stay that way.
|
|
380
|
+
const recordId = id === undefined ? undefined : (isNaN(id) ? id : parseInt(id));
|
|
331
381
|
|
|
332
382
|
// Returning false explicitly denies access to this record
|
|
333
383
|
if (recordId === 'angela') return false;
|
|
334
384
|
|
|
335
|
-
// No `
|
|
385
|
+
// No `id` means the collection route. Returning a function plugs it in to
|
|
336
386
|
// the response object as a filter. NOTE: a function return authorizes the
|
|
337
387
|
// request outright — the operations list below is not consulted — so this
|
|
338
388
|
// branch permits POST /owners as well as reads.
|
|
@@ -365,77 +415,31 @@ unrecognised spelling falls through to whatever your method returns next, so
|
|
|
365
415
|
prefer one class per model. A class may still list several models in `models`
|
|
366
416
|
when they share one rule.
|
|
367
417
|
|
|
368
|
-
**Numeric ids:
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
`+7`, `%207` (a leading space), `%097` (a tab) and `7%0A` (a
|
|
372
|
-
all address record `7`, while a `===` against the raw text
|
|
373
|
-
spelling you wrote down. Every other spelling
|
|
374
|
-
your method returns next — which, in the shape above, is a full CRUD
|
|
375
|
-
of them are plain address-bar requests.
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
is
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
left untouched, so anything that needs the raw client text still has it.
|
|
383
|
-
|
|
384
|
-
**Version floor: `request.recordId` requires the release that carries
|
|
385
|
-
abofs/stonyx-orm#270.** Measured on the published tarballs: `0.3.2-alpha.106`
|
|
386
|
-
(this change's alpha) has `request.recordId` and exports `normalizeRecordId`;
|
|
387
|
-
`0.3.2-beta.231`, the newest build on the `beta` tag at the time of writing, has
|
|
388
|
-
neither, and `latest` is `0.3.1`. **On any earlier build the samples above fail
|
|
389
|
-
open, completely** — measured against `origin/dev`: `recordId` is `undefined` on
|
|
390
|
-
every route, so `if (recordId === undefined) return record => …` fires on the
|
|
391
|
-
*record* route, and a function return authorizes the request outright.
|
|
392
|
-
`GET /owners/angela` and `GET /animals/7` both returned 200 and served the
|
|
393
|
-
protected record, and `DELETE` on both returned 204 and destroyed it.
|
|
394
|
-
|
|
395
|
-
If you cannot pin the version, make the samples fail *closed* instead — one line,
|
|
396
|
-
a no-op on a new build and a refusal on an old one:
|
|
397
|
-
|
|
398
|
-
```javascript
|
|
399
|
-
access(request) {
|
|
400
|
-
// Old builds do not attach recordId. Refuse rather than fall through to the
|
|
401
|
-
// collection branch, which would authorize the record route outright.
|
|
402
|
-
if (!('recordId' in request)) return false;
|
|
403
|
-
|
|
404
|
-
const { recordId } = request;
|
|
405
|
-
// …
|
|
406
|
-
}
|
|
407
|
-
```
|
|
408
|
-
|
|
409
|
-
The same normalisation is exported for the places `access()` does not reach —
|
|
410
|
-
hooks, custom handlers, your own lookups:
|
|
411
|
-
|
|
412
|
-
```javascript
|
|
413
|
-
import { normalizeRecordId } from '@stonyx/orm';
|
|
414
|
-
|
|
415
|
-
normalizeRecordId('0x7'); // 7 — the same value the ORM resolves by
|
|
416
|
-
```
|
|
417
|
-
|
|
418
|
-
Do not re-implement it. A hand-written copy is correct only for as long as it
|
|
419
|
-
happens to match, and nothing holds the two together.
|
|
420
|
-
|
|
421
|
-
**`normalizeRecordId(undefined)` is `''`, not `undefined`.** The two values this
|
|
422
|
-
section documents side by side do not agree, and the difference is load-bearing:
|
|
423
|
-
`request.recordId` is `undefined` on a collection route, while
|
|
424
|
-
`normalizeRecordId` returns `''` for any falsy id — including `undefined` — because
|
|
425
|
-
`store.get(key, undefined)` returns the whole model Map rather than a record
|
|
426
|
-
(abofs/stonyx-orm#167). So `normalizeRecordId(context.params.id) === undefined`
|
|
427
|
-
is **never** true and a collection branch written that way never runs. Branch on
|
|
428
|
-
`request.recordId === undefined`, or compare against `''`.
|
|
418
|
+
**Numeric ids: normalise before you compare.** `request.params.id` is raw text
|
|
419
|
+
from the client. When it looks numeric the ORM coerces it — `isNaN(id) ? id :
|
|
420
|
+
parseInt(id)` — *before* it resolves the record, so `7`, `007`, `7.0`, `7.9`,
|
|
421
|
+
`7e0`, `0x7`, `+7`, `%207` (a leading space), `%097` (a tab) and `7%0A` (a
|
|
422
|
+
trailing newline) all address record `7`, while a `===` against the raw text
|
|
423
|
+
matches only the one spelling you wrote down. Every other spelling falls through
|
|
424
|
+
to whatever your method returns next — which, in the shape above, is a full CRUD
|
|
425
|
+
grant. All of them are plain address-bar requests.
|
|
426
|
+
|
|
427
|
+
Two details are load-bearing. `parseInt` is called with **no radix**, so `0x7`
|
|
428
|
+
is `7` and not `0`; writing `parseInt(id, 10)` in your predicate re-opens the
|
|
429
|
+
hex spelling. And the coercion applies only when the id looks numeric, so a
|
|
430
|
+
model with string ids (like `owner` above) is unaffected — which is exactly why
|
|
431
|
+
this is easy to miss. Normalise the same way the lookup does:
|
|
429
432
|
|
|
430
433
|
```javascript
|
|
431
434
|
export default class AnimalAccess {
|
|
432
435
|
models = ['animal'];
|
|
433
436
|
|
|
434
437
|
access(request) {
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
//
|
|
438
|
-
|
|
438
|
+
const { id } = request.params;
|
|
439
|
+
|
|
440
|
+
// Agrees with the lookup for every spelling of 7 above. Compare the
|
|
441
|
+
// coerced value, which for a numeric-id model is a number, not a string.
|
|
442
|
+
const recordId = id === undefined ? undefined : (isNaN(id) ? id : parseInt(id));
|
|
439
443
|
|
|
440
444
|
if (recordId === 7) return false;
|
|
441
445
|
|
|
@@ -667,13 +671,9 @@ beforeHook('create', 'animal', (context) => {
|
|
|
667
671
|
}
|
|
668
672
|
});
|
|
669
673
|
|
|
670
|
-
// Return an object to send a custom response
|
|
671
|
-
// Look the record up by `context.request.recordId` — the value the ORM
|
|
672
|
-
// resolved the record by. `context.params.id` is the raw client text and is a
|
|
673
|
-
// different value for every alias of the same id, so a lookup keyed on it
|
|
674
|
-
// finds nothing on a numeric-id model (abofs/stonyx-orm#270).
|
|
674
|
+
// Return an object to send a custom response
|
|
675
675
|
beforeHook('delete', 'animal', (context) => {
|
|
676
|
-
const animal = store.get('animal', context.
|
|
676
|
+
const animal = store.get('animal', context.params.id);
|
|
677
677
|
if (animal.protected) {
|
|
678
678
|
return { errors: [{ detail: 'Cannot delete protected animals' }] };
|
|
679
679
|
}
|
|
@@ -714,12 +714,9 @@ afterHook('update', 'animal', async (context) => {
|
|
|
714
714
|
}
|
|
715
715
|
});
|
|
716
716
|
|
|
717
|
-
// Cache invalidation
|
|
718
|
-
// Keyed on `recordId`, not `params.id`: `/animals/7` and `/animals/007` are
|
|
719
|
-
// one record but two strings, so a raw-text key invalidates two entries and
|
|
720
|
-
// misses the one the write used.
|
|
717
|
+
// Cache invalidation
|
|
721
718
|
afterHook('delete', 'animal', async (context) => {
|
|
722
|
-
await cache.invalidate(`owner:${context.
|
|
719
|
+
await cache.invalidate(`owner:${context.params.id}:pets`);
|
|
723
720
|
});
|
|
724
721
|
```
|
|
725
722
|
|
|
@@ -760,16 +757,10 @@ afterHook('delete', 'animal', async (context) => {
|
|
|
760
757
|
#### Authorization
|
|
761
758
|
|
|
762
759
|
```javascript
|
|
763
|
-
// Additional access control - halt with 403 if unauthorized
|
|
764
|
-
//
|
|
765
|
-
// `context.request.recordId` is the id the ORM resolved the record by — the
|
|
766
|
-
// same value `access()` is handed. Authorizing on `context.params.id` instead
|
|
767
|
-
// looks up the raw client text: on a numeric-id model that lookup returns
|
|
768
|
-
// `undefined` for EVERY spelling, `animal.owner` throws, and the check never
|
|
769
|
-
// runs. Measured; abofs/stonyx-orm#270.
|
|
760
|
+
// Additional access control - halt with 403 if unauthorized
|
|
770
761
|
beforeHook('delete', 'animal', (context) => {
|
|
771
762
|
const user = context.state.currentUser;
|
|
772
|
-
const animal = store.get('animal', context.
|
|
763
|
+
const animal = store.get('animal', context.params.id);
|
|
773
764
|
|
|
774
765
|
if (animal.owner !== user.id && !user.isAdmin) {
|
|
775
766
|
return 403; // Forbidden
|
|
@@ -970,52 +961,12 @@ test('validation hook rejects negative age', async () => {
|
|
|
970
961
|
| `afterHook` | Register an after hook for post-operation logic. |
|
|
971
962
|
| `clearHook` | Clear hooks for a specific operation:model. |
|
|
972
963
|
| `clearAllHooks` | Clear all registered hooks (useful for testing). |
|
|
973
|
-
| `normalizeRecordId` | Turn a raw URL id into the value the ORM resolves the record by. |
|
|
974
|
-
|
|
975
|
-
### `normalizeRecordId(id)`
|
|
976
|
-
|
|
977
|
-
```ts
|
|
978
|
-
normalizeRecordId(id?: string | null): string | number
|
|
979
|
-
```
|
|
980
|
-
|
|
981
|
-
The **one** implementation of URL-id normalisation in the package
|
|
982
|
-
(abofs/stonyx-orm#270). `access()` is already handed its result as
|
|
983
|
-
`request.recordId`; import it for the places `access()` does not reach — hooks,
|
|
984
|
-
custom handlers, your own lookups. Synchronous, and it must stay synchronous:
|
|
985
|
-
`auth()` is invoked without `await`.
|
|
986
|
-
|
|
987
|
-
| Input | Returns | Note |
|
|
988
|
-
| --- | --- | --- |
|
|
989
|
-
| `'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` |
|
|
990
|
-
| `'angela'`, `'ANGELA'` | the same string, case included | a non-numeric id is passed through untouched |
|
|
991
|
-
| `'0'`, `'00'`, `'-0'`, `'0x0'` | `0` (number) | falsy, and a legitimate record id |
|
|
992
|
-
| `' '`, `'\t'`, `'\n'`, `'\u00a0'` | `NaN` | whitespace-only ids are numeric to `isNaN` but parse to nothing |
|
|
993
|
-
| `''`, `null`, `undefined` | `''` (empty string) | **not** `undefined` — see the trap below |
|
|
994
|
-
|
|
995
|
-
**The trap.** `request.recordId` is `undefined` on a collection route;
|
|
996
|
-
`normalizeRecordId(undefined)` is `''`. They are different values and a
|
|
997
|
-
collection check written against the wrong one silently never fires:
|
|
998
|
-
|
|
999
|
-
```javascript
|
|
1000
|
-
// WRONG — never true, so this branch never runs
|
|
1001
|
-
if (normalizeRecordId(context.params.id) === undefined) { /* … */ }
|
|
1002
|
-
|
|
1003
|
-
// Right — the ORM attaches undefined for "this route carries no :id"
|
|
1004
|
-
if (context.request.recordId === undefined) { /* collection route */ }
|
|
1005
|
-
```
|
|
1006
|
-
|
|
1007
|
-
The `''` is deliberate: `store.get(key, undefined)` returns the whole model Map
|
|
1008
|
-
rather than a record (abofs/stonyx-orm#167), so the resolution path depends on
|
|
1009
|
-
the empty string this function returns today.
|
|
1010
|
-
|
|
1011
|
-
**A normalised id is not a promise that a record exists.** `0` and `NaN` are both
|
|
1012
|
-
possible returns and neither addresses a record you can rely on — `NaN` is not
|
|
1013
|
-
even `===` itself, so `recordId === NaN` can never be written as a guard. Treat
|
|
1014
|
-
`recordId` as "the key the lookup will use", not as "a record is there".
|
|
1015
964
|
|
|
1016
965
|
## Project Structure
|
|
1017
966
|
|
|
1018
|
-
For a full architectural reference, see
|
|
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.
|
|
1019
970
|
|
|
1020
971
|
## License
|
|
1021
972
|
|
package/dist/index.d.ts
CHANGED
|
@@ -12,6 +12,4 @@ 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';
|
|
17
15
|
export { beforeHook, afterHook, clearHook, clearAllHooks } from './hooks.js';
|
package/dist/index.js
CHANGED
|
@@ -26,7 +26,6 @@ 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)
|
|
30
29
|
export { beforeHook, afterHook, clearHook, clearAllHooks } from './hooks.js'; // middleware hooks
|
|
31
30
|
// Store API:
|
|
32
31
|
// store.get(model, id) -- sync, memory-only
|
package/dist/main.js
CHANGED
|
@@ -19,7 +19,6 @@ 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';
|
|
23
22
|
import baseTransforms from './transforms.js';
|
|
24
23
|
import Store from './store.js';
|
|
25
24
|
import Serializer from './serializer.js';
|
|
@@ -131,6 +130,23 @@ export default class Orm {
|
|
|
131
130
|
promises.push(db.init());
|
|
132
131
|
}
|
|
133
132
|
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 at all, so the `await import()`
|
|
145
|
+
// there is not what isolates pg / mysql2 / @aws-sdk — that happens one
|
|
146
|
+
// layer down, in src/*/connection.ts (and src/dynamodb/dynamodb-db.ts).
|
|
147
|
+
// setup-rest-server.js is the only dist module whose laziness is
|
|
148
|
+
// load-bearing for peer resolution. (#280)
|
|
149
|
+
const { default: setupRestServer } = await import('./setup-rest-server.js');
|
|
134
150
|
promises.push(setupRestServer(restServer.route, paths.access, restServer.metaRoute));
|
|
135
151
|
}
|
|
136
152
|
// Wire up memory resolver so store.find() can check model memory flags
|
package/dist/orm-request.d.ts
CHANGED
|
@@ -1,26 +1,11 @@
|
|
|
1
1
|
import { Request } from '@stonyx/rest-server';
|
|
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 {
|
|
2
|
+
interface OrmRequest$ extends Request {
|
|
17
3
|
protocol?: string;
|
|
18
4
|
baseUrl?: string;
|
|
19
5
|
method: string;
|
|
20
6
|
params: {
|
|
21
7
|
[key: string]: string;
|
|
22
8
|
};
|
|
23
|
-
recordId?: string | number | undefined;
|
|
24
9
|
body?: {
|
|
25
10
|
[key: string]: unknown;
|
|
26
11
|
};
|
package/dist/orm-request.js
CHANGED
|
@@ -5,7 +5,6 @@ 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';
|
|
9
8
|
const methodAccessMap = {
|
|
10
9
|
GET: 'read',
|
|
11
10
|
POST: 'create',
|
|
@@ -66,23 +65,13 @@ function getBaseUrl(request, pluralizedModel) {
|
|
|
66
65
|
const prefix = mountPath.endsWith(modelSegment) ? mountPath.slice(0, -modelSegment.length) : '';
|
|
67
66
|
return `${protocol}://${host}${prefix}`;
|
|
68
67
|
}
|
|
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.
|
|
84
68
|
function getId(params) {
|
|
85
|
-
|
|
69
|
+
const id = params.id;
|
|
70
|
+
if (!id)
|
|
71
|
+
return '';
|
|
72
|
+
if (isNaN(id))
|
|
73
|
+
return id;
|
|
74
|
+
return parseInt(id);
|
|
86
75
|
}
|
|
87
76
|
function buildResponse(data, includeParam, recordOrRecords, options = {}) {
|
|
88
77
|
const { links, baseUrl } = options;
|
|
@@ -492,35 +481,6 @@ export default class OrmRequest extends Request {
|
|
|
492
481
|
return routes;
|
|
493
482
|
}
|
|
494
483
|
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);
|
|
524
484
|
const access = this.access(request);
|
|
525
485
|
if (!access)
|
|
526
486
|
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.109",
|
|
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/
|
|
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'",
|
|
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,8 +30,6 @@ 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)
|
|
35
33
|
export { beforeHook, afterHook, clearHook, clearAllHooks } from './hooks.js'; // middleware hooks
|
|
36
34
|
|
|
37
35
|
// Store API:
|
package/src/main.ts
CHANGED
|
@@ -20,7 +20,6 @@ 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';
|
|
24
23
|
import baseTransforms from './transforms.js';
|
|
25
24
|
import Store from './store.js';
|
|
26
25
|
import Serializer from './serializer.js';
|
|
@@ -177,6 +176,23 @@ export default class Orm {
|
|
|
177
176
|
}
|
|
178
177
|
|
|
179
178
|
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 at all, so the `await import()`
|
|
191
|
+
// there is not what isolates pg / mysql2 / @aws-sdk — that happens one
|
|
192
|
+
// layer down, in src/*/connection.ts (and src/dynamodb/dynamodb-db.ts).
|
|
193
|
+
// setup-rest-server.js is the only dist module whose laziness is
|
|
194
|
+
// load-bearing for peer resolution. (#280)
|
|
195
|
+
const { default: setupRestServer } = await import('./setup-rest-server.js');
|
|
180
196
|
promises.push(setupRestServer(restServer.route, paths.access, restServer.metaRoute));
|
|
181
197
|
}
|
|
182
198
|
|
package/src/orm-request.ts
CHANGED
|
@@ -7,23 +7,8 @@ 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';
|
|
11
10
|
|
|
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 {
|
|
11
|
+
interface OrmRequest$ extends Request {
|
|
27
12
|
protocol?: string;
|
|
28
13
|
// Express sets this to the path the router was mounted at, e.g. '/api/animals'
|
|
29
14
|
// when orm.restServer.route is '/api'. Optional because non-Express callers
|
|
@@ -31,14 +16,6 @@ export interface OrmRequest$ extends Request {
|
|
|
31
16
|
baseUrl?: string;
|
|
32
17
|
method: string;
|
|
33
18
|
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;
|
|
42
19
|
body?: { [key: string]: unknown };
|
|
43
20
|
query?: { [key: string]: string };
|
|
44
21
|
get(header: string): string;
|
|
@@ -130,23 +107,12 @@ function getBaseUrl(request: OrmRequest$, pluralizedModel: string): string {
|
|
|
130
107
|
return `${protocol}://${host}${prefix}`;
|
|
131
108
|
}
|
|
132
109
|
|
|
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.
|
|
148
110
|
function getId(params: { id?: string; [key: string]: unknown }): string | number {
|
|
149
|
-
|
|
111
|
+
const id = params.id;
|
|
112
|
+
if (!id) return '';
|
|
113
|
+
if (isNaN(id as unknown as number)) return id;
|
|
114
|
+
|
|
115
|
+
return parseInt(id);
|
|
150
116
|
}
|
|
151
117
|
|
|
152
118
|
function buildResponse(
|
|
@@ -632,37 +598,6 @@ export default class OrmRequest extends Request {
|
|
|
632
598
|
}
|
|
633
599
|
|
|
634
600
|
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
|
-
|
|
666
601
|
const access = this.access(request);
|
|
667
602
|
|
|
668
603
|
if (!access) return 403;
|
|
@@ -1,67 +0,0 @@
|
|
|
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;
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Copyright 2025 Stone Costa
|
|
3
|
-
*
|
|
4
|
-
* Licensed under the Apache License, Version 2.0 (the 'License');
|
|
5
|
-
* you may not use this file except in compliance with the License.
|
|
6
|
-
* You may obtain a copy of the License at
|
|
7
|
-
*
|
|
8
|
-
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
-
*
|
|
10
|
-
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
-
* distributed under the License is distributed on an 'AS IS' BASIS,
|
|
12
|
-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
-
* See the License for the specific language governing permissions and
|
|
14
|
-
* limitations under the License.
|
|
15
|
-
*/
|
|
16
|
-
/**
|
|
17
|
-
* The ONE place a URL id is turned into the value a record is resolved by —
|
|
18
|
-
* abofs/stonyx-orm#270.
|
|
19
|
-
*
|
|
20
|
-
* Before this existed this coercion was written out seven times: twice in
|
|
21
|
-
* README.md, once in docs/usage-patterns.md, and four times inside the
|
|
22
|
-
* framework. Seven copies, not seven identical copies — the three persistence
|
|
23
|
-
* ones (abofs/stonyx-orm#282) omit the `if (!id) return ''` guard below, which
|
|
24
|
-
* is the point: nothing held them together, so they had already drifted.
|
|
25
|
-
* The framework's copy — `getId()`, module-private in
|
|
26
|
-
* orm-request.ts, unreachable through the package `exports` map — was the one
|
|
27
|
-
* that decided which record a request addressed, and a consumer's `access()`
|
|
28
|
-
* predicate had no way to obtain it. So the framework resolved the record by
|
|
29
|
-
* one value and asked the consumer to authorize on a different one.
|
|
30
|
-
*
|
|
31
|
-
* Measured consequence at the time of filing: with the documented predicate
|
|
32
|
-
* applied verbatim to a numeric-id model, `GET /animals/007`, `/7.9`, `/7e0`,
|
|
33
|
-
* `/0x7`, `/%207`, `/%2B7` and `/7%0A` each served the protected record, and
|
|
34
|
-
* `DELETE /animals/007` destroyed it — because `parseInt` folds every one of
|
|
35
|
-
* those onto `7` while `'007' === '7'` is false.
|
|
36
|
-
*
|
|
37
|
-
* Two things follow from this being a single exported function, and both are
|
|
38
|
-
* the point:
|
|
39
|
-
*
|
|
40
|
-
* 1. A permissive change here is HARMLESS, because both sides move together.
|
|
41
|
-
* Lowercasing string ids used to disclose and destroy `owner:angela` with
|
|
42
|
-
* the whole suite green; with one implementation the predicate simply sees
|
|
43
|
-
* the same lowercased value and still refuses.
|
|
44
|
-
* 2. A divergence — a second, private normaliser at the resolution site — is
|
|
45
|
-
* what is now dangerous, and that is what the tests pin, by observing the
|
|
46
|
-
* key the resolution path actually used.
|
|
47
|
-
*
|
|
48
|
-
* SEMANTICS ARE UNCHANGED FROM `getId()`, deliberately and byte-for-byte.
|
|
49
|
-
* Whether `7.9` / `0x7` / `%0A` *should* resolve to record 7 at all is a real
|
|
50
|
-
* question, and it is a behaviour change on the record-resolution path for
|
|
51
|
-
* every consumer rather than an authorization fix — it belongs in its own
|
|
52
|
-
* issue with its own compatibility argument (issue body scope item 4). #270
|
|
53
|
-
* preserves today's semantics exactly and makes both sides agree on them.
|
|
54
|
-
*
|
|
55
|
-
* Two details are load-bearing and are pinned by
|
|
56
|
-
* test/unit/normalize-record-id-test.ts:
|
|
57
|
-
*
|
|
58
|
-
* - `parseInt` is called with NO RADIX. `parseInt('0x7')` is 7;
|
|
59
|
-
* `parseInt('0x7', 10)` is 0. Adding the radix looks like a cleanup and
|
|
60
|
-
* silently changes which record every hex-spelled URL addresses.
|
|
61
|
-
* - The coercion applies only when the id LOOKS numeric, so a model with
|
|
62
|
-
* string ids is passed through untouched, case included.
|
|
63
|
-
*
|
|
64
|
-
* Must stay synchronous: `auth()` is invoked without `await` by
|
|
65
|
-
* @stonyx/rest-server, so a promise here would be handed to `access()` as the
|
|
66
|
-
* record id.
|
|
67
|
-
*
|
|
68
|
-
* FALSY AND `NaN` RETURNS ARE LOAD-BEARING ELSEWHERE — abofs/stonyx-orm#287.
|
|
69
|
-
* `''` is returned for any falsy id because `store.get(key, undefined)` returns
|
|
70
|
-
* the whole model Map rather than a record (abofs/stonyx-orm#167). But `''` is
|
|
71
|
-
* not the only falsy return: `'0'`, `'00'`, `'-0'` and `'0x0'` all normalise to
|
|
72
|
-
* `0`, and `' '`, `'\t'`, `'\n'`, `'\u00a0'` all normalise to `NaN` (their
|
|
73
|
-
* `Number()` is `0`, so the `isNaN` guard does not fire and `parseInt` runs).
|
|
74
|
-
* `store.remove(key, id)` branches on truthiness, so those spellings reach a
|
|
75
|
-
* fall-through this function does not own. Tracked as #287; every row is pinned
|
|
76
|
-
* in test/unit/normalize-record-id-test.ts so a cleanup here cannot move the
|
|
77
|
-
* boundary #287 is measured against.
|
|
78
|
-
*
|
|
79
|
-
* @param id the raw, already-URL-decoded id text from `request.params.id`
|
|
80
|
-
* @returns the value the ORM resolves the record by
|
|
81
|
-
*/
|
|
82
|
-
export default function normalizeRecordId(id) {
|
|
83
|
-
if (!id)
|
|
84
|
-
return '';
|
|
85
|
-
if (isNaN(id))
|
|
86
|
-
return id;
|
|
87
|
-
return parseInt(id);
|
|
88
|
-
}
|
|
@@ -1,88 +0,0 @@
|
|
|
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
|
-
}
|