@stonyx/orm 0.3.2-beta.227 → 0.3.2-beta.229

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 +84 -3
  2. package/package.json +5 -3
package/README.md CHANGED
@@ -318,16 +318,97 @@ 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
+ and percent-encoding (`/owners/%61ngela`). Each of those is a plain address-bar
356
+ request. Which of them reach your handler at all depends on how the REST server
357
+ configures Express route matching — that is a deployment detail you should not
358
+ be building an authorization decision on top of. `request.params.id` is
359
+ identical for every spelling that does reach you.
360
+
361
+ **One access class per model when the rules are model-specific.** `access()`
362
+ receives only the request, and the request does not carry the model name
363
+ directly — `request.baseUrl` is the *mount text as the client spelled it*
364
+ (`/OWNERS`), so it must be case-normalised before it is compared, and it is
365
+ still the mount rather than the model. Deriving a model from it means every
366
+ unrecognised spelling falls through to whatever your method returns next, so
367
+ prefer one class per model. A class may still list several models in `models`
368
+ when they share one rule.
369
+
370
+ **Numeric ids: normalise before you compare.** `request.params.id` is raw text
371
+ from the client. When it looks numeric the ORM coerces it — `isNaN(id) ? id :
372
+ parseInt(id)` — *before* it resolves the record, so `7`, `007`, `7.0`, `7.9`,
373
+ `7e0`, `0x7`, `+7`, `%207` (a leading space), `%097` (a tab) and `7%0A` (a
374
+ trailing newline) all address record `7`, while a `===` against the raw text
375
+ matches only the one spelling you wrote down. Every other spelling falls through
376
+ to whatever your method returns next — which, in the shape above, is a full CRUD
377
+ grant. All of them are plain address-bar requests.
378
+
379
+ Two details are load-bearing. `parseInt` is called with **no radix**, so `0x7`
380
+ is `7` and not `0`; writing `parseInt(id, 10)` in your predicate re-opens the
381
+ hex spelling. And the coercion applies only when the id looks numeric, so a
382
+ model with string ids (like `owner` above) is unaffected — which is exactly why
383
+ this is easy to miss. Normalise the same way the lookup does:
384
+
385
+ ```javascript
386
+ export default class AnimalAccess {
387
+ models = ['animal'];
388
+
389
+ access(request) {
390
+ const { id } = request.params;
391
+
392
+ // Agrees with the lookup for every spelling of 7 above. Compare the
393
+ // coerced value, which for a numeric-id model is a number, not a string.
394
+ const recordId = id === undefined ? undefined : (isNaN(id) ? id : parseInt(id));
395
+
396
+ if (recordId === 7) return false;
397
+
398
+ if (recordId === undefined) return record => record.id !== 7;
399
+
326
400
  return ['read', 'create', 'update', 'delete'];
327
401
  }
328
402
  }
329
403
  ```
330
404
 
405
+ Both samples above are extracted from this file and executed verbatim against a
406
+ live server on every CI run — see
407
+ [`test/integration/readme-access/`](https://github.com/abofs/stonyx-orm/tree/dev/test/integration/readme-access).
408
+ `DELETE /owners/angela` is asserted to be refused with the record intact, and
409
+ every spelling named above is measured individually, the numeric ones over a raw
410
+ socket. Nothing in this section is prose that was never run.
411
+
331
412
  ### Upgrading: behaviour changes
332
413
 
333
414
  **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-beta.227",
7
+ "version": "0.3.2-beta.229",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -61,7 +61,7 @@
61
61
  },
62
62
  "homepage": "https://github.com/abofs/stonyx-orm#readme",
63
63
  "dependencies": {
64
- "@stonyx/cron": "0.2.1-beta.121",
64
+ "@stonyx/cron": "0.2.1-beta.122",
65
65
  "@stonyx/events": "0.1.1-beta.64",
66
66
  "@stonyx/utils": "0.2.3-beta.27",
67
67
  "stonyx": "0.2.3-beta.94"
@@ -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": "pnpm build && node --import tsx/esm --import ./test/integration/readme-access/setup.ts node_modules/qunit/bin/qunit.js 'test/integration/readme-access/readme-sample.ts' 'test/zz-exit-test.ts'",
109
+ "test:reference": "pnpm build && node --import tsx/esm --import ./test/integration/reference-access/setup.ts node_modules/qunit/bin/qunit.js 'test/integration/reference-access/reference-sample.ts' 'test/zz-exit-test.ts'",
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
  }