@stonyx/orm 0.3.2-alpha.106 → 0.3.2-alpha.107
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 +80 -40
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/main.js +17 -1
- package/dist/orm-request.d.ts +0 -1
- package/dist/orm-request.js +6 -36
- package/package.json +2 -2
- package/src/index.ts +0 -1
- package/src/main.ts +17 -1
- package/src/orm-request.ts +5 -42
- package/dist/normalize-record-id.d.ts +0 -53
- package/dist/normalize-record-id.js +0 -74
- package/src/normalize-record-id.ts +0 -74
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,43 +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
|
-
The same function is exported for the places `access()` does not reach — hooks,
|
|
385
|
-
custom handlers, your own lookups:
|
|
386
|
-
|
|
387
|
-
```javascript
|
|
388
|
-
import { normalizeRecordId } from '@stonyx/orm';
|
|
389
|
-
|
|
390
|
-
normalizeRecordId('0x7'); // 7 — the same value the ORM resolves by
|
|
391
|
-
```
|
|
392
|
-
|
|
393
|
-
Do not re-implement it. A hand-written copy is correct only for as long as it
|
|
394
|
-
happens to match, and nothing holds the two together.
|
|
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:
|
|
395
432
|
|
|
396
433
|
```javascript
|
|
397
434
|
export default class AnimalAccess {
|
|
398
435
|
models = ['animal'];
|
|
399
436
|
|
|
400
437
|
access(request) {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
//
|
|
404
|
-
|
|
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));
|
|
405
443
|
|
|
406
444
|
if (recordId === 7) return false;
|
|
407
445
|
|
|
@@ -926,7 +964,9 @@ test('validation hook rejects negative age', async () => {
|
|
|
926
964
|
|
|
927
965
|
## Project Structure
|
|
928
966
|
|
|
929
|
-
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.
|
|
930
970
|
|
|
931
971
|
## License
|
|
932
972
|
|
package/dist/index.d.ts
CHANGED
|
@@ -12,5 +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
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
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,13 +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 call sites read `getId(request.params)`, and
|
|
70
|
-
// docs/hooks.md documents that spelling. It is now a thin delegate: there is
|
|
71
|
-
// exactly ONE normalisation in the repo, and it is the exported one a consumer
|
|
72
|
-
// can import. A second implementation here is the defect abofs/stonyx-orm#270
|
|
73
|
-
// exists to remove — see src/normalize-record-id.ts.
|
|
74
68
|
function getId(params) {
|
|
75
|
-
|
|
69
|
+
const id = params.id;
|
|
70
|
+
if (!id)
|
|
71
|
+
return '';
|
|
72
|
+
if (isNaN(id))
|
|
73
|
+
return id;
|
|
74
|
+
return parseInt(id);
|
|
76
75
|
}
|
|
77
76
|
function buildResponse(data, includeParam, recordOrRecords, options = {}) {
|
|
78
77
|
const { links, baseUrl } = options;
|
|
@@ -482,35 +481,6 @@ export default class OrmRequest extends Request {
|
|
|
482
481
|
return routes;
|
|
483
482
|
}
|
|
484
483
|
auth(request, state) {
|
|
485
|
-
// abofs/stonyx-orm#270 — hand the predicate the value the record is
|
|
486
|
-
// ACTUALLY resolved by, rather than the raw text it was parsed from.
|
|
487
|
-
//
|
|
488
|
-
// This is the default path precisely because it requires the consumer to
|
|
489
|
-
// remember nothing. Exporting `normalizeRecordId` alone would leave the
|
|
490
|
-
// framework owning resolution while asking every consumer to call one
|
|
491
|
-
// function, with no signal when they forget — the same silent fail-open,
|
|
492
|
-
// one step over. The export is the escape hatch for hooks and custom
|
|
493
|
-
// handlers; this line is the contract.
|
|
494
|
-
//
|
|
495
|
-
// `request.params` is deliberately NOT mutated. Twelve `getId(...)` call
|
|
496
|
-
// sites, the serializer, and consumer hooks (docs/hooks.md documents
|
|
497
|
-
// `getId(request.params)`) all read it, and changing `params.id` from
|
|
498
|
-
// string to number underneath them is a silent behaviour change on paths
|
|
499
|
-
// this issue is not about.
|
|
500
|
-
//
|
|
501
|
-
// Synchronous by necessity: @stonyx/rest-server calls auth() without
|
|
502
|
-
// awaiting it.
|
|
503
|
-
//
|
|
504
|
-
// The `undefined` branch is a PRESENCE check, not a second normalisation:
|
|
505
|
-
// it answers "does this route carry an :id at all", which is how both
|
|
506
|
-
// documented samples tell a collection request from a record request. It
|
|
507
|
-
// cannot be folded into normalizeRecordId, because that function must keep
|
|
508
|
-
// returning '' for a falsy id — `store.get(key, undefined)` returns the
|
|
509
|
-
// whole model Map rather than a record (abofs/stonyx-orm#167, pinned by
|
|
510
|
-
// test/unit/store-get-falsy-id-test.ts), so the resolution path depends on
|
|
511
|
-
// the '' it returns today.
|
|
512
|
-
const rawId = request.params?.id;
|
|
513
|
-
request.recordId = rawId === undefined ? undefined : normalizeRecordId(rawId);
|
|
514
484
|
const access = this.access(request);
|
|
515
485
|
if (!access)
|
|
516
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.107",
|
|
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,7 +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
33
|
export { beforeHook, afterHook, clearHook, clearAllHooks } from './hooks.js'; // middleware hooks
|
|
35
34
|
|
|
36
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,7 +7,6 @@ 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
11
|
interface OrmRequest$ extends Request {
|
|
13
12
|
protocol?: string;
|
|
@@ -17,10 +16,6 @@ interface OrmRequest$ extends Request {
|
|
|
17
16
|
baseUrl?: string;
|
|
18
17
|
method: string;
|
|
19
18
|
params: { [key: string]: string };
|
|
20
|
-
// Attached by auth() before access() runs — abofs/stonyx-orm#270. This is the
|
|
21
|
-
// value the ORM resolves the record by; `params.id` remains the raw client
|
|
22
|
-
// text it was parsed from.
|
|
23
|
-
recordId?: string | number;
|
|
24
19
|
body?: { [key: string]: unknown };
|
|
25
20
|
query?: { [key: string]: string };
|
|
26
21
|
get(header: string): string;
|
|
@@ -112,13 +107,12 @@ function getBaseUrl(request: OrmRequest$, pluralizedModel: string): string {
|
|
|
112
107
|
return `${protocol}://${host}${prefix}`;
|
|
113
108
|
}
|
|
114
109
|
|
|
115
|
-
// Kept as a name because twelve call sites read `getId(request.params)`, and
|
|
116
|
-
// docs/hooks.md documents that spelling. It is now a thin delegate: there is
|
|
117
|
-
// exactly ONE normalisation in the repo, and it is the exported one a consumer
|
|
118
|
-
// can import. A second implementation here is the defect abofs/stonyx-orm#270
|
|
119
|
-
// exists to remove — see src/normalize-record-id.ts.
|
|
120
110
|
function getId(params: { id?: string; [key: string]: unknown }): string | number {
|
|
121
|
-
|
|
111
|
+
const id = params.id;
|
|
112
|
+
if (!id) return '';
|
|
113
|
+
if (isNaN(id as unknown as number)) return id;
|
|
114
|
+
|
|
115
|
+
return parseInt(id);
|
|
122
116
|
}
|
|
123
117
|
|
|
124
118
|
function buildResponse(
|
|
@@ -604,37 +598,6 @@ export default class OrmRequest extends Request {
|
|
|
604
598
|
}
|
|
605
599
|
|
|
606
600
|
auth(request: OrmRequest$, state: { [key: string]: unknown }): number | undefined {
|
|
607
|
-
// abofs/stonyx-orm#270 — hand the predicate the value the record is
|
|
608
|
-
// ACTUALLY resolved by, rather than the raw text it was parsed from.
|
|
609
|
-
//
|
|
610
|
-
// This is the default path precisely because it requires the consumer to
|
|
611
|
-
// remember nothing. Exporting `normalizeRecordId` alone would leave the
|
|
612
|
-
// framework owning resolution while asking every consumer to call one
|
|
613
|
-
// function, with no signal when they forget — the same silent fail-open,
|
|
614
|
-
// one step over. The export is the escape hatch for hooks and custom
|
|
615
|
-
// handlers; this line is the contract.
|
|
616
|
-
//
|
|
617
|
-
// `request.params` is deliberately NOT mutated. Twelve `getId(...)` call
|
|
618
|
-
// sites, the serializer, and consumer hooks (docs/hooks.md documents
|
|
619
|
-
// `getId(request.params)`) all read it, and changing `params.id` from
|
|
620
|
-
// string to number underneath them is a silent behaviour change on paths
|
|
621
|
-
// this issue is not about.
|
|
622
|
-
//
|
|
623
|
-
// Synchronous by necessity: @stonyx/rest-server calls auth() without
|
|
624
|
-
// awaiting it.
|
|
625
|
-
//
|
|
626
|
-
// The `undefined` branch is a PRESENCE check, not a second normalisation:
|
|
627
|
-
// it answers "does this route carry an :id at all", which is how both
|
|
628
|
-
// documented samples tell a collection request from a record request. It
|
|
629
|
-
// cannot be folded into normalizeRecordId, because that function must keep
|
|
630
|
-
// returning '' for a falsy id — `store.get(key, undefined)` returns the
|
|
631
|
-
// whole model Map rather than a record (abofs/stonyx-orm#167, pinned by
|
|
632
|
-
// test/unit/store-get-falsy-id-test.ts), so the resolution path depends on
|
|
633
|
-
// the '' it returns today.
|
|
634
|
-
const rawId = request.params?.id;
|
|
635
|
-
|
|
636
|
-
request.recordId = rawId === undefined ? undefined : normalizeRecordId(rawId);
|
|
637
|
-
|
|
638
601
|
const access = this.access(request);
|
|
639
602
|
|
|
640
603
|
if (!access) return 403;
|
|
@@ -1,53 +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 the same expression was written out seven times: twice in
|
|
6
|
-
* README.md, once in docs/usage-patterns.md, and four times inside the
|
|
7
|
-
* framework. The framework's copy — `getId()`, module-private in
|
|
8
|
-
* orm-request.ts, unreachable through the package `exports` map — was the one
|
|
9
|
-
* that decided which record a request addressed, and a consumer's `access()`
|
|
10
|
-
* predicate had no way to obtain it. So the framework resolved the record by
|
|
11
|
-
* one value and asked the consumer to authorize on a different one.
|
|
12
|
-
*
|
|
13
|
-
* Measured consequence at the time of filing: with the documented predicate
|
|
14
|
-
* applied verbatim to a numeric-id model, `GET /animals/007`, `/7.9`, `/7e0`,
|
|
15
|
-
* `/0x7`, `/%207`, `/%2B7` and `/7%0A` each served the protected record, and
|
|
16
|
-
* `DELETE /animals/007` destroyed it — because `parseInt` folds every one of
|
|
17
|
-
* those onto `7` while `'007' === '7'` is false.
|
|
18
|
-
*
|
|
19
|
-
* Two things follow from this being a single exported function, and both are
|
|
20
|
-
* the point:
|
|
21
|
-
*
|
|
22
|
-
* 1. A permissive change here is HARMLESS, because both sides move together.
|
|
23
|
-
* Lowercasing string ids used to disclose and destroy `owner:angela` with
|
|
24
|
-
* the whole suite green; with one implementation the predicate simply sees
|
|
25
|
-
* the same lowercased value and still refuses.
|
|
26
|
-
* 2. A divergence — a second, private normaliser at the resolution site — is
|
|
27
|
-
* what is now dangerous, and that is what the tests pin, by observing the
|
|
28
|
-
* key the resolution path actually used.
|
|
29
|
-
*
|
|
30
|
-
* SEMANTICS ARE UNCHANGED FROM `getId()`, deliberately and byte-for-byte.
|
|
31
|
-
* Whether `7.9` / `0x7` / `%0A` *should* resolve to record 7 at all is a real
|
|
32
|
-
* question, and it is a behaviour change on the record-resolution path for
|
|
33
|
-
* every consumer rather than an authorization fix — it belongs in its own
|
|
34
|
-
* issue with its own compatibility argument (issue body scope item 4). #270
|
|
35
|
-
* preserves today's semantics exactly and makes both sides agree on them.
|
|
36
|
-
*
|
|
37
|
-
* Two details are load-bearing and are pinned by
|
|
38
|
-
* test/unit/normalize-record-id-test.ts:
|
|
39
|
-
*
|
|
40
|
-
* - `parseInt` is called with NO RADIX. `parseInt('0x7')` is 7;
|
|
41
|
-
* `parseInt('0x7', 10)` is 0. Adding the radix looks like a cleanup and
|
|
42
|
-
* silently changes which record every hex-spelled URL addresses.
|
|
43
|
-
* - The coercion applies only when the id LOOKS numeric, so a model with
|
|
44
|
-
* string ids is passed through untouched, case included.
|
|
45
|
-
*
|
|
46
|
-
* Must stay synchronous: `auth()` is invoked without `await` by
|
|
47
|
-
* @stonyx/rest-server, so a promise here would be handed to `access()` as the
|
|
48
|
-
* record id.
|
|
49
|
-
*
|
|
50
|
-
* @param id the raw, already-URL-decoded id text from `request.params.id`
|
|
51
|
-
* @returns the value the ORM resolves the record by
|
|
52
|
-
*/
|
|
53
|
-
export default function normalizeRecordId(id?: string | null): string | number;
|
|
@@ -1,74 +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 the same expression was written out seven times: twice in
|
|
21
|
-
* README.md, once in docs/usage-patterns.md, and four times inside the
|
|
22
|
-
* framework. The framework's copy — `getId()`, module-private in
|
|
23
|
-
* orm-request.ts, unreachable through the package `exports` map — was the one
|
|
24
|
-
* that decided which record a request addressed, and a consumer's `access()`
|
|
25
|
-
* predicate had no way to obtain it. So the framework resolved the record by
|
|
26
|
-
* one value and asked the consumer to authorize on a different one.
|
|
27
|
-
*
|
|
28
|
-
* Measured consequence at the time of filing: with the documented predicate
|
|
29
|
-
* applied verbatim to a numeric-id model, `GET /animals/007`, `/7.9`, `/7e0`,
|
|
30
|
-
* `/0x7`, `/%207`, `/%2B7` and `/7%0A` each served the protected record, and
|
|
31
|
-
* `DELETE /animals/007` destroyed it — because `parseInt` folds every one of
|
|
32
|
-
* those onto `7` while `'007' === '7'` is false.
|
|
33
|
-
*
|
|
34
|
-
* Two things follow from this being a single exported function, and both are
|
|
35
|
-
* the point:
|
|
36
|
-
*
|
|
37
|
-
* 1. A permissive change here is HARMLESS, because both sides move together.
|
|
38
|
-
* Lowercasing string ids used to disclose and destroy `owner:angela` with
|
|
39
|
-
* the whole suite green; with one implementation the predicate simply sees
|
|
40
|
-
* the same lowercased value and still refuses.
|
|
41
|
-
* 2. A divergence — a second, private normaliser at the resolution site — is
|
|
42
|
-
* what is now dangerous, and that is what the tests pin, by observing the
|
|
43
|
-
* key the resolution path actually used.
|
|
44
|
-
*
|
|
45
|
-
* SEMANTICS ARE UNCHANGED FROM `getId()`, deliberately and byte-for-byte.
|
|
46
|
-
* Whether `7.9` / `0x7` / `%0A` *should* resolve to record 7 at all is a real
|
|
47
|
-
* question, and it is a behaviour change on the record-resolution path for
|
|
48
|
-
* every consumer rather than an authorization fix — it belongs in its own
|
|
49
|
-
* issue with its own compatibility argument (issue body scope item 4). #270
|
|
50
|
-
* preserves today's semantics exactly and makes both sides agree on them.
|
|
51
|
-
*
|
|
52
|
-
* Two details are load-bearing and are pinned by
|
|
53
|
-
* test/unit/normalize-record-id-test.ts:
|
|
54
|
-
*
|
|
55
|
-
* - `parseInt` is called with NO RADIX. `parseInt('0x7')` is 7;
|
|
56
|
-
* `parseInt('0x7', 10)` is 0. Adding the radix looks like a cleanup and
|
|
57
|
-
* silently changes which record every hex-spelled URL addresses.
|
|
58
|
-
* - The coercion applies only when the id LOOKS numeric, so a model with
|
|
59
|
-
* string ids is passed through untouched, case included.
|
|
60
|
-
*
|
|
61
|
-
* Must stay synchronous: `auth()` is invoked without `await` by
|
|
62
|
-
* @stonyx/rest-server, so a promise here would be handed to `access()` as the
|
|
63
|
-
* record id.
|
|
64
|
-
*
|
|
65
|
-
* @param id the raw, already-URL-decoded id text from `request.params.id`
|
|
66
|
-
* @returns the value the ORM resolves the record by
|
|
67
|
-
*/
|
|
68
|
-
export default function normalizeRecordId(id) {
|
|
69
|
-
if (!id)
|
|
70
|
-
return '';
|
|
71
|
-
if (isNaN(id))
|
|
72
|
-
return id;
|
|
73
|
-
return parseInt(id);
|
|
74
|
-
}
|
|
@@ -1,74 +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 the same expression was written out seven times: twice in
|
|
22
|
-
* README.md, once in docs/usage-patterns.md, and four times inside the
|
|
23
|
-
* framework. The framework's copy — `getId()`, module-private in
|
|
24
|
-
* orm-request.ts, unreachable through the package `exports` map — was the one
|
|
25
|
-
* that decided which record a request addressed, and a consumer's `access()`
|
|
26
|
-
* predicate had no way to obtain it. So the framework resolved the record by
|
|
27
|
-
* one value and asked the consumer to authorize on a different one.
|
|
28
|
-
*
|
|
29
|
-
* Measured consequence at the time of filing: with the documented predicate
|
|
30
|
-
* applied verbatim to a numeric-id model, `GET /animals/007`, `/7.9`, `/7e0`,
|
|
31
|
-
* `/0x7`, `/%207`, `/%2B7` and `/7%0A` each served the protected record, and
|
|
32
|
-
* `DELETE /animals/007` destroyed it — because `parseInt` folds every one of
|
|
33
|
-
* those onto `7` while `'007' === '7'` is false.
|
|
34
|
-
*
|
|
35
|
-
* Two things follow from this being a single exported function, and both are
|
|
36
|
-
* the point:
|
|
37
|
-
*
|
|
38
|
-
* 1. A permissive change here is HARMLESS, because both sides move together.
|
|
39
|
-
* Lowercasing string ids used to disclose and destroy `owner:angela` with
|
|
40
|
-
* the whole suite green; with one implementation the predicate simply sees
|
|
41
|
-
* the same lowercased value and still refuses.
|
|
42
|
-
* 2. A divergence — a second, private normaliser at the resolution site — is
|
|
43
|
-
* what is now dangerous, and that is what the tests pin, by observing the
|
|
44
|
-
* key the resolution path actually used.
|
|
45
|
-
*
|
|
46
|
-
* SEMANTICS ARE UNCHANGED FROM `getId()`, deliberately and byte-for-byte.
|
|
47
|
-
* Whether `7.9` / `0x7` / `%0A` *should* resolve to record 7 at all is a real
|
|
48
|
-
* question, and it is a behaviour change on the record-resolution path for
|
|
49
|
-
* every consumer rather than an authorization fix — it belongs in its own
|
|
50
|
-
* issue with its own compatibility argument (issue body scope item 4). #270
|
|
51
|
-
* preserves today's semantics exactly and makes both sides agree on them.
|
|
52
|
-
*
|
|
53
|
-
* Two details are load-bearing and are pinned by
|
|
54
|
-
* test/unit/normalize-record-id-test.ts:
|
|
55
|
-
*
|
|
56
|
-
* - `parseInt` is called with NO RADIX. `parseInt('0x7')` is 7;
|
|
57
|
-
* `parseInt('0x7', 10)` is 0. Adding the radix looks like a cleanup and
|
|
58
|
-
* silently changes which record every hex-spelled URL addresses.
|
|
59
|
-
* - The coercion applies only when the id LOOKS numeric, so a model with
|
|
60
|
-
* string ids is passed through untouched, case included.
|
|
61
|
-
*
|
|
62
|
-
* Must stay synchronous: `auth()` is invoked without `await` by
|
|
63
|
-
* @stonyx/rest-server, so a promise here would be handed to `access()` as the
|
|
64
|
-
* record id.
|
|
65
|
-
*
|
|
66
|
-
* @param id the raw, already-URL-decoded id text from `request.params.id`
|
|
67
|
-
* @returns the value the ORM resolves the record by
|
|
68
|
-
*/
|
|
69
|
-
export default function normalizeRecordId(id?: string | null): string | number {
|
|
70
|
-
if (!id) return '';
|
|
71
|
-
if (isNaN(id as unknown as number)) return id;
|
|
72
|
-
|
|
73
|
-
return parseInt(id);
|
|
74
|
-
}
|