@stonyx/orm 0.3.2-alpha.47 → 0.3.2-alpha.49

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
@@ -309,19 +309,241 @@ import setupRestServer from '@stonyx/orm/setup-rest-server';
309
309
  await setupRestServer('/', './access');
310
310
  ```
311
311
 
312
- Access classes define models and provide custom filtering/authorization logic:
312
+ Access classes define models and provide custom filtering/authorization logic.
313
+
314
+ > **The URL-matching in this example is a stopgap. Read
315
+ > [Matching the url](#matching-the-url) before copying it.** The same three-line
316
+ > example has failed **open** in four distinct ways during one review, each found
317
+ > only after the previous was fixed. The sample below closes all four; that is
318
+ > not the same as being safe. The real fix is
319
+ > [#202](https://github.com/abofs/stonyx-orm/issues/202) — `access()` should
320
+ > receive the model, the operation and the record, so there is no URL to parse.
313
321
 
314
322
  ```js
323
+ import config from 'stonyx/config';
324
+
325
+ // Build the mount prefix from the SAME value the ORM mounts under, and compare
326
+ // lower-cased. Both are load-bearing — see "Matching the url".
327
+ function collectionPrefix(name) {
328
+ const route = config.orm.restServer.route ?? '/';
329
+ const trimmed = String(route).replace(/^\/+|\/+$/g, '');
330
+
331
+ return `${trimmed === '' ? '' : `/${trimmed}`}/${name}`.toLowerCase();
332
+ }
333
+
315
334
  export default class GlobalAccess {
316
335
  models = ['owner', 'animal'];
317
336
 
318
337
  access(request) {
319
- if (request.url.endsWith('/owner/angela')) return false;
338
+ // `originalUrl`, not `url` `url` is mount-relative, so a prefix match
339
+ // against it is ALWAYS false. Query string stripped, because `originalUrl`
340
+ // carries it. Lower-cased, because the router matched case-insensitively
341
+ // and a matcher stricter than the router can be walked past. Every one of
342
+ // those three omissions fails OPEN.
343
+ const path = String(request.originalUrl ?? '').split('?')[0].toLowerCase();
344
+ const owners = collectionPrefix('owners');
345
+
346
+ // false → 403 for the whole request
347
+ if (path.startsWith(`${owners}/archived`)) return false;
348
+
349
+ // A function is a per-record filter. Anchored on a `/` boundary so it
350
+ // cannot also match `/owners-archive`. Rejected records are 404 on record
351
+ // routes, 403 on POST.
352
+ if (path === owners || path.startsWith(`${owners}/`)) {
353
+ return record => record.id !== 'angela';
354
+ }
355
+
320
356
  return ['read', 'create', 'update', 'delete'];
321
357
  }
322
358
  }
323
359
  ```
324
360
 
361
+ ### Return values
362
+
363
+ | `access()` returns | Effect |
364
+ |---|---|
365
+ | `false` (or any falsy value) | `403` for the whole request |
366
+ | `true` | full access, no filter |
367
+ | `['read', 'create', 'update', 'delete']` | the listed operations only; anything else is `403` |
368
+ | `'read'` (a bare string) | **one** permission, equivalent to `['read']` |
369
+ | a function | a per-record filter — see below |
370
+ | anything else | `403` — unknown shapes fail **closed** |
371
+
372
+ A `throw` inside `access()` is a **denial**, not a 500.
373
+
374
+ ### Filter functions
375
+
376
+ A function return value is a **per-record predicate**, and it is enforced on
377
+ every endpoint that is addressed to a record — not only on the collection:
378
+
379
+ | Endpoint | A record the predicate rejects |
380
+ |---|---|
381
+ | `GET /:models` | omitted from the collection |
382
+ | `GET /:models/:id` | `404` |
383
+ | `GET /:models/:id/{relationship}` | `404` — the **addressed** record is filtered, not the related one |
384
+ | `GET /:models/:id/relationships/{relationship}` | `404` — same |
385
+ | `PATCH /:models/:id` | `404`, no attribute is applied |
386
+ | `DELETE /:models/:id` | `404`, the record is not removed and no SQL `DELETE` is issued |
387
+ | `POST /:models` | `403`, and nothing is left in the store |
388
+
389
+ **Denied record-level requests return 404, not 403.** This is deliberate and it
390
+ is the property most easily "improved" away. 403 would confirm that the record
391
+ exists to a caller who is not allowed to know that, which turns the filter into
392
+ an existence oracle: `404` means "no such record", `403` means "there is one and
393
+ it is not yours". Every status on a record route must therefore be identical for
394
+ "filtered out" and "does not exist" — including `DELETE`, which is why deleting
395
+ a record that never existed also returns 404 rather than 204.
396
+
397
+ `POST` is the one exception and returns **403**, because 404 on a mounted
398
+ collection route is indistinguishable from "model not mounted" — a genuinely
399
+ different failure a developer needs to diagnose.
400
+
401
+ **A client-supplied `id` on `POST` is refused with `403` whenever a function
402
+ filter is in force.** This is the part that keeps `POST` from being an
403
+ enumeration oracle, and it is worth understanding rather than working around.
404
+ The duplicate-id check has to run before the filter, and it sees records the
405
+ filter hides, so the *status* of a `POST` otherwise leaks whether an id is
406
+ taken:
407
+
408
+ | `POST /animals` with a payload the caller may create | before | now |
409
+ |---|---|---|
410
+ | an id held by a record the filter **hides** | `403` | `403` |
411
+ | an id that is **free** | `200` | `403` |
412
+ | an id held by a record the caller **can see** | `409` | `403` |
413
+
414
+ Three outcomes, one request per id, the whole id space. Filtering only the
415
+ *collision* status narrows that to callers who cannot create a record they are
416
+ allowed to see; it does not close it. It cannot be closed while a caller both
417
+ chooses the id and learns whether the create succeeded — so under a filter the
418
+ caller does not choose the id. The refusal happens before any store lookup, so
419
+ neither the status nor the response time depends on whether the id exists.
420
+
421
+ Let the server assign the id and read it back from the response. Callers with no
422
+ function-style filter are unaffected: `409` on a duplicate id and `200` on a free
423
+ one both behave exactly as before.
424
+
425
+ ### Matching the url
426
+
427
+ **This section describes a pattern the framework should not be asking you to
428
+ implement.** It has produced four separate fail-open defects, listed below,
429
+ each found only after the previous was fixed, and there is no reason to believe
430
+ the list is complete. [#202](https://github.com/abofs/stonyx-orm/issues/202)
431
+ replaces it. Until then, all four rules apply and each one, omitted, fails
432
+ **open**.
433
+
434
+ **1. Match `request.originalUrl`, never `request.url`.**
435
+ `RestServer.mountRoute` mounts each model as an Express **sub-app**, so by the
436
+ time `access()` runs the mount path has been stripped from `request.url`:
437
+
438
+ | request | `request.url` | `request.originalUrl` |
439
+ |---|---|---|
440
+ | `GET /owners` | `/` | `/owners` |
441
+ | `GET /owners/angela` | `/angela` | `/owners/angela` |
442
+ | `GET /owners/angela/pets` | `/angela/pets` | `/owners/angela/pets` |
443
+
444
+ `request.url.startsWith('/owners')` is therefore **always false**: the branch
445
+ never fires, `access()` falls through to whatever it returns last, and a filter
446
+ that looks correct enforces nothing on any surface.
447
+
448
+ **2. Strip the query string, and match the prefix rather than the exact url.**
449
+ The predicate has to be returned for record routes too, so
450
+ `url.endsWith('/owners')` leaves `/owners/angela` unguarded — and `originalUrl`
451
+ carries the query string, so a bare `=== '/owners'` misses
452
+ `/owners?filter[age]=30` and lets a filtered collection through unfiltered.
453
+ Anchoring on the path portion covers both without also matching
454
+ `/owners-archive`.
455
+
456
+ **3. Compare lower-cased.** `RestServer` mounts with a bare `express()`, whose
457
+ default is `caseSensitive: false`, while `originalUrl` preserves the caller's
458
+ case. A case-sensitive matcher is stricter than the router that dispatched the
459
+ request, so it can simply be stepped around:
460
+
461
+ ```
462
+ GET /owners/angela -> 404 GET /OwNeRs/angela -> 200, angela in full
463
+ GET /owners -> filtered GET /OWNERS -> unfiltered
464
+ DELETE /animals/22 -> 404 DELETE /ANIMALS/22 -> 204, record destroyed
465
+ ```
466
+
467
+ Lower-case the **path** only. Record ids are case-sensitive and must be compared
468
+ at their real case. The router-side fix is tracked as
469
+ [stonyx-rest-server#47](https://github.com/abofs/stonyx-rest-server/issues/47).
470
+
471
+ **4. Build the prefix from the configured mount route.** With
472
+ `ORM_REST_ROUTE=/api` the urls above become `/api/owners/...`, and a sample
473
+ hard-coded to `/owners` matches nothing — environment-specifically, which is
474
+ harder to notice than failing everywhere.
475
+
476
+ Note what this must *not* be. An earlier version of this document suggested
477
+ `` `${config.orm.restServer.route}owners` ``. For the default route that is
478
+ `/owners` and looks correct; for `ORM_REST_ROUTE=/api` it evaluates to
479
+ **`/apiowners`**, so a reader who followed the correction exactly still failed
480
+ open and believed they had handled it. Join on `/` and collapse the duplicate,
481
+ as `collectionPrefix()` above does.
482
+
483
+ ### Known limitations
484
+
485
+ - **Authorization by URL matching is a consumer-side reconstruction of
486
+ information the framework already holds.** `access()` receives a transport
487
+ artifact and is asked to re-derive, correctly and defensively, which model,
488
+ which operation and which record the request addresses. The four rules above
489
+ are the four ways that reconstruction has been observed to fail open so far.
490
+ Tracked as [#202](https://github.com/abofs/stonyx-orm/issues/202).
491
+ - **Related and included records are not filtered.** The predicate is evaluated
492
+ against the record the route is *addressed to*. `GET /animals/1/owner`,
493
+ `GET /animals/1/relationships/owner` and `?include=owner` all serialize the
494
+ related record without resolving that model's own access class, so a filter on
495
+ `/owners` does not hide an owner reached through `/animals`. Tracked as
496
+ [#196](https://github.com/abofs/stonyx-orm/issues/196), which covers
497
+ `include=`, related-resource routes and relationship-linkage routes.
498
+ - **A before-hook that returns a value short-circuits the request.** On write
499
+ operations addressed to a record the filter is consulted first, so a hook
500
+ cannot answer for a record the caller may not see. On reads it is not, so a
501
+ `beforeHook('get', ...)` read-through cache can answer past the filter.
502
+ - **`beforeHook('create', ...)` still runs for a `POST` that is denied.** For
503
+ `create` there is no record to test until the handler has built one, so the
504
+ denial is not knowable in time. Every *after*-hook is gated, and every
505
+ before-hook on `update` and `delete` is gated; before-`create` is the one
506
+ exception. A before-`create` hook must not assume the create will succeed.
507
+ - **A caller can still learn that a collection *has* a per-record filter**, by
508
+ observing `403` rather than `409`/`200` for an id-bearing `POST`. That
509
+ discloses a configuration fact, not the existence of any record.
510
+ - **Enforcement is post-fetch.** The record is loaded and then tested, which
511
+ leaves a small timing difference between a hidden record and one that never
512
+ existed. Tracked as [#197](https://github.com/abofs/stonyx-orm/issues/197).
513
+
514
+ ### Breaking changes in 0.4.0
515
+
516
+ There is no changelog or release-notes channel yet
517
+ ([stonyx-workflows#17](https://github.com/abofs/stonyx-workflows/issues/17)), so
518
+ they are recorded here.
519
+
520
+ 1. **`DELETE /{collection}/{id}` on a record that does not exist returns `404`
521
+ instead of `204`.** This affects **every** consumer issuing a DELETE against
522
+ any mounted collection, whether or not an access filter is configured, and
523
+ `models: '*'` mounts every model by default. It is not optional: if a denied
524
+ delete returned 404 while a missing one returned 204, the pair would be a
525
+ perfect existence oracle and the filter would be worthless.
526
+ 2. **After-hooks no longer fire for a write that failed** — denied, missing,
527
+ `400` or `409`. Previously `afterHook('delete', ...)` ran with a populated
528
+ `context.recordId` on a request that deleted nothing, so a consumer cascade
529
+ destroyed children behind a 404.
530
+ 3. **`POST` with a client-supplied `id` returns `403` when a function-style
531
+ `access` filter is in force**, whatever the payload and whether or not the id
532
+ exists. Only affects function-style `access` users. See
533
+ [Filter functions](#filter-functions) for why, and let the server assign the
534
+ id instead. `409`-on-duplicate is unchanged for everyone else.
535
+ 4. **Function-style `access` is now enforced on all seven surfaces.** Records
536
+ previously reachable by id despite being filtered from the collection now
537
+ return 404. Only affects function-style `access` users, for whom the old
538
+ behaviour was the bypass.
539
+ 5. **A predicate that throws is treated as a denial** rather than propagating to
540
+ Express's default 500 handler. So is an `access()` that throws.
541
+ 6. **`access()` returning a bare string is one permission, not full access.**
542
+ `AccessMethod` declares `string` legal, and it previously fell through every
543
+ branch and granted all four operations — `return 'read'` allowed `DELETE`.
544
+ It is now equivalent to `['read']`. Any other unrecognised shape (an object,
545
+ a number) now returns `403` rather than granting full access.
546
+
325
547
  ### Include Parameter (Sideloading Relationships)
326
548
 
327
549
  The ORM supports JSON API-compliant relationship sideloading via the `include` query parameter. This reduces the need for multiple API requests by embedding related records in a single response.
@@ -56,6 +56,30 @@ function getId(params) {
56
56
  return id;
57
57
  return parseInt(id);
58
58
  }
59
+ /**
60
+ * Normalise a caller-supplied BODY id to the key the store will hold it under.
61
+ *
62
+ * `getId()` above is params-shaped: it takes `{ id?: string }` off the URL,
63
+ * where the value is always a string and a falsy one means "no id". A JSON body
64
+ * id is neither -- it can arrive as a number, and `0` is a legitimate id that
65
+ * `getId()` would flatten to `''`.
66
+ *
67
+ * WHY THIS EXISTS AT ALL. createHandler used to look the collision up with the
68
+ * RAW body value while every other surface normalised through `getId()`. The
69
+ * store is a Map keyed by the coerced value, so `store.find(model, '21')` misses
70
+ * the entry held under `21` and the duplicate check is skipped by typing the id
71
+ * as a string. On `dev` that silently overwrote the colliding record and
72
+ * answered 200; combined with the denied-create rollback added for #190 it
73
+ * became an unauthenticated DELETE of any id. Normalising here is half of that
74
+ * fix -- see the rollback in createHandler for the other half.
75
+ */
76
+ function normalizeBodyId(id) {
77
+ if (typeof id === 'number')
78
+ return id;
79
+ if (typeof id !== 'string' || id.trim() === '')
80
+ return id;
81
+ return isNaN(id) ? id : parseInt(id, 10);
82
+ }
59
83
  function buildResponse(data, includeParam, recordOrRecords, options = {}) {
60
84
  const { links, baseUrl } = options;
61
85
  const response = { data };
@@ -187,6 +211,33 @@ function createFilterPredicate(filters) {
187
211
  return String(current) === value;
188
212
  });
189
213
  }
214
+ /**
215
+ * A function-style `access` return is a per-record predicate, and it is only
216
+ * meaningful if every surface that can hand a record to a caller consults it.
217
+ * Before #190 exactly one of seven did.
218
+ *
219
+ * Enforcement is deliberately post-fetch. `access` returns an opaque JS
220
+ * predicate and `store.findAll(model, conditions)` accepts only an equality
221
+ * conditions object that the SQL drivers translate to a WHERE clause, so
222
+ * query-layer enforcement would require a breaking change to the published
223
+ * `access` contract. That belongs in #197, not in a security patch. Six of the
224
+ * seven surfaces fetch by primary key anyway, so this costs exactly one row.
225
+ */
226
+ function isDenied(filter, record) {
227
+ if (typeof filter !== 'function')
228
+ return false;
229
+ // A predicate that throws is treated as a denial. Unguarded, a throw escapes
230
+ // to express's default handler, which answers 500 (with a stack trace outside
231
+ // NODE_ENV=production) while a missing id still answers 404 -- so a
232
+ // record-dependent throw re-separates "hidden" from "does not exist" and
233
+ // hands back the oracle this whole change exists to close.
234
+ try {
235
+ return !filter(record);
236
+ }
237
+ catch {
238
+ return true;
239
+ }
240
+ }
190
241
  export default class OrmRequest extends Request {
191
242
  model;
192
243
  access;
@@ -216,10 +267,15 @@ export default class OrmRequest extends Request {
216
267
  baseUrl
217
268
  });
218
269
  };
219
- const getSingleHandler = async (request) => {
270
+ const getSingleHandler = async (request, { filter }) => {
220
271
  const record = await store.find(model, getId(request.params));
221
272
  if (!record)
222
273
  return 404;
274
+ // 404, never 403: the status for "exists but filtered out" must be
275
+ // identical to "does not exist", or the fix trades an authorization
276
+ // bypass for a narrower existence oracle.
277
+ if (isDenied(filter, record))
278
+ return 404;
223
279
  const fieldsMap = parseFields(request.query);
224
280
  const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
225
281
  const baseUrl = getBaseUrl(request);
@@ -228,15 +284,54 @@ export default class OrmRequest extends Request {
228
284
  baseUrl
229
285
  });
230
286
  };
231
- const createHandler = async ({ body, query }) => {
287
+ const createHandler = async ({ body, query }, { filter }) => {
232
288
  const { type, id, attributes, relationships: rels } = (body?.data || {});
233
289
  if (!type)
234
290
  return 400; // Bad request
235
291
  const fieldsMap = parseFields(query);
236
292
  const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
237
- // Check for duplicate ID
238
- if (id !== undefined && await store.find(model, id))
239
- return 409; // Conflict
293
+ // GATE 0 -- the POST existence oracle.
294
+ //
295
+ // The duplicate check runs before the filter and `store.find` sees hidden
296
+ // records, so POST leaks existence through its STATUS. A previous revision
297
+ // filtered the collision status (403 when the colliding record is denied,
298
+ // 409 when it is visible) and that is NOT sufficient, because the status
299
+ // of a create is a third outcome. With a payload the caller is permitted
300
+ // to create -- the normative case for a per-tenant filter, and the case an
301
+ // attacker picks -- all three are distinguishable in ONE request per id:
302
+ //
303
+ // POST /animals {id:22, owner:'gina'} -> 403 a HIDDEN record has this id
304
+ // POST /animals {id:9500, owner:'gina'} -> 200 this id is FREE
305
+ // POST /animals {id:8, owner:'gina'} -> 409 a VISIBLE record has this id
306
+ //
307
+ // Filtering only the collision status narrows that to callers who cannot
308
+ // create a record they are allowed to see. It does not close it.
309
+ //
310
+ // It cannot be closed while a caller both chooses the id and learns
311
+ // whether the create succeeded: a successful create must answer
312
+ // differently from a refused one. So when a per-record filter is in force
313
+ // the caller does not get to choose the id at all. The refusal is
314
+ // UNCONDITIONAL and happens BEFORE any store lookup, so no status, and no
315
+ // lookup cost, can depend on whether that id exists. 403 -- the same
316
+ // status as a denied create -- so the two cannot be separated either.
317
+ //
318
+ // Scoped to function-style `access` because that is exactly the population
319
+ // the oracle exists for: with no per-record filter there are no hidden
320
+ // records, and 409 discloses nothing GET /:id does not already.
321
+ //
322
+ // RESIDUAL, stated rather than implied: a caller can still learn that a
323
+ // collection HAS a per-record filter (403 rather than 409/200 for an
324
+ // id-bearing POST). That discloses a configuration fact, not a record.
325
+ // See README `### Known limitations`.
326
+ if (id !== undefined) {
327
+ if (typeof filter === 'function')
328
+ return 403; // Forbidden
329
+ // `normalizeBodyId`, not the raw value: a string-typed id misses the
330
+ // store's numeric key, which skipped this check entirely.
331
+ const existing = await store.find(model, normalizeBodyId(id));
332
+ if (existing)
333
+ return 409; // Conflict
334
+ }
240
335
  const { id: _ignoredId, ...sanitizedAttributes } = attributes || {};
241
336
  // Extract relationship IDs from JSON:API relationships object
242
337
  if (rels) {
@@ -248,16 +343,69 @@ export default class OrmRequest extends Request {
248
343
  }
249
344
  }
250
345
  const recordAttributes = id !== undefined ? { id, ...sanitizedAttributes } : sanitizedAttributes;
346
+ // Slot count BEFORE the write. `createRecord` writes to the store before
347
+ // the predicate can run, and the rollback below must be able to prove the
348
+ // slot it removes is one THIS REQUEST created. Identity alone cannot
349
+ // prove it: when `assignRecordId` lands on an occupied id, `createRecord`
350
+ // mutates the existing OrmRecord IN PLACE, so `store.get(...) === record`
351
+ // is true for a record the request did not create. The map's size is the
352
+ // only O(1) signal that distinguishes an insert from an overwrite.
353
+ const slotsBefore = store.get(model)?.size ?? 0;
251
354
  const created = createRecord(model, recordAttributes, { serialize: false, _skipAutoPersist: true });
252
355
  const record = isOrmRecord(created) ? created : null;
253
356
  if (!record)
254
357
  return 500;
358
+ const createdNewSlot = (store.get(model)?.size ?? 0) > slotsBefore;
359
+ // 403 here, NOT 404. The oracle argument does not apply to create: there
360
+ // is no pre-existing record whose existence could leak, the caller
361
+ // supplied the attributes, and 404 on a mounted collection route is
362
+ // indistinguishable from "model not mounted" -- a genuinely different
363
+ // failure a developer needs to diagnose.
364
+ //
365
+ // The rollback is not optional. createRecord writes to the store BEFORE
366
+ // the predicate can run, so returning 403 alone would leave the record
367
+ // behind: a worse bug than the bypass being fixed.
368
+ if (isDenied(filter, record)) {
369
+ // ROLL BACK BY IDENTITY, NEVER BY ID. `store.remove(model, record.id)`
370
+ // on its own is a write primitive keyed by a value the caller may have
371
+ // supplied: with the raw-id collision bypass above, a denied
372
+ // `POST {"id":"21"}` answered 403 and DELETED hidden record 21 -- an
373
+ // unauthenticated deletion primitive across the whole id space, created
374
+ // by adding a rollback to a lookup that could be skipped.
375
+ //
376
+ // Both conditions are required and neither implies the other:
377
+ // createdNewSlot -- the store grew, so this request inserted rather
378
+ // than overwrote. Guards `assignRecordId` picking an
379
+ // id that is already taken (it returns
380
+ // last-INSERTED + 1, not max + 1, so a store whose
381
+ // insertion order is not ascending collides) -- see
382
+ // abofs/stonyx-orm#203.
383
+ // identity -- the slot still holds the object we just created,
384
+ // so nothing between createRecord and here replaced
385
+ // it.
386
+ if (createdNewSlot && store.get(model, record.id) === record) {
387
+ store.remove(model, record.id, { _skipAutoPersist: true });
388
+ }
389
+ return 403;
390
+ }
255
391
  return { data: record.toJSON?.({ fields: modelFields }) };
256
392
  };
257
- const updateHandler = async ({ body, params }) => {
393
+ const updateHandler = async ({ body, params }, { filter }) => {
258
394
  const found = await store.find(model, getId(params));
259
395
  if (!found || !isOrmRecord(found))
260
396
  return 404;
397
+ // Checked BEFORE any attribute is applied. 404 rather than 403 for the
398
+ // same reason as GET /:id -- 403 would disclose both that the record
399
+ // exists and that this caller specifically is excluded.
400
+ //
401
+ // NOT redundant behind GATE 1, and not defence in depth either: GATE 1's
402
+ // verdict is computed BEFORE the before-hook loop runs, and a before-hook
403
+ // is a published extension point that can change the answer -- by
404
+ // mutating the record, or against a predicate that closes over
405
+ // per-request state. This is the only re-evaluation after that window.
406
+ // Pinned by assertion 32; deleting it turns a 404 into an applied update.
407
+ if (isDenied(filter, found))
408
+ return 404;
261
409
  const record = found;
262
410
  const { attributes, relationships: rels } = (body?.data || {});
263
411
  if (!attributes && !rels)
@@ -288,7 +436,22 @@ export default class OrmRequest extends Request {
288
436
  }
289
437
  return { data: record.toJSON?.() };
290
438
  };
291
- const deleteHandler = ({ params }) => {
439
+ const deleteHandler = async ({ params }, { filter }) => {
440
+ const record = await store.find(model, getId(params));
441
+ // BEHAVIOUR CHANGE (#190): a DELETE of a record that never existed
442
+ // returned 204 before this change. It now returns 404, matching the
443
+ // denied case below. This is deliberate and load-bearing -- if a denied
444
+ // delete returned 404 while a missing one returned 204, the pair would be
445
+ // a perfect existence oracle and the whole fix would be worthless.
446
+ // Returning 204 for a denied delete was rejected instead: it falsely
447
+ // reports success for a request that changed nothing.
448
+ if (!record)
449
+ return 404;
450
+ // Re-evaluated after the before-hook loop, exactly as in updateHandler --
451
+ // GATE 1 decided before the hooks ran. Pinned by assertion 33; deleting it
452
+ // turns a 404 into a destroyed record.
453
+ if (isDenied(filter, record))
454
+ return 404;
292
455
  store.remove(model, getId(params), { _skipAutoPersist: true });
293
456
  return 204;
294
457
  };
@@ -314,9 +477,36 @@ export default class OrmRequest extends Request {
314
477
  };
315
478
  }
316
479
  }
317
- // Wraps a handler with before/after hook execution
480
+ // Wraps a handler with before/after hook execution.
481
+ //
482
+ // ===========================================================================
483
+ // TWO AUTHORIZATION GATES, AND EVERY EXECUTOR SITS BEHIND ONE OF THEM (#190)
484
+ //
485
+ // The defect this function was fixed for is NOT "a delete persists past a
486
+ // 404". It is that _withHooks has SEVERAL executors downstream of the
487
+ // handler, and originally the handler's response gated none of them. Three
488
+ // exist today:
489
+ //
490
+ // 1. sqlDb.persist -- issues real SQL against the backing store
491
+ // 2. the after-hook pipeline -- the PUBLISHED consumer extension point;
492
+ // a cascade delete, a webhook, a search-index
493
+ // purge. `context.recordId` and
494
+ // `context.oldState` are populated for it.
495
+ // 3. Orm.db.save() -- a full serialize-and-write of the store
496
+ //
497
+ // Gating them one at a time is how this keeps regressing, so the rule is:
498
+ // compute denial ONCE at each point where it becomes knowable, and keep every
499
+ // executor downstream of a gate. If you add a fourth executor to this
500
+ // function, it goes below GATE 2 or it is a security bug.
501
+ //
502
+ // GATE 1 (pre-handler) is required because before-hooks and `context.oldState`
503
+ // run/are built BEFORE the handler can consult the filter. Without it a denied
504
+ // DELETE still handed the hidden record's full contents to consumer code.
505
+ // GATE 2 (post-handler) covers everything the handler's status can reach.
506
+ // ===========================================================================
318
507
  _withHooks(operation, handler) {
319
508
  return async (request, state) => {
509
+ const { filter } = (state || {});
320
510
  // Build context object for hooks
321
511
  const context = {
322
512
  model: this.model,
@@ -330,6 +520,26 @@ export default class OrmRequest extends Request {
330
520
  // Capture old state for operations that modify data
331
521
  if (operation === 'update' || operation === 'delete') {
332
522
  const existingRecord = await store.find(this.model, getId(request.params));
523
+ // GATE 1 -- pre-handler. This record fetch already happened for
524
+ // oldState, so the check is free.
525
+ //
526
+ // Returning here rather than letting updateHandler/deleteHandler
527
+ // produce the same 404 is the point: everything between here and there
528
+ // is an executor the caller is not authorized to reach.
529
+ // - context.oldState is a deep copy of the HIDDEN RECORD'S CONTENTS.
530
+ // Building it and handing it to a before-hook discloses exactly what
531
+ // the filter exists to hide.
532
+ // - context.recordId is populated for delete BEFORE the handler runs,
533
+ // which is the same shape as the sqlDb landmine one layer up:
534
+ // `afterHook('delete', ctx => cascadeDelete(ctx.recordId))` destroys
535
+ // children behind a correct 404.
536
+ // - a before-hook may return a value and short-circuit, which would
537
+ // otherwise return a response without the filter ever executing.
538
+ //
539
+ // 404, not 403, for the same reason as getSingleHandler: the status for
540
+ // "exists but filtered out" must equal "does not exist".
541
+ if (existingRecord && isDenied(filter, existingRecord))
542
+ return 404;
333
543
  if (existingRecord) {
334
544
  // Deep copy the record's data to preserve old state
335
545
  context.oldState = JSON.parse(JSON.stringify(existingRecord.__data || existingRecord));
@@ -352,9 +562,27 @@ export default class OrmRequest extends Request {
352
562
  if (operation === 'update' && response?.data) {
353
563
  context.record = store.get(this.model, getId(request.params));
354
564
  }
355
- // Persist to SQL database for all write operations (create/update/delete)
565
+ // GATE 2 -- post-handler. A denied or failed handler returns a bare status
566
+ // integer, and no executor below may run for one.
567
+ //
568
+ // `>= 400` deliberately covers every failure status, not just the
569
+ // authorization ones: a 400 (POST with no `type`) and a 409 (duplicate id)
570
+ // are equally requests in which nothing happened, and a persist or a
571
+ // cascade hook for one of them is just as wrong.
572
+ const denied = Number.isInteger(response) && response >= 400;
573
+ // EXECUTOR 1 -- SQL persistence, for all write operations.
574
+ //
575
+ // `response` is passed to sqlDb.persist below, but it is dropped at the
576
+ // driver boundary: _persistDelete(modelName, context) never receives it
577
+ // and guards only on context.recordId -- which _withHooks set above,
578
+ // BEFORE the handler ran. Without this gate a correct 404 still issues
579
+ // DELETE FROM ... WHERE id = ? on every SQL backend.
580
+ //
581
+ // No file-backed test can observe that, because Orm.instance.sqlDb is
582
+ // null in file/directory mode. See the stubbed-sqlDb assertions in
583
+ // test/unit/access-filter-enforcement-test.ts.
356
584
  const sqlDb = Orm.instance.sqlDb;
357
- if (sqlDb && WRITE_OPERATIONS.has(operation)) {
585
+ if (sqlDb && WRITE_OPERATIONS.has(operation) && !denied) {
358
586
  await sqlDb.persist(operation, this.model, context, response);
359
587
  }
360
588
  // Add response and relevant records to context
@@ -375,12 +603,29 @@ export default class OrmRequest extends Request {
375
603
  // For delete, the record may no longer exist, but we have oldState
376
604
  context.recordId = getId(request.params);
377
605
  }
378
- // Run after hooks sequentially
379
- for (const hook of getAfterHooks(operation, this.model)) {
380
- await hook(context);
606
+ // EXECUTOR 2 -- the after-hook pipeline. This is the published consumer
607
+ // extension point (`afterHook` is exported from @stonyx/orm and from
608
+ // ./hooks), so it is the executor with the widest possible blast radius:
609
+ // a cascade delete, a webhook, a token revocation, a search-index purge.
610
+ //
611
+ // BEHAVIOUR CHANGE (#190): after-hooks no longer fire for a request that
612
+ // failed. Previously `afterHook('delete', ...)` ran with a populated
613
+ // context.recordId on a 404, so a consumer cascade destroyed children for
614
+ // a request that deleted nothing. Firing a hook named "after<operation>"
615
+ // for an operation that did not occur is a booby trap, and the denied case
616
+ // is unreachable-before-#190 while the missing case is inherited debt --
617
+ // both are closed by the same gate. `context.response` therefore only ever
618
+ // carries a success status into a hook.
619
+ if (!denied) {
620
+ for (const hook of getAfterHooks(operation, this.model)) {
621
+ await hook(context);
622
+ }
381
623
  }
382
- // Auto-save DB after write operations when configured
383
- if (config.orm.db.autosave === 'onUpdate' && WRITE_OPERATIONS.has(operation)) {
624
+ // EXECUTOR 3 -- file/directory autosave. Ungated this let an
625
+ // unauthenticated caller force a full serialize-and-write of the entire
626
+ // store on every DELETE of any id, with no record touched: amplification
627
+ // rather than corruption, but the same root cause and the same fix.
628
+ if (config.orm.db.autosave === 'onUpdate' && WRITE_OPERATIONS.has(operation) && !denied) {
384
629
  await Orm.db.save();
385
630
  }
386
631
  return response;
@@ -392,10 +637,21 @@ export default class OrmRequest extends Request {
392
637
  // Dasherize the relationship name for URL paths (e.g., accessLinks -> access-links)
393
638
  const dasherizedName = camelCaseToKebabCase(relationshipName);
394
639
  // Related resource route: GET /:id/{relationship}
395
- routes[`/:id/${dasherizedName}`] = async (request) => {
640
+ //
641
+ // These generated routes are not wrapped by _withHooks, which is why they
642
+ // were the least obvious two of the seven unguarded surfaces in #190.
643
+ // They are still dispatched by @stonyx/rest-server as
644
+ // `handler(req, getState(req))`, so `state` -- and therefore the filter
645
+ // planted by auth() -- has always been available here; it was simply
646
+ // never declared or read.
647
+ routes[`/:id/${dasherizedName}`] = async (request, { filter } = {}) => {
396
648
  const record = await store.find(model, getId(request.params));
397
649
  if (!record)
398
650
  return 404;
651
+ // Filtering the PARENT: a caller who may not see the record may not see
652
+ // what it is related to either.
653
+ if (isDenied(filter, record))
654
+ return 404;
399
655
  const relatedData = record.__relationships[relationshipName];
400
656
  const baseUrl = getBaseUrl(request);
401
657
  let data;
@@ -414,10 +670,12 @@ export default class OrmRequest extends Request {
414
670
  };
415
671
  };
416
672
  // Relationship linkage route: GET /:id/relationships/{relationship}
417
- routes[`/:id/relationships/${dasherizedName}`] = async (request) => {
673
+ routes[`/:id/relationships/${dasherizedName}`] = async (request, { filter } = {}) => {
418
674
  const record = await store.find(model, getId(request.params));
419
675
  if (!record)
420
676
  return 404;
677
+ if (isDenied(filter, record))
678
+ return 404;
421
679
  const relatedData = record.__relationships[relationshipName];
422
680
  const baseUrl = getBaseUrl(request);
423
681
  let data;
@@ -446,31 +704,59 @@ export default class OrmRequest extends Request {
446
704
  };
447
705
  };
448
706
  }
449
- // Catch-all for invalid relationship names on related resource route
450
- routes[`/:id/:relationship`] = async (request) => {
451
- const record = await store.find(model, getId(request.params));
452
- if (!record)
453
- return 404;
454
- // If we reach here, relationship doesn't exist (valid ones were registered above)
455
- return 404;
456
- };
457
- // Catch-all for invalid relationship names on relationship linkage route
458
- routes[`/:id/relationships/:relationship`] = async (request) => {
459
- const record = await store.find(model, getId(request.params));
460
- if (!record)
461
- return 404;
462
- return 404;
463
- };
707
+ // Catch-alls for invalid relationship names. Every valid relationship was
708
+ // registered above, so reaching either of these means the relationship does
709
+ // not exist and the answer is 404 regardless of the record.
710
+ //
711
+ // These deliberately carry NO access check and no store lookup. An earlier
712
+ // revision of #190 added `if (isDenied(filter, record)) return 404` here for
713
+ // symmetry with the seven real surfaces, but both branches returned 404, so
714
+ // the guard was unobservable by construction -- a mutation deleting it
715
+ // survived the entire suite because no test that could distinguish it can
716
+ // exist. Unkillable code in an authorization diff reads as coverage and is
717
+ // not, so it is gone; skipping the lookup also removes the timing difference
718
+ // between an existing and a missing parent.
719
+ //
720
+ // IF THIS ROUTE EVER RETURNS ANYTHING OTHER THAN A CONSTANT 404, it becomes
721
+ // the eighth surface and must filter the parent first, exactly like
722
+ // `/:id/{relationship}` above.
723
+ routes[`/:id/:relationship`] = async () => 404;
724
+ routes[`/:id/relationships/:relationship`] = async () => 404;
464
725
  return routes;
465
726
  }
466
727
  auth(request, state) {
467
- const access = this.access(request);
728
+ // A consumer `access()` that throws is a DENIAL, matching `isDenied` one
729
+ // layer down. Unguarded it propagates to express's default handler, which
730
+ // answers 500 -- and the documented sample itself can throw
731
+ // (`request.originalUrl.split(...)` when originalUrl is absent), so the
732
+ // failure mode is reachable by following the docs.
733
+ let access;
734
+ try {
735
+ access = this.access(request);
736
+ }
737
+ catch {
738
+ return 403; // Forbidden
739
+ }
468
740
  if (!access)
469
741
  return 403;
470
- if (Array.isArray(access) && !access.includes(methodAccessMap[request.method]))
471
- return 403;
472
- if (typeof access === 'function')
742
+ if (typeof access === 'function') {
473
743
  state.filter = access;
744
+ return undefined;
745
+ }
746
+ if (access === true)
747
+ return undefined;
748
+ // `AccessMethod` declares `string` legal and it fell through every branch
749
+ // above, returning undefined -- i.e. FULL CRUD, no filter. `return 'read'`
750
+ // is the natural reading of a type that lists `string` first, and it
751
+ // granted DELETE. A bare string is one permission, not a grant of all four.
752
+ const permitted = typeof access === 'string' ? [access] : access;
753
+ // Anything that is not a permission array by this point -- an object, a
754
+ // number, a Symbol -- is a consumer mistake, and the only safe reading of a
755
+ // shape the contract does not define is a denial. Fail CLOSED.
756
+ if (!Array.isArray(permitted))
757
+ return 403;
758
+ if (!permitted.includes(methodAccessMap[request.method]))
759
+ return 403;
474
760
  return undefined;
475
761
  }
476
762
  }
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-alpha.47",
7
+ "version": "0.3.2-alpha.49",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -61,10 +61,10 @@
61
61
  },
62
62
  "homepage": "https://github.com/abofs/stonyx-orm#readme",
63
63
  "dependencies": {
64
- "@stonyx/cron": "0.2.1-beta.79",
64
+ "@stonyx/cron": "0.2.1-beta.84",
65
65
  "@stonyx/events": "0.1.1-beta.52",
66
66
  "@stonyx/utils": "0.2.3-beta.26",
67
- "stonyx": "0.2.3-beta.76"
67
+ "stonyx": "0.2.3-beta.77"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "@aws-sdk/client-dynamodb": "^3.0.0",
@@ -91,7 +91,7 @@
91
91
  }
92
92
  },
93
93
  "devDependencies": {
94
- "@stonyx/rest-server": "0.2.1-beta.80",
94
+ "@stonyx/rest-server": "0.2.1-beta.83",
95
95
  "@types/node": "^25.6.0",
96
96
  "mysql2": "^3.20.0",
97
97
  "pg": "^8.20.0",
@@ -92,6 +92,30 @@ function getId(params: { id?: string; [key: string]: unknown }): string | number
92
92
  return parseInt(id);
93
93
  }
94
94
 
95
+ /**
96
+ * Normalise a caller-supplied BODY id to the key the store will hold it under.
97
+ *
98
+ * `getId()` above is params-shaped: it takes `{ id?: string }` off the URL,
99
+ * where the value is always a string and a falsy one means "no id". A JSON body
100
+ * id is neither -- it can arrive as a number, and `0` is a legitimate id that
101
+ * `getId()` would flatten to `''`.
102
+ *
103
+ * WHY THIS EXISTS AT ALL. createHandler used to look the collision up with the
104
+ * RAW body value while every other surface normalised through `getId()`. The
105
+ * store is a Map keyed by the coerced value, so `store.find(model, '21')` misses
106
+ * the entry held under `21` and the duplicate check is skipped by typing the id
107
+ * as a string. On `dev` that silently overwrote the colliding record and
108
+ * answered 200; combined with the denied-create rollback added for #190 it
109
+ * became an unauthenticated DELETE of any id. Normalising here is half of that
110
+ * fix -- see the rollback in createHandler for the other half.
111
+ */
112
+ function normalizeBodyId(id: string | number): string | number {
113
+ if (typeof id === 'number') return id;
114
+ if (typeof id !== 'string' || id.trim() === '') return id;
115
+
116
+ return isNaN(id as unknown as number) ? id : parseInt(id, 10);
117
+ }
118
+
95
119
  function buildResponse(
96
120
  data: unknown,
97
121
  includeParam: string | undefined,
@@ -251,6 +275,33 @@ function createFilterPredicate(filters: Filter[]): ((record: { [key: string]: un
251
275
  });
252
276
  }
253
277
 
278
+ /**
279
+ * A function-style `access` return is a per-record predicate, and it is only
280
+ * meaningful if every surface that can hand a record to a caller consults it.
281
+ * Before #190 exactly one of seven did.
282
+ *
283
+ * Enforcement is deliberately post-fetch. `access` returns an opaque JS
284
+ * predicate and `store.findAll(model, conditions)` accepts only an equality
285
+ * conditions object that the SQL drivers translate to a WHERE clause, so
286
+ * query-layer enforcement would require a breaking change to the published
287
+ * `access` contract. That belongs in #197, not in a security patch. Six of the
288
+ * seven surfaces fetch by primary key anyway, so this costs exactly one row.
289
+ */
290
+ function isDenied(filter: unknown, record: unknown): boolean {
291
+ if (typeof filter !== 'function') return false;
292
+
293
+ // A predicate that throws is treated as a denial. Unguarded, a throw escapes
294
+ // to express's default handler, which answers 500 (with a stack trace outside
295
+ // NODE_ENV=production) while a missing id still answers 404 -- so a
296
+ // record-dependent throw re-separates "hidden" from "does not exist" and
297
+ // hands back the oracle this whole change exists to close.
298
+ try {
299
+ return !(filter as (record: unknown) => boolean)(record);
300
+ } catch {
301
+ return true;
302
+ }
303
+ }
304
+
254
305
  export default class OrmRequest extends Request {
255
306
  model: string;
256
307
  access: (request: unknown) => AccessMethod;
@@ -287,9 +338,13 @@ export default class OrmRequest extends Request {
287
338
  });
288
339
  };
289
340
 
290
- const getSingleHandler: HandlerFn = async (request) => {
341
+ const getSingleHandler: HandlerFn = async (request, { filter }) => {
291
342
  const record = await store.find(model, getId(request.params)) as OrmRecord | undefined;
292
343
  if (!record) return 404;
344
+ // 404, never 403: the status for "exists but filtered out" must be
345
+ // identical to "does not exist", or the fix trades an authorization
346
+ // bypass for a narrower existence oracle.
347
+ if (isDenied(filter, record)) return 404;
293
348
 
294
349
  const fieldsMap = parseFields(request.query);
295
350
  const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
@@ -301,7 +356,7 @@ export default class OrmRequest extends Request {
301
356
  });
302
357
  };
303
358
 
304
- const createHandler: HandlerFn = async ({ body, query }) => {
359
+ const createHandler: HandlerFn = async ({ body, query }, { filter }) => {
305
360
  const { type, id, attributes, relationships: rels } = (body?.data || {}) as {
306
361
  type?: string;
307
362
  id?: string | number;
@@ -314,8 +369,47 @@ export default class OrmRequest extends Request {
314
369
  const fieldsMap = parseFields(query);
315
370
  const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
316
371
 
317
- // Check for duplicate ID
318
- if (id !== undefined && await store.find(model, id)) return 409; // Conflict
372
+ // GATE 0 -- the POST existence oracle.
373
+ //
374
+ // The duplicate check runs before the filter and `store.find` sees hidden
375
+ // records, so POST leaks existence through its STATUS. A previous revision
376
+ // filtered the collision status (403 when the colliding record is denied,
377
+ // 409 when it is visible) and that is NOT sufficient, because the status
378
+ // of a create is a third outcome. With a payload the caller is permitted
379
+ // to create -- the normative case for a per-tenant filter, and the case an
380
+ // attacker picks -- all three are distinguishable in ONE request per id:
381
+ //
382
+ // POST /animals {id:22, owner:'gina'} -> 403 a HIDDEN record has this id
383
+ // POST /animals {id:9500, owner:'gina'} -> 200 this id is FREE
384
+ // POST /animals {id:8, owner:'gina'} -> 409 a VISIBLE record has this id
385
+ //
386
+ // Filtering only the collision status narrows that to callers who cannot
387
+ // create a record they are allowed to see. It does not close it.
388
+ //
389
+ // It cannot be closed while a caller both chooses the id and learns
390
+ // whether the create succeeded: a successful create must answer
391
+ // differently from a refused one. So when a per-record filter is in force
392
+ // the caller does not get to choose the id at all. The refusal is
393
+ // UNCONDITIONAL and happens BEFORE any store lookup, so no status, and no
394
+ // lookup cost, can depend on whether that id exists. 403 -- the same
395
+ // status as a denied create -- so the two cannot be separated either.
396
+ //
397
+ // Scoped to function-style `access` because that is exactly the population
398
+ // the oracle exists for: with no per-record filter there are no hidden
399
+ // records, and 409 discloses nothing GET /:id does not already.
400
+ //
401
+ // RESIDUAL, stated rather than implied: a caller can still learn that a
402
+ // collection HAS a per-record filter (403 rather than 409/200 for an
403
+ // id-bearing POST). That discloses a configuration fact, not a record.
404
+ // See README `### Known limitations`.
405
+ if (id !== undefined) {
406
+ if (typeof filter === 'function') return 403; // Forbidden
407
+
408
+ // `normalizeBodyId`, not the raw value: a string-typed id misses the
409
+ // store's numeric key, which skipped this check entirely.
410
+ const existing = await store.find(model, normalizeBodyId(id));
411
+ if (existing) return 409; // Conflict
412
+ }
319
413
 
320
414
  const { id: _ignoredId, ...sanitizedAttributes } = attributes || {};
321
415
 
@@ -330,16 +424,73 @@ export default class OrmRequest extends Request {
330
424
  }
331
425
 
332
426
  const recordAttributes = id !== undefined ? { id, ...sanitizedAttributes } : sanitizedAttributes;
427
+
428
+ // Slot count BEFORE the write. `createRecord` writes to the store before
429
+ // the predicate can run, and the rollback below must be able to prove the
430
+ // slot it removes is one THIS REQUEST created. Identity alone cannot
431
+ // prove it: when `assignRecordId` lands on an occupied id, `createRecord`
432
+ // mutates the existing OrmRecord IN PLACE, so `store.get(...) === record`
433
+ // is true for a record the request did not create. The map's size is the
434
+ // only O(1) signal that distinguishes an insert from an overwrite.
435
+ const slotsBefore = (store.get(model) as Map<string | number, unknown> | undefined)?.size ?? 0;
436
+
333
437
  const created = createRecord(model, recordAttributes as { [key: string]: unknown }, { serialize: false, _skipAutoPersist: true });
334
438
  const record = isOrmRecord(created) ? created : null;
335
439
  if (!record) return 500;
336
440
 
441
+ const createdNewSlot = ((store.get(model) as Map<string | number, unknown> | undefined)?.size ?? 0) > slotsBefore;
442
+
443
+ // 403 here, NOT 404. The oracle argument does not apply to create: there
444
+ // is no pre-existing record whose existence could leak, the caller
445
+ // supplied the attributes, and 404 on a mounted collection route is
446
+ // indistinguishable from "model not mounted" -- a genuinely different
447
+ // failure a developer needs to diagnose.
448
+ //
449
+ // The rollback is not optional. createRecord writes to the store BEFORE
450
+ // the predicate can run, so returning 403 alone would leave the record
451
+ // behind: a worse bug than the bypass being fixed.
452
+ if (isDenied(filter, record)) {
453
+ // ROLL BACK BY IDENTITY, NEVER BY ID. `store.remove(model, record.id)`
454
+ // on its own is a write primitive keyed by a value the caller may have
455
+ // supplied: with the raw-id collision bypass above, a denied
456
+ // `POST {"id":"21"}` answered 403 and DELETED hidden record 21 -- an
457
+ // unauthenticated deletion primitive across the whole id space, created
458
+ // by adding a rollback to a lookup that could be skipped.
459
+ //
460
+ // Both conditions are required and neither implies the other:
461
+ // createdNewSlot -- the store grew, so this request inserted rather
462
+ // than overwrote. Guards `assignRecordId` picking an
463
+ // id that is already taken (it returns
464
+ // last-INSERTED + 1, not max + 1, so a store whose
465
+ // insertion order is not ascending collides) -- see
466
+ // abofs/stonyx-orm#203.
467
+ // identity -- the slot still holds the object we just created,
468
+ // so nothing between createRecord and here replaced
469
+ // it.
470
+ if (createdNewSlot && store.get(model, record.id as string | number) === record) {
471
+ store.remove(model, record.id as string | number, { _skipAutoPersist: true });
472
+ }
473
+
474
+ return 403;
475
+ }
476
+
337
477
  return { data: record.toJSON?.({ fields: modelFields }) };
338
478
  };
339
479
 
340
- const updateHandler: HandlerFn = async ({ body, params }) => {
480
+ const updateHandler: HandlerFn = async ({ body, params }, { filter }) => {
341
481
  const found = await store.find(model, getId(params));
342
482
  if (!found || !isOrmRecord(found)) return 404;
483
+ // Checked BEFORE any attribute is applied. 404 rather than 403 for the
484
+ // same reason as GET /:id -- 403 would disclose both that the record
485
+ // exists and that this caller specifically is excluded.
486
+ //
487
+ // NOT redundant behind GATE 1, and not defence in depth either: GATE 1's
488
+ // verdict is computed BEFORE the before-hook loop runs, and a before-hook
489
+ // is a published extension point that can change the answer -- by
490
+ // mutating the record, or against a predicate that closes over
491
+ // per-request state. This is the only re-evaluation after that window.
492
+ // Pinned by assertion 32; deleting it turns a 404 into an applied update.
493
+ if (isDenied(filter, found)) return 404;
343
494
  const record = found;
344
495
  const { attributes, relationships: rels } = (body?.data || {}) as {
345
496
  attributes?: { [key: string]: unknown };
@@ -375,7 +526,22 @@ export default class OrmRequest extends Request {
375
526
  return { data: record.toJSON?.() };
376
527
  };
377
528
 
378
- const deleteHandler: HandlerFn = ({ params }) => {
529
+ const deleteHandler: HandlerFn = async ({ params }, { filter }) => {
530
+ const record = await store.find(model, getId(params));
531
+
532
+ // BEHAVIOUR CHANGE (#190): a DELETE of a record that never existed
533
+ // returned 204 before this change. It now returns 404, matching the
534
+ // denied case below. This is deliberate and load-bearing -- if a denied
535
+ // delete returned 404 while a missing one returned 204, the pair would be
536
+ // a perfect existence oracle and the whole fix would be worthless.
537
+ // Returning 204 for a denied delete was rejected instead: it falsely
538
+ // reports success for a request that changed nothing.
539
+ if (!record) return 404;
540
+ // Re-evaluated after the before-hook loop, exactly as in updateHandler --
541
+ // GATE 1 decided before the hooks ran. Pinned by assertion 33; deleting it
542
+ // turns a 404 into a destroyed record.
543
+ if (isDenied(filter, record)) return 404;
544
+
379
545
  store.remove(model, getId(params), { _skipAutoPersist: true });
380
546
  return 204;
381
547
  };
@@ -405,9 +571,37 @@ export default class OrmRequest extends Request {
405
571
  }
406
572
  }
407
573
 
408
- // Wraps a handler with before/after hook execution
574
+ // Wraps a handler with before/after hook execution.
575
+ //
576
+ // ===========================================================================
577
+ // TWO AUTHORIZATION GATES, AND EVERY EXECUTOR SITS BEHIND ONE OF THEM (#190)
578
+ //
579
+ // The defect this function was fixed for is NOT "a delete persists past a
580
+ // 404". It is that _withHooks has SEVERAL executors downstream of the
581
+ // handler, and originally the handler's response gated none of them. Three
582
+ // exist today:
583
+ //
584
+ // 1. sqlDb.persist -- issues real SQL against the backing store
585
+ // 2. the after-hook pipeline -- the PUBLISHED consumer extension point;
586
+ // a cascade delete, a webhook, a search-index
587
+ // purge. `context.recordId` and
588
+ // `context.oldState` are populated for it.
589
+ // 3. Orm.db.save() -- a full serialize-and-write of the store
590
+ //
591
+ // Gating them one at a time is how this keeps regressing, so the rule is:
592
+ // compute denial ONCE at each point where it becomes knowable, and keep every
593
+ // executor downstream of a gate. If you add a fourth executor to this
594
+ // function, it goes below GATE 2 or it is a security bug.
595
+ //
596
+ // GATE 1 (pre-handler) is required because before-hooks and `context.oldState`
597
+ // run/are built BEFORE the handler can consult the filter. Without it a denied
598
+ // DELETE still handed the hidden record's full contents to consumer code.
599
+ // GATE 2 (post-handler) covers everything the handler's status can reach.
600
+ // ===========================================================================
409
601
  private _withHooks(operation: string, handler: HandlerFn): HandlerFn {
410
602
  return async (request: OrmRequest$, state: { [key: string]: unknown }) => {
603
+ const { filter } = (state || {}) as { filter?: unknown };
604
+
411
605
  // Build context object for hooks
412
606
  const context: HookContext = {
413
607
  model: this.model,
@@ -422,6 +616,27 @@ export default class OrmRequest extends Request {
422
616
  // Capture old state for operations that modify data
423
617
  if (operation === 'update' || operation === 'delete') {
424
618
  const existingRecord = await store.find(this.model, getId(request.params)) as OrmRecord | undefined;
619
+
620
+ // GATE 1 -- pre-handler. This record fetch already happened for
621
+ // oldState, so the check is free.
622
+ //
623
+ // Returning here rather than letting updateHandler/deleteHandler
624
+ // produce the same 404 is the point: everything between here and there
625
+ // is an executor the caller is not authorized to reach.
626
+ // - context.oldState is a deep copy of the HIDDEN RECORD'S CONTENTS.
627
+ // Building it and handing it to a before-hook discloses exactly what
628
+ // the filter exists to hide.
629
+ // - context.recordId is populated for delete BEFORE the handler runs,
630
+ // which is the same shape as the sqlDb landmine one layer up:
631
+ // `afterHook('delete', ctx => cascadeDelete(ctx.recordId))` destroys
632
+ // children behind a correct 404.
633
+ // - a before-hook may return a value and short-circuit, which would
634
+ // otherwise return a response without the filter ever executing.
635
+ //
636
+ // 404, not 403, for the same reason as getSingleHandler: the status for
637
+ // "exists but filtered out" must equal "does not exist".
638
+ if (existingRecord && isDenied(filter, existingRecord)) return 404;
639
+
425
640
  if (existingRecord) {
426
641
  // Deep copy the record's data to preserve old state
427
642
  context.oldState = JSON.parse(JSON.stringify(existingRecord.__data || existingRecord));
@@ -448,9 +663,28 @@ export default class OrmRequest extends Request {
448
663
  context.record = store.get(this.model, getId(request.params));
449
664
  }
450
665
 
451
- // Persist to SQL database for all write operations (create/update/delete)
666
+ // GATE 2 -- post-handler. A denied or failed handler returns a bare status
667
+ // integer, and no executor below may run for one.
668
+ //
669
+ // `>= 400` deliberately covers every failure status, not just the
670
+ // authorization ones: a 400 (POST with no `type`) and a 409 (duplicate id)
671
+ // are equally requests in which nothing happened, and a persist or a
672
+ // cascade hook for one of them is just as wrong.
673
+ const denied = Number.isInteger(response) && (response as number) >= 400;
674
+
675
+ // EXECUTOR 1 -- SQL persistence, for all write operations.
676
+ //
677
+ // `response` is passed to sqlDb.persist below, but it is dropped at the
678
+ // driver boundary: _persistDelete(modelName, context) never receives it
679
+ // and guards only on context.recordId -- which _withHooks set above,
680
+ // BEFORE the handler ran. Without this gate a correct 404 still issues
681
+ // DELETE FROM ... WHERE id = ? on every SQL backend.
682
+ //
683
+ // No file-backed test can observe that, because Orm.instance.sqlDb is
684
+ // null in file/directory mode. See the stubbed-sqlDb assertions in
685
+ // test/unit/access-filter-enforcement-test.ts.
452
686
  const sqlDb = Orm.instance.sqlDb;
453
- if (sqlDb && WRITE_OPERATIONS.has(operation)) {
687
+ if (sqlDb && WRITE_OPERATIONS.has(operation) && !denied) {
454
688
  await sqlDb.persist(operation, this.model, context, response);
455
689
  }
456
690
 
@@ -471,13 +705,30 @@ export default class OrmRequest extends Request {
471
705
  context.recordId = getId(request.params);
472
706
  }
473
707
 
474
- // Run after hooks sequentially
475
- for (const hook of getAfterHooks(operation, this.model)) {
476
- await hook(context);
708
+ // EXECUTOR 2 -- the after-hook pipeline. This is the published consumer
709
+ // extension point (`afterHook` is exported from @stonyx/orm and from
710
+ // ./hooks), so it is the executor with the widest possible blast radius:
711
+ // a cascade delete, a webhook, a token revocation, a search-index purge.
712
+ //
713
+ // BEHAVIOUR CHANGE (#190): after-hooks no longer fire for a request that
714
+ // failed. Previously `afterHook('delete', ...)` ran with a populated
715
+ // context.recordId on a 404, so a consumer cascade destroyed children for
716
+ // a request that deleted nothing. Firing a hook named "after<operation>"
717
+ // for an operation that did not occur is a booby trap, and the denied case
718
+ // is unreachable-before-#190 while the missing case is inherited debt --
719
+ // both are closed by the same gate. `context.response` therefore only ever
720
+ // carries a success status into a hook.
721
+ if (!denied) {
722
+ for (const hook of getAfterHooks(operation, this.model)) {
723
+ await hook(context);
724
+ }
477
725
  }
478
726
 
479
- // Auto-save DB after write operations when configured
480
- if (config.orm.db.autosave === 'onUpdate' && WRITE_OPERATIONS.has(operation)) {
727
+ // EXECUTOR 3 -- file/directory autosave. Ungated this let an
728
+ // unauthenticated caller force a full serialize-and-write of the entire
729
+ // store on every DELETE of any id, with no record touched: amplification
730
+ // rather than corruption, but the same root cause and the same fix.
731
+ if (config.orm.db.autosave === 'onUpdate' && WRITE_OPERATIONS.has(operation) && !denied) {
481
732
  await (Orm.db as { save(): Promise<void> }).save();
482
733
  }
483
734
 
@@ -497,9 +748,19 @@ export default class OrmRequest extends Request {
497
748
  const dasherizedName = camelCaseToKebabCase(relationshipName);
498
749
 
499
750
  // Related resource route: GET /:id/{relationship}
500
- routes[`/:id/${dasherizedName}`] = async (request: OrmRequest$) => {
751
+ //
752
+ // These generated routes are not wrapped by _withHooks, which is why they
753
+ // were the least obvious two of the seven unguarded surfaces in #190.
754
+ // They are still dispatched by @stonyx/rest-server as
755
+ // `handler(req, getState(req))`, so `state` -- and therefore the filter
756
+ // planted by auth() -- has always been available here; it was simply
757
+ // never declared or read.
758
+ routes[`/:id/${dasherizedName}`] = async (request: OrmRequest$, { filter }: { [key: string]: unknown } = {}) => {
501
759
  const record = await store.find(model, getId(request.params)) as OrmRecord | undefined;
502
760
  if (!record) return 404;
761
+ // Filtering the PARENT: a caller who may not see the record may not see
762
+ // what it is related to either.
763
+ if (isDenied(filter, record)) return 404;
503
764
 
504
765
  const relatedData = record.__relationships[relationshipName];
505
766
  const baseUrl = getBaseUrl(request);
@@ -521,9 +782,10 @@ export default class OrmRequest extends Request {
521
782
  };
522
783
 
523
784
  // Relationship linkage route: GET /:id/relationships/{relationship}
524
- routes[`/:id/relationships/${dasherizedName}`] = async (request: OrmRequest$) => {
785
+ routes[`/:id/relationships/${dasherizedName}`] = async (request: OrmRequest$, { filter }: { [key: string]: unknown } = {}) => {
525
786
  const record = await store.find(model, getId(request.params)) as OrmRecord | undefined;
526
787
  if (!record) return 404;
788
+ if (isDenied(filter, record)) return 404;
527
789
 
528
790
  const relatedData = record.__relationships[relationshipName];
529
791
  const baseUrl = getBaseUrl(request);
@@ -554,32 +816,60 @@ export default class OrmRequest extends Request {
554
816
  };
555
817
  }
556
818
 
557
- // Catch-all for invalid relationship names on related resource route
558
- routes[`/:id/:relationship`] = async (request: OrmRequest$) => {
559
- const record = await store.find(model, getId(request.params));
560
- if (!record) return 404;
561
-
562
- // If we reach here, relationship doesn't exist (valid ones were registered above)
563
- return 404;
564
- };
565
-
566
- // Catch-all for invalid relationship names on relationship linkage route
567
- routes[`/:id/relationships/:relationship`] = async (request: OrmRequest$) => {
568
- const record = await store.find(model, getId(request.params));
569
- if (!record) return 404;
570
-
571
- return 404;
572
- };
819
+ // Catch-alls for invalid relationship names. Every valid relationship was
820
+ // registered above, so reaching either of these means the relationship does
821
+ // not exist and the answer is 404 regardless of the record.
822
+ //
823
+ // These deliberately carry NO access check and no store lookup. An earlier
824
+ // revision of #190 added `if (isDenied(filter, record)) return 404` here for
825
+ // symmetry with the seven real surfaces, but both branches returned 404, so
826
+ // the guard was unobservable by construction -- a mutation deleting it
827
+ // survived the entire suite because no test that could distinguish it can
828
+ // exist. Unkillable code in an authorization diff reads as coverage and is
829
+ // not, so it is gone; skipping the lookup also removes the timing difference
830
+ // between an existing and a missing parent.
831
+ //
832
+ // IF THIS ROUTE EVER RETURNS ANYTHING OTHER THAN A CONSTANT 404, it becomes
833
+ // the eighth surface and must filter the parent first, exactly like
834
+ // `/:id/{relationship}` above.
835
+ routes[`/:id/:relationship`] = async () => 404;
836
+ routes[`/:id/relationships/:relationship`] = async () => 404;
573
837
 
574
838
  return routes;
575
839
  }
576
840
 
577
841
  auth(request: OrmRequest$, state: { [key: string]: unknown }): number | undefined {
578
- const access = this.access(request);
842
+ // A consumer `access()` that throws is a DENIAL, matching `isDenied` one
843
+ // layer down. Unguarded it propagates to express's default handler, which
844
+ // answers 500 -- and the documented sample itself can throw
845
+ // (`request.originalUrl.split(...)` when originalUrl is absent), so the
846
+ // failure mode is reachable by following the docs.
847
+ let access: AccessMethod;
848
+ try {
849
+ access = this.access(request);
850
+ } catch {
851
+ return 403; // Forbidden
852
+ }
579
853
 
580
854
  if (!access) return 403;
581
- if (Array.isArray(access) && !access.includes(methodAccessMap[request.method])) return 403;
582
- if (typeof access === 'function') state.filter = access;
855
+ if (typeof access === 'function') {
856
+ state.filter = access;
857
+ return undefined;
858
+ }
859
+ if (access === true) return undefined;
860
+
861
+ // `AccessMethod` declares `string` legal and it fell through every branch
862
+ // above, returning undefined -- i.e. FULL CRUD, no filter. `return 'read'`
863
+ // is the natural reading of a type that lists `string` first, and it
864
+ // granted DELETE. A bare string is one permission, not a grant of all four.
865
+ const permitted = typeof access === 'string' ? [access] : access;
866
+
867
+ // Anything that is not a permission array by this point -- an object, a
868
+ // number, a Symbol -- is a consumer mistake, and the only safe reading of a
869
+ // shape the contract does not define is a denial. Fail CLOSED.
870
+ if (!Array.isArray(permitted)) return 403;
871
+ if (!permitted.includes(methodAccessMap[request.method])) return 403;
872
+
583
873
  return undefined;
584
874
  }
585
875
  }