@stonyx/orm 0.3.2-beta.16 → 0.3.2-beta.160
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 +1409 -11
- package/config/environment.js +99 -12
- package/dist/access-verdict.d.ts +85 -0
- package/dist/access-verdict.js +284 -0
- package/dist/commands.js +34 -0
- package/dist/dynamodb/connection.d.ts +31 -0
- package/dist/dynamodb/connection.js +28 -0
- package/dist/dynamodb/dynamodb-db.d.ts +142 -0
- package/dist/dynamodb/dynamodb-db.js +596 -0
- package/dist/dynamodb/operation-builder.d.ts +76 -0
- package/dist/dynamodb/operation-builder.js +116 -0
- package/dist/dynamodb/type-map.d.ts +31 -0
- package/dist/dynamodb/type-map.js +48 -0
- package/dist/hooks.d.ts +15 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +8 -0
- package/dist/main.d.ts +116 -0
- package/dist/main.js +129 -0
- package/dist/manage-record.js +268 -12
- package/dist/mysql/connection.d.ts +1 -0
- package/dist/mysql/mysql-db.d.ts +8 -0
- package/dist/mysql/mysql-db.js +44 -10
- package/dist/orm-request.d.ts +274 -3
- package/dist/orm-request.js +1259 -65
- package/dist/postgres/connection.d.ts +1 -0
- package/dist/postgres/connection.js +8 -6
- package/dist/postgres/postgres-db.d.ts +8 -0
- package/dist/postgres/postgres-db.js +44 -10
- package/dist/record.d.ts +16 -0
- package/dist/record.js +154 -6
- package/dist/relationships.js +1 -1
- package/dist/serializer.js +38 -2
- package/dist/setup-rest-server.js +51 -5
- package/dist/standalone-db.js +17 -5
- package/dist/store.d.ts +13 -1
- package/dist/store.js +65 -6
- package/dist/types/orm-types.d.ts +260 -0
- package/dist/utils.d.ts +44 -0
- package/dist/utils.js +47 -0
- package/package.json +16 -7
- package/src/access-verdict.ts +312 -0
- package/src/commands.ts +43 -0
- package/src/dynamodb/connection.ts +50 -0
- package/src/dynamodb/dynamodb-db.ts +811 -0
- package/src/dynamodb/operation-builder.ts +202 -0
- package/src/dynamodb/type-map.ts +54 -0
- package/src/hooks.ts +15 -1
- package/src/index.ts +10 -0
- package/src/main.ts +133 -0
- package/src/manage-record.ts +294 -18
- package/src/mysql/connection.ts +1 -0
- package/src/mysql/mysql-db.ts +44 -12
- package/src/orm-request.ts +1281 -67
- package/src/postgres/connection.ts +10 -6
- package/src/postgres/postgres-db.ts +44 -12
- package/src/record.ts +182 -6
- package/src/relationships.ts +1 -1
- package/src/serializer.ts +39 -2
- package/src/setup-rest-server.ts +59 -6
- package/src/standalone-db.ts +17 -6
- package/src/store.ts +68 -6
- package/src/types/orm-types.ts +268 -1
- package/src/types/stonyx-rest-server.d.ts +14 -1
- package/src/types/stonyx.d.ts +7 -1
- package/src/utils.ts +50 -0
- package/config/environment.ts +0 -91
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,1330 @@ 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. That section is now a
|
|
319
|
+
> record of what not to do, not a matching recipe: the sample below reads `model`
|
|
320
|
+
> from [the access context](#the-access-context-second-argument) and never looks
|
|
321
|
+
> at the mount at all, so variants 1, 2, 4 and 5 are **unconstructible** against
|
|
322
|
+
> it rather than merely handled. **Variant 3 survives.** It is the general shape
|
|
323
|
+
> "a hand-written matcher normalises differently from the router", and the
|
|
324
|
+
> migrated sample still runs one string comparison — the `/archived` sub-path
|
|
325
|
+
> deny — which folds case but does not decode, so `GET /owners/%61rchived` steps
|
|
326
|
+
> past it ([#228](https://github.com/abofs/stonyx-orm/issues/228)).
|
|
327
|
+
>
|
|
328
|
+
> **Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
|
|
329
|
+
> [#237](https://github.com/abofs/stonyx-orm/issues/237), and left standing rather
|
|
330
|
+
> than rewritten.** Both claims above are now false: `#228` is **closed**, and the
|
|
331
|
+
> one string comparison variant 3 lived in is gone — the sample below compares the
|
|
332
|
+
> **decoded `recordId`** the access context supplies, so there is no comparison
|
|
333
|
+
> left to step around. The paragraph about `request.path` further down is
|
|
334
|
+
> superseded the same way. Nothing here is deleted because the same "variant 3
|
|
335
|
+
> survives" wording sits at four sites (this file twice, `src/orm-request.ts`, and
|
|
336
|
+
> the test fixture) and retiring one of four leaves the shipped copies
|
|
337
|
+
> contradicting each other; retiring all four **with the measurement that retires
|
|
338
|
+
> them** is [#238](https://github.com/abofs/stonyx-orm/issues/238), which also owns
|
|
339
|
+
> this blockquote and the reference section below.
|
|
340
|
+
>
|
|
341
|
+
> That is still a stopgap. **The real fix is
|
|
342
|
+
> [#202](https://github.com/abofs/stonyx-orm/issues/202)** — `access()` should
|
|
343
|
+
> receive the model, the operation and the record, so there is nothing to
|
|
344
|
+
> identify. Until it lands, prefer the array shape (`['read']`) or `false` where
|
|
345
|
+
> you can: the **function** shape is the one that requires any matching at all.
|
|
346
|
+
>
|
|
347
|
+
> The one read of argument **one** that survives is `request.path`, for the
|
|
348
|
+
> `/archived` sub-path deny — and it has to. The context names which model and
|
|
349
|
+
> which verb, not which route, so that deny **cannot be expressed from the
|
|
350
|
+
> context alone** and a context-only rewrite would silently turn it into an
|
|
351
|
+
> allow.
|
|
352
|
+
>
|
|
353
|
+
> **Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
|
|
354
|
+
> [#237](https://github.com/abofs/stonyx-orm/issues/237)** — see the dated note
|
|
355
|
+
> above. No read of argument **one** survives in the sample below: the context
|
|
356
|
+
> carries `recordId`, the decoded route-parameter id, so the `/archived` deny **is**
|
|
357
|
+
> expressible from the context alone. It still must not be dropped — expressible is
|
|
358
|
+
> not optional. Retirement of this wording:
|
|
359
|
+
> [#238](https://github.com/abofs/stonyx-orm/issues/238).
|
|
360
|
+
>
|
|
361
|
+
> The same warning is repeated at the top of `src/orm-request.ts`, which ships;
|
|
362
|
+
> the longer write-up in `docs/usage-patterns.md` does **not** ship, so this
|
|
363
|
+
> README and that source header are the two copies a consumer sees.
|
|
364
|
+
>
|
|
365
|
+
> This sample is the same code as the shipped test fixture, and a test asserts
|
|
366
|
+
> the two `access()` bodies are identical line for line. For four rounds they
|
|
367
|
+
> were two independently written copies — and the fifth fail-open variant was
|
|
368
|
+
> found in the one nothing was mutating.
|
|
279
369
|
|
|
280
370
|
```js
|
|
281
371
|
export default class GlobalAccess {
|
|
282
372
|
models = ['owner', 'animal'];
|
|
283
373
|
|
|
284
|
-
access(request) {
|
|
285
|
-
|
|
374
|
+
access(request, { model, operation, recordId }) {
|
|
375
|
+
// `model` is the model this route was mounted for. It is assigned once, at
|
|
376
|
+
// mount time, and no request can influence it — not a mount prefix, not a
|
|
377
|
+
// query string, not a case-varied path, not an absolute-form request
|
|
378
|
+
// target. `recordId` is the record this route was ADDRESSED TO, decoded by
|
|
379
|
+
// the router and coerced to the key the store lookup uses. Nothing below
|
|
380
|
+
// parses anything, and since abofs/stonyx-orm#236 nothing below reads
|
|
381
|
+
// argument one AT ALL. Variants 1, 2, 4 and 5 were already unconstructible;
|
|
382
|
+
// the sub-path STRING COMPARISON that variant 3 lived in is gone too,
|
|
383
|
+
// replaced by a comparison against the decoded id. Retiring the "variant 3
|
|
384
|
+
// survives" wording at the four sites that still carry it — with the
|
|
385
|
+
// measurement that retires it, rather than by deletion — is
|
|
386
|
+
// abofs/stonyx-orm#238.
|
|
387
|
+
//
|
|
388
|
+
// `operation` is destructured to name the whole contract at the point of
|
|
389
|
+
// use. This sample's rules are per-model and per-record rather than
|
|
390
|
+
// per-verb, so it does not branch on it; the permission array at the bottom
|
|
391
|
+
// is where the verb is answered.
|
|
392
|
+
|
|
393
|
+
// FAIL CLOSED ON AN UNIDENTIFIABLE MODEL. `model` is absent for any caller
|
|
394
|
+
// that resolved this predicate without supplying the context, and a request
|
|
395
|
+
// this function cannot identify DENIES rather than falling through to the
|
|
396
|
+
// CRUD grant at the bottom. An unidentifiable input must never be the
|
|
397
|
+
// permissive path.
|
|
398
|
+
if (typeof model !== 'string' || model === '') return false;
|
|
399
|
+
|
|
400
|
+
if (model === 'owner') {
|
|
401
|
+
// FAIL CLOSED ON AN ABSENT `recordId` TOO, AND `undefined` IS THE ONLY
|
|
402
|
+
// SPELLING OF ABSENT. `auth()` ALWAYS sets the key — `null` on a
|
|
403
|
+
// collection route, which is addressed to no record — so `undefined`
|
|
404
|
+
// means the context did not come from `auth()`: it was hand-assembled by
|
|
405
|
+
// a caller resolving this predicate through the documented
|
|
406
|
+
// `Orm.instance.getAccess()` path. Letting that through would fall
|
|
407
|
+
// straight to the per-record filter below, which is a DENY becoming an
|
|
408
|
+
// ALLOW. This is the same rule the old guard on `request.path` enforced,
|
|
409
|
+
// moved to the argument this predicate now actually reads.
|
|
410
|
+
if (recordId === undefined) return false;
|
|
411
|
+
|
|
412
|
+
// THE `/archived` DENY, EXPRESSED AGAINST THE DECODED ID. It used to be
|
|
413
|
+
// `request.path.toLowerCase()` compared against `'/archived'`, and that
|
|
414
|
+
// was wrong in both directions at once.
|
|
415
|
+
//
|
|
416
|
+
// TOO PERMISSIVE: express sets `request.path` from the RAW pathname while
|
|
417
|
+
// the router DECODES `:id`, so `GET /owners/%61rchived` reached the
|
|
418
|
+
// comparison as `/%61rchived`, walked past the deny and was dispatched as
|
|
419
|
+
// the record `archived` — 200 with the record in full, and DELETE
|
|
420
|
+
// answered 204 with the record DESTROYED, unauthenticated. 255
|
|
421
|
+
// non-canonical spellings of that 8-character id decode to the same key,
|
|
422
|
+
// so no deny-list of spellings was ever going to close it.
|
|
423
|
+
//
|
|
424
|
+
// TOO STRICT: a record id is a VALUE, not a literal route segment, and
|
|
425
|
+
// express's `case sensitive routing` governs literal segments only. With
|
|
426
|
+
// a distinct owner seeded at `ARCHIVED`, the `.toLowerCase()` 403'd
|
|
427
|
+
// `GET /owners/ARCHIVED` — the wrong record — while still admitting
|
|
428
|
+
// `GET /owners/%41RCHIVED`, the same record encoded.
|
|
429
|
+
//
|
|
430
|
+
// SO DO NOT NORMALISE `recordId`. It is already decoded, exactly ONCE,
|
|
431
|
+
// which is what a route parameter means: `/owners/%2561rchived` is the
|
|
432
|
+
// legitimate id `%61rchived`, and decoding until stable would deny it. Do
|
|
433
|
+
// not case-fold it. Do not rebuild it from `request.path` — decoding the
|
|
434
|
+
// whole path decodes THEN splits while the router splits THEN decodes,
|
|
435
|
+
// which over-denies the distinct record at `/owners/archived%2fx`.
|
|
436
|
+
//
|
|
437
|
+
// THE DENY IS NOW EXPRESSIBLE FROM THE CONTEXT ALONE, which is exactly
|
|
438
|
+
// what `recordId` bought — and it still must not be dropped. Deleting it
|
|
439
|
+
// does not remove a rule loudly, it turns a deny into an ALLOW, silently.
|
|
440
|
+
if (recordId === 'archived') return false;
|
|
441
|
+
|
|
442
|
+
// Returning a function plugs it in as a per-record filter. It is enforced
|
|
443
|
+
// on every surface addressed to one of these records —
|
|
444
|
+
// /owners, /owners/:id, /owners/:id/pets, /owners/:id/relationships/pets
|
|
445
|
+
// — AND, since #232, on every surface that reaches one of these records
|
|
446
|
+
// as the RELATED resource of another model:
|
|
447
|
+
// /animals/:id/owner, /animals/:id/relationships/owner
|
|
448
|
+
// Both readings are the same rule: an owner this predicate rejects is
|
|
449
|
+
// withheld wherever she is reachable, not only on /owners.
|
|
450
|
+
//
|
|
451
|
+
// NOTHING HERE IS AN EXISTENCE ORACLE, AND THE SPELLING DIFFERS BY WHOSE
|
|
452
|
+
// RECORD IS BEING REJECTED. A rejected ADDRESSED record is 404 — the same
|
|
453
|
+
// status as a record that does not exist. A rejected RELATED record is
|
|
454
|
+
// `data: null` at 200 — byte-identical to a relationship that is
|
|
455
|
+
// genuinely empty, because on those routes 404 is already the answer for
|
|
456
|
+
// a PARENT that does not exist. In both cases "rejected" and "not there"
|
|
457
|
+
// are the same answer, which is the property that matters.
|
|
458
|
+
return record => record.id !== 'angela' && record.id !== 'restricted';
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// `record.owner` resolves to an OrmRecord, not to the owner's id string —
|
|
462
|
+
// comparing it directly against a string is the bug that made this predicate
|
|
463
|
+
// inert. Deliberately NO `?? record.owner` fallback: accepting the raw
|
|
464
|
+
// shape as well as the resolved one would absorb a resolution regression
|
|
465
|
+
// silently, which is exactly what blinded this fixture before.
|
|
466
|
+
// `record.id !== 18` hides one animal whose OWNER is permitted. It is the
|
|
467
|
+
// fixture that makes the `hasMany` half of the relationship-route rules
|
|
468
|
+
// observable: gina is served, animal 18 is not, and every surface that
|
|
469
|
+
// names gina's pets has to drop it.
|
|
470
|
+
if (model === 'animal') return record => record.owner?.id !== 'restricted' && record.id !== 18;
|
|
471
|
+
|
|
472
|
+
// Allows full access to all calls that don't match any of the above conditions
|
|
286
473
|
return ['read', 'create', 'update', 'delete'];
|
|
287
474
|
}
|
|
288
475
|
}
|
|
289
476
|
```
|
|
290
477
|
|
|
478
|
+
|
|
479
|
+
### The access context (second argument)
|
|
480
|
+
|
|
481
|
+
`access()` is called with **two** arguments:
|
|
482
|
+
|
|
483
|
+
```js
|
|
484
|
+
access(request, { model, operation })
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
The second is the **access context** — the structural facts about the request,
|
|
488
|
+
which the framework already holds at authorization time. Read these instead of
|
|
489
|
+
parsing anything.
|
|
490
|
+
|
|
491
|
+
| Key | Value |
|
|
492
|
+
|---|---|
|
|
493
|
+
| `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. |
|
|
494
|
+
| `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. |
|
|
495
|
+
|
|
496
|
+
So a predicate can be written without reference to any URL:
|
|
497
|
+
|
|
498
|
+
```js
|
|
499
|
+
export default class OwnerAccess {
|
|
500
|
+
models = ['owner'];
|
|
501
|
+
|
|
502
|
+
access(request, { model, operation }) {
|
|
503
|
+
if (model === 'owner' && operation === 'read') {
|
|
504
|
+
return record => record.id !== 'angela';
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return ['read'];
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
There is no string to parse, no variant to miss, and no way to fail open through
|
|
513
|
+
a URL shape nobody anticipated. `model` is fixed at mount time and no request
|
|
514
|
+
can influence it — not a mount prefix, not a query string, not a case-varied
|
|
515
|
+
path, not an absolute-form request target.
|
|
516
|
+
|
|
517
|
+
#### What the context does not tell you: which surface
|
|
518
|
+
|
|
519
|
+
It names **which model and which verb**, not **which route**. Measured over the
|
|
520
|
+
live router, six surfaces produce one identical context:
|
|
521
|
+
|
|
522
|
+
```
|
|
523
|
+
GET /owners { model: 'owner', operation: 'read' }
|
|
524
|
+
GET /owners/gina { model: 'owner', operation: 'read' }
|
|
525
|
+
GET /owners/gina/pets { model: 'owner', operation: 'read' }
|
|
526
|
+
GET /owners/gina/relationships/pets { model: 'owner', operation: 'read' }
|
|
527
|
+
GET /owners/archived { model: 'owner', operation: 'read' }
|
|
528
|
+
GET /owners/gina?include=pets { model: 'owner', operation: 'read' }
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
So a rule that depends on the **sub-path** still needs `request.path` —
|
|
532
|
+
mount-relative and query-free, and the one read of argument one that
|
|
533
|
+
[Identifying the collection](#identifying-the-collection) sanctions. The sample
|
|
534
|
+
access class shipped with this repo has such a rule: its `/archived` deny
|
|
535
|
+
**cannot be expressed from the context alone**, and a predicate migrated to
|
|
536
|
+
context-only would silently drop it — a deny becoming an allow.
|
|
537
|
+
|
|
538
|
+
**Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
|
|
539
|
+
[#237](https://github.com/abofs/stonyx-orm/issues/237).** The context also carries
|
|
540
|
+
`recordId` — the record this route was addressed to, already decoded — so the
|
|
541
|
+
`/archived` deny **is** expressible from the context alone, and the shipped sample
|
|
542
|
+
no longer reads `request.path`. The full contract is `AccessContext.recordId` in
|
|
543
|
+
`src/types/orm-types.ts`, which ships. This section — the signature, the key table
|
|
544
|
+
and this paragraph — is corrected by
|
|
545
|
+
[#238](https://github.com/abofs/stonyx-orm/issues/238); the pointer is here because
|
|
546
|
+
what it currently says is an instruction, and the instruction is wrong.
|
|
547
|
+
|
|
548
|
+
Note also that the related-resource and `?include=` surfaces serve *another
|
|
549
|
+
model's* records under `model: 'owner'`, and the context gives a predicate no
|
|
550
|
+
signal that it is authorizing a related-resource route. That is
|
|
551
|
+
[#196](https://github.com/abofs/stonyx-orm/issues/196).
|
|
552
|
+
|
|
553
|
+
#### `operation` is not the hook `operation`
|
|
554
|
+
|
|
555
|
+
This module exposes a **second** `operation` vocabulary, on an identically-named
|
|
556
|
+
key of an identically-shaped context object:
|
|
557
|
+
[hook contexts](#hook-context-object) carry `list` / `get` / `create` /
|
|
558
|
+
`update` / `delete`. The access vocabulary collapses `list` and `get` into
|
|
559
|
+
`'read'`, so for one `GET /animals/1` a hook sees `'get'` while `access()` sees
|
|
560
|
+
`'read'` — and a predicate cannot distinguish a collection read from a
|
|
561
|
+
record read.
|
|
562
|
+
|
|
563
|
+
"No second vocabulary" above is a statement about the **access path**, where
|
|
564
|
+
both the context and the permission array come from one method map. It is not a
|
|
565
|
+
statement about the module. Writing `operation === 'get'` in a predicate never
|
|
566
|
+
matches, and a predicate that stops matching falls through to the permission
|
|
567
|
+
array — so the misreading is fail-open shaped. In TypeScript the exported
|
|
568
|
+
`AccessOperation` union makes it a compile error.
|
|
569
|
+
|
|
570
|
+
The four `operation` values are the same four strings the permission-array
|
|
571
|
+
return shape is written in (`['read', 'create', 'update', 'delete']`), because
|
|
572
|
+
both come from one method map inside the framework. The two forms cannot
|
|
573
|
+
disagree about the same request.
|
|
574
|
+
|
|
575
|
+
**`operation` is `undefined`, never defaulted, for an unmapped method.** Express
|
|
576
|
+
delivers `HEAD` to the `GET` handler, so this is reachable. It is deliberately
|
|
577
|
+
not defaulted to `'read'`: a fabricated operation would turn an unclassified
|
|
578
|
+
request into an authorized one. Treat `undefined` as *not classified* and deny.
|
|
579
|
+
|
|
580
|
+
**The second argument is additive.** JavaScript ignores extra arguments, so an
|
|
581
|
+
existing `access(request)` predicate keeps working exactly as it did. Nothing
|
|
582
|
+
needs to be migrated to keep running — but note that argument **one** is still
|
|
583
|
+
the raw request, so the warning in
|
|
584
|
+
[Identifying the collection](#identifying-the-collection) still applies to any
|
|
585
|
+
predicate that reads it.
|
|
586
|
+
|
|
587
|
+
#### `record` is not in the context
|
|
588
|
+
|
|
589
|
+
Deliberately, and it is not an oversight. `auth()` runs after route matching but
|
|
590
|
+
**before any handler executes**, so nothing has been fetched yet. Supplying a
|
|
591
|
+
record would force a pre-fetch on every request — a second store hit, a new
|
|
592
|
+
failure mode, and an ordering change in the middle of an authorization path.
|
|
593
|
+
|
|
594
|
+
It is also unnecessary: the **function** return shape already *is* the
|
|
595
|
+
per-record hook. Return `(record) => boolean` and the handlers apply it to every
|
|
596
|
+
record the request touches. Auth-time and record-time are separate decision
|
|
597
|
+
points, and the contract keeps them separate.
|
|
598
|
+
|
|
599
|
+
#### Reaching another model's predicate
|
|
600
|
+
|
|
601
|
+
The model → predicate map is published on the ORM instance at boot, before any
|
|
602
|
+
route is mounted, so a predicate can be resolved by model name and asked about a
|
|
603
|
+
request routed to a *different* model:
|
|
604
|
+
|
|
605
|
+
```js
|
|
606
|
+
import Orm from '@stonyx/orm';
|
|
607
|
+
|
|
608
|
+
const predicate = Orm.instance.getAccess('animal');
|
|
609
|
+
if (!predicate) return deny;
|
|
610
|
+
|
|
611
|
+
const verdict = predicate(request, { model: 'animal', operation: 'read' });
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
**`undefined` means no predicate could be resolved — not that the model is
|
|
615
|
+
unrestricted. Treat it as deny.** It covers a model with no access class *and* a
|
|
616
|
+
model whose access class failed to **load**: a load failure is caught and warned
|
|
617
|
+
about, and the partial map is published anyway, so a missing key is not evidence
|
|
618
|
+
of an unrestricted model. This is the same rule as `operation === undefined`
|
|
619
|
+
above, and for the same reason.
|
|
620
|
+
|
|
621
|
+
The raw map is `Orm.instance.accessFunctions`, keyed by model name; prefer
|
|
622
|
+
`getAccess()` — it is guarded against inherited `Object.prototype` members and a
|
|
623
|
+
direct index is not. Note that it maps a model name to the predicate of the
|
|
624
|
+
access *class* that claims it, which may claim many models: against this repo's
|
|
625
|
+
sample, `getAccess('owner') === getAccess('animal')`.
|
|
626
|
+
|
|
627
|
+
#### Passing the context makes a model-correct answer *possible*
|
|
628
|
+
|
|
629
|
+
It does not make the answer model-correct on its own. **The resolved predicate
|
|
630
|
+
has to read the context.** Against a predicate that ignores it the failure is
|
|
631
|
+
measurable. On a request Express dispatched to `GET /owners/angela`, asked about
|
|
632
|
+
**animals**, the sample as it shipped before
|
|
633
|
+
[#222](https://github.com/abofs/stonyx-orm/issues/222) answered:
|
|
634
|
+
|
|
635
|
+
```
|
|
636
|
+
getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
|
|
637
|
+
-> record => record.id !== 'angela' && record.id !== 'restricted'
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
That is the **owners** filter, and it returns `true` for animal 21 — the record
|
|
641
|
+
hidden on every animal surface. Under a mount such a predicate recognizes
|
|
642
|
+
neither way it is worse still: it falls through to
|
|
643
|
+
`['read', 'create', 'update', 'delete']`, a full CRUD grant. Either way the
|
|
644
|
+
context was supplied and the answer is not the animal answer, and it is wrong in
|
|
645
|
+
the direction that **grants** — because that predicate was single-argument and
|
|
646
|
+
identified its collection from the request, so it answered about the collection
|
|
647
|
+
the request was *addressed to* while being asked about another one.
|
|
648
|
+
|
|
649
|
+
The sample shipped with this repo has since been migrated to read the context,
|
|
650
|
+
and the same call now answers with the **animal** filter:
|
|
651
|
+
|
|
652
|
+
```
|
|
653
|
+
getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
|
|
654
|
+
-> record => record.owner?.id !== 'restricted'
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
A single-argument predicate remains the default in every consumer tree, and a
|
|
658
|
+
caller has no supported way to tell which kind it resolved. The boot-time arity
|
|
659
|
+
warning that surfaces one is
|
|
660
|
+
[#221](https://github.com/abofs/stonyx-orm/issues/221).
|
|
661
|
+
|
|
662
|
+
So: pass the context, and do not treat a resolved predicate's answer as
|
|
663
|
+
model-specific until that predicate has been migrated to read it.
|
|
664
|
+
|
|
665
|
+
### Return values
|
|
666
|
+
|
|
667
|
+
| `access()` returns | Effect |
|
|
668
|
+
|---|---|
|
|
669
|
+
| `false` (or any falsy value) | `403` for the whole request |
|
|
670
|
+
| `true` | full access, no filter |
|
|
671
|
+
| `['read', 'create', 'update', 'delete']` | the listed operations only; anything else is `403` |
|
|
672
|
+
| `'read'` (a bare string) | **one** permission, equivalent to `['read']` |
|
|
673
|
+
| a function | a per-record filter — see below |
|
|
674
|
+
| anything else | `403` — unknown shapes fail **closed** |
|
|
675
|
+
|
|
676
|
+
A `throw` inside `access()` is a **denial**, not a 500.
|
|
677
|
+
|
|
678
|
+
### Filter functions
|
|
679
|
+
|
|
680
|
+
A function return value is a **per-record predicate**, and it is enforced on
|
|
681
|
+
every endpoint that is addressed to a record — not only on the collection.
|
|
682
|
+
|
|
683
|
+
It is evaluated against the record the route is *addressed to*. On the two
|
|
684
|
+
relationship route families it is **also** evaluated against the **related**
|
|
685
|
+
record, by that record's *own* model's predicate — see the two `{relationship}`
|
|
686
|
+
rows below. It is not a guarantee that a hidden record cannot be reached or
|
|
687
|
+
modified: a write to a *different* collection can still re-parent one. See
|
|
688
|
+
[Known limitations](#known-limitations) and
|
|
689
|
+
[#207](https://github.com/abofs/stonyx-orm/issues/207).
|
|
690
|
+
|
|
691
|
+
| Endpoint | A record the predicate rejects |
|
|
692
|
+
|---|---|
|
|
693
|
+
| `GET /:models` | omitted from the collection |
|
|
694
|
+
| `GET /:models/:id` | `404` |
|
|
695
|
+
| `GET /:models/:id/{relationship}` | the **addressed** record → `404`. The **related** record → `200` with `data: null` for a `belongsTo`, or dropped from the array for a `hasMany` |
|
|
696
|
+
| `GET /:models/:id/relationships/{relationship}` | same, on the linkage objects |
|
|
697
|
+
| `PATCH /:models/:id` | `404`, no attribute is applied |
|
|
698
|
+
| `DELETE /:models/:id` | `404`, the record is not removed and no SQL `DELETE` is issued |
|
|
699
|
+
| `POST /:models` | `403`, and a record **this request inserted** is rolled back — see [Known limitations](#known-limitations) for why the rollback is conditional |
|
|
700
|
+
|
|
701
|
+
**A withheld related record is not an error, and that is the same rule.** The
|
|
702
|
+
addressed record is filtered with `404` and the related one with `data: null`
|
|
703
|
+
because in both cases the answer must be **identical to the answer for a record
|
|
704
|
+
that does not exist**. A `belongsTo` whose target is genuinely absent already
|
|
705
|
+
answers `200 {"data": null}`; a `hasMany` with no members already answers `200`
|
|
706
|
+
with an empty array. Withholding therefore has to be spelled the same way, or
|
|
707
|
+
the route becomes an existence oracle for a record on a collection the caller
|
|
708
|
+
may have no access to at all. Measured before this was closed — unauthenticated,
|
|
709
|
+
zero query parameters, one request each:
|
|
710
|
+
|
|
711
|
+
```
|
|
712
|
+
GET /traits/1/tag [target absent] -> 200 application/json 68 bytes
|
|
713
|
+
GET /traits/2/tag [target denied] -> 404 text/plain 9 bytes
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
`tag` is a model with **no route mounted at all**, so those two requests were the
|
|
717
|
+
only way to ask about it — and they answered differently. Both now answer
|
|
718
|
+
`200 {"data": null}`.
|
|
719
|
+
|
|
720
|
+
**Denied record-level requests return 404, not 403.** This is deliberate and it
|
|
721
|
+
is the property most easily "improved" away. 403 would confirm that the record
|
|
722
|
+
exists to a caller who is not allowed to know that, which turns the filter into
|
|
723
|
+
an existence oracle: `404` means "no such record", `403` means "there is one and
|
|
724
|
+
it is not yours". Every status on a record route must therefore be identical for
|
|
725
|
+
"filtered out" and "does not exist" — including `DELETE`, which is why deleting
|
|
726
|
+
a record that never existed also returns 404 rather than 204, and including the
|
|
727
|
+
**related** record on the two relationship families, which is why a denied
|
|
728
|
+
`belongsTo` target is `200 {"data": null}` rather than `404`: on that route
|
|
729
|
+
`404` is the answer for a parent that does not exist, so it is `data: null`, and
|
|
730
|
+
not the status, that carries "no target you may see".
|
|
731
|
+
|
|
732
|
+
`POST` is the one exception and returns **403**, because 404 on a mounted
|
|
733
|
+
collection route is indistinguishable from "model not mounted" — a genuinely
|
|
734
|
+
different failure a developer needs to diagnose.
|
|
735
|
+
|
|
736
|
+
**A client-supplied `id` on `POST` is refused with `403` whenever a function
|
|
737
|
+
filter is in force.** This is the part that keeps `POST` from being an
|
|
738
|
+
enumeration oracle, and it is worth understanding rather than working around.
|
|
739
|
+
The duplicate-id check has to run before the filter, and it sees records the
|
|
740
|
+
filter hides, so the *status* of a `POST` otherwise leaks whether an id is
|
|
741
|
+
taken:
|
|
742
|
+
|
|
743
|
+
| `POST /animals` with a payload the caller may create | before | now |
|
|
744
|
+
|---|---|---|
|
|
745
|
+
| an id held by a record the filter **hides** | `403` | `403` |
|
|
746
|
+
| an id that is **free** | `200` | `403` |
|
|
747
|
+
| an id held by a record the caller **can see** | `409` | `403` |
|
|
748
|
+
|
|
749
|
+
Three outcomes, one request per id, the whole id space. Filtering only the
|
|
750
|
+
*collision* status narrows that to callers who cannot create a record they are
|
|
751
|
+
allowed to see; it does not close it. It cannot be closed while a caller both
|
|
752
|
+
chooses the id and learns whether the create succeeded — so under a filter the
|
|
753
|
+
caller does not choose the id. The refusal happens before any store lookup, so
|
|
754
|
+
neither the status nor the response time depends on whether the id exists.
|
|
755
|
+
|
|
756
|
+
Let the server assign the id and read it back from the response — and read it
|
|
757
|
+
back rather than predicting it, because the value it returns is documented but
|
|
758
|
+
not stable across model kinds: a numeric-id model gets `max + 1` (or, at the
|
|
759
|
+
numeric ceiling, the lowest free integer), and a string-id model gets
|
|
760
|
+
`<model>-<n>`. See breaking change 8.
|
|
761
|
+
|
|
762
|
+
**What a server-assigned id is not.** It is not a secret. On a string-id
|
|
763
|
+
collection it is dense and enumerable from `1`, where previously it inherited
|
|
764
|
+
whatever entropy the last-inserted id happened to carry — a UUID-seeded store
|
|
765
|
+
answered a UUID-derived key. If a collection has **no** `access` config its
|
|
766
|
+
record-level routes are ungated, so the id was the only thing standing between
|
|
767
|
+
an unauthenticated caller and `GET`/`PATCH`/`DELETE` on a record. That was never
|
|
768
|
+
a control and must not become one; configure `access`.
|
|
769
|
+
|
|
770
|
+
**And the id itself is an occupancy signal — on both model kinds.**
|
|
771
|
+
`assignRecordId` reads the whole store, not the caller's filtered view — it
|
|
772
|
+
never sees `state.filter` — so the id it returns is a function of records the
|
|
773
|
+
caller may not be permitted to read. **This applies to numeric-id collections
|
|
774
|
+
as well as string-id ones**, and the conditions differ, so read both:
|
|
775
|
+
|
|
776
|
+
- **String-id collections, always.** The assigned `n` is the smallest positive
|
|
777
|
+
integer whose landing key is free, which tells the caller that every key
|
|
778
|
+
below it is taken, hidden or not.
|
|
779
|
+
- **Numeric-id collections, once one record sits at the numeric ceiling.** The
|
|
780
|
+
normal answer is `max + 1`, which discloses only the maximum. But `max + 1`
|
|
781
|
+
is not representable at or above 2^53, so the walk restarts from `1` (see
|
|
782
|
+
breaking change 8) and the assigned id becomes the smallest free integer —
|
|
783
|
+
the same occupancy predicate, now over arbitrary low keys. Each subsequent
|
|
784
|
+
no-id `POST` names the next free one, so a caller can enumerate the holes in
|
|
785
|
+
a range it cannot read.
|
|
786
|
+
|
|
787
|
+
**A ceiling record reaches a filter-protected collection even though `POST`
|
|
788
|
+
refuses caller ids on one.** Breaking change 3 makes
|
|
789
|
+
`POST /animals {"id": 9007199254740992}` answer `403`, but the same id lands
|
|
790
|
+
through a *relationship write on another collection* —
|
|
791
|
+
`POST /owners` carrying `attributes: { pets: [{ "id": 9007199254740992 }] }`
|
|
792
|
+
creates the animal under that key
|
|
793
|
+
([#207](https://github.com/abofs/stonyx-orm/issues/207), the same channel the
|
|
794
|
+
**Known limitations** re-parenting note describes). So the precondition is
|
|
795
|
+
reachable by an unauthenticated caller on exactly the collections `access`
|
|
796
|
+
exists to protect. Measured on the sample fixture, with every animal hidden by
|
|
797
|
+
the `/animals` predicate and keys 4 and 7 deleted:
|
|
798
|
+
|
|
799
|
+
```
|
|
800
|
+
GET /animals -> 200 [] (nothing visible)
|
|
801
|
+
GET /animals/4 -> 404 (free — indistinguishable from hidden)
|
|
802
|
+
POST /animals {"id":4} -> 403 (breaking change 3)
|
|
803
|
+
POST /owners {... pets:[{"id":9007199254740992}]} -> 200 (#207 plants the ceiling record)
|
|
804
|
+
POST /animals (no id) -> 200 id=4 <- names a hole in the hidden range
|
|
805
|
+
POST /animals (no id) -> 200 id=7 <- and the other one
|
|
806
|
+
POST /animals (no id) -> 200 id=13
|
|
807
|
+
POST /animals (no id) -> 200 id=14
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
Closing this requires the assignment to be filter-aware, which is a change to
|
|
811
|
+
the `access` contract rather than a fix; it is stated here rather than left to
|
|
812
|
+
be discovered. Callers with no function-style filter are unaffected — there are
|
|
813
|
+
no hidden records to disclose.
|
|
814
|
+
|
|
815
|
+
### Identifying the collection
|
|
816
|
+
|
|
817
|
+
**Do not reconstruct the request path — and since
|
|
818
|
+
[#202](https://github.com/abofs/stonyx-orm/issues/202) you do not have to
|
|
819
|
+
identify the collection at all.** Read `model` from
|
|
820
|
+
[the access context](#the-access-context-second-argument): it is fixed at mount
|
|
821
|
+
time, no request can influence it, and there is nothing left to parse.
|
|
822
|
+
|
|
823
|
+
**Everything below is the record of what happened when this sample did parse
|
|
824
|
+
it.** It is kept as history, not as a recipe — none of these matching strategies
|
|
825
|
+
should be written into a new predicate. Every version of this sample that tried
|
|
826
|
+
to identify the collection from the request target failed **open**, and each
|
|
827
|
+
variant was found only after the previous one was fixed:
|
|
828
|
+
|
|
829
|
+
| # | Variant | Why it fails open |
|
|
830
|
+
|---|---|---|
|
|
831
|
+
| 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. |
|
|
832
|
+
| 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. |
|
|
833
|
+
| 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). |
|
|
834
|
+
| 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. |
|
|
835
|
+
| 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. |
|
|
836
|
+
|
|
837
|
+
**The fix is not a sixth rule, and it is not a better string to match.** It is
|
|
838
|
+
to stop identifying the collection at all. That is a statement about
|
|
839
|
+
**identifying the collection**, and it is not a statement about the sample as a
|
|
840
|
+
whole: the `/archived` sub-path rule *is* still a string match, and
|
|
841
|
+
[#228](https://github.com/abofs/stonyx-orm/issues/228) is a sixth spelling that
|
|
842
|
+
gets past it. Sub-path rules are the residue this fix does not cover, which is
|
|
843
|
+
why they must normalise the way the router does.
|
|
844
|
+
|
|
845
|
+
An intermediate revision read **`request.baseUrl`** — the mount Express
|
|
846
|
+
*actually matched*. That closed all five variants: it carries no query string
|
|
847
|
+
(variant 2), it is not mount-relative (variant 1), it already contains the
|
|
848
|
+
configured `ORM_REST_ROUTE` prefix (variant 4 — there is nothing left to derive,
|
|
849
|
+
so `/apiowners` is unconstructible), and it is unaffected by an absolute-form
|
|
850
|
+
target (variant 5). It was still a transport artifact standing in for a
|
|
851
|
+
structural fact, and it is **no longer what the sample does**: the sample reads
|
|
852
|
+
`model`, so variants 1, 2, 4 and 5 are unconstructible against it rather than
|
|
853
|
+
handled. **Variant 3 survives**, in the one string comparison the migration
|
|
854
|
+
leaves behind: the `/archived` sub-path deny folds case but does not decode
|
|
855
|
+
([#228](https://github.com/abofs/stonyx-orm/issues/228)). The table below is
|
|
856
|
+
retained as the measured evidence behind the five variants, not because any of
|
|
857
|
+
these values should be matched on:
|
|
858
|
+
|
|
859
|
+
| request | `request.url` | `request.originalUrl` | `request.baseUrl` | `request.path` |
|
|
860
|
+
|---|---|---|---|---|
|
|
861
|
+
| `GET /owners` | `/` | `/owners` | `/owners` | `/` |
|
|
862
|
+
| `GET /owners/angela` | `/angela` | `/owners/angela` | `/owners` | `/angela` |
|
|
863
|
+
| `GET /owners/angela?filter[age]=30` | `/angela?filter[age]=30` | `/owners/angela?filter[age]=30` | `/owners` | `/angela` |
|
|
864
|
+
| `GET /OwNeRs/angela` | `/angela` | `/OwNeRs/angela` | `/OwNeRs` | `/angela` |
|
|
865
|
+
| `GET http://anything.example/owners/angela` | `http://anything.example/angela` | `http://anything.example/owners/angela` | `/owners` | `/angela` |
|
|
866
|
+
| `GET /api/animals/22` (`ORM_REST_ROUTE=/api`) | `/22` | `/api/animals/22` | `/api/animals` | `/22` |
|
|
867
|
+
|
|
868
|
+
**Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
|
|
869
|
+
[#237](https://github.com/abofs/stonyx-orm/issues/237) — "Variant 3 survives" above,
|
|
870
|
+
and the two paragraphs below, are no longer true.** The access context now carries
|
|
871
|
+
`recordId`, the **decoded** route-parameter id, and the sample compares against it:
|
|
872
|
+
the one string comparison variant 3 lived in is gone, `#228` is **closed**, and no
|
|
873
|
+
read of argument one survives in the sample. The wording is left standing rather
|
|
874
|
+
than deleted because it appears at four sites (this file twice,
|
|
875
|
+
`src/orm-request.ts`, and the test fixture) and retiring one of four leaves the
|
|
876
|
+
shipped copies contradicting each other; retiring all four **with the measurement
|
|
877
|
+
that retires them** is [#238](https://github.com/abofs/stonyx-orm/issues/238).
|
|
878
|
+
|
|
879
|
+
**One read of argument one survives, and it must: `request.path`.** It is
|
|
880
|
+
mount-relative and query-free, and it is for rules that distinguish **sub-paths**
|
|
881
|
+
beneath the mount — as the `/archived` deny in the sample above does. The context
|
|
882
|
+
names which model and which verb, **not which route**, so that deny *cannot be
|
|
883
|
+
expressed from the context alone*, and a context-only rewrite would silently turn
|
|
884
|
+
it into an allow.
|
|
885
|
+
|
|
886
|
+
**Normalise the way the router that dispatched the request does — and
|
|
887
|
+
case-folding alone does not.** A matcher stricter than the router can be stepped
|
|
888
|
+
around, so the sample lower-cases before comparing (the router matched
|
|
889
|
+
case-insensitively). That closes the case gap and **it is not the whole rule**:
|
|
890
|
+
Express sets `request.path` from the **raw, undecoded** pathname while the router
|
|
891
|
+
**decodes** `:id`, so `GET /owners/%61rchived` reaches a `path === '/archived'`
|
|
892
|
+
comparison as `/%61rchived`, walks past the deny, and is dispatched as the record
|
|
893
|
+
`archived`. That gap is live in the sample above and is tracked as
|
|
894
|
+
[#228](https://github.com/abofs/stonyx-orm/issues/228) — **do not read the
|
|
895
|
+
`.toLowerCase()` there as a complete normalisation recipe.** Record ids are
|
|
896
|
+
case-sensitive and must be compared at their real case.
|
|
897
|
+
|
|
898
|
+
**Do not follow the two paragraphs above — superseded 2026-09-01 by
|
|
899
|
+
[#236](https://github.com/abofs/stonyx-orm/issues/236) /
|
|
900
|
+
[#237](https://github.com/abofs/stonyx-orm/issues/237).** They are *instructions*,
|
|
901
|
+
not merely stale observations, which is why this note is louder than a date. The
|
|
902
|
+
sample no longer reads `request.path` and no longer calls `.toLowerCase()` on
|
|
903
|
+
anything it compares: `.toLowerCase()` was measured wrong in **both directions at
|
|
904
|
+
once** — with a distinct owner seeded at `ARCHIVED`, `GET /owners/ARCHIVED` was a
|
|
905
|
+
false **deny** on the wrong record and `GET /owners/%41RCHIVED` a false **allow**
|
|
906
|
+
on that same record. Compare `recordId` **as it arrives**: do not case-fold it, do
|
|
907
|
+
not decode it, do not derive it from `request.path`. The contract is
|
|
908
|
+
`AccessContext.recordId` in `src/types/orm-types.ts`, which ships and says "Do NOT
|
|
909
|
+
case-fold it". Retirement of this wording, with its measurement:
|
|
910
|
+
[#238](https://github.com/abofs/stonyx-orm/issues/238).
|
|
911
|
+
|
|
912
|
+
**Fail closed on anything you cannot identify — on *either* argument.**
|
|
913
|
+
`String(request.originalUrl ?? '')` was once added here to stop a `TypeError`,
|
|
914
|
+
and it traded fail-closed for fail-**open**: an empty string matched no
|
|
915
|
+
collection, so `access()` fell through to the permission array and granted full
|
|
916
|
+
CRUD. The same rule applies to the context — the sample returns `false` for an
|
|
917
|
+
absent `model` rather than falling through. Since #202 the guard and the read can
|
|
918
|
+
sit on **different objects**, and a guard on argument two does not protect a read
|
|
919
|
+
of argument one: the sample therefore also returns `false` when `request.path` is
|
|
920
|
+
absent or is not a string, rather than letting `?? ''` fall through to the
|
|
921
|
+
per-record filter. An input you cannot identify must **deny**.
|
|
922
|
+
|
|
923
|
+
### Known limitations
|
|
924
|
+
|
|
925
|
+
- **A function-style filter is not a guarantee that a hidden record cannot be
|
|
926
|
+
modified.** A write to a *different* collection can re-parent one and de-hide
|
|
927
|
+
it: `POST /owners` (or `PATCH /owners/{id}`) carrying
|
|
928
|
+
`relationships: { pets: { data: { id: 21 } } }` — or
|
|
929
|
+
`attributes: { pets: [21, 22] } `, which never enters the relationships loop at
|
|
930
|
+
all — re-parents animal 21 onto an owner the caller is permitted to write. The
|
|
931
|
+
animal's `owner` is the field the `/animals` predicate reads, so the record
|
|
932
|
+
stops being rejected: it becomes readable through `GET /animals/21` and
|
|
933
|
+
deletable through `DELETE /animals/21`. **Reachable unauthenticated** wherever
|
|
934
|
+
one collection is writable and another is filtered on a field the first can
|
|
935
|
+
set. Blocking it requires checking animal 21 against the **animal** model's
|
|
936
|
+
predicate while servicing an **owners** route — cross-model access resolution,
|
|
937
|
+
which the contract could not express before
|
|
938
|
+
[#202](https://github.com/abofs/stonyx-orm/issues/202): `access()` never
|
|
939
|
+
received the model structurally and `setup-rest-server.ts` discarded the
|
|
940
|
+
model→predicate map at boot. **#202 has landed and both halves now exist** —
|
|
941
|
+
see [The access context](#the-access-context-second-argument):
|
|
942
|
+
`Orm.instance.getAccess(modelName)` makes another model's predicate
|
|
943
|
+
**reachable**, and `context.model` makes a **model-correct answer possible** —
|
|
944
|
+
possible, not guaranteed: the resolved predicate has to read the context. The
|
|
945
|
+
sample shipped with this repo now does
|
|
946
|
+
([#222](https://github.com/abofs/stonyx-orm/issues/222)), so
|
|
947
|
+
`getAccess('animal')` answers with the animal filter; a predicate that ignores
|
|
948
|
+
the second argument still answers about the collection the request is
|
|
949
|
+
addressed to, and the boot-time warning that surfaces one is
|
|
950
|
+
[#221](https://github.com/abofs/stonyx-orm/issues/221). **The mechanism
|
|
951
|
+
exists; the ORM does not yet use it on this path.** The re-parenting write above is still
|
|
952
|
+
**not refused** — that enforcement is
|
|
953
|
+
[#196](https://github.com/abofs/stonyx-orm/issues/196) and
|
|
954
|
+
[#207](https://github.com/abofs/stonyx-orm/issues/207), which were blocked on
|
|
955
|
+
#202 and are now free to proceed. Until they land, do not rely on a filter to
|
|
956
|
+
keep a record unmodifiable; keep the *writable* collections' predicates as tight as
|
|
957
|
+
the hidden ones.
|
|
958
|
+
- **Authorization by identifying the collection is a consumer-side
|
|
959
|
+
reconstruction of information the framework already holds.** `access()`
|
|
960
|
+
receives a transport artifact and is asked to work out which model, which
|
|
961
|
+
operation and which record the request addresses. The five variants above are
|
|
962
|
+
the five ways that has been observed to fail open so far. Tracked as
|
|
963
|
+
[#202](https://github.com/abofs/stonyx-orm/issues/202).
|
|
964
|
+
- **The two relationship route families now resolve the *related* model's own
|
|
965
|
+
access class — `GET /:models/:id/{relationship}` and
|
|
966
|
+
`GET /:models/:id/relationships/{relationship}`**
|
|
967
|
+
([#232](https://github.com/abofs/stonyx-orm/issues/232)). This is
|
|
968
|
+
**membership**: the related resource is the route's *primary* data, so the
|
|
969
|
+
filter decides whether it is served at all, not merely which ids a document
|
|
970
|
+
may name. A denied `hasMany` member is **dropped from the array** and nothing
|
|
971
|
+
in the relationship marks the drop: `links` intact, no `errors` member, same
|
|
972
|
+
status, and an array of survivors shaped exactly like one from a parent that
|
|
973
|
+
only ever had those members. A denied `belongsTo` target answers **`200` with
|
|
974
|
+
`data: null`**, byte-identical to a target that is genuinely absent, for the
|
|
975
|
+
same reason.
|
|
976
|
+
|
|
977
|
+
**That is a claim about the relationship, not about the document, and the gap
|
|
978
|
+
is measurable in this repo's own fixture.** `owner` declares a computed
|
|
979
|
+
`totalPets` returning `this.pets.length`, which reads the **store** and is
|
|
980
|
+
never filtered. Measured on this branch, unauthenticated, at zero query
|
|
981
|
+
parameters: `GET /owners/gina` answers `attributes.totalPets: 5` while its
|
|
982
|
+
`relationships.pets.data` names **four** ids, and both relationship routes
|
|
983
|
+
serve the same four. The relationship discloses nothing; the document it
|
|
984
|
+
arrives in discloses that exactly one child was withheld. Do not read
|
|
985
|
+
"indistinguishable" as a property of the response — it is a property of the
|
|
986
|
+
relationship member alone. The other channels in the same class are
|
|
987
|
+
[#245](https://github.com/abofs/stonyx-orm/issues/245) (computed attributes,
|
|
988
|
+
which is the one measured above),
|
|
989
|
+
[#233](https://github.com/abofs/stonyx-orm/issues/233) (`included`
|
|
990
|
+
membership) and [#246](https://github.com/abofs/stonyx-orm/issues/246) (the
|
|
991
|
+
absence of `attributes.<fk>` on a `POST` response), all open. **Audit your
|
|
992
|
+
computed properties before you treat a dropped member as unobservable.** Nothing on either family errors and no status changes — the
|
|
993
|
+
status on these routes belongs to the **parent**, and `data` carries the
|
|
994
|
+
answer about the related record. The `/relationships/` family built its `{type, id}` by
|
|
995
|
+
hand rather than through `toJSON()`, which is why the linkage filter shipped in
|
|
996
|
+
[#234](https://github.com/abofs/stonyx-orm/issues/234) did not reach it.
|
|
997
|
+
|
|
998
|
+
Before this, both families served a record hidden on every one of its own
|
|
999
|
+
surfaces, in full, from another model's route, at **zero query parameters**.
|
|
1000
|
+
The severe case is a model **claimed by no access class**: `getAccess()`
|
|
1001
|
+
returns `undefined`, no route is mounted for it at all, and it was still
|
|
1002
|
+
readable as a related resource — a collection the consumer deliberately never
|
|
1003
|
+
exposed.
|
|
1004
|
+
|
|
1005
|
+
**The `belongsTo` shape is not an existence oracle, and it was measured
|
|
1006
|
+
rather than reasoned about.** An earlier revision of this fix answered `404`
|
|
1007
|
+
for a denied target, which made it distinguishable from a target that is
|
|
1008
|
+
genuinely absent. Unauthenticated, zero query parameters, one request each, on
|
|
1009
|
+
`tag` — a model with **no route mounted at all**:
|
|
1010
|
+
|
|
1011
|
+
```
|
|
1012
|
+
GET /traits/1/tag [target absent] -> 200 application/json 68 bytes
|
|
1013
|
+
GET /traits/2/tag [target denied] -> 404 text/plain 9 bytes
|
|
1014
|
+
```
|
|
1015
|
+
|
|
1016
|
+
`GET /traits/1` and `GET /traits/2` report `relationships.tag = {"data":null}`
|
|
1017
|
+
byte-identical modulo the id, because
|
|
1018
|
+
[#234](https://github.com/abofs/stonyx-orm/issues/234) closed that oracle
|
|
1019
|
+
deliberately — so this route was the one remaining way to ask which of those
|
|
1020
|
+
two nulls was a denial. Under `data: null` both requests answer `200`, same
|
|
1021
|
+
content-type, same content-length, same bytes modulo the parent id the caller
|
|
1022
|
+
put in the URL. It discloses nothing further: `links` on these routes are
|
|
1023
|
+
derived entirely from the parent and the relationship name, there is no `meta`
|
|
1024
|
+
and there are no counts. This also brings the two families back into line with
|
|
1025
|
+
the module-wide rule under [Filter functions](#filter-functions) — *every
|
|
1026
|
+
status on a record route must be identical for "filtered out" and "does not
|
|
1027
|
+
exist"* — which the `404` spelling was an exception to.
|
|
1028
|
+
|
|
1029
|
+
**Per-record denies for a related resource are not expressible.** A predicate resolved for a
|
|
1030
|
+
related resource on these routes receives `recordId: null` and a `request`
|
|
1031
|
+
whose `params` name a record of a **different model**. So the inputs it has
|
|
1032
|
+
are the model name, the operation and the request — and **a rule that needs to
|
|
1033
|
+
know *which* related record it is being asked about cannot be written**.
|
|
1034
|
+
Model-level denies (`return false` for a model) work. Request-level denies (a
|
|
1035
|
+
rule reading a header, a tenant, the method) work. The per-record **filter**
|
|
1036
|
+
shape works too — `access()` may return a function, and that function receives
|
|
1037
|
+
the whole record, id included. What does not work is branching on the record's
|
|
1038
|
+
identity *before* returning, because `access()` is not told it.
|
|
1039
|
+
|
|
1040
|
+
This is not an oversight and it is not closed here. The verdict is resolved
|
|
1041
|
+
**once per type**, cached, before any record has been examined — a `hasMany`
|
|
1042
|
+
related-resource route returns many records of one type, so seeding `recordId`
|
|
1043
|
+
from a record would let the first one decide the context for all of them. The
|
|
1044
|
+
rule the framework holds to is: **`recordId` may name a record only where the
|
|
1045
|
+
route addresses exactly one record of the model being asked about.** That is
|
|
1046
|
+
true for `GET /owners/{id}`, false for linkage, and false for a `hasMany`
|
|
1047
|
+
related-resource route.
|
|
1048
|
+
|
|
1049
|
+
- **`?include=` records are still not filtered — the relationship routes now
|
|
1050
|
+
are.** *Re-specified by [#232](https://github.com/abofs/stonyx-orm/issues/232);
|
|
1051
|
+
the sentence this replaces said all three surfaces were unfiltered, and two of
|
|
1052
|
+
them no longer are.* `GET /animals/1/owner` and
|
|
1053
|
+
`GET /animals/1/relationships/owner` resolve the related model's own access
|
|
1054
|
+
class (see the bullet above). **`?include=owner` still does not**: it
|
|
1055
|
+
serializes the related record without resolving that class, so a filter on
|
|
1056
|
+
`/owners` does not hide an owner reached through `?include=` on `/animals`.
|
|
1057
|
+
There are **two** open questions here and they are owned separately —
|
|
1058
|
+
[#233](https://github.com/abofs/stonyx-orm/issues/233), the remaining child of
|
|
1059
|
+
[#196](https://github.com/abofs/stonyx-orm/issues/196), owns whether a
|
|
1060
|
+
resource enters `included` **at all** (membership), and
|
|
1061
|
+
[#235](https://github.com/abofs/stonyx-orm/issues/235) owns the
|
|
1062
|
+
`relationships.*.data` emitted **inside** a record that is already there
|
|
1063
|
+
(linkage). Neither closes the other, and following only #233 will not lead you
|
|
1064
|
+
to the second. Membership — whether the related resource is served at all — is
|
|
1065
|
+
a different question from which ids a document may *name*, immediately
|
|
1066
|
+
below.
|
|
1067
|
+
- **Relationship linkage is filtered on every request-bound surface that
|
|
1068
|
+
serializes a record — the reads, the two writes, and `included`.** A
|
|
1069
|
+
document's `relationships.*.data` used to publish the id of every related
|
|
1070
|
+
record unconditionally, so a record hidden on every one of its own surfaces
|
|
1071
|
+
was still named inside another model's document — with no `include=`, no
|
|
1072
|
+
relationship route and no query string
|
|
1073
|
+
([#234](https://github.com/abofs/stonyx-orm/issues/234)). The ORM now resolves
|
|
1074
|
+
the **related** model's own access class on `GET /:models`, `GET /:models/:id`,
|
|
1075
|
+
both `GET /:models/:id/{relationship}` shapes, the `POST /:models` and
|
|
1076
|
+
`PATCH /:models/:id` **response documents**, and every record inside an
|
|
1077
|
+
`?include=` **`included`** array
|
|
1078
|
+
([#235](https://github.com/abofs/stonyx-orm/issues/235)), and asks it
|
|
1079
|
+
`{ model: <related>, operation: 'read' }`. **`operation` is `'read'` even on a
|
|
1080
|
+
write route, and that is correct rather than an oversight** — the question
|
|
1081
|
+
asked of the *related* model is "may this caller **read** this id", not "may
|
|
1082
|
+
they update it". An access class that grants `['create']` but not `['read']`
|
|
1083
|
+
on the related model therefore denies that linkage on its own `POST`
|
|
1084
|
+
response; that is the fail-closed direction. Do **not** wire these handlers to
|
|
1085
|
+
`methodAccessMap[request.method]`: it would ask a different question on a
|
|
1086
|
+
write route than on a read route, which is the two-vocabularies failure
|
|
1087
|
+
`createLinkageFilter` exists to prevent. An unresolvable class
|
|
1088
|
+
(`getAccess()` → `undefined`) and a predicate that throws both **deny**.
|
|
1089
|
+
|
|
1090
|
+
**What the two write surfaces cost before #235, measured rather than
|
|
1091
|
+
described:** one HTTP verb defeated the filter on the same record. On
|
|
1092
|
+
`dev @ 8dda5d6`, seconds apart, with no query string and no relationship
|
|
1093
|
+
route, `GET /animals/1` returned `owner.data: null` while `PATCH /animals/1`
|
|
1094
|
+
returned **200 naming angela**. Any caller who could read a record could also
|
|
1095
|
+
write it and be handed the id the read withheld. That consequence is kept here
|
|
1096
|
+
after the fix, and stated as a measurement, because **naming the two handlers
|
|
1097
|
+
is not a substitute for it** — a reader who is told only that `POST` and
|
|
1098
|
+
`PATCH` are now covered cannot tell what was wrong, and a reviewer cannot tell
|
|
1099
|
+
whether the fix addressed it. A
|
|
1100
|
+
filtered-out relationship is **indistinguishable from a genuinely empty one** —
|
|
1101
|
+
an emptied `hasMany` is `data: []` and an emptied `belongsTo` is `data: null`,
|
|
1102
|
+
both **keeping their `links`**, which are built from the serialized record's
|
|
1103
|
+
own id and never from the related one. On the two **write** surfaces there are
|
|
1104
|
+
no `links` to keep: neither handler passes a `baseUrl`, so a filtered and a
|
|
1105
|
+
genuinely-empty relationship are both a bare `{ "data": … }` there. That is
|
|
1106
|
+
pre-existing and deliberate — adding `baseUrl` to the write handlers would be
|
|
1107
|
+
an unrelated change to their response shape. Nothing errors and no status changes,
|
|
1108
|
+
because throwing here would be an existence oracle *and* would throw out of
|
|
1109
|
+
the enclosing `JSON.stringify`.
|
|
1110
|
+
|
|
1111
|
+
**[#232](https://github.com/abofs/stonyx-orm/issues/232) holds to the same
|
|
1112
|
+
spelling on the routes where that linkage is the *primary* data.** A denied
|
|
1113
|
+
member is dropped from the `hasMany` array and a denied `belongsTo` target is
|
|
1114
|
+
`data: null`, at `200`, `links` intact — so the claim above is true of both
|
|
1115
|
+
`GET /:models/:id/{relationship}` shapes as *routes* and not only as linkage
|
|
1116
|
+
emitted inside somebody else's document. An earlier revision of #232 answered
|
|
1117
|
+
`404` on the `belongsTo` shape and did contradict this paragraph; that is
|
|
1118
|
+
measured and closed in the #232 bullet above.
|
|
1119
|
+
|
|
1120
|
+
**That resolves the right class; it does not guarantee a model-correct
|
|
1121
|
+
answer, and the failure direction is not the safe one.** Only a predicate that
|
|
1122
|
+
*reads* `context.model` can answer about the model it was asked about — see
|
|
1123
|
+
[Passing the context makes a model-correct answer *possible*](#passing-the-context-makes-a-model-correct-answer-possible)
|
|
1124
|
+
above. A **single-argument predicate remains the default in every consumer
|
|
1125
|
+
tree**, it identifies its collection from the request, and asked about
|
|
1126
|
+
`owner` on a request dispatched to `/animals` it answers about **animals**.
|
|
1127
|
+
Measured against this repo's own fixture with an arity-1 predicate registered
|
|
1128
|
+
for `owner`: `GET /owners` correctly returns `["gina","michael","bob"]` while
|
|
1129
|
+
`GET /animals/1` returns `owner.data {"type":"owner","id":"angela"}` — the
|
|
1130
|
+
#234 defect, on the #234 surface, after the #234 fix. This is not a
|
|
1131
|
+
regression (the id was published unconditionally before), it cannot be fixed
|
|
1132
|
+
from this side, and the signal that surfaces such a predicate is
|
|
1133
|
+
[#221](https://github.com/abofs/stonyx-orm/issues/221) /
|
|
1134
|
+
[#213](https://github.com/abofs/stonyx-orm/issues/213). **Migrate your
|
|
1135
|
+
predicates to read the context before relying on this filter.** A migrated,
|
|
1136
|
+
context-reading predicate degrades the other way — it can over-deny a
|
|
1137
|
+
*permitted* related record, which is recorded in the release notes as a
|
|
1138
|
+
breaking change.
|
|
1139
|
+
|
|
1140
|
+
**Not yet covered by #235. Each still publishes ids the surfaces above
|
|
1141
|
+
withhold, except where its own owning issue has since closed it — the first
|
|
1142
|
+
entry names an issue that is in flight as this is written:**
|
|
1143
|
+
|
|
1144
|
+
- **`GET /:models/:id/relationships/{relationship}`, and its state is #232's
|
|
1145
|
+
to report rather than this entry's.**
|
|
1146
|
+
[#232](https://github.com/abofs/stonyx-orm/issues/232) owns the
|
|
1147
|
+
relationships-linkage route. Its *primary data* is linkage,
|
|
1148
|
+
so filtering it is a **membership** decision — which is why it is the filed
|
|
1149
|
+
child of [#196](https://github.com/abofs/stonyx-orm/issues/196) and not of
|
|
1150
|
+
#234. The route builds its `{type, id}` objects by hand and never calls
|
|
1151
|
+
`toJSON`, so the `linkage` **option** never reaches it; whatever that route
|
|
1152
|
+
filters, it filters itself. Measured **on `dev @ 8dda5d6`**, the commit
|
|
1153
|
+
#235 branched from: `GET /animals/1/relationships/owner` answered
|
|
1154
|
+
`{"type":"owner","id":"angela"}` while `GET /owners/angela` was `404`. That
|
|
1155
|
+
measurement is pinned to a commit on purpose, so that it does not quietly
|
|
1156
|
+
become a false claim about `dev`. **PR
|
|
1157
|
+
[#247](https://github.com/abofs/stonyx-orm/pull/247) is in flight against
|
|
1158
|
+
this entry**; if it has landed, this route is covered and the bullet #247
|
|
1159
|
+
adds above supersedes this one.
|
|
1160
|
+
- **Whether a related resource appears in `included` at all.**
|
|
1161
|
+
[#233](https://github.com/abofs/stonyx-orm/issues/233) owns whether a
|
|
1162
|
+
related resource appears in `included`. #235 filters what a record
|
|
1163
|
+
*already in* `included` may **name**; a hidden record is still a
|
|
1164
|
+
**member** of that array. The two are different questions and neither closes
|
|
1165
|
+
the other: after #235, `GET /animals/1?include=owner,owner.pets` returns
|
|
1166
|
+
`owner.data: null` on every permitted animal it sideloads **and still
|
|
1167
|
+
includes the hidden owner as a resource**.
|
|
1168
|
+
- **A computed attribute that interpolates a related record's id.**
|
|
1169
|
+
[#245](https://github.com/abofs/stonyx-orm/issues/245) owns this channel,
|
|
1170
|
+
and **it is open as this is written**. `relationships.*.data` is a structure
|
|
1171
|
+
this module builds, so it can be filtered; a computed property is arbitrary
|
|
1172
|
+
consumer code returning an arbitrary value. Whether that makes the channel a
|
|
1173
|
+
**framework defect** the ORM should close — by handing computed getters a
|
|
1174
|
+
verdict, or by refusing to run them while a filter is in force — or a
|
|
1175
|
+
**consumer contract** the ORM should only document, is the question #245
|
|
1176
|
+
must decide. **This README does not decide it; neither reading should be
|
|
1177
|
+
read out of the text here.** Measured on this repo's own fixture, where the
|
|
1178
|
+
`animal` model has a `get tag()` that interpolates `owner.id`: **every**
|
|
1179
|
+
animal document on **every** surface — including the ones above — carries
|
|
1180
|
+
`attributes.tag: "angela's small dog"` for an owner that answers `404`. That
|
|
1181
|
+
measurement is where #245 starts, and it holds whichever way the decision
|
|
1182
|
+
lands. Until it lands, if your access rules hide a record, audit your
|
|
1183
|
+
computed properties for its identifiers.
|
|
1184
|
+
- **The absence of `attributes.<fk>` on a `POST` response proves a hidden
|
|
1185
|
+
record exists** — [#246](https://github.com/abofs/stonyx-orm/issues/246).
|
|
1186
|
+
`createHandler` copies each supplied relationship's raw id into
|
|
1187
|
+
`attributes`; when the related record resolves, the value is consumed and
|
|
1188
|
+
does not appear, and when it does not resolve, it survives. The oracle runs
|
|
1189
|
+
in the negative space, so nothing this list's surfaces withhold is
|
|
1190
|
+
*published* — the **absence** is the signal. Pre-existing, and it does not
|
|
1191
|
+
compose with the two relationship families above: they emit no `attributes`
|
|
1192
|
+
for a related record at all.
|
|
1193
|
+
- **A bare `toJSON()` still emits unfiltered linkage, and that is deliberate.**
|
|
1194
|
+
`Record.toJSON()` **applies** a verdict; it never **resolves** one. It has no
|
|
1195
|
+
request, and the documented `access()` contract permits a predicate to read
|
|
1196
|
+
one — the sample in this README does, for its sub-path rule — so a filter
|
|
1197
|
+
resolved inside `toJSON()` denies *permitted* records rather than hidden ones
|
|
1198
|
+
(measured: 967 → 964, all three failures over-denials). `toJSON` is also the
|
|
1199
|
+
`JSON.stringify` hook, so `JSON.stringify(record)`, `res.json(record)` and
|
|
1200
|
+
`console.log(JSON.stringify(record))` reach it with a **string** in the
|
|
1201
|
+
options slot and have no syntactic place to pass a verdict. The no-argument
|
|
1202
|
+
call therefore returns the pre-#234 document unchanged. Fail-closed by default
|
|
1203
|
+
is not available either: `Orm.instance.accessFunctions` is `{}` in any process
|
|
1204
|
+
that never ran `setup-rest-server` — a CLI, an SQL-only process, a test — so
|
|
1205
|
+
it would empty every relationship on every document in processes with no REST
|
|
1206
|
+
surface to protect. Closing the residual means moving JSON:API serialization
|
|
1207
|
+
**off** the `toJSON` name, tracked as
|
|
1208
|
+
[#230](https://github.com/abofs/stonyx-orm/issues/230). If you hand a `Record`
|
|
1209
|
+
to an untrusted consumer, serialize it through the REST layer, or resolve a
|
|
1210
|
+
verdict with the **exported** `createLinkageFilter(request)` and pass it as
|
|
1211
|
+
the `linkage` option — do not write your own reading of `access()`. This is a
|
|
1212
|
+
consumer obligation with no signal when it lapses; it is stated once, in full,
|
|
1213
|
+
under [Consumer Contracts](#consumer-contracts) below.
|
|
1214
|
+
- **`format()` and `serialize()` are deliberately not filtered, and must stay
|
|
1215
|
+
that way.** `format()` is the **persistence** path — its output is what
|
|
1216
|
+
`Orm.db.save()` writes to disk — so applying an access filter there would
|
|
1217
|
+
write a truncated database. That is **data loss**, not disclosure prevention.
|
|
1218
|
+
Neither method appears anywhere in the REST response path.
|
|
1219
|
+
- **A before-hook that returns a value short-circuits the request.** On write
|
|
1220
|
+
operations addressed to a record the filter is consulted first, so a hook
|
|
1221
|
+
cannot answer for a record the caller may not see. On reads it is not, so a
|
|
1222
|
+
`beforeHook('get', ...)` read-through cache can answer past the filter.
|
|
1223
|
+
- **`beforeHook('create', ...)` still runs for a `POST` that is denied.** For
|
|
1224
|
+
`create` there is no record to test until the handler has built one, so the
|
|
1225
|
+
denial is not knowable in time. Every *after*-hook is gated, and every
|
|
1226
|
+
before-hook on `update` and `delete` is gated; before-`create` is the one
|
|
1227
|
+
exception. A before-`create` hook must not assume the create will succeed.
|
|
1228
|
+
- **A caller can still learn that a collection *has* a per-record filter**, by
|
|
1229
|
+
observing `403` rather than `409`/`200` for an id-bearing `POST`. That
|
|
1230
|
+
discloses a configuration fact, not the existence of any record.
|
|
1231
|
+
- **Enforcement is post-fetch.** The record is loaded and then tested, which
|
|
1232
|
+
leaves a small timing difference between a hidden record and one that never
|
|
1233
|
+
existed. Tracked as [#197](https://github.com/abofs/stonyx-orm/issues/197).
|
|
1234
|
+
- **A `relationships` key that is not a declared relationship is still applied
|
|
1235
|
+
to the record.** The key comes verbatim from the request body and is checked
|
|
1236
|
+
against nothing except `id`, which is stripped. On a `POST` that makes an
|
|
1237
|
+
undeclared key a mass-assigned attribute; on a `PATCH` it is passed to
|
|
1238
|
+
`updateRecord`. `id` is stripped in both handlers because it defeats breaking
|
|
1239
|
+
change 3 above; the general form is tracked as
|
|
1240
|
+
[#204](https://github.com/abofs/stonyx-orm/issues/204).
|
|
1241
|
+
- **A `POST` body `id` that the duplicate check cannot resolve can still
|
|
1242
|
+
overwrite a different record on an unfiltered collection.** The lookup is
|
|
1243
|
+
correct and deliberately does not coerce: `"9105h"` is rejected as a string
|
|
1244
|
+
rather than truncated, and `9105.5`, `[9105]` and `true` are passed through as
|
|
1245
|
+
themselves. The model's id transform then coerces anyway — a bare `parseInt`
|
|
1246
|
+
with no such guard — so the create lands on `9105` (or on `NaN`) and
|
|
1247
|
+
overwrites whatever is there. **This is not string-only**: any body id whose
|
|
1248
|
+
transform output differs from its lookup key is the same defect. Filtered
|
|
1249
|
+
collections are unaffected — breaking change 3 refuses any client-supplied id
|
|
1250
|
+
— so this reaches consumers with **no** function-style filter. Tracked as
|
|
1251
|
+
[#205](https://github.com/abofs/stonyx-orm/issues/205). Its sibling
|
|
1252
|
+
[#203](https://github.com/abofs/stonyx-orm/issues/203) — a **server-assigned**
|
|
1253
|
+
id landing on an occupied slot — is **fixed**; see breaking change 8. #205 is
|
|
1254
|
+
the client-supplied half and is still open.
|
|
1255
|
+
- **`context.record` is `undefined` for an after-`create` hook when a string-id
|
|
1256
|
+
model is given a numeric-looking id.** The post-create lookup uses the same id
|
|
1257
|
+
coercion as every other surface, which resolves `'9107'` to the number `9107`,
|
|
1258
|
+
while a model declaring `id = attr('string')` files the record under the string
|
|
1259
|
+
key. The create itself succeeds and `context.response.data` is correct; only
|
|
1260
|
+
the hook's view of the record is wrong, and it is wrong *silently*. Tracked as
|
|
1261
|
+
[#209](https://github.com/abofs/stonyx-orm/issues/209).
|
|
1262
|
+
- **A denied `POST` rolls back only a record it *inserted*.** The rollback
|
|
1263
|
+
requires the store to have grown, because removing by id alone is a write
|
|
1264
|
+
primitive keyed by a caller-supplied value. **The reachability condition this
|
|
1265
|
+
bullet used to state is gone**: it was
|
|
1266
|
+
[#203](https://github.com/abofs/stonyx-orm/issues/203) — `assignRecordId`
|
|
1267
|
+
returned last-*inserted* + 1, so a server-assigned id could land on an
|
|
1268
|
+
occupied slot and `createRecord` would update it in place — and #203 is fixed
|
|
1269
|
+
(breaking change 8). A server-assigned create can no longer overwrite, so on a
|
|
1270
|
+
collection whose only id channel is `createHandler` this guard has no
|
|
1271
|
+
observable effect today. It is kept because a caller-supplied id reaching
|
|
1272
|
+
`createRecord` from another route — a relationship write,
|
|
1273
|
+
[#207](https://github.com/abofs/stonyx-orm/issues/207) — puts the condition
|
|
1274
|
+
back, and without the guard a denied `403` would delete a record the request
|
|
1275
|
+
did not create.
|
|
1276
|
+
|
|
1277
|
+
### Consumer Contracts
|
|
1278
|
+
|
|
1279
|
+
Obligations this package **cannot enforce**, where nothing fails, warns or
|
|
1280
|
+
changes shape when a consumer omits them. One place, findable, per
|
|
1281
|
+
`quality.md` rule 2 — if you are relying on `@stonyx/orm` for access control,
|
|
1282
|
+
read all of these.
|
|
1283
|
+
|
|
1284
|
+
#### `Record.toJSON()` does not filter relationship linkage unless you pass a verdict
|
|
1285
|
+
|
|
1286
|
+
**The framework resolves a verdict for you on every request-bound surface that
|
|
1287
|
+
serializes a record through `toJSON()`. You own it everywhere else.**
|
|
1288
|
+
|
|
1289
|
+
Those surfaces are `GET /:models`, `GET /:models/:id`, both shapes of
|
|
1290
|
+
`GET /:models/:id/{relationship}`, the `POST /:models` and `PATCH /:models/:id`
|
|
1291
|
+
**response documents**, and every record inside an `?include=` **`included`**
|
|
1292
|
+
array ([#234](https://github.com/abofs/stonyx-orm/issues/234) for the four
|
|
1293
|
+
reads, [#235](https://github.com/abofs/stonyx-orm/issues/235) for the two
|
|
1294
|
+
writes and `included`). Each resolves a linkage verdict and passes it to
|
|
1295
|
+
`toJSON()` for you.
|
|
1296
|
+
|
|
1297
|
+
**`GET /:models/:id/relationships/{relationship}` is not on that list, and its
|
|
1298
|
+
state is not this section's to report.** It builds its `{ type, id }` objects by
|
|
1299
|
+
hand instead of calling `toJSON()`, so the `linkage` **option** never reaches it
|
|
1300
|
+
— whatever that route filters, it filters itself. And because its linkage *is*
|
|
1301
|
+
its primary data, filtering it is a **membership** decision rather than a
|
|
1302
|
+
linkage one. Membership on both relationship route families is owned by
|
|
1303
|
+
[#232](https://github.com/abofs/stonyx-orm/issues/232) (PR
|
|
1304
|
+
[#247](https://github.com/abofs/stonyx-orm/pull/247), in flight as this is
|
|
1305
|
+
written); read that issue for its state rather than inferring it here, because
|
|
1306
|
+
this section describes only what `toJSON()` filters.
|
|
1307
|
+
|
|
1308
|
+
Any other path to a document — `JSON.stringify(record)`, `res.json(record)`,
|
|
1309
|
+
`console.log(record)`, a custom route, a queue payload, a websocket frame —
|
|
1310
|
+
calls `toJSON()` with no verdict, and **the no-verdict document names every
|
|
1311
|
+
related id, including records hidden on every one of their own surfaces**
|
|
1312
|
+
([#234](https://github.com/abofs/stonyx-orm/issues/234)). That default is
|
|
1313
|
+
deliberate and cannot be inverted; the reasons are in
|
|
1314
|
+
[Known limitations](#known-limitations) above.
|
|
1315
|
+
|
|
1316
|
+
**There is no signal when you omit it.** `linkage` is optional, absent is the
|
|
1317
|
+
default, the default is the unfiltered document, and a filtered relationship is
|
|
1318
|
+
byte-identical to a genuinely empty one — so nothing on the wire distinguishes
|
|
1319
|
+
"filtered" from "forgotten".
|
|
1320
|
+
|
|
1321
|
+
**And `linkage` cannot reach a document you build by hand.** It is an *option to
|
|
1322
|
+
`toJSON()`*, so it filters only what goes through `toJSON()`. The ORM's own
|
|
1323
|
+
`GET /:models/:id/relationships/{relationship}` route is the worked example: its
|
|
1324
|
+
primary data *is* linkage, it assembles `{ type, id }` directly rather than
|
|
1325
|
+
serializing a record, and it therefore resolves and applies the verdict itself
|
|
1326
|
+
([#232](https://github.com/abofs/stonyx-orm/issues/232)). If you assemble
|
|
1327
|
+
linkage the same way anywhere — a custom relationship route, a projection, a
|
|
1328
|
+
hand-built document — **passing `linkage` to `toJSON()` does nothing for it and
|
|
1329
|
+
nothing warns**. Build the filter and consult it before you emit an id:
|
|
1330
|
+
|
|
1331
|
+
```js
|
|
1332
|
+
const linkage = createLinkageFilter(request);
|
|
1333
|
+
|
|
1334
|
+
if (related && linkage(related.__model.__name, related)) {
|
|
1335
|
+
data = { type: related.__model.__name, id: related.id };
|
|
1336
|
+
} else {
|
|
1337
|
+
data = null; // withheld and genuinely-empty must be the SAME answer
|
|
1338
|
+
}
|
|
1339
|
+
```
|
|
1340
|
+
|
|
1341
|
+
The `else` branch is the part that is easy to get wrong. Answering `404`, `403`
|
|
1342
|
+
or an `errors` member for the withheld case makes the route an **existence
|
|
1343
|
+
oracle** — see [Filter functions](#filter-functions) for the rule and for the
|
|
1344
|
+
measurement that closed it on this route.
|
|
1345
|
+
|
|
1346
|
+
**A per-record deny for a *related* resource cannot be expressed, and nothing
|
|
1347
|
+
tells you so at the point you would write it.** `createLinkageFilter` resolves
|
|
1348
|
+
the related model's access class by **type**: `context.recordId` is `null`, and
|
|
1349
|
+
`request.params` names a record of a **different model** — the one the route is
|
|
1350
|
+
addressed to. So `access()` is handed the model, the operation and the request,
|
|
1351
|
+
and **a rule that has to know *which* related record it is being asked about
|
|
1352
|
+
cannot be written**. Model-level denies (`return false` for a model) work.
|
|
1353
|
+
Request-level denies (a header, a tenant, the method) work. The per-record
|
|
1354
|
+
**filter** shape works too — `access()` may return a function, and that function
|
|
1355
|
+
receives the whole record, id included. What does not work is branching on the
|
|
1356
|
+
record's identity *before* returning, because `access()` is not told it.
|
|
1357
|
+
|
|
1358
|
+
This is a fixed property of the mechanism rather than a defect awaiting a fix.
|
|
1359
|
+
The verdict is resolved **once per type** and cached before any record has been
|
|
1360
|
+
examined, so seeding `recordId` from a record would let the first member of a
|
|
1361
|
+
`hasMany` decide the context for all of them. The rule the framework holds to is
|
|
1362
|
+
**`recordId` may name a record only where the route addresses exactly one record
|
|
1363
|
+
of the model being asked about** — true for `GET /owners/{id}`, false for
|
|
1364
|
+
linkage, and false for a `hasMany` related-resource route. The consumer-facing
|
|
1365
|
+
consequence is the part to check: a predicate that branches on `recordId` sees
|
|
1366
|
+
`null` here and takes whichever branch `null` takes, with no warning, and if
|
|
1367
|
+
that branch grants then it **grants**. Express the rule as a returned filter
|
|
1368
|
+
function instead. Stated again, with the same label, under
|
|
1369
|
+
[Known limitations](#known-limitations)
|
|
1370
|
+
([#232](https://github.com/abofs/stonyx-orm/issues/232)).
|
|
1371
|
+
|
|
1372
|
+
Do this:
|
|
1373
|
+
|
|
1374
|
+
```js
|
|
1375
|
+
import { createLinkageFilter } from '@stonyx/orm';
|
|
1376
|
+
|
|
1377
|
+
// `request` is the live request the caller was authorised against. The verdict
|
|
1378
|
+
// is REQUEST-SCOPED: build one per request and never cache it across requests,
|
|
1379
|
+
// or a second caller is answered with the first caller's authorization.
|
|
1380
|
+
const linkage = createLinkageFilter(request);
|
|
1381
|
+
|
|
1382
|
+
res.json({ data: record.toJSON({ baseUrl, linkage }) });
|
|
1383
|
+
```
|
|
1384
|
+
|
|
1385
|
+
Not this:
|
|
1386
|
+
|
|
1387
|
+
```js
|
|
1388
|
+
// A second, unreviewed reading of access(). It will drift from the one in
|
|
1389
|
+
// src/access-verdict.ts, and it will drift in consumer code where no reviewer
|
|
1390
|
+
// of this repository will ever see it.
|
|
1391
|
+
const linkage = (type, r) => Orm.instance.getAccess(type)?.(request)?.(r) ?? true;
|
|
1392
|
+
```
|
|
1393
|
+
|
|
1394
|
+
**`createLinkageFilter` requires a live request, and there is no safe call
|
|
1395
|
+
without one.** `request` is the only authorization input the filter has — it is
|
|
1396
|
+
handed straight to your `access()` predicates, and a predicate that does not
|
|
1397
|
+
*read* it cannot fail closed when it is missing. Passing `undefined`, `null` or
|
|
1398
|
+
any non-object therefore denies **all** linkage and logs, once, at construction.
|
|
1399
|
+
Measured before that guard existed, `createLinkageFilter(undefined)` granted
|
|
1400
|
+
four of the five models in this repository's own fixture, silently.
|
|
1401
|
+
|
|
1402
|
+
**This is the catch for the request-less contexts named above.** In a queue
|
|
1403
|
+
consumer or a websocket handler there is no live request, so there is nothing to
|
|
1404
|
+
authorize against and nothing this package can resolve for you. Either carry the
|
|
1405
|
+
originating request through to the point of serialization, or publish no linkage
|
|
1406
|
+
at all — `record.toJSON({ linkage: () => false })` emits the document with every
|
|
1407
|
+
relationship empty. A stand-in is **not** a substitute: `{}` is an object
|
|
1408
|
+
and passes the guard, and any predicate that ignores its request will grant.
|
|
1409
|
+
|
|
1410
|
+
**`linkage` itself is validated, and an unusable value DENIES.** `undefined`
|
|
1411
|
+
means "no verdict supplied" and emits today's document. Anything else must be a
|
|
1412
|
+
**synchronous function that answers with a boolean**. Each of the following
|
|
1413
|
+
drops **all** linkage on that document and logs once:
|
|
1414
|
+
|
|
1415
|
+
- **A non-function** — `null`, `0`, `false`, `''`, `true`, a string, an object.
|
|
1416
|
+
`null` is the natural return of a resolver that could not resolve a session:
|
|
1417
|
+
it used to be read as "absent" and emit the full document silently.
|
|
1418
|
+
- **An `async` function, a generator function, or any predicate that returns a
|
|
1419
|
+
promise or thenable.** `toJSON` is the `JSON.stringify` hook and cannot await
|
|
1420
|
+
a verdict, and **an `async` resolver returns a promise, a promise is
|
|
1421
|
+
truthy**, so every related id was published, silently, exactly as if this fix
|
|
1422
|
+
were not here. If your
|
|
1423
|
+
authorization lookup is asynchronous, `await` it *before* you serialize and
|
|
1424
|
+
close over the result.
|
|
1425
|
+
- **Any answer that is not a boolean** — `{}`, `'no'`, `1`, `undefined`. A
|
|
1426
|
+
non-boolean is a resolver that did not answer, and a truthy one granted.
|
|
1427
|
+
- **A predicate that throws**, including a `class` passed by mistake. It is
|
|
1428
|
+
caught and denied; it used to escape the enclosing `JSON.stringify` and take
|
|
1429
|
+
the rest of that serialization down with it.
|
|
1430
|
+
|
|
1431
|
+
#### A predicate that ignores `context.model` makes cross-model resolution GRANT
|
|
1432
|
+
|
|
1433
|
+
The linkage filter above asks the **related** model's access class the
|
|
1434
|
+
model-correct question, but only a predicate that *reads*
|
|
1435
|
+
[`context.model`](#the-access-context-second-argument) can give a model-correct
|
|
1436
|
+
answer. A single-argument predicate identifies its collection from the request
|
|
1437
|
+
and therefore answers about the collection the request was *addressed to* —
|
|
1438
|
+
which is the direction that **grants**. Measured, and worked through in
|
|
1439
|
+
[Known limitations](#known-limitations). There is no boot-time warning yet
|
|
1440
|
+
([#221](https://github.com/abofs/stonyx-orm/issues/221)). **Migrate your
|
|
1441
|
+
predicates to the two-argument contract.**
|
|
1442
|
+
|
|
1443
|
+
#### `format()` and `serialize()` are never filtered, by design
|
|
1444
|
+
|
|
1445
|
+
They are the persistence path. Do not hand their output to an untrusted
|
|
1446
|
+
consumer, and do not add a filter to them — `Orm.db.save()` writes `format()`
|
|
1447
|
+
output to disk, so filtering there is data loss rather than disclosure
|
|
1448
|
+
prevention.
|
|
1449
|
+
|
|
1450
|
+
### Breaking changes
|
|
1451
|
+
|
|
1452
|
+
These land in the next published build. There is no changelog or release-notes channel yet
|
|
1453
|
+
([stonyx-workflows#17](https://github.com/abofs/stonyx-workflows/issues/17)), so
|
|
1454
|
+
they are recorded here.
|
|
1455
|
+
|
|
1456
|
+
1. **`DELETE /{collection}/{id}` on a record that does not exist returns `404`
|
|
1457
|
+
instead of `204`.** This affects **every** consumer issuing a DELETE against
|
|
1458
|
+
any mounted collection, whether or not an access filter is configured, and
|
|
1459
|
+
`models: '*'` mounts every model by default. It is not optional: if a denied
|
|
1460
|
+
delete returned 404 while a missing one returned 204, the pair would be a
|
|
1461
|
+
perfect existence oracle and the filter would be worthless.
|
|
1462
|
+
2. **After-hooks no longer fire for a request that failed** — denied, missing,
|
|
1463
|
+
`400` or `409`. The gate is on the handler's status, not on the operation, so
|
|
1464
|
+
it covers reads as well: a `GET /:id` that answers 404 runs no after-`get`
|
|
1465
|
+
hook either. Previously `afterHook('delete', ...)` ran with a populated
|
|
1466
|
+
`context.recordId` on a request that deleted nothing, so a consumer cascade
|
|
1467
|
+
destroyed children behind a 404.
|
|
1468
|
+
3. **`POST` with a client-supplied `id` returns `403` when a function-style
|
|
1469
|
+
`access` filter is in force**, whatever the payload and whether or not the id
|
|
1470
|
+
exists, and *before* any store lookup — so neither the status nor the lookup
|
|
1471
|
+
cost can depend on whether that id exists. Only affects function-style
|
|
1472
|
+
`access` users. See [Filter functions](#filter-functions) for why, and let the
|
|
1473
|
+
server assign the id instead. `409`-on-duplicate is unchanged for everyone
|
|
1474
|
+
else.
|
|
1475
|
+
|
|
1476
|
+
"Whatever the payload" is a statement about the **`id` member of the resource
|
|
1477
|
+
object**, and it holds only because that is the sole channel a caller id can
|
|
1478
|
+
arrive on. It was not always: a caller id moved into
|
|
1479
|
+
`relationships: {"id": {"data": {"id": 21}}}` reached `createRecord` past this
|
|
1480
|
+
refusal and overwrote a hidden record in place. Both strips — `attributes.id`
|
|
1481
|
+
and `relationships.id` — are part of this behaviour, not tidiness. A future
|
|
1482
|
+
change that adds a third channel without stripping it re-opens the oracle;
|
|
1483
|
+
see [#204](https://github.com/abofs/stonyx-orm/issues/204).
|
|
1484
|
+
4. **Function-style `access` is now enforced on all seven surfaces.** Records
|
|
1485
|
+
previously reachable by id despite being filtered from the collection now
|
|
1486
|
+
return 404. Only affects function-style `access` users, for whom the old
|
|
1487
|
+
behaviour was the bypass.
|
|
1488
|
+
|
|
1489
|
+
"Seven surfaces" means the seven endpoints of **the filtered model** —
|
|
1490
|
+
`GET /:models`, `GET /:models/:id`, `GET /:models/:id/{relationship}`,
|
|
1491
|
+
`GET /:models/:id/relationships/{relationship}`, `POST /:models`,
|
|
1492
|
+
`PATCH /:models/:id` and `DELETE /:models/:id`. That count is still seven and
|
|
1493
|
+
is still the model's own endpoints, but **it is no longer the whole
|
|
1494
|
+
population**: the boundary moved outward rather than the number changing, and
|
|
1495
|
+
this sentence used to be read as saying a filtered model's predicate is
|
|
1496
|
+
consulted nowhere else. It now is, in two further places, both from
|
|
1497
|
+
*another* model's routes —
|
|
1498
|
+
|
|
1499
|
+
- on **both relationship route families**, where the related record is the
|
|
1500
|
+
primary data and the filtered model's own class decides whether it is
|
|
1501
|
+
served at all (breaking change 9 below,
|
|
1502
|
+
[#232](https://github.com/abofs/stonyx-orm/issues/232)); and
|
|
1503
|
+
- on the `relationships.*.data` **linkage** of every request-bound surface
|
|
1504
|
+
that serializes a record through `toJSON()`
|
|
1505
|
+
([#234](https://github.com/abofs/stonyx-orm/issues/234),
|
|
1506
|
+
[#235](https://github.com/abofs/stonyx-orm/issues/235)), which decides
|
|
1507
|
+
which ids another model's document may name.
|
|
1508
|
+
|
|
1509
|
+
A **write** to another collection can still reach one of its records through
|
|
1510
|
+
a relationship — [#207](https://github.com/abofs/stonyx-orm/issues/207),
|
|
1511
|
+
which is **not** closed here. That is the half of the old sentence that
|
|
1512
|
+
survives, and it is a read/write asymmetry now rather than a blanket
|
|
1513
|
+
statement about cross-model reach.
|
|
1514
|
+
5. **A predicate that throws is treated as a denial** rather than propagating to
|
|
1515
|
+
Express's default 500 handler. So is an `access()` that throws.
|
|
1516
|
+
6. **`access()` returning a bare string is one permission, not full access.**
|
|
1517
|
+
`AccessMethod` declares `string` legal, and it previously fell through every
|
|
1518
|
+
branch and granted all four operations — `return 'read'` allowed `DELETE`.
|
|
1519
|
+
It is now equivalent to `['read']`. Any other unrecognised shape (an object,
|
|
1520
|
+
a number) now returns `403` rather than granting full access.
|
|
1521
|
+
7. **`POST` with a client-supplied `id` normalises the body id before the
|
|
1522
|
+
duplicate check**, so an id shape that previously *missed* the store's key
|
|
1523
|
+
now finds it. `POST {"id":"0x2391"}` and `POST {"id":" 21 "}` answer `409`
|
|
1524
|
+
where they answered `200`, and the `200` was not a success: the lookup missed,
|
|
1525
|
+
the duplicate check was skipped, and `createRecord` overwrote the colliding
|
|
1526
|
+
record in place. **This one reaches consumers with no filter at all** — the
|
|
1527
|
+
population breaking changes 3 and 4 explicitly exempt. If you were relying on
|
|
1528
|
+
a hex-shaped or whitespace-padded id creating a second record, it never did.
|
|
1529
|
+
|
|
1530
|
+
8. **Server-assigned ids change value on string-id models, numeric ids stop
|
|
1531
|
+
being monotonic at the numeric ceiling, and the create route gains a
|
|
1532
|
+
`409`.** Three consumer-visible changes from
|
|
1533
|
+
[#203](https://github.com/abofs/stonyx-orm/issues/203).
|
|
1534
|
+
|
|
1535
|
+
**The value.** A `POST` with no `id` against a model declaring
|
|
1536
|
+
`id = attr('string')` previously produced the *last-inserted* id with `1`
|
|
1537
|
+
concatenated onto it — an owner store holding `['gina', 'bob']` answered
|
|
1538
|
+
`'bob1'`. It now answers `'owner-1'`: the model name, a hyphen, and the
|
|
1539
|
+
lowest positive integer whose landing key is free. **No test in this repo
|
|
1540
|
+
pinned the old value**, so a consumer relying on it gets no failing test, no
|
|
1541
|
+
deprecation and no other signal — which is why it is recorded here. Numeric
|
|
1542
|
+
id models (`id = attr('number')`, the default) are unaffected in shape: they
|
|
1543
|
+
still get an integer, but it is now the **maximum** existing id plus one
|
|
1544
|
+
rather than the last-inserted id plus one, which is the defect #203 is about.
|
|
1545
|
+
They are **not** unaffected in *sequence* — see the monotonicity half below.
|
|
1546
|
+
|
|
1547
|
+
The value is deliberately **not** numeric-looking, and that is not cosmetic.
|
|
1548
|
+
Every id-bearing surface resolves a numeric-looking string id to a **number**
|
|
1549
|
+
(`GET /owners/1` looks up `1`), while a string-id model files its records
|
|
1550
|
+
under the **string** key `'1'`. A server-assigned `'1'` would therefore be a
|
|
1551
|
+
record that was created successfully and could not be fetched, updated or
|
|
1552
|
+
deleted by id, and whose after-`create` hook received
|
|
1553
|
+
`context.record === undefined`
|
|
1554
|
+
([#209](https://github.com/abofs/stonyx-orm/issues/209)).
|
|
1555
|
+
|
|
1556
|
+
**Numeric ids are no longer monotonic, and deleted ids can be re-issued.**
|
|
1557
|
+
The precondition is narrow but it is reachable, and there is no signal when
|
|
1558
|
+
it is met: **one record filed at or above 2^53** (`9007199254740992`). `max
|
|
1559
|
+
+ 1` is not representable there, so assignment restarts from `1` and walks
|
|
1560
|
+
up to the lowest free key — which means the id of a *deleted* record is
|
|
1561
|
+
handed to the next `POST`. Both `dev` and every prior release were strictly
|
|
1562
|
+
monotonic and never re-issued a numeric id, so a consumer that relied on
|
|
1563
|
+
that — audit rows, cursors, cached authorization decisions, external
|
|
1564
|
+
references keyed on the id — now has a stale reference that silently points
|
|
1565
|
+
at a **different record, created by a different caller**, rather than at a
|
|
1566
|
+
deleted one. Nothing fails; the reference simply resolves to the wrong
|
|
1567
|
+
record.
|
|
1568
|
+
|
|
1569
|
+
The restart is deliberate and is not itself optional: without it, one record
|
|
1570
|
+
at the ceiling made every subsequent server-assigned create on that
|
|
1571
|
+
collection fail permanently. Re-use is the cost of keeping the collection
|
|
1572
|
+
writable. **If you need monotonic ids, assign them yourself** rather than
|
|
1573
|
+
letting the server assign, and note that a ceiling record can be planted by
|
|
1574
|
+
an unauthenticated caller — see *And the id itself is an occupancy signal*
|
|
1575
|
+
under [Filter functions](#filter-functions) for the reachability path.
|
|
1576
|
+
String-id models are unaffected by this half: their keys are
|
|
1577
|
+
`<model>-<n>` and were never monotonic over an integer sequence.
|
|
1578
|
+
|
|
1579
|
+
**The status.** `POST /{collection}` can now answer `409` for a reason other
|
|
1580
|
+
than a duplicate id: the server could not derive a free id. That requires a
|
|
1581
|
+
**non-injective** id transform — one that maps distinct candidates onto the
|
|
1582
|
+
same store key, such as `boolean`, or anything you registered on
|
|
1583
|
+
`Orm.instance.transforms` and named as an id type. It is a configuration
|
|
1584
|
+
fault rather than a request fault; the message is logged through
|
|
1585
|
+
`stonyx/log`. Previously this case threw out of the handler and express
|
|
1586
|
+
answered `500` with a stack trace.
|
|
1587
|
+
|
|
1588
|
+
9. **Both relationship route families now resolve the *related* model's own
|
|
1589
|
+
access class, so a related record that is hidden on its own routes is no
|
|
1590
|
+
longer served through another model's.**
|
|
1591
|
+
[#232](https://github.com/abofs/stonyx-orm/issues/232). Affects
|
|
1592
|
+
function-style `access` users with relationships between filtered models. The
|
|
1593
|
+
old behaviour was a bypass at **zero query parameters** and with no
|
|
1594
|
+
`include=`: measured on `dev @ 8dda5d6`, `GET /animals/1/owner` returned
|
|
1595
|
+
angela's full document and `GET /animals/1/relationships/owner` returned
|
|
1596
|
+
`{"type":"owner","id":"angela"}`, while `GET /owners/angela` answered `404`.
|
|
1597
|
+
The severe case is a model claimed by **no** access class — `getAccess()`
|
|
1598
|
+
returns `undefined`, no route is mounted for it at all, and it was still
|
|
1599
|
+
readable as a related resource.
|
|
1600
|
+
|
|
1601
|
+
**The shapes, on both families.** A denied `hasMany` member is **dropped from
|
|
1602
|
+
the array**: `200`, `links` intact, no `errors` member. A denied `belongsTo`
|
|
1603
|
+
target answers **`200` with `data: null`**, byte-identical to a target that
|
|
1604
|
+
genuinely does not exist — same status, same bytes modulo the parent id the
|
|
1605
|
+
caller put in the URL. `404` on these routes is now reserved for the
|
|
1606
|
+
**parent**.
|
|
1607
|
+
|
|
1608
|
+
**`data: null` and not `404`, deliberately.** The 404 spelling was an
|
|
1609
|
+
existence oracle and was measured as one on this branch: unauthenticated, no
|
|
1610
|
+
query string, one request each, against `tag` — the model with no route
|
|
1611
|
+
mounted at all — `GET /traits/1/tag` (absent) answered `200`
|
|
1612
|
+
`application/json` at 68 bytes while `GET /traits/2/tag` (denied) answered
|
|
1613
|
+
`404` `text/plain` at 9 bytes, and the document surface reported both as
|
|
1614
|
+
`{"data":null}`. It also brings these two routes into line with this module's
|
|
1615
|
+
rule that every status on a record route is identical for filtered-out and
|
|
1616
|
+
does-not-exist (see [Filter functions](#filter-functions)), which the `404`
|
|
1617
|
+
spelling was the one exception to.
|
|
1618
|
+
|
|
1619
|
+
**What to check before you upgrade.** If a consumer reaches a related record
|
|
1620
|
+
through `GET /:models/:id/{relationship}` that it cannot reach on that
|
|
1621
|
+
record's own collection route, it was relying on the bypass and will now get
|
|
1622
|
+
`data: null` or a shorter array. And the related model's class is resolved
|
|
1623
|
+
through the same `Orm.instance.getAccess` path as the linkage filter, so it
|
|
1624
|
+
inherits the same arity limit: a **single-argument** predicate answers about
|
|
1625
|
+
the collection the request was *addressed to*, not the one it was asked
|
|
1626
|
+
about, and that is the direction that **grants**. See
|
|
1627
|
+
[Known limitations](#known-limitations) and
|
|
1628
|
+
[#221](https://github.com/abofs/stonyx-orm/issues/221).
|
|
1629
|
+
|
|
1630
|
+
**Not closed here:** whether a related resource appears in `included` at all
|
|
1631
|
+
([#233](https://github.com/abofs/stonyx-orm/issues/233)), and the
|
|
1632
|
+
re-parenting write ([#207](https://github.com/abofs/stonyx-orm/issues/207)).
|
|
1633
|
+
A **per-record** deny for a related resource is not expressible on these
|
|
1634
|
+
routes at all — see [Consumer Contracts](#consumer-contracts).
|
|
1635
|
+
|
|
291
1636
|
### Include Parameter (Sideloading Relationships)
|
|
292
1637
|
|
|
293
1638
|
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.
|
|
@@ -380,6 +1725,15 @@ GET /animals/1
|
|
|
380
1725
|
#### Limitations
|
|
381
1726
|
|
|
382
1727
|
- Only available on GET endpoints (not POST/PATCH)
|
|
1728
|
+
- **`included` is access-filtered on one of the two questions, not both.** What
|
|
1729
|
+
a record already in `included` may **name** in its own
|
|
1730
|
+
`relationships.*.data` is filtered
|
|
1731
|
+
([#235](https://github.com/abofs/stonyx-orm/issues/235)) — `?include=` no
|
|
1732
|
+
longer republishes ids the primary document withholds. Whether a resource
|
|
1733
|
+
appears in `included` **at all** is *membership* and is still unfiltered
|
|
1734
|
+
([#233](https://github.com/abofs/stonyx-orm/issues/233)): a record that is
|
|
1735
|
+
404 on its own routes is still served as an `included` resource, attributes
|
|
1736
|
+
and all. See [Consumer Contracts](#consumer-contracts).
|
|
383
1737
|
|
|
384
1738
|
## Lifecycle Hooks
|
|
385
1739
|
|
|
@@ -434,6 +1788,16 @@ Each hook receives a context object with comprehensive information:
|
|
|
434
1788
|
- It contains a deep copy of the record's state **before** the operation executes (captured before the `before` hook fires)
|
|
435
1789
|
- The deep copy is created via JSON serialization (`JSON.parse(JSON.stringify())`) to ensure complete isolation
|
|
436
1790
|
- For `delete` operations, `recordId` is provided in after hooks since the record may no longer exist in the store
|
|
1791
|
+
- **`context.recordId` here is NOT `AccessContext.recordId`.** Same name, same-shaped
|
|
1792
|
+
object, different coverage: `_withHooks` sets this key **only** under
|
|
1793
|
+
`operation === 'delete'`, so on `get` / `list` / `create` / `update` the key is
|
|
1794
|
+
**absent** — `beforeHook('update', 'owner', ctx => ctx.recordId === 'archived' ? 403 : undefined)`
|
|
1795
|
+
never fires (measured: `PATCH /owners/{id}` → 200 with `ctx.recordId === undefined`
|
|
1796
|
+
and the id sitting in `ctx.params`). The access context, by contrast, carries
|
|
1797
|
+
`recordId` on every route it classifies and spells absence as `null`, never
|
|
1798
|
+
`undefined`. Tracked as
|
|
1799
|
+
[#242](https://github.com/abofs/stonyx-orm/issues/242); see
|
|
1800
|
+
`AccessContext.recordId` in `src/types/orm-types.ts` for the other side.
|
|
437
1801
|
- `oldState` is captured as a deep copy of the record's data before the operation, providing access to the previous field values
|
|
438
1802
|
|
|
439
1803
|
### Usage Examples
|
|
@@ -561,7 +1925,19 @@ afterHook('delete', 'animal', async (context) => {
|
|
|
561
1925
|
// Additional access control - halt with 403 if unauthorized
|
|
562
1926
|
beforeHook('delete', 'animal', (context) => {
|
|
563
1927
|
const user = context.state.currentUser;
|
|
564
|
-
|
|
1928
|
+
|
|
1929
|
+
// `context.oldState` — NOT a store lookup. For `update` and `delete` the ORM
|
|
1930
|
+
// has already fetched the record (and already applied the access filter to
|
|
1931
|
+
// it) before this hook runs, so re-fetching it here is a fourth id coercion
|
|
1932
|
+
// that has to agree with three others.
|
|
1933
|
+
//
|
|
1934
|
+
// And it would not agree. `context.params.id` is the raw url segment, always
|
|
1935
|
+
// a string, while the store keys numeric-id models by NUMBER — so
|
|
1936
|
+
// `store.get('animal', '21')` misses the record held under `21`, and so does
|
|
1937
|
+
// `store.find('animal', '21')`: neither coerces. The miss reads as "no such
|
|
1938
|
+
// record", which in an authorization hook fails whichever way your code
|
|
1939
|
+
// happens to handle a null.
|
|
1940
|
+
const animal = context.oldState;
|
|
565
1941
|
|
|
566
1942
|
if (animal.owner !== user.id && !user.isAdmin) {
|
|
567
1943
|
return 403; // Forbidden
|
|
@@ -569,6 +1945,10 @@ beforeHook('delete', 'animal', (context) => {
|
|
|
569
1945
|
});
|
|
570
1946
|
```
|
|
571
1947
|
|
|
1948
|
+
> If you do need a lookup for some *other* model inside a hook, coerce the id
|
|
1949
|
+
> yourself to the type that model's `id` attribute declares — the store is a
|
|
1950
|
+
> `Map` and `'21'` and `21` are different keys.
|
|
1951
|
+
|
|
572
1952
|
#### Auditing
|
|
573
1953
|
|
|
574
1954
|
```javascript
|
|
@@ -702,11 +2082,29 @@ beforeHook('create', 'post', (context) => {
|
|
|
702
2082
|
|
|
703
2083
|
### Hook Execution Order
|
|
704
2084
|
|
|
705
|
-
1. **
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
2085
|
+
1. **Authorization is evaluated first for `update` and `delete`.** A record the
|
|
2086
|
+
access filter rejects returns `404` **before any before-hook runs**, so a
|
|
2087
|
+
hook never sees a record — or a `context.oldState` — that the caller is not
|
|
2088
|
+
allowed to read. `create` is the exception: there is no record to test until
|
|
2089
|
+
the handler has built one, so `beforeHook('create', ...)` **does** fire for a
|
|
2090
|
+
`POST` that goes on to answer `403`.
|
|
2091
|
+
2. **Before hooks** fire next (sequentially, in registration order).
|
|
2092
|
+
3. **Main operation** executes (if no before hook halted).
|
|
2093
|
+
4. **After hooks** fire last (sequentially, in registration order) — **only if
|
|
2094
|
+
the request succeeded.**
|
|
2095
|
+
|
|
2096
|
+
Before hooks can halt the operation by returning a value, and that value becomes
|
|
2097
|
+
the response.
|
|
2098
|
+
|
|
2099
|
+
**After hooks do not run for a failed request.** Any status `>= 400` — denied,
|
|
2100
|
+
missing, `400`, `409` — skips the entire after-hook pipeline, along with SQL
|
|
2101
|
+
persistence and `onUpdate` autosave. This is a behaviour change; see
|
|
2102
|
+
[Breaking changes](#breaking-changes). It applies to the samples above: the
|
|
2103
|
+
`afterHook('delete', ...)` auditing hook writes **no** audit row for a `DELETE`
|
|
2104
|
+
that answered `404`, and the `afterHook('update', ...)` change-tracking hook
|
|
2105
|
+
writes none for a `PATCH` that answered `404` or `400`. If you need a record of
|
|
2106
|
+
refused requests, log them from a before-hook or from your own middleware —
|
|
2107
|
+
`after<operation>` fires only for an operation that actually happened.
|
|
710
2108
|
|
|
711
2109
|
### Best Practices
|
|
712
2110
|
|