@stonyx/orm 0.3.2-alpha.6 → 0.3.2-alpha.60

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 (53) hide show
  1. package/README.md +580 -10
  2. package/config/{environment.ts → environment.js} +8 -0
  3. package/dist/commands.js +34 -0
  4. package/dist/dynamodb/connection.d.ts +31 -0
  5. package/dist/dynamodb/connection.js +28 -0
  6. package/dist/dynamodb/dynamodb-db.d.ts +142 -0
  7. package/dist/dynamodb/dynamodb-db.js +596 -0
  8. package/dist/dynamodb/operation-builder.d.ts +76 -0
  9. package/dist/dynamodb/operation-builder.js +116 -0
  10. package/dist/dynamodb/type-map.d.ts +31 -0
  11. package/dist/dynamodb/type-map.js +48 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/main.d.ts +116 -0
  14. package/dist/main.js +129 -0
  15. package/dist/manage-record.js +34 -3
  16. package/dist/mysql/connection.d.ts +1 -0
  17. package/dist/mysql/mysql-db.d.ts +8 -0
  18. package/dist/mysql/mysql-db.js +44 -10
  19. package/dist/orm-request.d.ts +181 -3
  20. package/dist/orm-request.js +794 -47
  21. package/dist/postgres/connection.d.ts +1 -0
  22. package/dist/postgres/connection.js +8 -6
  23. package/dist/postgres/postgres-db.d.ts +8 -0
  24. package/dist/postgres/postgres-db.js +44 -10
  25. package/dist/record.js +7 -5
  26. package/dist/relationships.js +1 -1
  27. package/dist/serializer.js +38 -2
  28. package/dist/setup-rest-server.js +51 -5
  29. package/dist/store.d.ts +13 -1
  30. package/dist/store.js +65 -6
  31. package/dist/types/orm-types.d.ts +112 -0
  32. package/package.json +16 -7
  33. package/src/commands.ts +43 -0
  34. package/src/dynamodb/connection.ts +50 -0
  35. package/src/dynamodb/dynamodb-db.ts +811 -0
  36. package/src/dynamodb/operation-builder.ts +202 -0
  37. package/src/dynamodb/type-map.ts +54 -0
  38. package/src/index.ts +1 -0
  39. package/src/main.ts +133 -0
  40. package/src/manage-record.ts +41 -9
  41. package/src/mysql/connection.ts +1 -0
  42. package/src/mysql/mysql-db.ts +44 -12
  43. package/src/orm-request.ts +809 -50
  44. package/src/postgres/connection.ts +10 -6
  45. package/src/postgres/postgres-db.ts +44 -12
  46. package/src/record.ts +8 -5
  47. package/src/relationships.ts +1 -1
  48. package/src/serializer.ts +39 -2
  49. package/src/setup-rest-server.ts +59 -6
  50. package/src/store.ts +68 -6
  51. package/src/types/orm-types.ts +118 -0
  52. package/src/types/stonyx-rest-server.d.ts +14 -1
  53. package/src/types/stonyx.d.ts +7 -1
package/README.md CHANGED
@@ -13,7 +13,7 @@ A lightweight ORM for Stonyx projects, featuring model definitions, serializers,
13
13
  - **Models**: Define attributes with type-safe proxies (`attr`) and relationships (`hasMany`, `belongsTo`).
14
14
  - **Serializers**: Map raw data into model-friendly structures, including nested properties.
15
15
  - **Transforms**: Apply custom transformations on data values automatically.
16
- - **DB Integration**: Optional file-based persistence with auto-save support, or MySQL for production workloads.
16
+ - **DB Integration**: Optional file-based persistence with auto-save support, or MySQL/PostgreSQL/TimescaleDB/DynamoDB for production workloads.
17
17
  - **REST Server Integration**: Automatic route setup with customizable access control.
18
18
  - **Lifecycle Hooks**: Middleware-based before/after hooks for validation, authorization, side effects, and auditing.
19
19
 
@@ -65,13 +65,16 @@ const {
65
65
  MYSQL_DATABASE,
66
66
  MYSQL_CONNECTION_LIMIT,
67
67
  MYSQL_MIGRATIONS_DIR,
68
+ DYNAMODB_REGION,
69
+ DYNAMODB_ENDPOINT,
70
+ DYNAMODB_TABLE_PREFIX,
68
71
  } = process.env;
69
72
 
70
73
  export default {
71
74
  orm: {
72
75
  logColor: 'white',
73
76
  logMethod: 'db',
74
-
77
+
75
78
  db: {
76
79
  autosave: DB_AUTO_SAVE ?? 'false',
77
80
  file: DB_FILE ?? 'db.json',
@@ -95,6 +98,12 @@ export default {
95
98
  connectionLimit: parseInt(MYSQL_CONNECTION_LIMIT ?? '10'),
96
99
  migrationsDir: MYSQL_MIGRATIONS_DIR ?? 'migrations',
97
100
  migrationsTable: '__migrations',
101
+ autoMigrate: AUTO_MIGRATE === 'true' ? true : AUTO_MIGRATE === 'false' ? false : undefined,
102
+ } : undefined,
103
+ dynamodb: DYNAMODB_REGION ? {
104
+ region: DYNAMODB_REGION,
105
+ endpoint: DYNAMODB_ENDPOINT, // optional, for DynamoDB Local
106
+ tablePrefix: DYNAMODB_TABLE_PREFIX, // optional table name prefix
98
107
  } : undefined,
99
108
  restServer: {
100
109
  enabled: ORM_USE_REST_SERVER ?? 'true',
@@ -243,6 +252,31 @@ Set the `MYSQL_HOST` environment variable to enable MySQL persistence. The ORM l
243
252
  | `stonyx db:migrate` | Apply pending migrations |
244
253
  | `stonyx db:migrate:rollback` | Rollback the most recent migration |
245
254
  | `stonyx db:migrate:status` | Show migration status |
255
+ | `stonyx db:sync` | Sync DynamoDB table definitions to match current model schemas |
256
+
257
+ ### DynamoDB Mode
258
+
259
+ Set the `DYNAMODB_REGION` environment variable to enable DynamoDB persistence. Tables are created with PAY_PER_REQUEST (on-demand) billing. Global Secondary Indexes (GSIs) are auto-provisioned at startup based on model `belongsTo` relationships — each FK column gets a GSI. `findAll()` with conditions routes to a GSI Query when the condition key matches a GSI partition key; non-indexed attribute conditions fall back to Scan + FilterExpression (expensive for large tables). ULID generation replaces auto-increment for numeric-ID models.
260
+
261
+ ```javascript
262
+ dynamodb: {
263
+ region: 'us-east-1',
264
+ endpoint: 'http://localhost:8000', // optional, for DynamoDB Local
265
+ tablePrefix: 'myapp-', // optional table name prefix
266
+ }
267
+ ```
268
+
269
+ Environment variables:
270
+
271
+ * `DYNAMODB_REGION`: AWS region for DynamoDB (e.g., `'us-east-1'`).
272
+ * `DYNAMODB_ENDPOINT`: Optional custom endpoint URL, useful for DynamoDB Local during development.
273
+ * `DYNAMODB_TABLE_PREFIX`: Optional prefix prepended to all table names (e.g., `'myapp-'` yields `'myapp-animals'`).
274
+
275
+ **Peer dependencies:** `@aws-sdk/client-dynamodb` and `@aws-sdk/lib-dynamodb` must be installed when using the DynamoDB driver. The AWS SDK is dynamically imported and only loaded when the DynamoDB driver is selected.
276
+
277
+ ```bash
278
+ npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
279
+ ```
246
280
 
247
281
  ### Running MySQL Tests
248
282
 
@@ -275,19 +309,521 @@ import setupRestServer from '@stonyx/orm/setup-rest-server';
275
309
  await setupRestServer('/', './access');
276
310
  ```
277
311
 
278
- Access classes define models and provide custom filtering/authorization logic:
312
+ Access classes define models and provide custom filtering/authorization logic.
313
+
314
+ > **Do not reconstruct the request path inside `access()`. Read
315
+ > [Identifying the collection](#identifying-the-collection) before copying this.**
316
+ > Every attempt to identify the collection by parsing the request target has
317
+ > failed **open** — five distinct variants of this same example, each found only
318
+ > after the previous was fixed, by five different people. The sample below does
319
+ > not parse anything: it reads `request.baseUrl`, the mount Express actually
320
+ > matched.
321
+ >
322
+ > That is still a stopgap. **The real fix is
323
+ > [#202](https://github.com/abofs/stonyx-orm/issues/202)** — `access()` should
324
+ > receive the model, the operation and the record, so there is nothing to
325
+ > identify. Until it lands, prefer the array shape (`['read']`) or `false` where
326
+ > you can: the **function** shape is the one that requires any matching at all.
327
+ > The same warning is repeated at the top of `src/orm-request.ts`, which ships;
328
+ > the longer write-up in `docs/usage-patterns.md` does **not** ship, so this
329
+ > README and that source header are the two copies a consumer sees.
330
+ >
331
+ > This sample is the same code as the shipped test fixture, and a test asserts
332
+ > the two `access()` bodies are identical line for line. For four rounds they
333
+ > were two independently written copies — and the fifth fail-open variant was
334
+ > found in the one nothing was mutating.
279
335
 
280
336
  ```js
281
337
  export default class GlobalAccess {
282
338
  models = ['owner', 'animal'];
283
339
 
284
340
  access(request) {
285
- if (request.url.endsWith('/owner/angela')) return false;
341
+ // `request.baseUrl` is the mount Express matched — `/owners`, or
342
+ // `/api/owners` under ORM_REST_ROUTE=/api. Never parse `originalUrl`: it is
343
+ // the raw request target and can be absolute-form.
344
+ const mount = request.baseUrl;
345
+
346
+ // FAIL CLOSED. If Express did not tell us what it matched we are not behind
347
+ // the mount we think we are, and an unidentifiable request denies rather
348
+ // than falling through to the CRUD grant at the bottom.
349
+ if (typeof mount !== 'string' || mount === '') return false;
350
+
351
+ // Lower-cased because the router matched case-INSENSITIVELY, and a matcher
352
+ // stricter than the router that dispatched the request can be stepped
353
+ // around. The PATH only — record ids stay at their real case below.
354
+ const collection = mount.toLowerCase();
355
+
356
+ // `request.path` is mount-relative and query-free, so sub-path rules need no
357
+ // prefix arithmetic either. false → 403 for the whole request.
358
+ const path = String(request.path ?? '').toLowerCase();
359
+
360
+ if (collection.endsWith('/owners')) {
361
+ if (path === '/archived' || path.startsWith('/archived/')) return false;
362
+
363
+ // Returning a function plugs it in as a per-record filter, and it is
364
+ // enforced on every surface addressed to one of these records:
365
+ // /owners, /owners/:id, /owners/:id/pets, /owners/:id/relationships/pets
366
+ // A rejected record is 404 on record routes — the same status as a record
367
+ // that does not exist — so the filter is not an existence oracle.
368
+ return record => record.id !== 'angela' && record.id !== 'restricted';
369
+ }
370
+
371
+ // `record.owner` resolves to an OrmRecord, not to the owner's id string —
372
+ // comparing it directly against a string is the bug that made this predicate
373
+ // inert. Deliberately NO `?? record.owner` fallback: accepting the raw
374
+ // shape as well as the resolved one would absorb a resolution regression
375
+ // silently, which is exactly what blinded this fixture before.
376
+ if (collection.endsWith('/animals')) return record => record.owner?.id !== 'restricted';
377
+
378
+ // Allows full access to all calls that don't match any of the above conditions
286
379
  return ['read', 'create', 'update', 'delete'];
287
380
  }
288
381
  }
289
382
  ```
290
383
 
384
+
385
+ ### The access context (second argument)
386
+
387
+ `access()` is called with **two** arguments:
388
+
389
+ ```js
390
+ access(request, { model, operation })
391
+ ```
392
+
393
+ The second is the **access context** — the structural facts about the request,
394
+ which the framework already holds at authorization time. Read these instead of
395
+ parsing anything.
396
+
397
+ | Key | Value |
398
+ |---|---|
399
+ | `model` | The model this route was mounted for, as a **model name**: kebab-case, exactly as declared under `config.orm.paths.model` and keyed in the store — `'owner'`, `'animal'`, `'phone-number'`. **Not** the pluralized, dasherized, mount-prefixed *route* name. |
400
+ | `operation` | One of **`'read'`, `'create'`, `'update'`, `'delete'`** — and no second vocabulary *on this path*. Never an HTTP method name like `'GET'`, and **not** the hook vocabulary either (see [below](#operation-is-not-the-hook-operation)). `undefined` when the dispatched method has no entry in the framework's method map. |
401
+
402
+ So a predicate can be written without reference to any URL:
403
+
404
+ ```js
405
+ export default class OwnerAccess {
406
+ models = ['owner'];
407
+
408
+ access(request, { model, operation }) {
409
+ if (model === 'owner' && operation === 'read') {
410
+ return record => record.id !== 'angela';
411
+ }
412
+
413
+ return ['read'];
414
+ }
415
+ }
416
+ ```
417
+
418
+ There is no string to parse, no variant to miss, and no way to fail open through
419
+ a URL shape nobody anticipated. `model` is fixed at mount time and no request
420
+ can influence it — not a mount prefix, not a query string, not a case-varied
421
+ path, not an absolute-form request target.
422
+
423
+ #### What the context does not tell you: which surface
424
+
425
+ It names **which model and which verb**, not **which route**. Measured over the
426
+ live router, six surfaces produce one identical context:
427
+
428
+ ```
429
+ GET /owners { model: 'owner', operation: 'read' }
430
+ GET /owners/gina { model: 'owner', operation: 'read' }
431
+ GET /owners/gina/pets { model: 'owner', operation: 'read' }
432
+ GET /owners/gina/relationships/pets { model: 'owner', operation: 'read' }
433
+ GET /owners/archived { model: 'owner', operation: 'read' }
434
+ GET /owners/gina?include=pets { model: 'owner', operation: 'read' }
435
+ ```
436
+
437
+ So a rule that depends on the **sub-path** still needs `request.path` —
438
+ mount-relative and query-free, and the one read of argument one that
439
+ [Identifying the collection](#identifying-the-collection) sanctions. The sample
440
+ access class shipped with this repo has such a rule: its `/archived` deny
441
+ **cannot be expressed from the context alone**, and a predicate migrated to
442
+ context-only would silently drop it — a deny becoming an allow.
443
+
444
+ Note also that the related-resource and `?include=` surfaces serve *another
445
+ model's* records under `model: 'owner'`, and the context gives a predicate no
446
+ signal that it is authorizing a related-resource route. That is
447
+ [#196](https://github.com/abofs/stonyx-orm/issues/196).
448
+
449
+ #### `operation` is not the hook `operation`
450
+
451
+ This module exposes a **second** `operation` vocabulary, on an identically-named
452
+ key of an identically-shaped context object:
453
+ [hook contexts](#hook-context-object) carry `list` / `get` / `create` /
454
+ `update` / `delete`. The access vocabulary collapses `list` and `get` into
455
+ `'read'`, so for one `GET /animals/1` a hook sees `'get'` while `access()` sees
456
+ `'read'` — and a predicate cannot distinguish a collection read from a
457
+ record read.
458
+
459
+ "No second vocabulary" above is a statement about the **access path**, where
460
+ both the context and the permission array come from one method map. It is not a
461
+ statement about the module. Writing `operation === 'get'` in a predicate never
462
+ matches, and a predicate that stops matching falls through to the permission
463
+ array — so the misreading is fail-open shaped. In TypeScript the exported
464
+ `AccessOperation` union makes it a compile error.
465
+
466
+ The four `operation` values are the same four strings the permission-array
467
+ return shape is written in (`['read', 'create', 'update', 'delete']`), because
468
+ both come from one method map inside the framework. The two forms cannot
469
+ disagree about the same request.
470
+
471
+ **`operation` is `undefined`, never defaulted, for an unmapped method.** Express
472
+ delivers `HEAD` to the `GET` handler, so this is reachable. It is deliberately
473
+ not defaulted to `'read'`: a fabricated operation would turn an unclassified
474
+ request into an authorized one. Treat `undefined` as *not classified* and deny.
475
+
476
+ **The second argument is additive.** JavaScript ignores extra arguments, so an
477
+ existing `access(request)` predicate keeps working exactly as it did. Nothing
478
+ needs to be migrated to keep running — but note that argument **one** is still
479
+ the raw request, so the warning in
480
+ [Identifying the collection](#identifying-the-collection) still applies to any
481
+ predicate that reads it.
482
+
483
+ #### `record` is not in the context
484
+
485
+ Deliberately, and it is not an oversight. `auth()` runs after route matching but
486
+ **before any handler executes**, so nothing has been fetched yet. Supplying a
487
+ record would force a pre-fetch on every request — a second store hit, a new
488
+ failure mode, and an ordering change in the middle of an authorization path.
489
+
490
+ It is also unnecessary: the **function** return shape already *is* the
491
+ per-record hook. Return `(record) => boolean` and the handlers apply it to every
492
+ record the request touches. Auth-time and record-time are separate decision
493
+ points, and the contract keeps them separate.
494
+
495
+ #### Reaching another model's predicate
496
+
497
+ The model → predicate map is published on the ORM instance at boot, before any
498
+ route is mounted, so a predicate can be resolved by model name and asked about a
499
+ request routed to a *different* model:
500
+
501
+ ```js
502
+ import Orm from '@stonyx/orm';
503
+
504
+ const predicate = Orm.instance.getAccess('animal');
505
+ if (!predicate) return deny;
506
+
507
+ const verdict = predicate(request, { model: 'animal', operation: 'read' });
508
+ ```
509
+
510
+ **`undefined` means no predicate could be resolved — not that the model is
511
+ unrestricted. Treat it as deny.** It covers a model with no access class *and* a
512
+ model whose access class failed to **load**: a load failure is caught and warned
513
+ about, and the partial map is published anyway, so a missing key is not evidence
514
+ of an unrestricted model. This is the same rule as `operation === undefined`
515
+ above, and for the same reason.
516
+
517
+ The raw map is `Orm.instance.accessFunctions`, keyed by model name; prefer
518
+ `getAccess()` — it is guarded against inherited `Object.prototype` members and a
519
+ direct index is not. Note that it maps a model name to the predicate of the
520
+ access *class* that claims it, which may claim many models: against this repo's
521
+ sample, `getAccess('owner') === getAccess('animal')`.
522
+
523
+ #### Passing the context makes a model-correct answer *possible*
524
+
525
+ It does not make the answer model-correct on its own. **The resolved predicate
526
+ has to read the context.** Measured against the access class shipped with this
527
+ repo, on a request Express dispatched to `GET /owners/angela`, asked about
528
+ **animals**:
529
+
530
+ ```
531
+ getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
532
+ -> record => record.id !== 'angela' && record.id !== 'restricted'
533
+ ```
534
+
535
+ That is the **owners** filter, and it returns `true` for animal 21 — the record
536
+ hidden on every animal surface. Under a mount that predicate recognizes neither
537
+ way it is worse still: it falls through to
538
+ `['read', 'create', 'update', 'delete']`, a full CRUD grant.
539
+
540
+ Either way the context was supplied and the answer is not the animal answer, and
541
+ it is wrong in the direction that **grants**. That predicate is single-argument
542
+ and identifies its collection from the request, so it answered about the
543
+ collection the request is *addressed to* while being asked about another one.
544
+ Every predicate in this repo, and in every consumer tree, is single-argument on
545
+ the day this ships, and a caller has no supported way to tell which kind it
546
+ resolved. The boot-time arity warning that would surface it is
547
+ [#213](https://github.com/abofs/stonyx-orm/issues/213).
548
+
549
+ So: pass the context, and do not treat a resolved predicate's answer as
550
+ model-specific until that predicate has been migrated to read it.
551
+
552
+ ### Return values
553
+
554
+ | `access()` returns | Effect |
555
+ |---|---|
556
+ | `false` (or any falsy value) | `403` for the whole request |
557
+ | `true` | full access, no filter |
558
+ | `['read', 'create', 'update', 'delete']` | the listed operations only; anything else is `403` |
559
+ | `'read'` (a bare string) | **one** permission, equivalent to `['read']` |
560
+ | a function | a per-record filter — see below |
561
+ | anything else | `403` — unknown shapes fail **closed** |
562
+
563
+ A `throw` inside `access()` is a **denial**, not a 500.
564
+
565
+ ### Filter functions
566
+
567
+ A function return value is a **per-record predicate**, and it is enforced on
568
+ every endpoint that is addressed to a record — not only on the collection.
569
+
570
+ It is evaluated against the record the route is *addressed to*, **on that model
571
+ only**. It is not a guarantee that a hidden record cannot be reached or modified:
572
+ a write to a *different* collection can still re-parent one. See
573
+ [Known limitations](#known-limitations) and
574
+ [#207](https://github.com/abofs/stonyx-orm/issues/207).
575
+
576
+ | Endpoint | A record the predicate rejects |
577
+ |---|---|
578
+ | `GET /:models` | omitted from the collection |
579
+ | `GET /:models/:id` | `404` |
580
+ | `GET /:models/:id/{relationship}` | `404` — the **addressed** record is filtered, not the related one |
581
+ | `GET /:models/:id/relationships/{relationship}` | `404` — same |
582
+ | `PATCH /:models/:id` | `404`, no attribute is applied |
583
+ | `DELETE /:models/:id` | `404`, the record is not removed and no SQL `DELETE` is issued |
584
+ | `POST /:models` | `403`, and a record **this request inserted** is rolled back — see [Known limitations](#known-limitations) for the case where it did not insert one |
585
+
586
+ **Denied record-level requests return 404, not 403.** This is deliberate and it
587
+ is the property most easily "improved" away. 403 would confirm that the record
588
+ exists to a caller who is not allowed to know that, which turns the filter into
589
+ an existence oracle: `404` means "no such record", `403` means "there is one and
590
+ it is not yours". Every status on a record route must therefore be identical for
591
+ "filtered out" and "does not exist" — including `DELETE`, which is why deleting
592
+ a record that never existed also returns 404 rather than 204.
593
+
594
+ `POST` is the one exception and returns **403**, because 404 on a mounted
595
+ collection route is indistinguishable from "model not mounted" — a genuinely
596
+ different failure a developer needs to diagnose.
597
+
598
+ **A client-supplied `id` on `POST` is refused with `403` whenever a function
599
+ filter is in force.** This is the part that keeps `POST` from being an
600
+ enumeration oracle, and it is worth understanding rather than working around.
601
+ The duplicate-id check has to run before the filter, and it sees records the
602
+ filter hides, so the *status* of a `POST` otherwise leaks whether an id is
603
+ taken:
604
+
605
+ | `POST /animals` with a payload the caller may create | before | now |
606
+ |---|---|---|
607
+ | an id held by a record the filter **hides** | `403` | `403` |
608
+ | an id that is **free** | `200` | `403` |
609
+ | an id held by a record the caller **can see** | `409` | `403` |
610
+
611
+ Three outcomes, one request per id, the whole id space. Filtering only the
612
+ *collision* status narrows that to callers who cannot create a record they are
613
+ allowed to see; it does not close it. It cannot be closed while a caller both
614
+ chooses the id and learns whether the create succeeded — so under a filter the
615
+ caller does not choose the id. The refusal happens before any store lookup, so
616
+ neither the status nor the response time depends on whether the id exists.
617
+
618
+ Let the server assign the id and read it back from the response. Callers with no
619
+ function-style filter are unaffected: `409` on a duplicate id and `200` on a free
620
+ one both behave exactly as before.
621
+
622
+ ### Identifying the collection
623
+
624
+ **Do not reconstruct the request path.** Every version of this sample that tried
625
+ to has failed **open**, and each variant was found only after the previous one
626
+ was fixed:
627
+
628
+ | # | Variant | Why it fails open |
629
+ |---|---|---|
630
+ | 1 | match `request.url` | `RestServer.mountRoute` mounts each model as an Express **sub-app**, so `url` is mount-relative — `GET /owners/angela` arrives as `/angela`. A `/owners` prefix match is **always false**, so the branch never fires and `access()` falls through to whatever it returns last. |
631
+ | 2 | anchored match on a raw `request.originalUrl` | `originalUrl` carries the query string, so `=== '/owners'` misses `/owners?filter[age]=30` and that collection comes back unfiltered. `endsWith('/owners')` is the older half of the same trap: it leaves every record route unguarded. |
632
+ | 3 | case-sensitive matcher | `RestServer` mounts with a bare `express()`, whose default is `caseSensitive: false`. A matcher stricter than the router that dispatched the request can simply be stepped around: `GET /owners/angela` → 404 but `GET /OwNeRs/angela` → 200 in full, and `DELETE /ANIMALS/22` destroyed a hidden record. Router-side fix: [stonyx-rest-server#47](https://github.com/abofs/stonyx-rest-server/issues/47). |
633
+ | 4 | hard-coded `/owners` | With `ORM_REST_ROUTE=/api` every url becomes `/api/owners/...` and the sample matches nothing — environment-specifically, which is harder to notice than failing everywhere. The remediation this document used to give was itself broken: `` `${config.orm.restServer.route}owners` `` evaluates to **`/apiowners`**, so a reader who followed the correction exactly still failed open and believed they had handled it. |
634
+ | 5 | any match on `originalUrl` at all | HTTP/1.1 permits an **absolute-form** request-target. Express routes on `parseurl(req).pathname`, so the request dispatches normally — but `originalUrl` is the raw target. `GET http://anything.example/owners/angela` yields `originalUrl === 'http://anything.example/owners/angela'`, which has no `/owners` prefix. Measured: the record came back in full, `DELETE` succeeded, and it walked past a hard `return false` deny the same way. |
635
+
636
+ **The fix is not a sixth rule.** It is to stop parsing:
637
+
638
+ **Use `request.baseUrl`.** It is the mount Express *actually matched* when it
639
+ dispatched the request. It carries no query string (variant 2), it is not
640
+ mount-relative (variant 1), it already contains the configured `ORM_REST_ROUTE`
641
+ prefix (variant 4 — there is nothing left to derive, so `/apiowners` is
642
+ unconstructible), and it is unaffected by an absolute-form target (variant 5).
643
+
644
+ | request | `request.url` | `request.originalUrl` | `request.baseUrl` | `request.path` |
645
+ |---|---|---|---|---|
646
+ | `GET /owners` | `/` | `/owners` | `/owners` | `/` |
647
+ | `GET /owners/angela` | `/angela` | `/owners/angela` | `/owners` | `/angela` |
648
+ | `GET /owners/angela?filter[age]=30` | `/angela?filter[age]=30` | `/owners/angela?filter[age]=30` | `/owners` | `/angela` |
649
+ | `GET /OwNeRs/angela` | `/angela` | `/OwNeRs/angela` | `/OwNeRs` | `/angela` |
650
+ | `GET http://anything.example/owners/angela` | `http://anything.example/angela` | `http://anything.example/owners/angela` | `/owners` | `/angela` |
651
+ | `GET /api/animals/22` (`ORM_REST_ROUTE=/api`) | `/22` | `/api/animals/22` | `/api/animals` | `/22` |
652
+
653
+ Two rules remain, and they are the whole list:
654
+
655
+ **1. Compare lower-cased.** `baseUrl` is the text the caller sent, not the
656
+ registered mount — `GET /OwNeRs/angela` yields `/OwNeRs`. The router matched it
657
+ case-insensitively, so a case-sensitive comparison here is stricter than the
658
+ router and can be walked past. Lower-case the **mount and path only**; record ids
659
+ are case-sensitive and must be compared at their real case.
660
+
661
+ **2. Fail closed when `baseUrl` is absent.** `String(request.originalUrl ?? '')`
662
+ was added to stop a `TypeError`, and it traded fail-closed for fail-**open**: an
663
+ empty string matches no collection, so `access()` fell through to the permission
664
+ array and granted full CRUD. An input you cannot identify must **deny**.
665
+
666
+ Use `request.path` — mount-relative and query-free — if you need to distinguish
667
+ sub-paths beneath the mount, as the `/archived` deny above does.
668
+
669
+ ### Known limitations
670
+
671
+ - **A function-style filter is not a guarantee that a hidden record cannot be
672
+ modified.** A write to a *different* collection can re-parent one and de-hide
673
+ it: `POST /owners` (or `PATCH /owners/{id}`) carrying
674
+ `relationships: { pets: { data: { id: 21 } } }` — or
675
+ `attributes: { pets: [21, 22] } `, which never enters the relationships loop at
676
+ all — re-parents animal 21 onto an owner the caller is permitted to write. The
677
+ animal's `owner` is the field the `/animals` predicate reads, so the record
678
+ stops being rejected: it becomes readable through `GET /animals/21` and
679
+ deletable through `DELETE /animals/21`. **Reachable unauthenticated** wherever
680
+ one collection is writable and another is filtered on a field the first can
681
+ set. Blocking it requires checking animal 21 against the **animal** model's
682
+ predicate while servicing an **owners** route — cross-model access resolution,
683
+ which the contract could not express before
684
+ [#202](https://github.com/abofs/stonyx-orm/issues/202): `access()` never
685
+ received the model structurally and `setup-rest-server.ts` discarded the
686
+ model→predicate map at boot. **#202 has landed and both halves now exist** —
687
+ see [The access context](#the-access-context-second-argument):
688
+ `Orm.instance.getAccess(modelName)` makes another model's predicate
689
+ **reachable**, and `context.model` makes a **model-correct answer possible** —
690
+ possible, not guaranteed: the resolved predicate has to read the context, and
691
+ every predicate in tree is still single-argument
692
+ ([#213](https://github.com/abofs/stonyx-orm/issues/213)), so today it answers
693
+ about the collection the request is addressed to. **The mechanism exists; the
694
+ ORM does not yet use it on this path.** The re-parenting write above is still
695
+ **not refused** — that enforcement is
696
+ [#196](https://github.com/abofs/stonyx-orm/issues/196) and
697
+ [#207](https://github.com/abofs/stonyx-orm/issues/207), which were blocked on
698
+ #202 and are now free to proceed. Until they land, do not rely on a filter to
699
+ keep a record unmodifiable; keep the *writable* collections' predicates as tight as
700
+ the hidden ones.
701
+ - **Authorization by identifying the collection is a consumer-side
702
+ reconstruction of information the framework already holds.** `access()`
703
+ receives a transport artifact and is asked to work out which model, which
704
+ operation and which record the request addresses. The five variants above are
705
+ the five ways that has been observed to fail open so far. Tracked as
706
+ [#202](https://github.com/abofs/stonyx-orm/issues/202).
707
+ - **Related and included records are not filtered.** The predicate is evaluated
708
+ against the record the route is *addressed to*. `GET /animals/1/owner`,
709
+ `GET /animals/1/relationships/owner` and `?include=owner` all serialize the
710
+ related record without resolving that model's own access class, so a filter on
711
+ `/owners` does not hide an owner reached through `/animals`. Tracked as
712
+ [#196](https://github.com/abofs/stonyx-orm/issues/196), which covers
713
+ `include=`, related-resource routes and relationship-linkage routes.
714
+ - **A before-hook that returns a value short-circuits the request.** On write
715
+ operations addressed to a record the filter is consulted first, so a hook
716
+ cannot answer for a record the caller may not see. On reads it is not, so a
717
+ `beforeHook('get', ...)` read-through cache can answer past the filter.
718
+ - **`beforeHook('create', ...)` still runs for a `POST` that is denied.** For
719
+ `create` there is no record to test until the handler has built one, so the
720
+ denial is not knowable in time. Every *after*-hook is gated, and every
721
+ before-hook on `update` and `delete` is gated; before-`create` is the one
722
+ exception. A before-`create` hook must not assume the create will succeed.
723
+ - **A caller can still learn that a collection *has* a per-record filter**, by
724
+ observing `403` rather than `409`/`200` for an id-bearing `POST`. That
725
+ discloses a configuration fact, not the existence of any record.
726
+ - **Enforcement is post-fetch.** The record is loaded and then tested, which
727
+ leaves a small timing difference between a hidden record and one that never
728
+ existed. Tracked as [#197](https://github.com/abofs/stonyx-orm/issues/197).
729
+ - **A `relationships` key that is not a declared relationship is still applied
730
+ to the record.** The key comes verbatim from the request body and is checked
731
+ against nothing except `id`, which is stripped. On a `POST` that makes an
732
+ undeclared key a mass-assigned attribute; on a `PATCH` it is passed to
733
+ `updateRecord`. `id` is stripped in both handlers because it defeats breaking
734
+ change 3 above; the general form is tracked as
735
+ [#204](https://github.com/abofs/stonyx-orm/issues/204).
736
+ - **A `POST` body `id` that the duplicate check cannot resolve can still
737
+ overwrite a different record on an unfiltered collection.** The lookup is
738
+ correct and deliberately does not coerce: `"9105h"` is rejected as a string
739
+ rather than truncated, and `9105.5`, `[9105]` and `true` are passed through as
740
+ themselves. The model's id transform then coerces anyway — a bare `parseInt`
741
+ with no such guard — so the create lands on `9105` (or on `NaN`) and
742
+ overwrites whatever is there. **This is not string-only**: any body id whose
743
+ transform output differs from its lookup key is the same defect. Filtered
744
+ collections are unaffected — breaking change 3 refuses any client-supplied id
745
+ — so this reaches consumers with **no** function-style filter. Tracked as
746
+ [#205](https://github.com/abofs/stonyx-orm/issues/205), alongside
747
+ [#203](https://github.com/abofs/stonyx-orm/issues/203), which is the other way
748
+ a create can land on an id nobody named.
749
+ - **`context.record` is `undefined` for an after-`create` hook when a string-id
750
+ model is given a numeric-looking id.** The post-create lookup uses the same id
751
+ coercion as every other surface, which resolves `'9107'` to the number `9107`,
752
+ while a model declaring `id = attr('string')` files the record under the string
753
+ key. The create itself succeeds and `context.response.data` is correct; only
754
+ the hook's view of the record is wrong, and it is wrong *silently*. Tracked as
755
+ [#209](https://github.com/abofs/stonyx-orm/issues/209).
756
+ - **A denied `POST` rolls back only a record it *inserted*.** The rollback
757
+ requires the store to have grown, because removing by id alone is a write
758
+ primitive keyed by a caller-supplied value. When `assignRecordId` lands a
759
+ **server-assigned** id on an occupied slot
760
+ ([#203](https://github.com/abofs/stonyx-orm/issues/203) — it returns
761
+ last-*inserted* + 1, not max + 1, so a store whose insertion order is not
762
+ ascending collides), `createRecord` updates that record **in place**: the map
763
+ does not grow, the rollback correctly declines to remove a record this request
764
+ did not create, and the `403` leaves the caller's attributes on someone else's
765
+ record. Narrow — it needs a non-ascending insertion order — but it is the
766
+ reachability condition, so it is stated rather than implied.
767
+
768
+ ### Breaking changes
769
+
770
+ These land in the next published build. There is no changelog or release-notes channel yet
771
+ ([stonyx-workflows#17](https://github.com/abofs/stonyx-workflows/issues/17)), so
772
+ they are recorded here.
773
+
774
+ 1. **`DELETE /{collection}/{id}` on a record that does not exist returns `404`
775
+ instead of `204`.** This affects **every** consumer issuing a DELETE against
776
+ any mounted collection, whether or not an access filter is configured, and
777
+ `models: '*'` mounts every model by default. It is not optional: if a denied
778
+ delete returned 404 while a missing one returned 204, the pair would be a
779
+ perfect existence oracle and the filter would be worthless.
780
+ 2. **After-hooks no longer fire for a request that failed** — denied, missing,
781
+ `400` or `409`. The gate is on the handler's status, not on the operation, so
782
+ it covers reads as well: a `GET /:id` that answers 404 runs no after-`get`
783
+ hook either. Previously `afterHook('delete', ...)` ran with a populated
784
+ `context.recordId` on a request that deleted nothing, so a consumer cascade
785
+ destroyed children behind a 404.
786
+ 3. **`POST` with a client-supplied `id` returns `403` when a function-style
787
+ `access` filter is in force**, whatever the payload and whether or not the id
788
+ exists, and *before* any store lookup — so neither the status nor the lookup
789
+ cost can depend on whether that id exists. Only affects function-style
790
+ `access` users. See [Filter functions](#filter-functions) for why, and let the
791
+ server assign the id instead. `409`-on-duplicate is unchanged for everyone
792
+ else.
793
+
794
+ "Whatever the payload" is a statement about the **`id` member of the resource
795
+ object**, and it holds only because that is the sole channel a caller id can
796
+ arrive on. It was not always: a caller id moved into
797
+ `relationships: {"id": {"data": {"id": 21}}}` reached `createRecord` past this
798
+ refusal and overwrote a hidden record in place. Both strips — `attributes.id`
799
+ and `relationships.id` — are part of this behaviour, not tidiness. A future
800
+ change that adds a third channel without stripping it re-opens the oracle;
801
+ see [#204](https://github.com/abofs/stonyx-orm/issues/204).
802
+ 4. **Function-style `access` is now enforced on all seven surfaces.** Records
803
+ previously reachable by id despite being filtered from the collection now
804
+ return 404. Only affects function-style `access` users, for whom the old
805
+ behaviour was the bypass.
806
+
807
+ "Seven surfaces" means the seven endpoints of **the filtered model**. A write
808
+ to another collection can still reach one of its records through a
809
+ relationship — [#207](https://github.com/abofs/stonyx-orm/issues/207), which
810
+ is **not** closed here.
811
+ 5. **A predicate that throws is treated as a denial** rather than propagating to
812
+ Express's default 500 handler. So is an `access()` that throws.
813
+ 6. **`access()` returning a bare string is one permission, not full access.**
814
+ `AccessMethod` declares `string` legal, and it previously fell through every
815
+ branch and granted all four operations — `return 'read'` allowed `DELETE`.
816
+ It is now equivalent to `['read']`. Any other unrecognised shape (an object,
817
+ a number) now returns `403` rather than granting full access.
818
+ 7. **`POST` with a client-supplied `id` normalises the body id before the
819
+ duplicate check**, so an id shape that previously *missed* the store's key
820
+ now finds it. `POST {"id":"0x2391"}` and `POST {"id":" 21 "}` answer `409`
821
+ where they answered `200`, and the `200` was not a success: the lookup missed,
822
+ the duplicate check was skipped, and `createRecord` overwrote the colliding
823
+ record in place. **This one reaches consumers with no filter at all** — the
824
+ population breaking changes 3 and 4 explicitly exempt. If you were relying on
825
+ a hex-shaped or whitespace-padded id creating a second record, it never did.
826
+
291
827
  ### Include Parameter (Sideloading Relationships)
292
828
 
293
829
  The ORM supports JSON API-compliant relationship sideloading via the `include` query parameter. This reduces the need for multiple API requests by embedding related records in a single response.
@@ -561,7 +1097,19 @@ afterHook('delete', 'animal', async (context) => {
561
1097
  // Additional access control - halt with 403 if unauthorized
562
1098
  beforeHook('delete', 'animal', (context) => {
563
1099
  const user = context.state.currentUser;
564
- const animal = store.get('animal', context.params.id);
1100
+
1101
+ // `context.oldState` — NOT a store lookup. For `update` and `delete` the ORM
1102
+ // has already fetched the record (and already applied the access filter to
1103
+ // it) before this hook runs, so re-fetching it here is a fourth id coercion
1104
+ // that has to agree with three others.
1105
+ //
1106
+ // And it would not agree. `context.params.id` is the raw url segment, always
1107
+ // a string, while the store keys numeric-id models by NUMBER — so
1108
+ // `store.get('animal', '21')` misses the record held under `21`, and so does
1109
+ // `store.find('animal', '21')`: neither coerces. The miss reads as "no such
1110
+ // record", which in an authorization hook fails whichever way your code
1111
+ // happens to handle a null.
1112
+ const animal = context.oldState;
565
1113
 
566
1114
  if (animal.owner !== user.id && !user.isAdmin) {
567
1115
  return 403; // Forbidden
@@ -569,6 +1117,10 @@ beforeHook('delete', 'animal', (context) => {
569
1117
  });
570
1118
  ```
571
1119
 
1120
+ > If you do need a lookup for some *other* model inside a hook, coerce the id
1121
+ > yourself to the type that model's `id` attribute declares — the store is a
1122
+ > `Map` and `'21'` and `21` are different keys.
1123
+
572
1124
  #### Auditing
573
1125
 
574
1126
  ```javascript
@@ -702,11 +1254,29 @@ beforeHook('create', 'post', (context) => {
702
1254
 
703
1255
  ### Hook Execution Order
704
1256
 
705
- 1. **Before hooks** fire first (sequentially, in registration order)
706
- 2. **Main operation** executes (if no before hook halted)
707
- 3. **After hooks** fire last (sequentially, in registration order)
708
-
709
- Before hooks can halt the operation by returning a value. After hooks run after completion and cannot halt.
1257
+ 1. **Authorization is evaluated first for `update` and `delete`.** A record the
1258
+ access filter rejects returns `404` **before any before-hook runs**, so a
1259
+ hook never sees a record or a `context.oldState` — that the caller is not
1260
+ allowed to read. `create` is the exception: there is no record to test until
1261
+ the handler has built one, so `beforeHook('create', ...)` **does** fire for a
1262
+ `POST` that goes on to answer `403`.
1263
+ 2. **Before hooks** fire next (sequentially, in registration order).
1264
+ 3. **Main operation** executes (if no before hook halted).
1265
+ 4. **After hooks** fire last (sequentially, in registration order) — **only if
1266
+ the request succeeded.**
1267
+
1268
+ Before hooks can halt the operation by returning a value, and that value becomes
1269
+ the response.
1270
+
1271
+ **After hooks do not run for a failed request.** Any status `>= 400` — denied,
1272
+ missing, `400`, `409` — skips the entire after-hook pipeline, along with SQL
1273
+ persistence and `onUpdate` autosave. This is a behaviour change; see
1274
+ [Breaking changes](#breaking-changes). It applies to the samples above: the
1275
+ `afterHook('delete', ...)` auditing hook writes **no** audit row for a `DELETE`
1276
+ that answered `404`, and the `afterHook('update', ...)` change-tracking hook
1277
+ writes none for a `PATCH` that answered `404` or `400`. If you need a record of
1278
+ refused requests, log them from a before-hook or from your own middleware —
1279
+ `after<operation>` fires only for an operation that actually happened.
710
1280
 
711
1281
  ### Best Practices
712
1282