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

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/dist/index.d.ts CHANGED
@@ -9,7 +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
+ export type { AccessContext, AccessFunction, AccessMethod, AccessOperation } from './types/orm-types.js';
13
13
  export { Model, View, Serializer };
14
14
  export { attr, belongsTo, hasMany, createRecord, updateRecord };
15
15
  export { count, avg, sum, min, max };
package/dist/main.d.ts CHANGED
@@ -81,8 +81,23 @@ export default class Orm {
81
81
  * collection the request is ADDRESSED TO -- owners -- while being asked about
82
82
  * animals, and per #202's thesis it answers wrong in the granting direction.
83
83
  *
84
+ * OWN PROPERTIES ONLY. A bare `this.accessFiles[modelName]` walks the
85
+ * prototype chain, so `getAccess('constructor')` resolved `Object` and
86
+ * `getAccess('toString')` resolved `Object.prototype.toString` -- both
87
+ * callable, and the documented `predicate?.(request, ctx)` pattern then
88
+ * returned a TRUTHY value (`Object(request)` is the request), bypassing the
89
+ * `undefined`-means-deny contract entirely. Nothing in the ORM calls
90
+ * `getAccess` yet, so it was not exploitable as shipped -- but #207 takes the
91
+ * model name from the REQUEST BODY (`data.relationships.<key>.data.type`),
92
+ * which would have made a one-field body an authorization bypass. Guarded
93
+ * here at the read point rather than by constructing the map with a null
94
+ * prototype, because the field is public and reassignable and the guard has
95
+ * to hold whatever object it is holding.
96
+ *
84
97
  * @param modelName - Model name as declared and stored (kebab-case).
85
- * @returns The predicate, or `undefined` when the model has no access class.
98
+ * @returns The predicate, or `undefined` when no predicate could be resolved
99
+ * for that name. `undefined` is NOT "this model is unrestricted" -- see the
100
+ * note above. Treat it as deny.
86
101
  */
87
102
  getAccess(modelName: string): AccessFunction | undefined;
88
103
  startup(): Promise<void>;
package/dist/main.js CHANGED
@@ -187,10 +187,27 @@ export default class Orm {
187
187
  * collection the request is ADDRESSED TO -- owners -- while being asked about
188
188
  * animals, and per #202's thesis it answers wrong in the granting direction.
189
189
  *
190
+ * OWN PROPERTIES ONLY. A bare `this.accessFiles[modelName]` walks the
191
+ * prototype chain, so `getAccess('constructor')` resolved `Object` and
192
+ * `getAccess('toString')` resolved `Object.prototype.toString` -- both
193
+ * callable, and the documented `predicate?.(request, ctx)` pattern then
194
+ * returned a TRUTHY value (`Object(request)` is the request), bypassing the
195
+ * `undefined`-means-deny contract entirely. Nothing in the ORM calls
196
+ * `getAccess` yet, so it was not exploitable as shipped -- but #207 takes the
197
+ * model name from the REQUEST BODY (`data.relationships.<key>.data.type`),
198
+ * which would have made a one-field body an authorization bypass. Guarded
199
+ * here at the read point rather than by constructing the map with a null
200
+ * prototype, because the field is public and reassignable and the guard has
201
+ * to hold whatever object it is holding.
202
+ *
190
203
  * @param modelName - Model name as declared and stored (kebab-case).
191
- * @returns The predicate, or `undefined` when the model has no access class.
204
+ * @returns The predicate, or `undefined` when no predicate could be resolved
205
+ * for that name. `undefined` is NOT "this model is unrestricted" -- see the
206
+ * note above. Treat it as deny.
192
207
  */
193
208
  getAccess(modelName) {
209
+ if (!Object.hasOwn(this.accessFiles, modelName))
210
+ return undefined;
194
211
  return this.accessFiles[modelName];
195
212
  }
196
213
  async startup() {
@@ -176,6 +176,20 @@ export interface SnapshotEntry {
176
176
  * Anything else fails CLOSED. See `src/orm-request.ts` `auth()`.
177
177
  */
178
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';
179
193
  /**
180
194
  * The structural facts about the request being authorised, handed to a consumer
181
195
  * `access()` predicate as its SECOND argument (abofs/stonyx-orm#202).
@@ -205,27 +219,47 @@ export interface AccessContext {
205
219
  */
206
220
  model: string;
207
221
  /**
208
- * The operation being authorised: `'read'`, `'create'`, `'update'` or
209
- * `'delete'`, and no second vocabulary. These are exactly the values of
222
+ * The operation being authorised. Exactly one of the four {@link
223
+ * AccessOperation} verbs, or `undefined`. These are exactly the values of
210
224
  * `methodAccessMap` in `src/orm-request.ts`, which is also what the
211
225
  * permission-array return shape is matched against -- so the two forms cannot
212
226
  * disagree.
213
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
+ *
214
235
  * `undefined` when the dispatched method has no entry in that map. Express
215
236
  * delivers `HEAD` to the `GET` handler, so this is reachable. It is left
216
237
  * undefined rather than defaulted on purpose: a fabricated `'read'` would
217
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.
218
243
  */
219
- operation?: string;
244
+ operation: AccessOperation | undefined;
220
245
  }
221
246
  /**
222
247
  * A consumer `access()` predicate.
223
248
  *
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.
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`.
230
264
  */
231
- export type AccessFunction = (request: unknown, context?: AccessContext) => AccessMethod;
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-alpha.55",
7
+ "version": "0.3.2-alpha.57",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
package/src/index.ts CHANGED
@@ -27,7 +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
+ export type { AccessContext, AccessFunction, AccessMethod, AccessOperation } from './types/orm-types.js'; // access() contract (#202)
31
31
  export { Model, View, Serializer }; // base classes
32
32
  export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
33
33
  export { count, avg, sum, min, max }; // aggregate helpers
package/src/main.ts CHANGED
@@ -240,10 +240,27 @@ export default class Orm {
240
240
  * collection the request is ADDRESSED TO -- owners -- while being asked about
241
241
  * animals, and per #202's thesis it answers wrong in the granting direction.
242
242
  *
243
+ * OWN PROPERTIES ONLY. A bare `this.accessFiles[modelName]` walks the
244
+ * prototype chain, so `getAccess('constructor')` resolved `Object` and
245
+ * `getAccess('toString')` resolved `Object.prototype.toString` -- both
246
+ * callable, and the documented `predicate?.(request, ctx)` pattern then
247
+ * returned a TRUTHY value (`Object(request)` is the request), bypassing the
248
+ * `undefined`-means-deny contract entirely. Nothing in the ORM calls
249
+ * `getAccess` yet, so it was not exploitable as shipped -- but #207 takes the
250
+ * model name from the REQUEST BODY (`data.relationships.<key>.data.type`),
251
+ * which would have made a one-field body an authorization bypass. Guarded
252
+ * here at the read point rather than by constructing the map with a null
253
+ * prototype, because the field is public and reassignable and the guard has
254
+ * to hold whatever object it is holding.
255
+ *
243
256
  * @param modelName - Model name as declared and stored (kebab-case).
244
- * @returns The predicate, or `undefined` when the model has no access class.
257
+ * @returns The predicate, or `undefined` when no predicate could be resolved
258
+ * for that name. `undefined` is NOT "this model is unrestricted" -- see the
259
+ * note above. Treat it as deny.
245
260
  */
246
261
  getAccess(modelName: string): AccessFunction | undefined {
262
+ if (!Object.hasOwn(this.accessFiles, modelName)) return undefined;
263
+
247
264
  return this.accessFiles[modelName];
248
265
  }
249
266
 
@@ -122,7 +122,7 @@ import { getBeforeHooks, getAfterHooks } from './hooks.js';
122
122
  import type { HookContext } from './hooks.js';
123
123
  import config from 'stonyx/config';
124
124
  import log from 'stonyx/log';
125
- import type { OrmRecord, AccessContext, AccessFunction, AccessMethod } from './types/orm-types.js';
125
+ import type { OrmRecord, AccessContext, AccessFunction, AccessMethod, AccessOperation } from './types/orm-types.js';
126
126
  import { isOrmRecord } from './utils.js';
127
127
 
128
128
  interface OrmRequest$ extends Request {
@@ -152,7 +152,7 @@ interface JsonApiResponse {
152
152
 
153
153
  type HandlerFn = (request: OrmRequest$, state: { [key: string]: unknown }) => unknown | Promise<unknown>;
154
154
 
155
- const methodAccessMap: { [key: string]: string } = {
155
+ const methodAccessMap: { [key: string]: AccessOperation } = {
156
156
  GET: 'read',
157
157
  POST: 'create',
158
158
  DELETE: 'delete',
@@ -184,6 +184,21 @@ export interface SnapshotEntry {
184
184
  */
185
185
  export type AccessMethod = string | boolean | string[] | ((record: unknown) => boolean);
186
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
+
187
202
  /**
188
203
  * The structural facts about the request being authorised, handed to a consumer
189
204
  * `access()` predicate as its SECOND argument (abofs/stonyx-orm#202).
@@ -214,28 +229,48 @@ export interface AccessContext {
214
229
  model: string;
215
230
 
216
231
  /**
217
- * The operation being authorised: `'read'`, `'create'`, `'update'` or
218
- * `'delete'`, and no second vocabulary. These are exactly the values of
232
+ * The operation being authorised. Exactly one of the four {@link
233
+ * AccessOperation} verbs, or `undefined`. These are exactly the values of
219
234
  * `methodAccessMap` in `src/orm-request.ts`, which is also what the
220
235
  * permission-array return shape is matched against -- so the two forms cannot
221
236
  * disagree.
222
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
+ *
223
245
  * `undefined` when the dispatched method has no entry in that map. Express
224
246
  * delivers `HEAD` to the `GET` handler, so this is reachable. It is left
225
247
  * undefined rather than defaulted on purpose: a fabricated `'read'` would
226
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.
227
253
  */
228
- operation?: string;
254
+ operation: AccessOperation | undefined;
229
255
  }
230
256
 
231
257
  /**
232
258
  * A consumer `access()` predicate.
233
259
  *
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.
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`.
240
275
  */
241
- export type AccessFunction = (request: unknown, context?: AccessContext) => AccessMethod;
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 {