@stonyx/orm 0.3.2-alpha.53 → 0.3.2-alpha.55

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 CHANGED
@@ -381,6 +381,97 @@ export default class GlobalAccess {
381
381
  }
382
382
  ```
383
383
 
384
+
385
+ ### The access context (second argument)
386
+
387
+ `access()` is called with **two** arguments:
388
+
389
+ ```js
390
+ access(request, { model, operation })
391
+ ```
392
+
393
+ The second is the **access context** — the structural facts about the request,
394
+ which the framework already holds at authorization time. Read these instead of
395
+ parsing anything.
396
+
397
+ | Key | Value |
398
+ |---|---|
399
+ | `model` | The model this route was mounted for, as a **model name**: kebab-case, exactly as declared under `config.orm.paths.model` and keyed in the store — `'owner'`, `'animal'`, `'phone-number'`. **Not** the pluralized, dasherized, mount-prefixed *route* name. |
400
+ | `operation` | One of **`'read'`, `'create'`, `'update'`, `'delete'`** — and no second vocabulary. Never an HTTP method name like `'GET'`. `undefined` when the dispatched method has no entry in the framework's method map. |
401
+
402
+ So a predicate can be written without reference to any URL:
403
+
404
+ ```js
405
+ export default class OwnerAccess {
406
+ models = ['owner'];
407
+
408
+ access(request, { model, operation }) {
409
+ if (model === 'owner' && operation === 'read') {
410
+ return record => record.id !== 'angela';
411
+ }
412
+
413
+ return ['read'];
414
+ }
415
+ }
416
+ ```
417
+
418
+ There is no string to parse, no variant to miss, and no way to fail open through
419
+ a URL shape nobody anticipated. `model` is fixed at mount time and no request
420
+ can influence it — not a mount prefix, not a query string, not a case-varied
421
+ path, not an absolute-form request target.
422
+
423
+ The four `operation` values are the same four strings the permission-array
424
+ return shape is written in (`['read', 'create', 'update', 'delete']`), because
425
+ both come from one method map inside the framework. The two forms cannot
426
+ disagree about the same request.
427
+
428
+ **`operation` is `undefined`, never defaulted, for an unmapped method.** Express
429
+ delivers `HEAD` to the `GET` handler, so this is reachable. It is deliberately
430
+ not defaulted to `'read'`: a fabricated operation would turn an unclassified
431
+ request into an authorized one. Treat `undefined` as *not classified* and deny.
432
+
433
+ **The second argument is additive.** JavaScript ignores extra arguments, so an
434
+ existing `access(request)` predicate keeps working exactly as it did. Nothing
435
+ needs to be migrated to keep running — but note that argument **one** is still
436
+ the raw request, so the warning in
437
+ [Identifying the collection](#identifying-the-collection) still applies to any
438
+ predicate that reads it.
439
+
440
+ #### `record` is not in the context
441
+
442
+ Deliberately, and it is not an oversight. `auth()` runs after route matching but
443
+ **before any handler executes**, so nothing has been fetched yet. Supplying a
444
+ record would force a pre-fetch on every request — a second store hit, a new
445
+ failure mode, and an ordering change in the middle of an authorization path.
446
+
447
+ It is also unnecessary: the **function** return shape already *is* the
448
+ per-record hook. Return `(record) => boolean` and the handlers apply it to every
449
+ record the request touches. Auth-time and record-time are separate decision
450
+ points, and the contract keeps them separate.
451
+
452
+ #### Reaching another model's predicate
453
+
454
+ The model → predicate map is published on the ORM instance at boot, before any
455
+ route is mounted, so a predicate can be resolved by model name and asked about a
456
+ request routed to a *different* model:
457
+
458
+ ```js
459
+ import Orm from '@stonyx/orm';
460
+
461
+ const predicate = Orm.instance.getAccess('animal');
462
+ const verdict = predicate?.(request, { model: 'animal', operation: 'read' });
463
+ ```
464
+
465
+ `Orm.instance.getAccess(modelName)` returns the predicate, or `undefined` when
466
+ that model has no access class. The raw map is `Orm.instance.accessFiles`, keyed
467
+ by model name; prefer `getAccess()`.
468
+
469
+ Passing the context explicitly is what makes the answer **model-correct**. A
470
+ predicate that identifies its collection from the request would otherwise answer
471
+ about the collection the request is *addressed to* while being asked about
472
+ another one — and per the five variants below, it answers wrong in the direction
473
+ that grants access.
474
+
384
475
  ### Return values
385
476
 
386
477
  | `access()` returns | Effect |
@@ -512,14 +603,20 @@ sub-paths beneath the mount, as the `/archived` deny above does.
512
603
  one collection is writable and another is filtered on a field the first can
513
604
  set. Blocking it requires checking animal 21 against the **animal** model's
514
605
  predicate while servicing an **owners** route — cross-model access resolution,
515
- which the current contract cannot express: `access()` never receives the model
516
- structurally ([#202](https://github.com/abofs/stonyx-orm/issues/202)) and
517
- `setup-rest-server.ts` discards the model→predicate map at boot
518
- ([#196](https://github.com/abofs/stonyx-orm/issues/196)). Tracked as
519
- [#207](https://github.com/abofs/stonyx-orm/issues/207), blocked on that chain
520
- (#202 #196 #207). Until it lands, do not rely on a filter to keep a record
521
- unmodifiable; keep the *writable* collections' predicates as tight as the
522
- hidden ones.
606
+ which the contract could not express before
607
+ [#202](https://github.com/abofs/stonyx-orm/issues/202): `access()` never
608
+ received the model structurally and `setup-rest-server.ts` discarded the
609
+ model→predicate map at boot. **#202 has landed and both halves now exist** —
610
+ see [The access context](#the-access-context-second-argument): `context.model`
611
+ makes the answer model-correct and `Orm.instance.getAccess(modelName)` makes
612
+ another model's predicate reachable. **The mechanism exists; the ORM does not
613
+ yet use it on this path.** The re-parenting write above is still unblocked —
614
+ that enforcement is
615
+ [#196](https://github.com/abofs/stonyx-orm/issues/196) and
616
+ [#207](https://github.com/abofs/stonyx-orm/issues/207), which were blocked on
617
+ #202 and are now unblocked. Until they land, do not rely on a filter to keep a
618
+ record unmodifiable; keep the *writable* collections' predicates as tight as
619
+ the hidden ones.
523
620
  - **Authorization by identifying the collection is a consumer-side
524
621
  reconstruction of information the framework already holds.** `access()`
525
622
  receives a transport artifact and is asked to work out which model, which
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ import { count, avg, sum, min, max } from './aggregates.js';
9
9
  export { default } from './main.js';
10
10
  export { store, relationships } from './main.js';
11
11
  export type { PersistErrorDetail } from './main.js';
12
+ export type { AccessContext, AccessFunction, AccessMethod } from './types/orm-types.js';
12
13
  export { Model, View, Serializer };
13
14
  export { attr, belongsTo, hasMany, createRecord, updateRecord };
14
15
  export { count, avg, sum, min, max };
package/dist/main.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import Store from './store.js';
2
+ import type { AccessFunction } from './types/orm-types.js';
2
3
  interface OrmOptions {
3
4
  dbType?: string;
4
5
  }
@@ -32,12 +33,58 @@ export default class Orm {
32
33
  views: Record<string, unknown>;
33
34
  transforms: Record<string, (value: unknown) => unknown>;
34
35
  warnings: Set<string>;
36
+ /**
37
+ * Model name -> that model's `access` predicate (abofs/stonyx-orm#202).
38
+ *
39
+ * Populated by `setup-rest-server.ts` at boot, from the access classes under
40
+ * `config.orm.paths.access`, BEFORE any route is mounted -- so it is complete
41
+ * and reachable before the first request can be served. The mapping is
42
+ * one-to-one by construction: setup-rest-server throws if two access classes
43
+ * claim the same model.
44
+ *
45
+ * Keys are model names as declared and stored (kebab-case, e.g.
46
+ * `'phone-number'`), NOT pluralised or mount-prefixed route names.
47
+ *
48
+ * WHY THIS EXISTS AS A FIELD. It used to be a function-local in
49
+ * setup-rest-server that was discarded when that function returned, so at
50
+ * request time there was no way to get from a model name to that model's
51
+ * predicate at all. Each `OrmRequest` held only its OWN model's predicate.
52
+ * That made cross-model authorization -- asking model X's predicate about a
53
+ * request routed to model Y -- inexpressible, which is the capability
54
+ * abofs/stonyx-orm#196 and abofs/stonyx-orm#207 are built on.
55
+ *
56
+ * Empty when the REST server is disabled, or when no access configuration
57
+ * could be loaded. Prefer {@link Orm#getAccess} over indexing this directly.
58
+ */
59
+ accessFiles: Record<string, AccessFunction>;
35
60
  options: OrmOptions;
36
61
  sqlDb?: SqlDb;
37
62
  db?: OrmDB | SqlDb;
38
63
  private _persistErrorHandler;
39
64
  constructor(options?: OrmOptions);
40
65
  init(): Promise<void>;
66
+ /**
67
+ * Resolve a model's `access` predicate by model name (abofs/stonyx-orm#202).
68
+ *
69
+ * This is the supported way to reach another model's predicate while
70
+ * servicing a request routed to a different model. Call it with the model
71
+ * name and invoke the result with the live request and an explicit context
72
+ * naming THAT model:
73
+ *
74
+ * ```js
75
+ * const predicate = Orm.instance.getAccess('animal');
76
+ * const verdict = predicate?.(request, { model: 'animal', operation: 'read' });
77
+ * ```
78
+ *
79
+ * Passing the context is not optional in practice. A predicate that
80
+ * identifies its collection from the request would otherwise answer about the
81
+ * collection the request is ADDRESSED TO -- owners -- while being asked about
82
+ * animals, and per #202's thesis it answers wrong in the granting direction.
83
+ *
84
+ * @param modelName - Model name as declared and stored (kebab-case).
85
+ * @returns The predicate, or `undefined` when the model has no access class.
86
+ */
87
+ getAccess(modelName: string): AccessFunction | undefined;
41
88
  startup(): Promise<void>;
42
89
  shutdown(): Promise<void>;
43
90
  static get db(): OrmDB | SqlDb;
package/dist/main.js CHANGED
@@ -38,6 +38,30 @@ export default class Orm {
38
38
  views = {};
39
39
  transforms = { ...baseTransforms };
40
40
  warnings = new Set();
41
+ /**
42
+ * Model name -> that model's `access` predicate (abofs/stonyx-orm#202).
43
+ *
44
+ * Populated by `setup-rest-server.ts` at boot, from the access classes under
45
+ * `config.orm.paths.access`, BEFORE any route is mounted -- so it is complete
46
+ * and reachable before the first request can be served. The mapping is
47
+ * one-to-one by construction: setup-rest-server throws if two access classes
48
+ * claim the same model.
49
+ *
50
+ * Keys are model names as declared and stored (kebab-case, e.g.
51
+ * `'phone-number'`), NOT pluralised or mount-prefixed route names.
52
+ *
53
+ * WHY THIS EXISTS AS A FIELD. It used to be a function-local in
54
+ * setup-rest-server that was discarded when that function returned, so at
55
+ * request time there was no way to get from a model name to that model's
56
+ * predicate at all. Each `OrmRequest` held only its OWN model's predicate.
57
+ * That made cross-model authorization -- asking model X's predicate about a
58
+ * request routed to model Y -- inexpressible, which is the capability
59
+ * abofs/stonyx-orm#196 and abofs/stonyx-orm#207 are built on.
60
+ *
61
+ * Empty when the REST server is disabled, or when no access configuration
62
+ * could be loaded. Prefer {@link Orm#getAccess} over indexing this directly.
63
+ */
64
+ accessFiles = {};
41
65
  options;
42
66
  sqlDb;
43
67
  db;
@@ -145,6 +169,30 @@ export default class Orm {
145
169
  Orm.ready = await Promise.all(promises);
146
170
  Orm.initialized = true;
147
171
  }
172
+ /**
173
+ * Resolve a model's `access` predicate by model name (abofs/stonyx-orm#202).
174
+ *
175
+ * This is the supported way to reach another model's predicate while
176
+ * servicing a request routed to a different model. Call it with the model
177
+ * name and invoke the result with the live request and an explicit context
178
+ * naming THAT model:
179
+ *
180
+ * ```js
181
+ * const predicate = Orm.instance.getAccess('animal');
182
+ * const verdict = predicate?.(request, { model: 'animal', operation: 'read' });
183
+ * ```
184
+ *
185
+ * Passing the context is not optional in practice. A predicate that
186
+ * identifies its collection from the request would otherwise answer about the
187
+ * collection the request is ADDRESSED TO -- owners -- while being asked about
188
+ * animals, and per #202's thesis it answers wrong in the granting direction.
189
+ *
190
+ * @param modelName - Model name as declared and stored (kebab-case).
191
+ * @returns The predicate, or `undefined` when the model has no access class.
192
+ */
193
+ getAccess(modelName) {
194
+ return this.accessFiles[modelName];
195
+ }
148
196
  async startup() {
149
197
  if (this.sqlDb)
150
198
  await this.sqlDb.startup();
@@ -2,6 +2,62 @@
2
2
  * REST request handling and access enforcement for @stonyx/orm.
3
3
  *
4
4
  * ---------------------------------------------------------------------------
5
+ * THE `access()` CONTRACT: `access(request, { model, operation })`
6
+ * ---------------------------------------------------------------------------
7
+ * `auth()` calls your predicate with TWO arguments. The second is the access
8
+ * CONTEXT -- the structural facts about the request, which the framework
9
+ * already holds and which you should read INSTEAD of parsing anything:
10
+ *
11
+ * context.model The model this route was mounted for, as a model name:
12
+ * kebab-case, exactly as declared under
13
+ * `config.orm.paths.model` and keyed in the store --
14
+ * `'owner'`, `'animal'`, `'phone-number'`. NOT the
15
+ * pluralised, dasherized, mount-prefixed ROUTE name. It is
16
+ * read from the OrmRequest instance, fixed at mount time,
17
+ * and no request can influence it.
18
+ *
19
+ * context.operation The operation being authorised. Exactly one of the four
20
+ * verbs `'read'`, `'create'`, `'update'`, `'delete'` --
21
+ * there is no second vocabulary, and it is never an HTTP
22
+ * method name like `'GET'`. These are the same four
23
+ * strings the permission-array return shape is written in
24
+ * (`['read', 'create']`), because both come from the one
25
+ * `methodAccessMap` below.
26
+ *
27
+ * `undefined` when the dispatched method has no entry in
28
+ * that map. Express delivers `HEAD` to the `GET` handler,
29
+ * so this is reachable. It is left undefined rather than
30
+ * defaulted on purpose -- a fabricated `'read'` would turn
31
+ * an unclassified request into an authorised one. Treat
32
+ * `undefined` as "not classified" and deny.
33
+ *
34
+ * So a consumer writes `if (model === 'owner' && operation === 'read')`. There
35
+ * is no string to parse, no variant to miss, and no way to fail open through a
36
+ * URL shape nobody anticipated.
37
+ *
38
+ * `record` IS NOT IN THIS CONTEXT, deliberately. `auth()` runs after route
39
+ * matching but BEFORE any handler executes (`@stonyx/rest-server`
40
+ * `src/request.ts:58-60`), so nothing has been fetched yet -- supplying a
41
+ * record would force a pre-fetch on every request, a second store hit and an
42
+ * ordering change in the middle of an authorization path. It is also
43
+ * unnecessary: the FUNCTION return shape already is the per-record hook. Return
44
+ * `(record) => boolean` and the handlers apply it to every record the request
45
+ * touches. Auth-time and record-time are separate decision points.
46
+ *
47
+ * THE SECOND ARGUMENT IS ADDITIVE. JavaScript ignores extra arguments, so an
48
+ * existing `access(request)` predicate keeps working exactly as before. The
49
+ * warning immediately below is therefore still live: `request` is still
50
+ * argument ONE, and reading it is still how predicates fail open.
51
+ *
52
+ * To reach ANOTHER model's predicate -- e.g. to check an animal while servicing
53
+ * an owners route -- use the boot-time registry:
54
+ *
55
+ * const predicate = Orm.instance.getAccess('animal');
56
+ * const verdict = predicate?.(request, { model: 'animal', operation: 'read' });
57
+ *
58
+ * Passing the context explicitly is what makes that answer model-CORRECT.
59
+ *
60
+ * ---------------------------------------------------------------------------
5
61
  * DO NOT RECONSTRUCT THE REQUEST PATH INSIDE `access()`.
6
62
  * ---------------------------------------------------------------------------
7
63
  * `auth()` below hands your `access(request)` a raw transport artifact and asks
@@ -59,6 +115,7 @@
59
115
  * See `### Known limitations` in README.
60
116
  */
61
117
  import { Request } from '@stonyx/rest-server';
118
+ import type { AccessFunction } from './types/orm-types.js';
62
119
  interface OrmRequest$ extends Request {
63
120
  protocol?: string;
64
121
  method: string;
@@ -73,13 +130,12 @@ interface OrmRequest$ extends Request {
73
130
  };
74
131
  get(header: string): string;
75
132
  }
76
- type AccessMethod = string | boolean | string[] | ((record: unknown) => boolean);
77
133
  type HandlerFn = (request: OrmRequest$, state: {
78
134
  [key: string]: unknown;
79
135
  }) => unknown | Promise<unknown>;
80
136
  export default class OrmRequest extends Request {
81
137
  model: string;
82
- access: (request: unknown) => AccessMethod;
138
+ access: AccessFunction;
83
139
  handlers: {
84
140
  [key: string]: {
85
141
  [key: string]: HandlerFn;
@@ -87,7 +143,7 @@ export default class OrmRequest extends Request {
87
143
  };
88
144
  constructor({ model, access }: {
89
145
  model: string;
90
- access: (request: unknown) => AccessMethod;
146
+ access: AccessFunction;
91
147
  });
92
148
  private _withHooks;
93
149
  private _generateRelationshipRoutes;
@@ -2,6 +2,62 @@
2
2
  * REST request handling and access enforcement for @stonyx/orm.
3
3
  *
4
4
  * ---------------------------------------------------------------------------
5
+ * THE `access()` CONTRACT: `access(request, { model, operation })`
6
+ * ---------------------------------------------------------------------------
7
+ * `auth()` calls your predicate with TWO arguments. The second is the access
8
+ * CONTEXT -- the structural facts about the request, which the framework
9
+ * already holds and which you should read INSTEAD of parsing anything:
10
+ *
11
+ * context.model The model this route was mounted for, as a model name:
12
+ * kebab-case, exactly as declared under
13
+ * `config.orm.paths.model` and keyed in the store --
14
+ * `'owner'`, `'animal'`, `'phone-number'`. NOT the
15
+ * pluralised, dasherized, mount-prefixed ROUTE name. It is
16
+ * read from the OrmRequest instance, fixed at mount time,
17
+ * and no request can influence it.
18
+ *
19
+ * context.operation The operation being authorised. Exactly one of the four
20
+ * verbs `'read'`, `'create'`, `'update'`, `'delete'` --
21
+ * there is no second vocabulary, and it is never an HTTP
22
+ * method name like `'GET'`. These are the same four
23
+ * strings the permission-array return shape is written in
24
+ * (`['read', 'create']`), because both come from the one
25
+ * `methodAccessMap` below.
26
+ *
27
+ * `undefined` when the dispatched method has no entry in
28
+ * that map. Express delivers `HEAD` to the `GET` handler,
29
+ * so this is reachable. It is left undefined rather than
30
+ * defaulted on purpose -- a fabricated `'read'` would turn
31
+ * an unclassified request into an authorised one. Treat
32
+ * `undefined` as "not classified" and deny.
33
+ *
34
+ * So a consumer writes `if (model === 'owner' && operation === 'read')`. There
35
+ * is no string to parse, no variant to miss, and no way to fail open through a
36
+ * URL shape nobody anticipated.
37
+ *
38
+ * `record` IS NOT IN THIS CONTEXT, deliberately. `auth()` runs after route
39
+ * matching but BEFORE any handler executes (`@stonyx/rest-server`
40
+ * `src/request.ts:58-60`), so nothing has been fetched yet -- supplying a
41
+ * record would force a pre-fetch on every request, a second store hit and an
42
+ * ordering change in the middle of an authorization path. It is also
43
+ * unnecessary: the FUNCTION return shape already is the per-record hook. Return
44
+ * `(record) => boolean` and the handlers apply it to every record the request
45
+ * touches. Auth-time and record-time are separate decision points.
46
+ *
47
+ * THE SECOND ARGUMENT IS ADDITIVE. JavaScript ignores extra arguments, so an
48
+ * existing `access(request)` predicate keeps working exactly as before. The
49
+ * warning immediately below is therefore still live: `request` is still
50
+ * argument ONE, and reading it is still how predicates fail open.
51
+ *
52
+ * To reach ANOTHER model's predicate -- e.g. to check an animal while servicing
53
+ * an owners route -- use the boot-time registry:
54
+ *
55
+ * const predicate = Orm.instance.getAccess('animal');
56
+ * const verdict = predicate?.(request, { model: 'animal', operation: 'read' });
57
+ *
58
+ * Passing the context explicitly is what makes that answer model-CORRECT.
59
+ *
60
+ * ---------------------------------------------------------------------------
5
61
  * DO NOT RECONSTRUCT THE REQUEST PATH INSIDE `access()`.
6
62
  * ---------------------------------------------------------------------------
7
63
  * `auth()` below hands your `access(request)` a raw transport artifact and asks
@@ -1036,9 +1092,41 @@ export default class OrmRequest extends Request {
1036
1092
  // answers 500 -- and the documented sample itself can throw
1037
1093
  // (`request.originalUrl.split(...)` when originalUrl is absent), so the
1038
1094
  // failure mode is reachable by following the docs.
1095
+ // -------------------------------------------------------------------------
1096
+ // #202 -- hand the consumer the STRUCTURAL facts, not just the transport.
1097
+ //
1098
+ // Both members are already in hand here. `model` is `this.model`, the name
1099
+ // setup-rest-server mounted this route for; `operation` is the SAME
1100
+ // `methodAccessMap` lookup the permission-array branch at the bottom of
1101
+ // this method performs, so the predicate form and the array form cannot
1102
+ // answer differently about the same request.
1103
+ //
1104
+ // NEITHER IS DERIVED FROM THE REQUEST TARGET, and that is the whole point.
1105
+ // Deriving `model` here from `request.baseUrl` (or from the mounted route
1106
+ // name, or from `getPluralName(this.model)`) would move all five fail-open
1107
+ // variants listed in this file's header OUT of the consumer and INTO the
1108
+ // framework, where every consumer inherits them at once. `this.model` is
1109
+ // assigned once at mount time and no request can influence it.
1110
+ //
1111
+ // `operation` is left UNDEFINED for a method with no entry in
1112
+ // `methodAccessMap`, rather than defaulted. Express delivers HEAD to the
1113
+ // GET handler, so an unmapped method really does reach this line; a
1114
+ // `?? 'read'` here would hand the consumer a fabricated authorisation fact
1115
+ // and turn an unclassified request into an authorised one. Undefined is
1116
+ // the honest answer.
1117
+ //
1118
+ // `record` is deliberately absent -- see `AccessContext` in
1119
+ // src/types/orm-types.ts. Nothing is fetched at this point and adding a
1120
+ // lookup here would put a store read in the middle of an authorization
1121
+ // path. The function return shape below IS the per-record hook.
1122
+ // -------------------------------------------------------------------------
1123
+ const context = {
1124
+ model: this.model,
1125
+ operation: methodAccessMap[request.method],
1126
+ };
1039
1127
  let access;
1040
1128
  try {
1041
- access = this.access(request);
1129
+ access = this.access(request, context);
1042
1130
  }
1043
1131
  catch (error) {
1044
1132
  // Same reasoning as `isDenied`: fail closed, but say so. An `access()`
@@ -1,5 +1,5 @@
1
1
  import { waitForModule } from 'stonyx';
2
- import { store } from '@stonyx/orm';
2
+ import Orm, { store } from '@stonyx/orm';
3
3
  import OrmRequest from './orm-request.js';
4
4
  import MetaRequest from './meta-request.js';
5
5
  import RestServer from '@stonyx/rest-server';
@@ -35,6 +35,28 @@ export default async function (route, accessPath, metaRoute) {
35
35
  log.error?.(error instanceof Error ? error.message : String(error));
36
36
  log.warn?.('You must define a valid access configuration file in order to access ORM generated REST endpoints.');
37
37
  }
38
+ // -------------------------------------------------------------------------
39
+ // #202 -- the registry has to survive this function.
40
+ //
41
+ // `accessFiles` used to be a function-local that was discarded at the return
42
+ // below, so the only thing that ever saw it was the mount loop. Each mounted
43
+ // OrmRequest then held its OWN model's predicate and nothing held the map, so
44
+ // at request time there was no route from a model NAME to that model's
45
+ // predicate -- which is what abofs/stonyx-orm#196 and #207 need in order to
46
+ // ask model X's predicate about a request routed to model Y.
47
+ //
48
+ // Published BEFORE `waitForModule('rest-server')` and before the mount loop,
49
+ // deliberately. That module may already be listening by the time it reports
50
+ // ready, so assigning after the mounts would leave a window in which a route
51
+ // exists and the registry does not.
52
+ //
53
+ // Assigned unconditionally, including when the try above failed and the map
54
+ // is empty or partial: the mount loop below is driven by this exact object,
55
+ // so whatever is reachable through `Orm.instance` is by construction the same
56
+ // set of predicates that is actually enforcing. A guard here that skipped the
57
+ // assignment would let the registry go silently missing, which is precisely
58
+ // the failure #202's AC8 exists to catch.
59
+ Orm.instance.accessFiles = accessFiles;
38
60
  await waitForModule('rest-server');
39
61
  // Remove "/" prefix and name mount point accordingly
40
62
  const name = route === '/' ? 'index' : (route[0] === '/' ? route.slice(1) : route);
@@ -162,3 +162,70 @@ export interface SnapshotEntry {
162
162
  source?: string;
163
163
  viewQuery?: string;
164
164
  }
165
+ /**
166
+ * The shapes a consumer `access()` predicate may return.
167
+ *
168
+ * - `false` (or any falsy value) -- deny, 403.
169
+ * - `true` -- allow, with no per-record filter.
170
+ * - a permission string or array of them, drawn from the same four verbs as
171
+ * {@link AccessContext.operation}. A BARE STRING IS ONE PERMISSION, not a
172
+ * grant of all four.
173
+ * - a `(record) => boolean` predicate -- allow, and filter every record the
174
+ * request touches through it.
175
+ *
176
+ * Anything else fails CLOSED. See `src/orm-request.ts` `auth()`.
177
+ */
178
+ export type AccessMethod = string | boolean | string[] | ((record: unknown) => boolean);
179
+ /**
180
+ * The structural facts about the request being authorised, handed to a consumer
181
+ * `access()` predicate as its SECOND argument (abofs/stonyx-orm#202).
182
+ *
183
+ * These are the facts the framework already holds at authorisation time. Before
184
+ * #202 a consumer had to reconstruct both of them by string-matching a URL, and
185
+ * five independent fail-open variants of that reconstruction were found in one
186
+ * three-line documented example -- each one wrong in the direction that GRANTS
187
+ * access. Read these instead; there is nothing to parse and no variant to miss.
188
+ *
189
+ * `record` is deliberately NOT a member. `auth()` runs after route matching but
190
+ * before any handler executes (`@stonyx/rest-server` `src/request.ts:58-60`),
191
+ * so nothing has been fetched yet -- carrying a record here would force a
192
+ * pre-fetch on every request. It is also unnecessary: the `(record) => boolean`
193
+ * return shape of {@link AccessMethod} already IS the per-record hook, applied
194
+ * by the handlers. Auth-time and record-time are separate decision points.
195
+ */
196
+ export interface AccessContext {
197
+ /**
198
+ * The model this route was mounted for, e.g. `'owner'` or `'phone-number'`.
199
+ *
200
+ * Model names are kebab-case, as declared under `config.orm.paths.model` and
201
+ * keyed in the store -- NOT the pluralised, mount-prefixed route name. It is
202
+ * read from the `OrmRequest` instance and is never derived from the request
203
+ * target, so a mount prefix, a case-varied path, a query string or an
204
+ * absolute-form request-target cannot change it.
205
+ */
206
+ model: string;
207
+ /**
208
+ * The operation being authorised: `'read'`, `'create'`, `'update'` or
209
+ * `'delete'`, and no second vocabulary. These are exactly the values of
210
+ * `methodAccessMap` in `src/orm-request.ts`, which is also what the
211
+ * permission-array return shape is matched against -- so the two forms cannot
212
+ * disagree.
213
+ *
214
+ * `undefined` when the dispatched method has no entry in that map. Express
215
+ * delivers `HEAD` to the `GET` handler, so this is reachable. It is left
216
+ * undefined rather than defaulted on purpose: a fabricated `'read'` would
217
+ * turn an unclassified request into an authorised one.
218
+ */
219
+ operation?: string;
220
+ }
221
+ /**
222
+ * A consumer `access()` predicate.
223
+ *
224
+ * `context` is optional in the type because the second argument is ADDITIVE:
225
+ * JavaScript ignores extra arguments, so every pre-#202 single-argument
226
+ * predicate keeps working untouched. Changing the FIRST argument instead would
227
+ * have been the breaking form, and a predicate that can no longer identify its
228
+ * collection falls through to a full CRUD grant -- so the "safer" breaking
229
+ * change would have converted every unmigrated predicate into a fail-open.
230
+ */
231
+ export type AccessFunction = (request: unknown, context?: AccessContext) => AccessMethod;
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-alpha.53",
7
+ "version": "0.3.2-alpha.55",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ import { count, avg, sum, min, max } from './aggregates.js';
27
27
  export { default } from './main.js';
28
28
  export { store, relationships } from './main.js';
29
29
  export type { PersistErrorDetail } from './main.js';
30
+ export type { AccessContext, AccessFunction, AccessMethod } from './types/orm-types.js'; // access() contract (#202)
30
31
  export { Model, View, Serializer }; // base classes
31
32
  export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
32
33
  export { count, avg, sum, min, max }; // aggregate helpers
package/src/main.ts CHANGED
@@ -25,6 +25,7 @@ import baseTransforms from './transforms.js';
25
25
  import Store from './store.js';
26
26
  import Serializer from './serializer.js';
27
27
  import { setup } from '@stonyx/events';
28
+ import type { AccessFunction } from './types/orm-types.js';
28
29
 
29
30
  interface OrmOptions {
30
31
  dbType?: string;
@@ -68,6 +69,32 @@ export default class Orm {
68
69
  views: Record<string, unknown> = {};
69
70
  transforms: Record<string, (value: unknown) => unknown> = { ...baseTransforms };
70
71
  warnings: Set<string> = new Set();
72
+
73
+ /**
74
+ * Model name -> that model's `access` predicate (abofs/stonyx-orm#202).
75
+ *
76
+ * Populated by `setup-rest-server.ts` at boot, from the access classes under
77
+ * `config.orm.paths.access`, BEFORE any route is mounted -- so it is complete
78
+ * and reachable before the first request can be served. The mapping is
79
+ * one-to-one by construction: setup-rest-server throws if two access classes
80
+ * claim the same model.
81
+ *
82
+ * Keys are model names as declared and stored (kebab-case, e.g.
83
+ * `'phone-number'`), NOT pluralised or mount-prefixed route names.
84
+ *
85
+ * WHY THIS EXISTS AS A FIELD. It used to be a function-local in
86
+ * setup-rest-server that was discarded when that function returned, so at
87
+ * request time there was no way to get from a model name to that model's
88
+ * predicate at all. Each `OrmRequest` held only its OWN model's predicate.
89
+ * That made cross-model authorization -- asking model X's predicate about a
90
+ * request routed to model Y -- inexpressible, which is the capability
91
+ * abofs/stonyx-orm#196 and abofs/stonyx-orm#207 are built on.
92
+ *
93
+ * Empty when the REST server is disabled, or when no access configuration
94
+ * could be loaded. Prefer {@link Orm#getAccess} over indexing this directly.
95
+ */
96
+ accessFiles: Record<string, AccessFunction> = {};
97
+
71
98
  options!: OrmOptions;
72
99
  sqlDb?: SqlDb;
73
100
  db?: OrmDB | SqlDb;
@@ -195,6 +222,31 @@ export default class Orm {
195
222
  Orm.initialized = true;
196
223
  }
197
224
 
225
+ /**
226
+ * Resolve a model's `access` predicate by model name (abofs/stonyx-orm#202).
227
+ *
228
+ * This is the supported way to reach another model's predicate while
229
+ * servicing a request routed to a different model. Call it with the model
230
+ * name and invoke the result with the live request and an explicit context
231
+ * naming THAT model:
232
+ *
233
+ * ```js
234
+ * const predicate = Orm.instance.getAccess('animal');
235
+ * const verdict = predicate?.(request, { model: 'animal', operation: 'read' });
236
+ * ```
237
+ *
238
+ * Passing the context is not optional in practice. A predicate that
239
+ * identifies its collection from the request would otherwise answer about the
240
+ * collection the request is ADDRESSED TO -- owners -- while being asked about
241
+ * animals, and per #202's thesis it answers wrong in the granting direction.
242
+ *
243
+ * @param modelName - Model name as declared and stored (kebab-case).
244
+ * @returns The predicate, or `undefined` when the model has no access class.
245
+ */
246
+ getAccess(modelName: string): AccessFunction | undefined {
247
+ return this.accessFiles[modelName];
248
+ }
249
+
198
250
  async startup(): Promise<void> {
199
251
  if (this.sqlDb) await this.sqlDb.startup();
200
252
  }
@@ -2,6 +2,62 @@
2
2
  * REST request handling and access enforcement for @stonyx/orm.
3
3
  *
4
4
  * ---------------------------------------------------------------------------
5
+ * THE `access()` CONTRACT: `access(request, { model, operation })`
6
+ * ---------------------------------------------------------------------------
7
+ * `auth()` calls your predicate with TWO arguments. The second is the access
8
+ * CONTEXT -- the structural facts about the request, which the framework
9
+ * already holds and which you should read INSTEAD of parsing anything:
10
+ *
11
+ * context.model The model this route was mounted for, as a model name:
12
+ * kebab-case, exactly as declared under
13
+ * `config.orm.paths.model` and keyed in the store --
14
+ * `'owner'`, `'animal'`, `'phone-number'`. NOT the
15
+ * pluralised, dasherized, mount-prefixed ROUTE name. It is
16
+ * read from the OrmRequest instance, fixed at mount time,
17
+ * and no request can influence it.
18
+ *
19
+ * context.operation The operation being authorised. Exactly one of the four
20
+ * verbs `'read'`, `'create'`, `'update'`, `'delete'` --
21
+ * there is no second vocabulary, and it is never an HTTP
22
+ * method name like `'GET'`. These are the same four
23
+ * strings the permission-array return shape is written in
24
+ * (`['read', 'create']`), because both come from the one
25
+ * `methodAccessMap` below.
26
+ *
27
+ * `undefined` when the dispatched method has no entry in
28
+ * that map. Express delivers `HEAD` to the `GET` handler,
29
+ * so this is reachable. It is left undefined rather than
30
+ * defaulted on purpose -- a fabricated `'read'` would turn
31
+ * an unclassified request into an authorised one. Treat
32
+ * `undefined` as "not classified" and deny.
33
+ *
34
+ * So a consumer writes `if (model === 'owner' && operation === 'read')`. There
35
+ * is no string to parse, no variant to miss, and no way to fail open through a
36
+ * URL shape nobody anticipated.
37
+ *
38
+ * `record` IS NOT IN THIS CONTEXT, deliberately. `auth()` runs after route
39
+ * matching but BEFORE any handler executes (`@stonyx/rest-server`
40
+ * `src/request.ts:58-60`), so nothing has been fetched yet -- supplying a
41
+ * record would force a pre-fetch on every request, a second store hit and an
42
+ * ordering change in the middle of an authorization path. It is also
43
+ * unnecessary: the FUNCTION return shape already is the per-record hook. Return
44
+ * `(record) => boolean` and the handlers apply it to every record the request
45
+ * touches. Auth-time and record-time are separate decision points.
46
+ *
47
+ * THE SECOND ARGUMENT IS ADDITIVE. JavaScript ignores extra arguments, so an
48
+ * existing `access(request)` predicate keeps working exactly as before. The
49
+ * warning immediately below is therefore still live: `request` is still
50
+ * argument ONE, and reading it is still how predicates fail open.
51
+ *
52
+ * To reach ANOTHER model's predicate -- e.g. to check an animal while servicing
53
+ * an owners route -- use the boot-time registry:
54
+ *
55
+ * const predicate = Orm.instance.getAccess('animal');
56
+ * const verdict = predicate?.(request, { model: 'animal', operation: 'read' });
57
+ *
58
+ * Passing the context explicitly is what makes that answer model-CORRECT.
59
+ *
60
+ * ---------------------------------------------------------------------------
5
61
  * DO NOT RECONSTRUCT THE REQUEST PATH INSIDE `access()`.
6
62
  * ---------------------------------------------------------------------------
7
63
  * `auth()` below hands your `access(request)` a raw transport artifact and asks
@@ -66,7 +122,7 @@ import { getBeforeHooks, getAfterHooks } from './hooks.js';
66
122
  import type { HookContext } from './hooks.js';
67
123
  import config from 'stonyx/config';
68
124
  import log from 'stonyx/log';
69
- import type { OrmRecord } from './types/orm-types.js';
125
+ import type { OrmRecord, AccessContext, AccessFunction, AccessMethod } from './types/orm-types.js';
70
126
  import { isOrmRecord } from './utils.js';
71
127
 
72
128
  interface OrmRequest$ extends Request {
@@ -94,7 +150,6 @@ interface JsonApiResponse {
94
150
  included?: unknown[];
95
151
  }
96
152
 
97
- type AccessMethod = string | boolean | string[] | ((record: unknown) => boolean);
98
153
  type HandlerFn = (request: OrmRequest$, state: { [key: string]: unknown }) => unknown | Promise<unknown>;
99
154
 
100
155
  const methodAccessMap: { [key: string]: string } = {
@@ -455,10 +510,10 @@ function isDenied(filter: unknown, record: unknown): boolean {
455
510
 
456
511
  export default class OrmRequest extends Request {
457
512
  model: string;
458
- access: (request: unknown) => AccessMethod;
513
+ access: AccessFunction;
459
514
  handlers: { [key: string]: { [key: string]: HandlerFn } };
460
515
 
461
- constructor({ model, access }: { model: string; access: (request: unknown) => AccessMethod }) {
516
+ constructor({ model, access }: { model: string; access: AccessFunction }) {
462
517
  super(...arguments as unknown as unknown[]);
463
518
 
464
519
  this.model = model;
@@ -1155,9 +1210,42 @@ export default class OrmRequest extends Request {
1155
1210
  // answers 500 -- and the documented sample itself can throw
1156
1211
  // (`request.originalUrl.split(...)` when originalUrl is absent), so the
1157
1212
  // failure mode is reachable by following the docs.
1213
+ // -------------------------------------------------------------------------
1214
+ // #202 -- hand the consumer the STRUCTURAL facts, not just the transport.
1215
+ //
1216
+ // Both members are already in hand here. `model` is `this.model`, the name
1217
+ // setup-rest-server mounted this route for; `operation` is the SAME
1218
+ // `methodAccessMap` lookup the permission-array branch at the bottom of
1219
+ // this method performs, so the predicate form and the array form cannot
1220
+ // answer differently about the same request.
1221
+ //
1222
+ // NEITHER IS DERIVED FROM THE REQUEST TARGET, and that is the whole point.
1223
+ // Deriving `model` here from `request.baseUrl` (or from the mounted route
1224
+ // name, or from `getPluralName(this.model)`) would move all five fail-open
1225
+ // variants listed in this file's header OUT of the consumer and INTO the
1226
+ // framework, where every consumer inherits them at once. `this.model` is
1227
+ // assigned once at mount time and no request can influence it.
1228
+ //
1229
+ // `operation` is left UNDEFINED for a method with no entry in
1230
+ // `methodAccessMap`, rather than defaulted. Express delivers HEAD to the
1231
+ // GET handler, so an unmapped method really does reach this line; a
1232
+ // `?? 'read'` here would hand the consumer a fabricated authorisation fact
1233
+ // and turn an unclassified request into an authorised one. Undefined is
1234
+ // the honest answer.
1235
+ //
1236
+ // `record` is deliberately absent -- see `AccessContext` in
1237
+ // src/types/orm-types.ts. Nothing is fetched at this point and adding a
1238
+ // lookup here would put a store read in the middle of an authorization
1239
+ // path. The function return shape below IS the per-record hook.
1240
+ // -------------------------------------------------------------------------
1241
+ const context: AccessContext = {
1242
+ model: this.model,
1243
+ operation: methodAccessMap[request.method],
1244
+ };
1245
+
1158
1246
  let access: AccessMethod;
1159
1247
  try {
1160
- access = this.access(request);
1248
+ access = this.access(request, context);
1161
1249
  } catch (error) {
1162
1250
  // Same reasoning as `isDenied`: fail closed, but say so. An `access()`
1163
1251
  // that throws denies EVERY request to the collection, and a silent 403
@@ -1,5 +1,5 @@
1
1
  import { waitForModule } from 'stonyx';
2
- import { store } from '@stonyx/orm';
2
+ import Orm, { store } from '@stonyx/orm';
3
3
  import OrmRequest from './orm-request.js';
4
4
  import MetaRequest from './meta-request.js';
5
5
  import RestServer from '@stonyx/rest-server';
@@ -7,14 +7,20 @@ import { forEachFileImport } from '@stonyx/utils/file';
7
7
  import { dbKey } from './db.js';
8
8
  import { getPluralName } from './plural-registry.js';
9
9
  import log from 'stonyx/log';
10
+ import type { AccessFunction } from './types/orm-types.js';
10
11
 
11
12
  interface AccessInstance {
12
13
  models: string[] | '*';
13
- access: (request: unknown) => unknown;
14
+ /**
15
+ * The consumer predicate. Called as `access(request, { model, operation })`
16
+ * -- the second argument is additive (abofs/stonyx-orm#202), so a predicate
17
+ * declared with a single parameter is still valid and still works.
18
+ */
19
+ access: AccessFunction;
14
20
  }
15
21
 
16
22
  export default async function(route: string, accessPath: string, metaRoute: boolean): Promise<void> {
17
- const accessFiles: Record<string, (request: unknown) => unknown> = {};
23
+ const accessFiles: Record<string, AccessFunction> = {};
18
24
 
19
25
  try {
20
26
  await forEachFileImport(accessPath, (accessClass: unknown) => {
@@ -41,6 +47,29 @@ export default async function(route: string, accessPath: string, metaRoute: bool
41
47
  log.warn?.('You must define a valid access configuration file in order to access ORM generated REST endpoints.');
42
48
  }
43
49
 
50
+ // -------------------------------------------------------------------------
51
+ // #202 -- the registry has to survive this function.
52
+ //
53
+ // `accessFiles` used to be a function-local that was discarded at the return
54
+ // below, so the only thing that ever saw it was the mount loop. Each mounted
55
+ // OrmRequest then held its OWN model's predicate and nothing held the map, so
56
+ // at request time there was no route from a model NAME to that model's
57
+ // predicate -- which is what abofs/stonyx-orm#196 and #207 need in order to
58
+ // ask model X's predicate about a request routed to model Y.
59
+ //
60
+ // Published BEFORE `waitForModule('rest-server')` and before the mount loop,
61
+ // deliberately. That module may already be listening by the time it reports
62
+ // ready, so assigning after the mounts would leave a window in which a route
63
+ // exists and the registry does not.
64
+ //
65
+ // Assigned unconditionally, including when the try above failed and the map
66
+ // is empty or partial: the mount loop below is driven by this exact object,
67
+ // so whatever is reachable through `Orm.instance` is by construction the same
68
+ // set of predicates that is actually enforcing. A guard here that skipped the
69
+ // assignment would let the registry go silently missing, which is precisely
70
+ // the failure #202's AC8 exists to catch.
71
+ Orm.instance.accessFiles = accessFiles;
72
+
44
73
  await waitForModule('rest-server');
45
74
 
46
75
  // Remove "/" prefix and name mount point accordingly
@@ -168,3 +168,74 @@ export interface SnapshotEntry {
168
168
  source?: string;
169
169
  viewQuery?: string;
170
170
  }
171
+
172
+ /**
173
+ * The shapes a consumer `access()` predicate may return.
174
+ *
175
+ * - `false` (or any falsy value) -- deny, 403.
176
+ * - `true` -- allow, with no per-record filter.
177
+ * - a permission string or array of them, drawn from the same four verbs as
178
+ * {@link AccessContext.operation}. A BARE STRING IS ONE PERMISSION, not a
179
+ * grant of all four.
180
+ * - a `(record) => boolean` predicate -- allow, and filter every record the
181
+ * request touches through it.
182
+ *
183
+ * Anything else fails CLOSED. See `src/orm-request.ts` `auth()`.
184
+ */
185
+ export type AccessMethod = string | boolean | string[] | ((record: unknown) => boolean);
186
+
187
+ /**
188
+ * The structural facts about the request being authorised, handed to a consumer
189
+ * `access()` predicate as its SECOND argument (abofs/stonyx-orm#202).
190
+ *
191
+ * These are the facts the framework already holds at authorisation time. Before
192
+ * #202 a consumer had to reconstruct both of them by string-matching a URL, and
193
+ * five independent fail-open variants of that reconstruction were found in one
194
+ * three-line documented example -- each one wrong in the direction that GRANTS
195
+ * access. Read these instead; there is nothing to parse and no variant to miss.
196
+ *
197
+ * `record` is deliberately NOT a member. `auth()` runs after route matching but
198
+ * before any handler executes (`@stonyx/rest-server` `src/request.ts:58-60`),
199
+ * so nothing has been fetched yet -- carrying a record here would force a
200
+ * pre-fetch on every request. It is also unnecessary: the `(record) => boolean`
201
+ * return shape of {@link AccessMethod} already IS the per-record hook, applied
202
+ * by the handlers. Auth-time and record-time are separate decision points.
203
+ */
204
+ export interface AccessContext {
205
+ /**
206
+ * The model this route was mounted for, e.g. `'owner'` or `'phone-number'`.
207
+ *
208
+ * Model names are kebab-case, as declared under `config.orm.paths.model` and
209
+ * keyed in the store -- NOT the pluralised, mount-prefixed route name. It is
210
+ * read from the `OrmRequest` instance and is never derived from the request
211
+ * target, so a mount prefix, a case-varied path, a query string or an
212
+ * absolute-form request-target cannot change it.
213
+ */
214
+ model: string;
215
+
216
+ /**
217
+ * The operation being authorised: `'read'`, `'create'`, `'update'` or
218
+ * `'delete'`, and no second vocabulary. These are exactly the values of
219
+ * `methodAccessMap` in `src/orm-request.ts`, which is also what the
220
+ * permission-array return shape is matched against -- so the two forms cannot
221
+ * disagree.
222
+ *
223
+ * `undefined` when the dispatched method has no entry in that map. Express
224
+ * delivers `HEAD` to the `GET` handler, so this is reachable. It is left
225
+ * undefined rather than defaulted on purpose: a fabricated `'read'` would
226
+ * turn an unclassified request into an authorised one.
227
+ */
228
+ operation?: string;
229
+ }
230
+
231
+ /**
232
+ * A consumer `access()` predicate.
233
+ *
234
+ * `context` is optional in the type because the second argument is ADDITIVE:
235
+ * JavaScript ignores extra arguments, so every pre-#202 single-argument
236
+ * predicate keeps working untouched. Changing the FIRST argument instead would
237
+ * have been the breaking form, and a predicate that can no longer identify its
238
+ * collection falls through to a full CRUD grant -- so the "safer" breaking
239
+ * change would have converted every unmigrated predicate into a fail-open.
240
+ */
241
+ export type AccessFunction = (request: unknown, context?: AccessContext) => AccessMethod;