@stonyx/orm 0.3.2-beta.153 → 0.3.2-beta.154

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.
@@ -2,6 +2,124 @@
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
+ * no second vocabulary ON THIS PATH, and 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
+ * NOT the hook vocabulary. `HookContext.operation`
28
+ * (`src/hooks.ts`, documented under "Hook Context Object"
29
+ * in the README) carries `'list' | 'get' | 'create' |
30
+ * 'update' | 'delete'` on an identically-named key of an
31
+ * identically-shaped context object, and the access
32
+ * vocabulary collapses `list` and `get` into `'read'`. For
33
+ * one `GET /animals/1` a hook sees `'get'` and `access()`
34
+ * sees `'read'`, so a predicate cannot tell a collection
35
+ * read from a record read. `AccessOperation` makes
36
+ * `operation === 'get'` a compile error for a TypeScript
37
+ * consumer, because a predicate that stops matching falls
38
+ * through to the permission array -- the misreading is
39
+ * fail-open shaped.
40
+ *
41
+ * `undefined` when the dispatched method has no entry in
42
+ * that map. Express delivers `HEAD` to the `GET` handler,
43
+ * so this is reachable. It is left undefined rather than
44
+ * defaulted on purpose -- a fabricated `'read'` would turn
45
+ * an unclassified request into an authorised one. Treat
46
+ * `undefined` as "not classified" and deny.
47
+ *
48
+ * So a consumer writes `if (model === 'owner' && operation === 'read')`. There
49
+ * is no string to parse, no variant to miss, and no way to fail open through a
50
+ * URL shape nobody anticipated.
51
+ *
52
+ * WHAT THE CONTEXT DOES NOT TELL YOU: WHICH SURFACE. It names the model and
53
+ * the verb, not the route. Measured over the live router, six surfaces produce
54
+ * one identical context:
55
+ *
56
+ * GET /owners { model: 'owner', operation: 'read' }
57
+ * GET /owners/gina { model: 'owner', operation: 'read' }
58
+ * GET /owners/gina/pets { model: 'owner', operation: 'read' }
59
+ * GET /owners/gina/relationships/pets { model: 'owner', operation: 'read' }
60
+ * GET /owners/archived { model: 'owner', operation: 'read' }
61
+ * GET /owners/gina?include=pets { model: 'owner', operation: 'read' }
62
+ *
63
+ * So a rule that depends on the SUB-PATH still needs `request.path` -- which is
64
+ * mount-relative and query-free, and is the one read of argument one the
65
+ * warning below sanctions. This repo's own fixture has such a rule: its
66
+ * `/archived` deny cannot be expressed from the context alone, and a predicate
67
+ * migrated to context-only would silently drop it, turning a deny into an
68
+ * allow. The related-resource and `?include=` surfaces serve ANOTHER model's
69
+ * records under `model: 'owner'`, and the context gives no signal of that
70
+ * (abofs/stonyx-orm#196).
71
+ *
72
+ * `record` IS NOT IN THIS CONTEXT, deliberately. `auth()` runs after route
73
+ * matching but BEFORE any handler executes (`@stonyx/rest-server`
74
+ * `src/request.ts:58-60`), so nothing has been fetched yet -- supplying a
75
+ * record would force a pre-fetch on every request, a second store hit and an
76
+ * ordering change in the middle of an authorization path. It is also
77
+ * unnecessary: the FUNCTION return shape already is the per-record hook. Return
78
+ * `(record) => boolean` and the handlers apply it to every record the request
79
+ * touches. Auth-time and record-time are separate decision points.
80
+ *
81
+ * THE SECOND ARGUMENT IS ADDITIVE. JavaScript ignores extra arguments, so an
82
+ * existing `access(request)` predicate keeps working exactly as before. The
83
+ * warning immediately below is therefore still live: `request` is still
84
+ * argument ONE, and reading it is still how predicates fail open.
85
+ *
86
+ * To reach ANOTHER model's predicate -- e.g. to check an animal while servicing
87
+ * an owners route -- use the boot-time registry:
88
+ *
89
+ * const predicate = Orm.instance.getAccess('animal');
90
+ * if (!predicate) return deny;
91
+ * const verdict = predicate(request, { model: 'animal', operation: 'read' });
92
+ *
93
+ * `undefined` means NO PREDICATE COULD BE RESOLVED for that name -- which
94
+ * includes the case where the model has an access class that failed to load,
95
+ * because `setup-rest-server.ts` catches a load failure, warns, and publishes
96
+ * whatever partial map it had. It does NOT mean the model is unrestricted.
97
+ * Treat it as DENY, the same way `operation === undefined` is treated above.
98
+ *
99
+ * PASSING THE CONTEXT MAKES A MODEL-CORRECT ANSWER POSSIBLE. It does not make
100
+ * the answer model-correct on its own -- the resolved predicate has to READ it.
101
+ * Measured against this repo's own shipped access class, on a request express
102
+ * dispatched to `GET /owners/angela`, asked about ANIMALS:
103
+ *
104
+ * getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
105
+ * -> record => record.id !== 'angela' && record.id !== 'restricted'
106
+ *
107
+ * That is the OWNERS filter, and it returns `true` for animal 21 -- the record
108
+ * hidden on every animal surface. Under a mount that predicate recognises
109
+ * neither way it is worse: it falls through to
110
+ * `['read', 'create', 'update', 'delete']`, a full CRUD grant. Either way the
111
+ * context was supplied and the answer is not the animal answer, and it is wrong
112
+ * in the GRANTING direction, because that predicate is arity-1 and identifies
113
+ * its collection from the request. (The first of these is asserted on a live
114
+ * dispatch by AC9 in test/integration/orm-test.ts.)
115
+ *
116
+ * Every predicate in this repo and in every consumer tree is arity-1 on the day
117
+ * this ships, and the caller has no supported way to tell which kind it got --
118
+ * the boot-time arity warning that would surface it is abofs/stonyx-orm#213.
119
+ * So: pass the context, and do not treat a resolved predicate's answer as
120
+ * model-specific until that predicate has been migrated to read the context.
121
+ *
122
+ * ---------------------------------------------------------------------------
5
123
  * DO NOT RECONSTRUCT THE REQUEST PATH INSIDE `access()`.
6
124
  * ---------------------------------------------------------------------------
7
125
  * `auth()` below hands your `access(request)` a raw transport artifact and asks
@@ -1036,9 +1154,41 @@ export default class OrmRequest extends Request {
1036
1154
  // answers 500 -- and the documented sample itself can throw
1037
1155
  // (`request.originalUrl.split(...)` when originalUrl is absent), so the
1038
1156
  // failure mode is reachable by following the docs.
1157
+ // -------------------------------------------------------------------------
1158
+ // #202 -- hand the consumer the STRUCTURAL facts, not just the transport.
1159
+ //
1160
+ // Both members are already in hand here. `model` is `this.model`, the name
1161
+ // setup-rest-server mounted this route for; `operation` is the SAME
1162
+ // `methodAccessMap` lookup the permission-array branch at the bottom of
1163
+ // this method performs, so the predicate form and the array form cannot
1164
+ // answer differently about the same request.
1165
+ //
1166
+ // NEITHER IS DERIVED FROM THE REQUEST TARGET, and that is the whole point.
1167
+ // Deriving `model` here from `request.baseUrl` (or from the mounted route
1168
+ // name, or from `getPluralName(this.model)`) would move all five fail-open
1169
+ // variants listed in this file's header OUT of the consumer and INTO the
1170
+ // framework, where every consumer inherits them at once. `this.model` is
1171
+ // assigned once at mount time and no request can influence it.
1172
+ //
1173
+ // `operation` is left UNDEFINED for a method with no entry in
1174
+ // `methodAccessMap`, rather than defaulted. Express delivers HEAD to the
1175
+ // GET handler, so an unmapped method really does reach this line; a
1176
+ // `?? 'read'` here would hand the consumer a fabricated authorisation fact
1177
+ // and turn an unclassified request into an authorised one. Undefined is
1178
+ // the honest answer.
1179
+ //
1180
+ // `record` is deliberately absent -- see `AccessContext` in
1181
+ // src/types/orm-types.ts. Nothing is fetched at this point and adding a
1182
+ // lookup here would put a store read in the middle of an authorization
1183
+ // path. The function return shape below IS the per-record hook.
1184
+ // -------------------------------------------------------------------------
1185
+ const context = {
1186
+ model: this.model,
1187
+ operation: methodAccessMap[request.method],
1188
+ };
1039
1189
  let access;
1040
1190
  try {
1041
- access = this.access(request);
1191
+ access = this.access(request, context);
1042
1192
  }
1043
1193
  catch (error) {
1044
1194
  // 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';
@@ -8,7 +8,7 @@ import { dbKey } from './db.js';
8
8
  import { getPluralName } from './plural-registry.js';
9
9
  import log from 'stonyx/log';
10
10
  export default async function (route, accessPath, metaRoute) {
11
- const accessFiles = {};
11
+ const accessFunctions = {};
12
12
  try {
13
13
  await forEachFileImport(accessPath, (accessClass) => {
14
14
  const accessInstance = new accessClass();
@@ -25,9 +25,9 @@ export default async function (route, accessPath, metaRoute) {
25
25
  continue;
26
26
  if (!store.data.has(model))
27
27
  throw new Error(`Unable to define access for Invalid Model "${model}". Model does not exist`);
28
- if (accessFiles[model])
28
+ if (accessFunctions[model])
29
29
  throw new Error(`Access for model "${model}" has already been defined by another access class.`);
30
- accessFiles[model] = accessInstance.access;
30
+ accessFunctions[model] = accessInstance.access;
31
31
  }
32
32
  });
33
33
  }
@@ -35,11 +35,57 @@ 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
+ // `accessFunctions` 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 `await waitForModule('rest-server')`, deliberately: that
49
+ // await is the ONLY yield point in this function, and the rest-server module
50
+ // may already be listening by the time it reports ready, so an assignment
51
+ // after it would leave a window in which a route is live and the registry is
52
+ // not.
53
+ //
54
+ // It is NOT before the mount loop for that reason, and the comment here used
55
+ // to say it was. `RestServer.mountRoute` is fully synchronous -- construct,
56
+ // registerCalls(), api.use() -- and nothing between the loop and this
57
+ // function's closing brace yields, so the event loop cannot deliver a request
58
+ // in there and the window that clause described cannot open. Measured:
59
+ // moving this assignment to the last statement of the function leaves the
60
+ // suite at 951 pass / 0 fail. Being ahead of the mount loop is free and
61
+ // harmless; it is not what makes the ordering correct.
62
+ //
63
+ // Assigned unconditionally, including when the try above failed and the map
64
+ // is empty or partial: the mount loop below is driven by this exact object,
65
+ // so at the moment of assignment whatever is reachable through
66
+ // `Orm.instance` is the same set of predicates that is about to enforce.
67
+ // A guard such as `if (Object.keys(accessFunctions).length)` would let the
68
+ // registry go silently missing on a total load failure, and a later consumer
69
+ // would read `undefined` from `getAccess` and have to distinguish "no access
70
+ // class" from "the registry was never published" -- which it cannot. That is
71
+ // the reasoning, and it is REASONING, not something this suite tests: the
72
+ // guarded variant is also 951 pass / 0 fail, AC8 included, because every boot
73
+ // in this suite loads a non-empty access map so the guard never fires. AC8
74
+ // demonstrably cannot catch it. Catching it needs a boot with
75
+ // `orm.paths.access` pointed at an empty directory, which this suite has no
76
+ // harness for.
77
+ //
78
+ // One further limit on "by construction": the mount loop passes `access` BY
79
+ // VALUE into each OrmRequest, so the enforcing set is a snapshot taken here,
80
+ // while `getAccess` reads the map live. The two are the same set at boot and
81
+ // stay the same set only for as long as nobody writes to the public field.
82
+ // The equality is a boot-time fact, not an invariant.
83
+ Orm.instance.accessFunctions = accessFunctions;
38
84
  await waitForModule('rest-server');
39
85
  // Remove "/" prefix and name mount point accordingly
40
86
  const name = route === '/' ? 'index' : (route[0] === '/' ? route.slice(1) : route);
41
87
  // Configure endpoints for models and views with access configuration
42
- for (const [model, access] of Object.entries(accessFiles)) {
88
+ for (const [model, access] of Object.entries(accessFunctions)) {
43
89
  const pluralizedModel = getPluralName(model);
44
90
  const modelName = name === 'index' ? pluralizedModel : `${name}/${pluralizedModel}`;
45
91
  RestServer.instance.mountRoute(OrmRequest, { name: modelName, options: { model, access } });
@@ -162,3 +162,104 @@ 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 closed vocabulary `AccessContext.operation` is drawn from
181
+ * (abofs/stonyx-orm#202).
182
+ *
183
+ * A literal union rather than `string`, so the guarantee the prose makes is the
184
+ * one the compiler enforces: a consumer who writes `operation === 'GET'` or
185
+ * `operation === 'get'` -- the hook vocabulary, see below -- gets a compile
186
+ * error instead of a comparison that never matches. A predicate that stops
187
+ * matching falls through to the permission array, so the misreading is
188
+ * fail-open shaped.
189
+ *
190
+ * In-repo precedent: `PersistErrorDetail.operation` in `src/main.ts`.
191
+ */
192
+ export type AccessOperation = 'read' | 'create' | 'update' | 'delete';
193
+ /**
194
+ * The structural facts about the request being authorised, handed to a consumer
195
+ * `access()` predicate as its SECOND argument (abofs/stonyx-orm#202).
196
+ *
197
+ * These are the facts the framework already holds at authorisation time. Before
198
+ * #202 a consumer had to reconstruct both of them by string-matching a URL, and
199
+ * five independent fail-open variants of that reconstruction were found in one
200
+ * three-line documented example -- each one wrong in the direction that GRANTS
201
+ * access. Read these instead; there is nothing to parse and no variant to miss.
202
+ *
203
+ * `record` is deliberately NOT a member. `auth()` runs after route matching but
204
+ * before any handler executes (`@stonyx/rest-server` `src/request.ts:58-60`),
205
+ * so nothing has been fetched yet -- carrying a record here would force a
206
+ * pre-fetch on every request. It is also unnecessary: the `(record) => boolean`
207
+ * return shape of {@link AccessMethod} already IS the per-record hook, applied
208
+ * by the handlers. Auth-time and record-time are separate decision points.
209
+ */
210
+ export interface AccessContext {
211
+ /**
212
+ * The model this route was mounted for, e.g. `'owner'` or `'phone-number'`.
213
+ *
214
+ * Model names are kebab-case, as declared under `config.orm.paths.model` and
215
+ * keyed in the store -- NOT the pluralised, mount-prefixed route name. It is
216
+ * read from the `OrmRequest` instance and is never derived from the request
217
+ * target, so a mount prefix, a case-varied path, a query string or an
218
+ * absolute-form request-target cannot change it.
219
+ */
220
+ model: string;
221
+ /**
222
+ * The operation being authorised. Exactly one of the four {@link
223
+ * AccessOperation} verbs, or `undefined`. These are exactly the values of
224
+ * `methodAccessMap` in `src/orm-request.ts`, which is also what the
225
+ * permission-array return shape is matched against -- so the two forms cannot
226
+ * disagree.
227
+ *
228
+ * NOT the hook vocabulary. `HookContext.operation` (`src/hooks.ts`) carries
229
+ * `'list' | 'get' | 'create' | 'update' | 'delete'` on an identically-named
230
+ * key of an identically-shaped context object, and the access vocabulary
231
+ * collapses `list` and `get` into `'read'`. For one `GET /animals/1` a hook
232
+ * sees `'get'` and `access()` sees `'read'`. "No second vocabulary" is a
233
+ * statement about the ACCESS path only.
234
+ *
235
+ * `undefined` when the dispatched method has no entry in that map. Express
236
+ * delivers `HEAD` to the `GET` handler, so this is reachable. It is left
237
+ * undefined rather than defaulted on purpose: a fabricated `'read'` would
238
+ * turn an unclassified request into an authorised one.
239
+ *
240
+ * The KEY is required even though the value may be undefined: `auth()` always
241
+ * sets it, and a context that simply omitted it would be indistinguishable
242
+ * from one that classified the request and found nothing.
243
+ */
244
+ operation: AccessOperation | undefined;
245
+ }
246
+ /**
247
+ * A consumer `access()` predicate.
248
+ *
249
+ * The second argument is ADDITIVE: JavaScript ignores extra arguments, so every
250
+ * pre-#202 single-argument predicate keeps working untouched. Changing the
251
+ * FIRST argument instead would have been the breaking form, and a predicate
252
+ * that can no longer identify its collection falls through to a full CRUD
253
+ * grant -- so the "safer" breaking change would have converted every unmigrated
254
+ * predicate into a fail-open.
255
+ *
256
+ * `context` is nonetheless REQUIRED in the type, and that costs back-compat
257
+ * nothing. TypeScript already lets a fewer-parameter implementation satisfy a
258
+ * more-parameter signature, so an arity-1 predicate assigns to this type
259
+ * cleanly -- measured under `--strict`. What the `?` bought was the opposite of
260
+ * safety: it silently permitted `getAccess('animal')?.(request)` at the CALL
261
+ * site, i.e. exactly the omission {@link AccessContext} exists to prevent, and
262
+ * that call gets the model-wrong answer. Required, a caller that drops the
263
+ * context gets `TS2554: Expected 2 arguments, but got 1`.
264
+ */
265
+ 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-beta.153",
7
+ "version": "0.3.2-beta.154",
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, AccessOperation } 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,53 @@ 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 -> the `access` predicate of the access class that CLAIMS that
75
+ * model (abofs/stonyx-orm#202).
76
+ *
77
+ * Not "that model's own predicate". One access class may claim many models
78
+ * -- `GlobalAccess` in this repo's fixtures declares five, and `models = '*'`
79
+ * claims every model in the store -- and it declares ONE `access` method, so
80
+ * the same function object is registered under every one of those keys.
81
+ * `getAccess('owner') === getAccess('animal')` is `true` there. The
82
+ * one-to-one guarantee below is key -> function, never function -> model,
83
+ * and a caller must not read a resolved predicate as being animal-specific.
84
+ * What makes the ANSWER model-specific is the context the caller passes and
85
+ * the predicate actually reading it -- see {@link Orm#getAccess}.
86
+ *
87
+ * NAMED FOR WHAT IT HOLDS. It was `accessFiles` through review, inherited
88
+ * from the function-local in `setup-rest-server.ts` where the values came
89
+ * straight out of `forEachFileImport` and "files" was defensible. The values
90
+ * are `AccessFunction`s, and the sibling public registries on this class
91
+ * (`models`, `serializers`, `views`, `transforms`) are all plural nouns of
92
+ * the thing held. Renamed here because #202 is the last moment it is free.
93
+ *
94
+ * Populated by `setup-rest-server.ts` at boot, from the access classes under
95
+ * `config.orm.paths.access`, BEFORE any route is mounted -- so it is complete
96
+ * and reachable before the first request can be served. The mapping is
97
+ * one-to-one by construction: setup-rest-server throws if two access classes
98
+ * claim the same model.
99
+ *
100
+ * Keys are model names as declared and stored (kebab-case, e.g.
101
+ * `'phone-number'`), NOT pluralised or mount-prefixed route names.
102
+ *
103
+ * WHY THIS EXISTS AS A FIELD. It used to be a function-local in
104
+ * setup-rest-server that was discarded when that function returned, so at
105
+ * request time there was no way to get from a model name to that model's
106
+ * predicate at all. Each `OrmRequest` held only its OWN model's predicate.
107
+ * That made cross-model authorization -- asking model X's predicate about a
108
+ * request routed to model Y -- inexpressible, which is the capability
109
+ * abofs/stonyx-orm#196 and abofs/stonyx-orm#207 are built on.
110
+ *
111
+ * Empty when the REST server is disabled, and PARTIAL when one access file
112
+ * failed to load (`setup-rest-server.ts` catches, warns and assigns whatever
113
+ * it had). So a missing key does NOT mean the model has no access class.
114
+ * Prefer {@link Orm#getAccess} over indexing this directly -- it is guarded
115
+ * against the prototype chain and this is not.
116
+ */
117
+ accessFunctions: Record<string, AccessFunction> = {};
118
+
71
119
  options!: OrmOptions;
72
120
  sqlDb?: SqlDb;
73
121
  db?: OrmDB | SqlDb;
@@ -195,6 +243,81 @@ export default class Orm {
195
243
  Orm.initialized = true;
196
244
  }
197
245
 
246
+ /**
247
+ * Resolve the `access` predicate registered for a model name
248
+ * (abofs/stonyx-orm#202).
249
+ *
250
+ * This is the supported way to reach another model's predicate while
251
+ * servicing a request routed to a different model. Call it with the model
252
+ * name and invoke the result with the live request and an explicit context
253
+ * naming THAT model:
254
+ *
255
+ * ```js
256
+ * const predicate = Orm.instance.getAccess('animal');
257
+ * if (!predicate) return deny;
258
+ * const verdict = predicate(request, { model: 'animal', operation: 'read' });
259
+ * ```
260
+ *
261
+ * WHAT IT RESOLVES. The predicate of the access CLASS that claims the model,
262
+ * which is not necessarily specific to it: one class may claim many models
263
+ * and declares one `access` method, so
264
+ * `getAccess('owner') === getAccess('animal')` is `true` against this repo's
265
+ * fixture. See {@link Orm#accessFunctions}.
266
+ *
267
+ * `undefined` means NO PREDICATE COULD BE RESOLVED for that name. That
268
+ * includes a model whose access class failed to LOAD -- `setup-rest-server`
269
+ * catches, warns and publishes the partial map -- so it is not the same claim
270
+ * as "this model is unrestricted". Treat it as DENY, the same way
271
+ * `AccessContext.operation === undefined` is treated.
272
+ *
273
+ * PASSING THE CONTEXT MAKES A MODEL-CORRECT ANSWER POSSIBLE. It does not, on
274
+ * its own, make the answer model-correct: the resolved predicate has to READ
275
+ * the context. Measured against this repo's shipped access class on a request
276
+ * express dispatched to `GET /owners/angela`, asked about ANIMALS:
277
+ *
278
+ * ```
279
+ * getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
280
+ * -> record => record.id !== 'angela' && record.id !== 'restricted'
281
+ * ```
282
+ *
283
+ * The OWNERS filter, which returns `true` for animal 21 -- the record hidden
284
+ * on every animal surface. Under a mount that predicate recognises neither
285
+ * way it falls through to `['read', 'create', 'update', 'delete']`, a full
286
+ * CRUD grant. Either way: context supplied, answer not the animal answer,
287
+ * wrong in the GRANTING direction, because that predicate is arity-1 and
288
+ * identifies its collection from the request. AC9 asserts the first case on a
289
+ * live dispatch.
290
+ *
291
+ * Every predicate in this repo and in every consumer tree is arity-1 today,
292
+ * and there is no supported way for the caller to tell which kind it got; the
293
+ * boot-time arity warning that would surface it is abofs/stonyx-orm#213. Pass
294
+ * the context, and do not treat a resolved predicate's answer as
295
+ * model-specific until that predicate reads it.
296
+ *
297
+ * OWN PROPERTIES ONLY. A bare `this.accessFunctions[modelName]` walks the
298
+ * prototype chain, so `getAccess('constructor')` resolved `Object` and
299
+ * `getAccess('toString')` resolved `Object.prototype.toString` -- both
300
+ * callable, and the documented `predicate?.(request, ctx)` pattern then
301
+ * returned a TRUTHY value (`Object(request)` is the request), bypassing the
302
+ * `undefined`-means-deny contract entirely. Nothing in the ORM calls
303
+ * `getAccess` yet, so it was not exploitable as shipped -- but #207 takes the
304
+ * model name from the REQUEST BODY (`data.relationships.<key>.data.type`),
305
+ * which would have made a one-field body an authorization bypass. Guarded
306
+ * here at the read point rather than by constructing the map with a null
307
+ * prototype, because the field is public and reassignable and the guard has
308
+ * to hold whatever object it is holding.
309
+ *
310
+ * @param modelName - Model name as declared and stored (kebab-case).
311
+ * @returns The predicate, or `undefined` when no predicate could be resolved
312
+ * for that name. `undefined` is NOT "this model is unrestricted" -- see the
313
+ * note above. Treat it as deny.
314
+ */
315
+ getAccess(modelName: string): AccessFunction | undefined {
316
+ if (!Object.hasOwn(this.accessFunctions, modelName)) return undefined;
317
+
318
+ return this.accessFunctions[modelName];
319
+ }
320
+
198
321
  async startup(): Promise<void> {
199
322
  if (this.sqlDb) await this.sqlDb.startup();
200
323
  }