@stonyx/orm 0.3.2-alpha.93 → 0.3.2-alpha.95

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.
Files changed (2) hide show
  1. package/README.md +77 -3
  2. package/package.json +7 -5
package/README.md CHANGED
@@ -318,16 +318,90 @@ await setupRestServer('/', './access');
318
318
  Access classes define models and provide custom filtering/authorization logic:
319
319
 
320
320
  ```js
321
- export default class GlobalAccess {
322
- models = ['owner', 'animal'];
321
+ export default class OwnerAccess {
322
+ models = ['owner'];
323
323
 
324
324
  access(request) {
325
- if (request.url.endsWith('/owner/angela')) return false;
325
+ // `access` runs after route matching, so `request.params` is populated and
326
+ // `id` has already been URL-decoded. Authorize on it, never on a URL.
327
+ const { id } = request.params;
328
+
329
+ // `id` is still raw client text. Normalise it the way the record lookup
330
+ // does, or your predicate and the lookup disagree — see "Numeric ids" below.
331
+ // No radix on parseInt: that is deliberate, and it must stay that way.
332
+ const recordId = id === undefined ? undefined : (isNaN(id) ? id : parseInt(id));
333
+
334
+ // Returning false explicitly denies access to this record
335
+ if (recordId === 'angela') return false;
336
+
337
+ // No `id` means the collection route. Returning a function plugs it in to
338
+ // the response object as a filter. NOTE: a function return authorizes the
339
+ // request outright — the operations list below is not consulted — so this
340
+ // branch permits POST /owners as well as reads.
341
+ if (recordId === undefined) return record => record.id !== 'angela';
342
+
343
+ // Returning a list of operations allows full access to everything else
344
+ return ['read', 'create', 'update', 'delete'];
345
+ }
346
+ }
347
+ ```
348
+
349
+ **Do not authorize on the request URL.** `request.url` is rewritten relative to
350
+ the mount point, so inside the REST server it is `/angela`, not `/owners/angela`
351
+ — a suffix comparison against it never matches and the request falls through to
352
+ whatever the method returns next. `request.originalUrl` keeps the full path but
353
+ is still the raw text the client sent, so it varies with query strings
354
+ (`/owners?x=1`), trailing slashes (`/owners/angela/`), casing (`/OwNeRs/angela`,
355
+ which Express routes to the same handler) and percent-encoding
356
+ (`/owners/%61ngela`). Each of those is a plain address-bar request, and each one
357
+ slips past a URL predicate. `request.params.id` is identical for all of them.
358
+
359
+ **One access class per model when the rules are model-specific.** `access()`
360
+ receives only the request, and the request does not carry the model name in any
361
+ form that is safe to match on — `request.baseUrl` is the mount text as the
362
+ client spelled it (`/OWNERS`), not the model. A class may still list several
363
+ models in `models` when they share one rule.
364
+
365
+ **Numeric ids: normalise before you compare.** `request.params.id` is raw text
366
+ from the client. When it looks numeric the ORM coerces it — `isNaN(id) ? id :
367
+ parseInt(id)` — *before* it resolves the record, so `7`, `007`, `7.0`, `7.9`,
368
+ `7e0`, `0x7`, `+7`, `%207` (a leading space), `%09 7` (a tab) and `7%0A` (a
369
+ trailing newline) all address record `7`, while a `===` against the raw text
370
+ matches only the one spelling you wrote down. Every other spelling falls through
371
+ to whatever your method returns next — which, in the shape above, is a full CRUD
372
+ grant. All of them are plain address-bar requests.
373
+
374
+ Two details are load-bearing. `parseInt` is called with **no radix**, so `0x7`
375
+ is `7` and not `0`; writing `parseInt(id, 10)` in your predicate re-opens the
376
+ hex spelling. And the coercion applies only when the id looks numeric, so a
377
+ model with string ids (like `owner` above) is unaffected — which is exactly why
378
+ this is easy to miss. Normalise the same way the lookup does:
379
+
380
+ ```javascript
381
+ export default class AnimalAccess {
382
+ models = ['animal'];
383
+
384
+ access(request) {
385
+ const { id } = request.params;
386
+
387
+ // Agrees with the lookup for every spelling of 7 above. Compare the
388
+ // coerced value, which for a numeric-id model is a number, not a string.
389
+ const recordId = id === undefined ? undefined : (isNaN(id) ? id : parseInt(id));
390
+
391
+ if (recordId === 7) return false;
392
+
393
+ if (recordId === undefined) return record => record.id !== 7;
394
+
326
395
  return ['read', 'create', 'update', 'delete'];
327
396
  }
328
397
  }
329
398
  ```
330
399
 
400
+ The sample above is executed verbatim against a live server by
401
+ `test/integration/readme-access/`, which reads it out of this file: the request
402
+ `DELETE /owners/angela` is asserted to return 403 with the record intact, along
403
+ with each of the spellings named above.
404
+
331
405
  ### Upgrading: behaviour changes
332
406
 
333
407
  **Advertised `links.self` / `links.related` now carry the REST mount route.**
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-alpha.93",
7
+ "version": "0.3.2-alpha.95",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -61,8 +61,8 @@
61
61
  },
62
62
  "homepage": "https://github.com/abofs/stonyx-orm#readme",
63
63
  "dependencies": {
64
- "@stonyx/cron": "0.2.1-beta.95",
65
- "@stonyx/events": "0.1.1-beta.52",
64
+ "@stonyx/cron": "0.2.1-beta.98",
65
+ "@stonyx/events": "0.1.1-beta.54",
66
66
  "@stonyx/utils": "0.2.3-beta.26",
67
67
  "stonyx": "0.2.3-beta.81"
68
68
  },
@@ -91,7 +91,7 @@
91
91
  }
92
92
  },
93
93
  "devDependencies": {
94
- "@stonyx/rest-server": "0.2.1-beta.98",
94
+ "@stonyx/rest-server": "0.2.1-beta.100",
95
95
  "@types/node": "^25.6.0",
96
96
  "mysql2": "^3.20.0",
97
97
  "pg": "^8.20.0",
@@ -103,8 +103,10 @@
103
103
  "scripts": {
104
104
  "build": "tsc",
105
105
  "build:test": "tsc -p tsconfig.test.json",
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",
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
+ "test:readme": "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": "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'",
108
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'"
109
111
  }
110
112
  }