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

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
@@ -66,8 +184,8 @@ import { getBeforeHooks, getAfterHooks } from './hooks.js';
66
184
  import type { HookContext } from './hooks.js';
67
185
  import config from 'stonyx/config';
68
186
  import log from 'stonyx/log';
69
- import type { OrmRecord } from './types/orm-types.js';
70
- import { isOrmRecord } from './utils.js';
187
+ import type { OrmRecord, AccessContext, AccessFunction, AccessMethod, AccessOperation } from './types/orm-types.js';
188
+ import { isOrmRecord, NO_FREE_ID_ERROR } from './utils.js';
71
189
 
72
190
  interface OrmRequest$ extends Request {
73
191
  protocol?: string;
@@ -94,10 +212,9 @@ interface JsonApiResponse {
94
212
  included?: unknown[];
95
213
  }
96
214
 
97
- type AccessMethod = string | boolean | string[] | ((record: unknown) => boolean);
98
215
  type HandlerFn = (request: OrmRequest$, state: { [key: string]: unknown }) => unknown | Promise<unknown>;
99
216
 
100
- const methodAccessMap: { [key: string]: string } = {
217
+ const methodAccessMap: { [key: string]: AccessOperation } = {
101
218
  GET: 'read',
102
219
  POST: 'create',
103
220
  DELETE: 'delete',
@@ -455,10 +572,10 @@ function isDenied(filter: unknown, record: unknown): boolean {
455
572
 
456
573
  export default class OrmRequest extends Request {
457
574
  model: string;
458
- access: (request: unknown) => AccessMethod;
575
+ access: AccessFunction;
459
576
  handlers: { [key: string]: { [key: string]: HandlerFn } };
460
577
 
461
- constructor({ model, access }: { model: string; access: (request: unknown) => AccessMethod }) {
578
+ constructor({ model, access }: { model: string; access: AccessFunction }) {
462
579
  super(...arguments as unknown as unknown[]);
463
580
 
464
581
  this.model = model;
@@ -645,7 +762,41 @@ export default class OrmRequest extends Request {
645
762
  // only O(1) signal that distinguishes an insert from an overwrite.
646
763
  const slotsBefore = store.get(model)?.size ?? 0;
647
764
 
648
- const created = createRecord(model, recordAttributes as { [key: string]: unknown }, { serialize: false, _skipAutoPersist: true });
765
+ // THE ONE `createRecord` FAILURE THIS ROUTE ANSWERS RATHER THAN
766
+ // PROPAGATES, and it is narrow on purpose.
767
+ //
768
+ // `assignRecordId` throws when it cannot derive a free store key for a
769
+ // server-assigned id. Unguarded that rejection is auto-forwarded -- there
770
+ // is no catch here, none in @stonyx/rest-server's dispatcher
771
+ // (dist/request.js:41-70), and express 5 hands it to its default error
772
+ // handler, which serialises the STACK, with absolute install paths and the
773
+ // internal module graph, to an unauthenticated caller outside
774
+ // NODE_ENV=production. That is the hazard :553-558 already names in this
775
+ // file, and every sibling refusal in this handler returns an integer
776
+ // status instead. So this one returns 409, matching the client-duplicate
777
+ // refusal at :713: the caller asked for a record and the collection has no
778
+ // id to give it.
779
+ //
780
+ // MATCHED ON THE SHARED PREFIX, not on a literal, and NOT by catching
781
+ // everything: `createRecord` also throws for "ORM is not ready", a
782
+ // read-only view and an unregistered model store, and turning any of those
783
+ // into a 409 would report a configuration fault as a conflict. Anything
784
+ // else is re-thrown unchanged.
785
+ let created;
786
+
787
+ try {
788
+ created = createRecord(model, recordAttributes as { [key: string]: unknown }, { serialize: false, _skipAutoPersist: true });
789
+ } catch (error) {
790
+ if (!(error instanceof Error) || !error.message.startsWith(NO_FREE_ID_ERROR)) throw error;
791
+
792
+ // Not silently. A collection that can no longer assign an id is a
793
+ // configuration fault (a non-injective id transform), and a bare 409
794
+ // with no diagnostic is indistinguishable from an ordinary duplicate.
795
+ log.error?.(`[@stonyx/orm] ${error.message}`);
796
+
797
+ return 409; // Conflict
798
+ }
799
+
649
800
  const record = isOrmRecord(created) ? created : null;
650
801
  if (!record) return 500;
651
802
 
@@ -670,11 +821,32 @@ export default class OrmRequest extends Request {
670
821
  //
671
822
  // Both conditions are required and neither implies the other:
672
823
  // createdNewSlot -- the store grew, so this request inserted rather
673
- // than overwrote. Guards `assignRecordId` picking an
674
- // id that is already taken (it returns
675
- // last-INSERTED + 1, not max + 1, so a store whose
676
- // insertion order is not ascending collides) -- see
677
- // abofs/stonyx-orm#203.
824
+ // than overwrote. SURVIVOR AS OF #203, AND THAT IS
825
+ // WHAT THIS NOTE IS FOR. It used to be killable:
826
+ // `assignRecordId` returned last-INSERTED + 1, so a
827
+ // server-assigned id could land on an occupied slot,
828
+ // `createRecord` updated in place, and removing this
829
+ // half turned access-filter-enforcement-test.ts
830
+ // assertion 31 red. #203 closed that: the
831
+ // server-assigned path now walks past occupied keys,
832
+ // so no create reaching here can overwrite. Measured
833
+ // -- delete `createdNewSlot &&` below: `dev` gives
834
+ // 55 pass / 1 fail with assertion 31 RED, this tree
835
+ // gives 56 pass / 0 fail, GREEN.
836
+ // KEPT ANYWAY, AND NOT FOR THE OLD REASON. Without
837
+ // it a denied create becomes `store.remove` on a key
838
+ // the caller may have influenced, which :815-820
839
+ // records as having been an unauthenticated deletion
840
+ // primitive across the whole id space. BECOMES
841
+ // KILLABLE AGAIN the moment any caller-supplied id
842
+ // can reach `createRecord` from this handler --
843
+ // which is exactly what has-many.ts:65 and
844
+ // belongs-to.ts:45 already do for ANOTHER model's
845
+ // store (abofs/stonyx-orm#207), and what a third
846
+ // un-stripped id channel would do for this one
847
+ // (#204). Do not delete it on the strength of #203
848
+ // being closed; that is the reasoning :862-867 warns
849
+ // about, one level up.
678
850
  // identity -- the slot still holds the object we just created,
679
851
  // so nothing between createRecord and here replaced
680
852
  // it. Deleting this half SURVIVES the suite, and it
@@ -1155,9 +1327,42 @@ export default class OrmRequest extends Request {
1155
1327
  // answers 500 -- and the documented sample itself can throw
1156
1328
  // (`request.originalUrl.split(...)` when originalUrl is absent), so the
1157
1329
  // failure mode is reachable by following the docs.
1330
+ // -------------------------------------------------------------------------
1331
+ // #202 -- hand the consumer the STRUCTURAL facts, not just the transport.
1332
+ //
1333
+ // Both members are already in hand here. `model` is `this.model`, the name
1334
+ // setup-rest-server mounted this route for; `operation` is the SAME
1335
+ // `methodAccessMap` lookup the permission-array branch at the bottom of
1336
+ // this method performs, so the predicate form and the array form cannot
1337
+ // answer differently about the same request.
1338
+ //
1339
+ // NEITHER IS DERIVED FROM THE REQUEST TARGET, and that is the whole point.
1340
+ // Deriving `model` here from `request.baseUrl` (or from the mounted route
1341
+ // name, or from `getPluralName(this.model)`) would move all five fail-open
1342
+ // variants listed in this file's header OUT of the consumer and INTO the
1343
+ // framework, where every consumer inherits them at once. `this.model` is
1344
+ // assigned once at mount time and no request can influence it.
1345
+ //
1346
+ // `operation` is left UNDEFINED for a method with no entry in
1347
+ // `methodAccessMap`, rather than defaulted. Express delivers HEAD to the
1348
+ // GET handler, so an unmapped method really does reach this line; a
1349
+ // `?? 'read'` here would hand the consumer a fabricated authorisation fact
1350
+ // and turn an unclassified request into an authorised one. Undefined is
1351
+ // the honest answer.
1352
+ //
1353
+ // `record` is deliberately absent -- see `AccessContext` in
1354
+ // src/types/orm-types.ts. Nothing is fetched at this point and adding a
1355
+ // lookup here would put a store read in the middle of an authorization
1356
+ // path. The function return shape below IS the per-record hook.
1357
+ // -------------------------------------------------------------------------
1358
+ const context: AccessContext = {
1359
+ model: this.model,
1360
+ operation: methodAccessMap[request.method],
1361
+ };
1362
+
1158
1363
  let access: AccessMethod;
1159
1364
  try {
1160
- access = this.access(request);
1365
+ access = this.access(request, context);
1161
1366
  } catch (error) {
1162
1367
  // Same reasoning as `isDenied`: fail closed, but say so. An `access()`
1163
1368
  // 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 accessFunctions: Record<string, AccessFunction> = {};
18
24
 
19
25
  try {
20
26
  await forEachFileImport(accessPath, (accessClass: unknown) => {
@@ -31,9 +37,9 @@ export default async function(route: string, accessPath: string, metaRoute: bool
31
37
  for (const model of models === '*' ? availableModels : models) {
32
38
  if (model === dbKey) continue;
33
39
  if (!store.data.has(model)) throw new Error(`Unable to define access for Invalid Model "${model}". Model does not exist`);
34
- if (accessFiles![model]) throw new Error(`Access for model "${model}" has already been defined by another access class.`);
40
+ if (accessFunctions![model]) throw new Error(`Access for model "${model}" has already been defined by another access class.`);
35
41
 
36
- accessFiles![model] = accessInstance.access;
42
+ accessFunctions![model] = accessInstance.access;
37
43
  }
38
44
  });
39
45
  } catch (error) {
@@ -41,13 +47,60 @@ 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
+ // `accessFunctions` 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 `await waitForModule('rest-server')`, deliberately: that
61
+ // await is the ONLY yield point in this function, and the rest-server module
62
+ // may already be listening by the time it reports ready, so an assignment
63
+ // after it would leave a window in which a route is live and the registry is
64
+ // not.
65
+ //
66
+ // It is NOT before the mount loop for that reason, and the comment here used
67
+ // to say it was. `RestServer.mountRoute` is fully synchronous -- construct,
68
+ // registerCalls(), api.use() -- and nothing between the loop and this
69
+ // function's closing brace yields, so the event loop cannot deliver a request
70
+ // in there and the window that clause described cannot open. Measured:
71
+ // moving this assignment to the last statement of the function leaves the
72
+ // suite at 951 pass / 0 fail. Being ahead of the mount loop is free and
73
+ // harmless; it is not what makes the ordering correct.
74
+ //
75
+ // Assigned unconditionally, including when the try above failed and the map
76
+ // is empty or partial: the mount loop below is driven by this exact object,
77
+ // so at the moment of assignment whatever is reachable through
78
+ // `Orm.instance` is the same set of predicates that is about to enforce.
79
+ // A guard such as `if (Object.keys(accessFunctions).length)` would let the
80
+ // registry go silently missing on a total load failure, and a later consumer
81
+ // would read `undefined` from `getAccess` and have to distinguish "no access
82
+ // class" from "the registry was never published" -- which it cannot. That is
83
+ // the reasoning, and it is REASONING, not something this suite tests: the
84
+ // guarded variant is also 951 pass / 0 fail, AC8 included, because every boot
85
+ // in this suite loads a non-empty access map so the guard never fires. AC8
86
+ // demonstrably cannot catch it. Catching it needs a boot with
87
+ // `orm.paths.access` pointed at an empty directory, which this suite has no
88
+ // harness for.
89
+ //
90
+ // One further limit on "by construction": the mount loop passes `access` BY
91
+ // VALUE into each OrmRequest, so the enforcing set is a snapshot taken here,
92
+ // while `getAccess` reads the map live. The two are the same set at boot and
93
+ // stay the same set only for as long as nobody writes to the public field.
94
+ // The equality is a boot-time fact, not an invariant.
95
+ Orm.instance.accessFunctions = accessFunctions;
96
+
44
97
  await waitForModule('rest-server');
45
98
 
46
99
  // Remove "/" prefix and name mount point accordingly
47
100
  const name = route === '/' ? 'index' : (route[0] === '/' ? route.slice(1) : route);
48
101
 
49
102
  // Configure endpoints for models and views with access configuration
50
- for (const [model, access] of Object.entries(accessFiles!)) {
103
+ for (const [model, access] of Object.entries(accessFunctions!)) {
51
104
  const pluralizedModel = getPluralName(model);
52
105
  const modelName = name === 'index' ? pluralizedModel : `${name}/${pluralizedModel}`;
53
106
  RestServer.instance.mountRoute(OrmRequest, { name: modelName, options: { model, access } });
@@ -8,6 +8,10 @@
8
8
 
9
9
  import fs from 'fs/promises';
10
10
  import path from 'path';
11
+ // `./utils.js` pulls in `@stonyx/utils/string` and nothing else -- no ORM
12
+ // bootstrap, no `@stonyx/orm` index, no side effects -- so the "no framework
13
+ // dependencies" property above still holds.
14
+ import { maxNumericId } from './utils.js';
11
15
 
12
16
  interface StandaloneDBOptions {
13
17
  dbPath?: string;
@@ -131,12 +135,19 @@ export default class StandaloneDB {
131
135
  const records = await this.readCollection(collection);
132
136
 
133
137
  if (!data.id) {
134
- const maxId = records.reduce((max, r) => {
135
- const rid = typeof r.id === 'number' ? r.id : 0;
136
- return rid > max ? rid : max;
137
- }, 0);
138
-
139
- data.id = maxId + 1;
138
+ // SHARED WITH `assignRecordId` (src/manage-record.ts), which is the other
139
+ // place this repo picks a server-assigned id. It was a second copy of the
140
+ // reduce, and nothing here pointed at it — a maintainer editing this
141
+ // method could not discover the other existed. See `maxNumericId` for why
142
+ // it is not `Math.max` (abofs/stonyx-orm#203).
143
+ //
144
+ // THE TWO ARE NOT THE SAME FUNCTION beyond this line, deliberately.
145
+ // `StandaloneDB` has no model, id-type or transform concept, so `maxId + 1`
146
+ // IS its store key; `assignRecordId` has to map the candidate through the
147
+ // model's declared id transform first, and then walk past occupied keys.
148
+ // Transplanting this method's remaining logic into the ORM reproduces
149
+ // #203's landing-key defect exactly — which is what AC4 pins.
150
+ data.id = maxNumericId(records) + 1;
140
151
  }
141
152
 
142
153
  // Check for duplicate id
@@ -168,3 +168,109 @@ 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 closed vocabulary `AccessContext.operation` is drawn from
189
+ * (abofs/stonyx-orm#202).
190
+ *
191
+ * A literal union rather than `string`, so the guarantee the prose makes is the
192
+ * one the compiler enforces: a consumer who writes `operation === 'GET'` or
193
+ * `operation === 'get'` -- the hook vocabulary, see below -- gets a compile
194
+ * error instead of a comparison that never matches. A predicate that stops
195
+ * matching falls through to the permission array, so the misreading is
196
+ * fail-open shaped.
197
+ *
198
+ * In-repo precedent: `PersistErrorDetail.operation` in `src/main.ts`.
199
+ */
200
+ export type AccessOperation = 'read' | 'create' | 'update' | 'delete';
201
+
202
+ /**
203
+ * The structural facts about the request being authorised, handed to a consumer
204
+ * `access()` predicate as its SECOND argument (abofs/stonyx-orm#202).
205
+ *
206
+ * These are the facts the framework already holds at authorisation time. Before
207
+ * #202 a consumer had to reconstruct both of them by string-matching a URL, and
208
+ * five independent fail-open variants of that reconstruction were found in one
209
+ * three-line documented example -- each one wrong in the direction that GRANTS
210
+ * access. Read these instead; there is nothing to parse and no variant to miss.
211
+ *
212
+ * `record` is deliberately NOT a member. `auth()` runs after route matching but
213
+ * before any handler executes (`@stonyx/rest-server` `src/request.ts:58-60`),
214
+ * so nothing has been fetched yet -- carrying a record here would force a
215
+ * pre-fetch on every request. It is also unnecessary: the `(record) => boolean`
216
+ * return shape of {@link AccessMethod} already IS the per-record hook, applied
217
+ * by the handlers. Auth-time and record-time are separate decision points.
218
+ */
219
+ export interface AccessContext {
220
+ /**
221
+ * The model this route was mounted for, e.g. `'owner'` or `'phone-number'`.
222
+ *
223
+ * Model names are kebab-case, as declared under `config.orm.paths.model` and
224
+ * keyed in the store -- NOT the pluralised, mount-prefixed route name. It is
225
+ * read from the `OrmRequest` instance and is never derived from the request
226
+ * target, so a mount prefix, a case-varied path, a query string or an
227
+ * absolute-form request-target cannot change it.
228
+ */
229
+ model: string;
230
+
231
+ /**
232
+ * The operation being authorised. Exactly one of the four {@link
233
+ * AccessOperation} verbs, or `undefined`. These are exactly the values of
234
+ * `methodAccessMap` in `src/orm-request.ts`, which is also what the
235
+ * permission-array return shape is matched against -- so the two forms cannot
236
+ * disagree.
237
+ *
238
+ * NOT the hook vocabulary. `HookContext.operation` (`src/hooks.ts`) carries
239
+ * `'list' | 'get' | 'create' | 'update' | 'delete'` on an identically-named
240
+ * key of an identically-shaped context object, and the access vocabulary
241
+ * collapses `list` and `get` into `'read'`. For one `GET /animals/1` a hook
242
+ * sees `'get'` and `access()` sees `'read'`. "No second vocabulary" is a
243
+ * statement about the ACCESS path only.
244
+ *
245
+ * `undefined` when the dispatched method has no entry in that map. Express
246
+ * delivers `HEAD` to the `GET` handler, so this is reachable. It is left
247
+ * undefined rather than defaulted on purpose: a fabricated `'read'` would
248
+ * turn an unclassified request into an authorised one.
249
+ *
250
+ * The KEY is required even though the value may be undefined: `auth()` always
251
+ * sets it, and a context that simply omitted it would be indistinguishable
252
+ * from one that classified the request and found nothing.
253
+ */
254
+ operation: AccessOperation | undefined;
255
+ }
256
+
257
+ /**
258
+ * A consumer `access()` predicate.
259
+ *
260
+ * The second argument is ADDITIVE: JavaScript ignores extra arguments, so every
261
+ * pre-#202 single-argument predicate keeps working untouched. Changing the
262
+ * FIRST argument instead would have been the breaking form, and a predicate
263
+ * that can no longer identify its collection falls through to a full CRUD
264
+ * grant -- so the "safer" breaking change would have converted every unmigrated
265
+ * predicate into a fail-open.
266
+ *
267
+ * `context` is nonetheless REQUIRED in the type, and that costs back-compat
268
+ * nothing. TypeScript already lets a fewer-parameter implementation satisfy a
269
+ * more-parameter signature, so an arity-1 predicate assigns to this type
270
+ * cleanly -- measured under `--strict`. What the `?` bought was the opposite of
271
+ * safety: it silently permitted `getAccess('animal')?.(request)` at the CALL
272
+ * site, i.e. exactly the omission {@link AccessContext} exists to prevent, and
273
+ * that call gets the model-wrong answer. Required, a caller that drops the
274
+ * context gets `TS2554: Expected 2 arguments, but got 1`.
275
+ */
276
+ export type AccessFunction = (request: unknown, context: AccessContext) => AccessMethod;
@@ -5,7 +5,20 @@ declare module '@stonyx/rest-server' {
5
5
 
6
6
  interface RouteOptions {
7
7
  name: string;
8
- options?: { model: string; access: (request: unknown) => unknown } | Record<string, unknown>;
8
+ /**
9
+ * `access` is the two-argument post-#202 shape. This is the THIRD place the
10
+ * contract is declared (`AccessInstance.access` in
11
+ * `src/setup-rest-server.ts` and `OrmRequest.access` in
12
+ * `src/orm-request.ts` are the other two) and it is the one `mountRoute` is
13
+ * actually called through, at `src/setup-rest-server.ts`. It kept the
14
+ * pre-#202 single-argument signature after the other two migrated; the
15
+ * union with `Record<string, unknown>` meant nothing broke, which is
16
+ * exactly why it would have drifted silently.
17
+ *
18
+ * Spelled structurally rather than as `AccessFunction`: an ambient
19
+ * `declare module` block cannot carry an `import type`.
20
+ */
21
+ options?: { model: string; access: (request: unknown, context: { model: string; operation: string | undefined }) => unknown } | Record<string, unknown>;
9
22
  }
10
23
 
11
24
  export default class RestServer {
package/src/utils.ts CHANGED
@@ -20,3 +20,53 @@ export function pluralize(word: string): string {
20
20
 
21
21
  return basePluralize(word);
22
22
  }
23
+
24
+ /**
25
+ * The highest NUMERIC id held by a set of records, or `0` when there is none.
26
+ *
27
+ * ONE COPY, and the duplication it replaces is the reason it lives here. Three
28
+ * near-identical reduces existed at once: `assignRecordId` (server-assigned id
29
+ * selection), `StandaloneDB.create` (src/standalone-db.ts) and the #203 test
30
+ * helper. `docs/improvements.md`'s standing WET Code category prescribes
31
+ * exactly this remedy -- extract into the module that already acts as the
32
+ * shared utility -- and `assignRecordId` already imported `isOrmRecord` from
33
+ * here.
34
+ *
35
+ * NON-NUMBERS ARE SKIPPED RATHER THAN COERCED TO `0`, AND THAT IS STYLISTIC.
36
+ * `StandaloneDB`'s shape mapped them to `0`, which can never beat a seed of
37
+ * `0`. Measured over eleven input classes (`[]`, `1`, `NaN`, `'5'`, `'abc'`,
38
+ * `-3`, `0`, `null`, `undefined`, `Infinity`, and mixed arrays) the two shapes
39
+ * produce IDENTICAL output on every one. In particular `typeof NaN` is
40
+ * `'number'`, so NEITHER shape coerces `NaN` -- both reject it on `NaN > max`,
41
+ * which is `false`. An earlier revision of this code asserted that the skip was
42
+ * what made the `NaN` case work; it is not, the comparison is, and that claim
43
+ * has been removed rather than left standing.
44
+ *
45
+ * WHAT IS LOAD-BEARING is that this is not `Math.max(...ids)`. `Math.max`
46
+ * returns `NaN` if any operand is `NaN`, and a record CAN be held under the key
47
+ * `NaN` -- so the obvious fix assigns `NaN`, lands on that slot and overwrites
48
+ * it, which is abofs/stonyx-orm#203 in a new disguise. Pinned by
49
+ * test/unit/assign-record-id-test.ts AC2; before that file existed the whole
50
+ * suite scored 951/0 under exactly that fix.
51
+ */
52
+ export function maxNumericId(records: { id?: unknown }[]): number {
53
+ return records.reduce((max: number, record) => {
54
+ const { id } = record;
55
+
56
+ return typeof id === 'number' && id > max ? id : max;
57
+ }, 0);
58
+ }
59
+
60
+ /**
61
+ * The message prefix `assignRecordId` throws with when no free id can be
62
+ * derived for a model, and the ONE string `createHandler` matches on to answer
63
+ * `409` instead of letting the rejection reach express's default handler.
64
+ *
65
+ * It lives here rather than in either file because both need it and neither
66
+ * should own a copy: a literal in two places is how the two id coercions in
67
+ * orm-request.ts drifted apart (see `coerceId`). The repo has no error codes
68
+ * and no custom error classes -- 24 bare `throw new Error` sites across `src/`
69
+ * -- so a shared prefix is the narrowest way to make ONE failure distinguishable
70
+ * without inventing an error taxonomy this codebase does not use.
71
+ */
72
+ export const NO_FREE_ID_ERROR = 'Cannot assign record ID: no free id available';