@stonyx/orm 0.3.2-alpha.68 → 0.3.2-alpha.69

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
@@ -350,66 +350,73 @@ Access classes define models and provide custom filtering/authorization logic.
350
350
  export default class GlobalAccess {
351
351
  models = ['owner', 'animal'];
352
352
 
353
- access(request, { model, operation }) {
353
+ access(request, { model, operation, recordId }) {
354
354
  // `model` is the model this route was mounted for. It is assigned once, at
355
355
  // mount time, and no request can influence it — not a mount prefix, not a
356
356
  // query string, not a case-varied path, not an absolute-form request
357
- // target. Nothing below parses anything, so variants 1, 2, 4 and 5 are not
358
- // constructible against this predicate any more they are history, not
359
- // rules to follow. VARIANT 3 IS THE EXCEPTION AND THE CLAIM IS NARROWER
360
- // THAN IT WAS: a matcher stricter than the router can still be stepped
361
- // around, because the sub-path rule below is still a string comparison.
362
- // Case is handled; percent-encoding is not abofs/stonyx-orm#228.
357
+ // target. `recordId` is the record this route was ADDRESSED TO, decoded by
358
+ // the router and coerced to the key the store lookup uses. Nothing below
359
+ // parses anything, and since abofs/stonyx-orm#236 nothing below reads
360
+ // argument one AT ALL. Variants 1, 2, 4 and 5 were already unconstructible;
361
+ // the sub-path STRING COMPARISON that variant 3 lived in is gone too,
362
+ // replaced by a comparison against the decoded id. Retiring the "variant 3
363
+ // survives" wording at the four sites that still carry it — with the
364
+ // measurement that retires it, rather than by deletion — is
365
+ // abofs/stonyx-orm#238.
363
366
  //
364
367
  // `operation` is destructured to name the whole contract at the point of
365
- // use. This sample's rules are per-model and per-sub-path rather than
368
+ // use. This sample's rules are per-model and per-record rather than
366
369
  // per-verb, so it does not branch on it; the permission array at the bottom
367
370
  // is where the verb is answered.
368
371
 
369
- // FAIL CLOSED ON ARGUMENT TWO. `model` is absent for any caller that
370
- // resolved this predicate without supplying the context, and a request this
371
- // function cannot identify DENIES rather than falling through to the CRUD
372
- // grant at the bottom. An unidentifiable input must never be the permissive
373
- // path. Argument ONE is guarded at its own read, below — this guard does
374
- // not cover it.
372
+ // FAIL CLOSED ON AN UNIDENTIFIABLE MODEL. `model` is absent for any caller
373
+ // that resolved this predicate without supplying the context, and a request
374
+ // this function cannot identify DENIES rather than falling through to the
375
+ // CRUD grant at the bottom. An unidentifiable input must never be the
376
+ // permissive path.
375
377
  if (typeof model !== 'string' || model === '') return false;
376
378
 
377
379
  if (model === 'owner') {
378
- // The context names WHICH MODEL and WHICH VERB not which route. Six
379
- // distinct owner surfaces produce one identical context, so a rule that
380
- // depends on the SUB-PATH still needs argument one. `request.path` is
381
- // mount-relative and query-free, and it is the one read of the raw
382
- // request the README sanctions. false 403 for the whole request.
380
+ // FAIL CLOSED ON AN ABSENT `recordId` TOO, AND `undefined` IS THE ONLY
381
+ // SPELLING OF ABSENT. `auth()` ALWAYS sets the key `null` on a
382
+ // collection route, which is addressed to no record — so `undefined`
383
+ // means the context did not come from `auth()`: it was hand-assembled by
384
+ // a caller resolving this predicate through the documented
385
+ // `Orm.instance.getAccess()` path. Letting that through would fall
386
+ // straight to the per-record filter below, which is a DENY becoming an
387
+ // ALLOW. This is the same rule the old guard on `request.path` enforced,
388
+ // moved to the argument this predicate now actually reads.
389
+ if (recordId === undefined) return false;
390
+
391
+ // THE `/archived` DENY, EXPRESSED AGAINST THE DECODED ID. It used to be
392
+ // `request.path.toLowerCase()` compared against `'/archived'`, and that
393
+ // was wrong in both directions at once.
383
394
  //
384
- // THIS DENY CANNOT BE EXPRESSED FROM THE CONTEXT ALONE. Migrating it away
385
- // does not remove a rule, it turns a deny into an ALLOW, silently.
395
+ // TOO PERMISSIVE: express sets `request.path` from the RAW pathname while
396
+ // the router DECODES `:id`, so `GET /owners/%61rchived` reached the
397
+ // comparison as `/%61rchived`, walked past the deny and was dispatched as
398
+ // the record `archived` — 200 with the record in full, and DELETE
399
+ // answered 204 with the record DESTROYED, unauthenticated. 255
400
+ // non-canonical spellings of that 8-character id decode to the same key,
401
+ // so no deny-list of spellings was ever going to close it.
386
402
  //
387
- // FAIL CLOSED ON ARGUMENT ONE TOO. The guard above covers the context;
388
- // this one covers the request, and since #202 they are two different
389
- // objects. A caller that resolves this predicate through the documented
390
- // `Orm.instance.getAccess()` path and hand-assembles a request can supply
391
- // a perfectly valid context with no usable `path` — and
392
- // `String(request.path ?? '')` is then `''`, which matches no sub-path
393
- // rule and falls straight through to the per-record filter below. That is
394
- // a DENY becoming an ALLOW. An input this function cannot identify DENIES,
395
- // whichever ARGUMENT it arrived on — which is also why the `?? ''` this
396
- // file's header condemns does not appear below.
397
- if (typeof request?.path !== 'string' || request.path === '') return false;
398
-
399
- // Lower-cased because the router matched case-insensitively, so a
400
- // case-sensitive rule here would be stricter than the router and could be
401
- // stepped around.
403
+ // TOO STRICT: a record id is a VALUE, not a literal route segment, and
404
+ // express's `case sensitive routing` governs literal segments only. With
405
+ // a distinct owner seeded at `ARCHIVED`, the `.toLowerCase()` 403'd
406
+ // `GET /owners/ARCHIVED` the wrong record while still admitting
407
+ // `GET /owners/%41RCHIVED`, the same record encoded.
402
408
  //
403
- // CASE-FOLDING ALONE IS NOT A SUFFICIENT NORMALISATION, and this line is
404
- // not a recipe for one. Express sets `request.path` from the RAW pathname
405
- // while the router DECODES `:id`, so `GET /owners/%61rchived` reaches this
406
- // comparison as `/%61rchived`, walks past the deny, and is dispatched as
407
- // the record `archived` abofs/stonyx-orm#228. A matcher must normalise
408
- // the way the router that dispatched the request does. Record ids are
409
- // case-sensitive and must be compared at their real case.
410
- const path = request.path.toLowerCase();
411
-
412
- if (path === '/archived' || path.startsWith('/archived/')) return false;
409
+ // SO DO NOT NORMALISE `recordId`. It is already decoded, exactly ONCE,
410
+ // which is what a route parameter means: `/owners/%2561rchived` is the
411
+ // legitimate id `%61rchived`, and decoding until stable would deny it. Do
412
+ // not case-fold it. Do not rebuild it from `request.path` decoding the
413
+ // whole path decodes THEN splits while the router splits THEN decodes,
414
+ // which over-denies the distinct record at `/owners/archived%2fx`.
415
+ //
416
+ // THE DENY IS NOW EXPRESSIBLE FROM THE CONTEXT ALONE, which is exactly
417
+ // what `recordId` bought — and it still must not be dropped. Deleting it
418
+ // does not remove a rule loudly, it turns a deny into an ALLOW, silently.
419
+ if (recordId === 'archived') return false;
413
420
 
414
421
  // Returning a function plugs it in as a per-record filter, and it is
415
422
  // enforced on every surface addressed to one of these records:
@@ -864,50 +871,7 @@ per-record filter. An input you cannot identify must **deny**.
864
871
  related record without resolving that model's own access class, so a filter on
865
872
  `/owners` does not hide an owner reached through `/animals`. Tracked as
866
873
  [#196](https://github.com/abofs/stonyx-orm/issues/196), which covers
867
- `include=`, related-resource routes and relationship-linkage routes. This is
868
- **membership** — whether the related resource is served at all — and it is a
869
- different question from which ids a document may *name*, immediately below.
870
- - **Relationship linkage is filtered on the four request-bound read surfaces,
871
- and only there.** A document's `relationships.*.data` used to publish the id
872
- of every related record unconditionally, so a record hidden on every one of
873
- its own surfaces was still named inside another model's document — with no
874
- `include=`, no relationship route and no query string
875
- ([#234](https://github.com/abofs/stonyx-orm/issues/234)). It is now filtered
876
- through the related model's own access class on `GET /:models`,
877
- `GET /:models/:id` and both `GET /:models/:id/{relationship}` shapes. A
878
- filtered-out relationship is **indistinguishable from a genuinely empty one** —
879
- an emptied `hasMany` is `data: []` and an emptied `belongsTo` is `data: null`,
880
- both **keeping their `links`**, which are built from the serialized record's
881
- own id and never from the related one. Nothing errors and no status changes,
882
- because throwing here would be an existence oracle *and* would throw out of
883
- the enclosing `JSON.stringify`. **Not yet covered:** `included`
884
- ([#233](https://github.com/abofs/stonyx-orm/issues/233) owns whether a
885
- resource appears there at all), the `POST`/`PATCH` response documents, and
886
- `GET /:models/:id/relationships/{relationship}`, whose *primary data* is
887
- linkage ([#196](https://github.com/abofs/stonyx-orm/issues/196)).
888
- - **A bare `toJSON()` still emits unfiltered linkage, and that is deliberate.**
889
- `Record.toJSON()` **applies** a verdict; it never **resolves** one. It has no
890
- request, and the documented `access()` contract permits a predicate to read
891
- one — the sample in this README does, for its sub-path rule — so a filter
892
- resolved inside `toJSON()` denies *permitted* records rather than hidden ones
893
- (measured: 967 → 964, all three failures over-denials). `toJSON` is also the
894
- `JSON.stringify` hook, so `JSON.stringify(record)`, `res.json(record)` and
895
- `console.log(JSON.stringify(record))` reach it with a **string** in the
896
- options slot and have no syntactic place to pass a verdict. The no-argument
897
- call therefore returns the pre-#234 document unchanged. Fail-closed by default
898
- is not available either: `Orm.instance.accessFunctions` is `{}` in any process
899
- that never ran `setup-rest-server` — a CLI, an SQL-only process, a test — so
900
- it would empty every relationship on every document in processes with no REST
901
- surface to protect. Closing the residual means moving JSON:API serialization
902
- **off** the `toJSON` name, tracked as
903
- [#230](https://github.com/abofs/stonyx-orm/issues/230). If you hand a `Record`
904
- to an untrusted consumer, serialize it through the REST layer or pass your own
905
- resolved `linkage` option.
906
- - **`format()` and `serialize()` are deliberately not filtered, and must stay
907
- that way.** `format()` is the **persistence** path — its output is what
908
- `Orm.db.save()` writes to disk — so applying an access filter there would
909
- write a truncated database. That is **data loss**, not disclosure prevention.
910
- Neither method appears anywhere in the REST response path.
874
+ `include=`, related-resource routes and relationship-linkage routes.
911
875
  - **A before-hook that returns a value short-circuits the request.** On write
912
876
  operations addressed to a record the filter is consulted first, so a hook
913
877
  cannot answer for a record the caller may not see. On reads it is not, so a
@@ -219,7 +219,6 @@ import { getBeforeHooks, getAfterHooks } from './hooks.js';
219
219
  import config from 'stonyx/config';
220
220
  import log from 'stonyx/log';
221
221
  import { isOrmRecord, NO_FREE_ID_ERROR } from './utils.js';
222
- import { interpretAccess, createLinkageFilter } from './access-verdict.js';
223
222
  const methodAccessMap = {
224
223
  GET: 'read',
225
224
  POST: 'create',
@@ -562,13 +561,7 @@ export default class OrmRequest extends Request {
562
561
  if (queryFilterPredicate)
563
562
  recordsToReturn = recordsToReturn.filter(queryFilterPredicate);
564
563
  const baseUrl = getBaseUrl(request);
565
- // ONE filter per REQUEST, not one per record: it carries the per-type
566
- // verdict cache and the per-(type, id) decision cache, and both are
567
- // worthless if it is rebuilt inside the map. Measured on this exact
568
- // surface with no `include=`: 48 linkage entries collapse to 7 distinct
569
- // (type, id) pairs.
570
- const linkage = createLinkageFilter(request);
571
- const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl, linkage }));
564
+ const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl }));
572
565
  return buildResponse(data, request.query?.include, recordsToReturn, {
573
566
  links: { self: `${baseUrl}/${pluralizedModel}` },
574
567
  baseUrl
@@ -586,13 +579,7 @@ export default class OrmRequest extends Request {
586
579
  const fieldsMap = parseFields(request.query);
587
580
  const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
588
581
  const baseUrl = getBaseUrl(request);
589
- const linkage = createLinkageFilter(request);
590
- // `buildResponse` is deliberately NOT given the linkage filter. It builds
591
- // `included`, and WHETHER A RESOURCE APPEARS THERE AT ALL is membership,
592
- // which belongs to abofs/stonyx-orm#233 -- see this file's #234 note and
593
- // the ownership boundary in that issue. Only the PRIMARY document's
594
- // linkage is filtered here.
595
- return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
582
+ return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl }), request.query?.include, record, {
596
583
  links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
597
584
  baseUrl
598
585
  });
@@ -1179,21 +1166,15 @@ export default class OrmRequest extends Request {
1179
1166
  return 404;
1180
1167
  const relatedData = record.__relationships[relationshipName];
1181
1168
  const baseUrl = getBaseUrl(request);
1182
- // LINKAGE ONLY. This filter decides which ids the emitted documents may
1183
- // NAME in their own `relationships.*.data`; it does NOT decide whether
1184
- // the related records themselves are served -- that is the parent-only
1185
- // filtering this route has done since #190, and widening it to the
1186
- // related record is abofs/stonyx-orm#196.
1187
- const linkage = createLinkageFilter(request);
1188
1169
  let data;
1189
1170
  if (info.isArray) {
1190
1171
  // hasMany - return array
1191
1172
  const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
1192
- data = related.map(r => r.toJSON?.({ baseUrl, linkage }));
1173
+ data = related.map(r => r.toJSON?.({ baseUrl }));
1193
1174
  }
1194
1175
  else {
1195
1176
  // belongsTo - return single or null
1196
- data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl, linkage }) : null;
1177
+ data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl }) : null;
1197
1178
  }
1198
1179
  return {
1199
1180
  links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}/${dasherizedName}` },
@@ -1288,10 +1269,53 @@ export default class OrmRequest extends Request {
1288
1269
  // src/types/orm-types.ts. Nothing is fetched at this point and adding a
1289
1270
  // lookup here would put a store read in the middle of an authorization
1290
1271
  // path. The function return shape below IS the per-record hook.
1272
+ //
1273
+ // -------------------------------------------------------------------------
1274
+ // #236 -- `recordId`, the DECODED route-parameter id, for the same reason.
1275
+ //
1276
+ // WHICH RECORD is the third structural fact the framework already holds and
1277
+ // the consumer was left to re-derive, and re-deriving it failed OPEN. The
1278
+ // documented sample compared `request.path` -- the RAW, undecoded pathname
1279
+ // -- against a literal `/archived`, while the router DECODES `:id`. So
1280
+ // `GET /owners/%61rchived` walked past the deny and was dispatched as the
1281
+ // record `archived`: 200 with the record in full, and DELETE answered 204
1282
+ // with the record destroyed, unauthenticated. Four spellings measured, all
1283
+ // four through; 255 non-canonical spellings of that 8-character id decode
1284
+ // to the same key, so this was never a deny-list of one.
1285
+ //
1286
+ // TWO CONSUMER-SIDE NORMALISATIONS WERE MEASURED WRONG IN OPPOSITE
1287
+ // DIRECTIONS, which is the argument for doing it once, here.
1288
+ // `.toLowerCase()` case-folds a route-parameter VALUE on the axis that
1289
+ // governs literal SEGMENTS: with a distinct owner seeded at `ARCHIVED`,
1290
+ // `GET /owners/ARCHIVED` was a false DENY on the wrong record and
1291
+ // `GET /owners/%41RCHIVED` a false ALLOW on that same one.
1292
+ // `decodeURIComponent(request.path)` decodes THEN splits while the router
1293
+ // splits THEN decodes, so it over-denied `/owners/archived%2fx` -- 403 for
1294
+ // a genuinely distinct record. Failing closed there was luck, not design.
1295
+ //
1296
+ // `getId(request.params)` AND NOT `request.params.id`, for exactly the
1297
+ // reason `operation` is a `methodAccessMap` lookup: it is the SAME single
1298
+ // coercion the store lookup one layer down performs, so the predicate and
1299
+ // the dispatch cannot disagree about which record a request addresses.
1300
+ // The raw string would reintroduce that divergence on hex-shaped ids --
1301
+ // `GET /animals/0x2391` looks up record `9105`.
1302
+ //
1303
+ // NOTHING HERE PARSES THE REQUEST TARGET EITHER. `request.params` is what
1304
+ // the router matched, so a mount prefix, an absolute-form target, a query
1305
+ // string or a case-varied mount cannot move this value -- the same
1306
+ // guarantee `model` carries, by the same means.
1307
+ //
1308
+ // `null` and not `undefined` on a collection route, so the KEY IS ALWAYS
1309
+ // PRESENT -- the rule `operation`'s own docblock already establishes. A
1310
+ // context reaching a predicate WITHOUT the key therefore did not come from
1311
+ // here; it was hand-assembled by a caller resolving the predicate through
1312
+ // `Orm.instance.getAccess()`, and that absence stays deniable only because
1313
+ // `auth()` never produces it.
1291
1314
  // -------------------------------------------------------------------------
1292
1315
  const context = {
1293
1316
  model: this.model,
1294
1317
  operation: methodAccessMap[request.method],
1318
+ recordId: request.params && 'id' in request.params ? getId(request.params) : null,
1295
1319
  };
1296
1320
  let access;
1297
1321
  try {
@@ -1304,23 +1328,26 @@ export default class OrmRequest extends Request {
1304
1328
  log.error?.(`[@stonyx/orm] access() threw for model "${this.model}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
1305
1329
  return 403; // Forbidden
1306
1330
  }
1307
- // THE READING OF THE RETURN SHAPE LIVES IN ONE PLACE (#234).
1308
- //
1309
- // It used to be inline here, and it was the only copy, which was fine while
1310
- // `auth()` was the only thing that had to ask. It is not any more: the
1311
- // linkage path has to ask model X's predicate about model X's records while
1312
- // servicing a request routed to model Y, and a second inline copy of these
1313
- // six branches would be a second authorization vocabulary -- one that can
1314
- // drift, and that reviewers would have to notice had drifted. The branch
1315
- // order in `interpretAccess` is this block, moved, not rewritten.
1316
- const verdict = interpretAccess(access, methodAccessMap[request.method]);
1317
- if (!verdict.granted)
1331
+ if (!access)
1332
+ return 403;
1333
+ if (typeof access === 'function') {
1334
+ state.filter = access;
1335
+ return undefined;
1336
+ }
1337
+ if (access === true)
1338
+ return undefined;
1339
+ // `AccessMethod` declares `string` legal and it fell through every branch
1340
+ // above, returning undefined -- i.e. FULL CRUD, no filter. `return 'read'`
1341
+ // is the natural reading of a type that lists `string` first, and it
1342
+ // granted DELETE. A bare string is one permission, not a grant of all four.
1343
+ const permitted = typeof access === 'string' ? [access] : access;
1344
+ // Anything that is not a permission array by this point -- an object, a
1345
+ // number, a Symbol -- is a consumer mistake, and the only safe reading of a
1346
+ // shape the contract does not define is a denial. Fail CLOSED.
1347
+ if (!Array.isArray(permitted))
1348
+ return 403;
1349
+ if (!permitted.includes(methodAccessMap[request.method]))
1318
1350
  return 403;
1319
- // The function return shape is the per-record hook, and `state` is the
1320
- // whole transport for it: @stonyx/rest-server memoises one state object per
1321
- // request and hands the same one to `auth()` and to the handler.
1322
- if (verdict.filter)
1323
- state.filter = verdict.filter;
1324
1351
  return undefined;
1325
1352
  }
1326
1353
  }
package/dist/record.d.ts CHANGED
@@ -2,18 +2,6 @@ import type Serializer from './serializer.js';
2
2
  interface ToJSONOptions {
3
3
  fields?: Set<string>;
4
4
  baseUrl?: string;
5
- /**
6
- * An ALREADY-RESOLVED linkage decision, supplied by a caller that holds the
7
- * request (abofs/stonyx-orm#234). Returning `false` for a related record
8
- * drops that record's `{ type, id }` from `relationships.*.data`.
9
- *
10
- * This method APPLIES a verdict; it never RESOLVES one -- see
11
- * `src/access-verdict.ts` for the two measured reasons it cannot. ABSENT is
12
- * the default and the default is TODAY'S DOCUMENT, unchanged, because
13
- * `toJSON` is also the `JSON.stringify` hook and an implicit caller has no
14
- * syntactic place to pass this (abofs/stonyx-orm#230).
15
- */
16
- linkage?: (type: string, record: unknown) => boolean;
17
5
  }
18
6
  interface SerializeOptions {
19
7
  update?: boolean;
package/dist/record.js CHANGED
@@ -65,13 +65,7 @@ export default class Record {
65
65
  toJSON(options = {}) {
66
66
  if (!this.__serialized)
67
67
  throw new Error('Record must be serialized before being converted to JSON');
68
- // DESTRUCTURED FROM A VALUE THAT IS NOT ALWAYS AN OBJECT. `toJSON` is the
69
- // ECMAScript serialization hook, so `JSON.stringify({ data: record })`
70
- // arrives here as `toJSON('data')` -- a STRING in the options slot.
71
- // Destructuring a string yields `undefined` for every key, which is exactly
72
- // the no-argument default, so the implicit path keeps working and keeps
73
- // emitting today's document (abofs/stonyx-orm#230).
74
- const { fields, baseUrl, linkage } = options;
68
+ const { fields, baseUrl } = options;
75
69
  const { __data: data } = this;
76
70
  const modelName = this.__model.__name;
77
71
  const pluralizedModelName = getPluralName(modelName);
@@ -93,20 +87,9 @@ export default class Record {
93
87
  for (const [key, childRecord] of Object.entries(this.__relationships)) {
94
88
  if (fields && !fields.has(key))
95
89
  continue;
96
- // The linkage decision is applied HERE, alongside the existing
97
- // `__model` liveness check, and it produces exactly the shapes that
98
- // check already produces: a dropped hasMany member leaves `data: []`,
99
- // a dropped belongsTo leaves `data: null`. Both already ship -- a
100
- // genuinely-empty hasMany emits `data: []` with links, and a cleaned
101
- // belongsTo emits `data: null` -- so a filtered relationship is
102
- // BYTE-IDENTICAL to an empty one and there is no new wire shape and no
103
- // oracle. It never throws: a throw here escapes the enclosing
104
- // `JSON.stringify` and takes `console.log` and `Orm.db.save()`'s
105
- // neighbours with it, which is a far worse failure mode than a status.
106
- const isLinkable = (r) => !linkage || linkage(r.__model.__name, r);
107
90
  const relationshipData = Array.isArray(childRecord)
108
- ? childRecord.filter((r) => r?.__model).filter(isLinkable).map((r) => ({ type: r.__model.__name, id: r.id }))
109
- : (childRecord && childRecord.__model && isLinkable(childRecord)) ? { type: childRecord.__model.__name, id: childRecord.id } : null;
91
+ ? childRecord.filter((r) => r?.__model).map((r) => ({ type: r.__model.__name, id: r.id }))
92
+ : (childRecord && childRecord.__model) ? { type: childRecord.__model.__name, id: childRecord.id } : null;
110
93
  // Dasherize the key for URL paths (e.g., accessLinks -> access-links)
111
94
  const dasherizedKey = camelCaseToKebabCase(key);
112
95
  relationships[dasherizedKey] = { data: relationshipData };
@@ -87,18 +87,9 @@ export interface OrmRecord {
87
87
  __pendingSqlId?: boolean;
88
88
  };
89
89
  __relationships: Record<string, unknown>;
90
- /**
91
- * `linkage` is an ALREADY-RESOLVED decision supplied by a caller that holds
92
- * the request (abofs/stonyx-orm#234): return `false` for a related record and
93
- * its `{ type, id }` is dropped from `relationships.*.data`. Omitting it is
94
- * the default, and the default is the pre-#234 document unchanged -- this
95
- * method is also the `JSON.stringify` hook, so an implicit caller has no
96
- * syntactic place to pass it (abofs/stonyx-orm#230).
97
- */
98
90
  toJSON?(options?: {
99
91
  fields?: Set<string>;
100
92
  baseUrl?: string;
101
- linkage?: (type: string, record: unknown) => boolean;
102
93
  }): Record<string, unknown>;
103
94
  [key: string]: unknown;
104
95
  }
@@ -251,6 +242,72 @@ export interface AccessContext {
251
242
  * from one that classified the request and found nothing.
252
243
  */
253
244
  operation: AccessOperation | undefined;
245
+ /**
246
+ * The record this route was addressed to, as the store key -- or `null` on a
247
+ * collection route, which is addressed to no record (abofs/stonyx-orm#236).
248
+ *
249
+ * IT IS ALREADY DECODED, AND THAT IS THE WHOLE POINT. Express decodes route
250
+ * PARAMETERS while leaving `request.path` raw, so a consumer comparing
251
+ * `request.path` against a literal compares an undecoded string against a
252
+ * decoded dispatch. `GET /owners/%61rchived` reached such a comparison as
253
+ * `/%61rchived`, walked past a `/archived` deny, and was dispatched as the
254
+ * record `archived` -- 200 with the record in full, and `DELETE` destroyed
255
+ * it, unauthenticated. 255 non-canonical spellings of an 8-character id
256
+ * decode to the same key, so a deny-list of spellings is the wrong shape.
257
+ *
258
+ * SO DO NOT NORMALISE THIS, AND DO NOT NORMALISE ANYTHING ELSE INSTEAD:
259
+ *
260
+ * - Do NOT decode it. Express decodes exactly ONCE, which is what a route
261
+ * parameter means. `GET /owners/%2561rchived` is the legitimate id
262
+ * `%61rchived`, not a second-order spelling of `archived`; a predicate that
263
+ * decoded until stable would deny a record it was never asked about.
264
+ * - Do NOT case-fold it. A record id is a VALUE, not a literal route segment,
265
+ * and express's `case sensitive routing` governs literal segments only.
266
+ * With a distinct owner seeded at `ARCHIVED`, `.toLowerCase()` was measured
267
+ * wrong in BOTH directions at once: `GET /owners/ARCHIVED` 403 (a false
268
+ * deny, on the wrong record) and `GET /owners/%41RCHIVED` 200 (a false
269
+ * allow, on that same record).
270
+ * - Do NOT derive it from `request.path` or the request target. Decoding the
271
+ * whole path decodes THEN splits, while the router splits THEN decodes, so
272
+ * `/owners/archived%2fx` -- a genuinely distinct record whose id is
273
+ * `archived/x` -- was measured over-denied 403.
274
+ *
275
+ * IT IS `getId(request.params)`, BYTE FOR BYTE -- the same single coercion
276
+ * the store lookup uses, exactly as `operation` is the same `methodAccessMap`
277
+ * lookup the permission-array branch uses. The predicate and the dispatch
278
+ * therefore cannot disagree about which record a request addresses. Handing
279
+ * over the raw `request.params.id` instead would reintroduce that divergence
280
+ * on hex-shaped ids: `GET /animals/0x2391` looks up record `9105`.
281
+ *
282
+ * It inherits abofs/stonyx-orm#209 along with that coercion -- on a model
283
+ * declaring `id = attr('string')`, `'9107'` arrives here as the number
284
+ * `9107`. That is consistency WITH THE LOOKUP, which is the property this key
285
+ * exists to buy; it is not a defect to repair here.
286
+ *
287
+ * `null`, not `undefined`, on a collection route -- and the KEY IS ALWAYS
288
+ * PRESENT, the same rule `operation` states above. `auth()` always sets it,
289
+ * so a context arriving WITHOUT the key did not come from `auth()`: it was
290
+ * hand-assembled by a caller resolving the predicate through
291
+ * `Orm.instance.getAccess()`. That absence stays a distinguishable, deniable
292
+ * signal only because the framework never produces it.
293
+ *
294
+ * IT IS THE ONE KEY THE HOOK VOCABULARY DOES *NOT* DISAGREE WITH.
295
+ * `HookContext.recordId` (`src/hooks.ts`) is an identically-named key on an
296
+ * identically-shaped context object, which is the exact configuration that
297
+ * makes `operation` fail-open shaped -- a hook sees `'get'` where `access()`
298
+ * sees `'read'`. Here they AGREE, and not by coincidence: `_withHooks` sets
299
+ * `context.recordId = getId(request.params)`, the same single coercion this
300
+ * key is built from. They differ in ONE way and it is the absence spelling --
301
+ * `HookContext.recordId` is optional and `undefined` when unset, while this
302
+ * key is always present and `null` on a collection route. A predicate must
303
+ * not read `undefined` here as "collection".
304
+ *
305
+ * IT NAMES WHICH RECORD, NOT WHICH SURFACE. `GET /owners/gina`,
306
+ * `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` all
307
+ * carry `recordId: 'gina'`; the related-resource gap is abofs/stonyx-orm#196
308
+ * and is untouched by this key.
309
+ */
310
+ recordId: string | number | null;
254
311
  }
255
312
  /**
256
313
  * A consumer `access()` predicate.
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-alpha.68",
7
+ "version": "0.3.2-alpha.69",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -221,7 +221,6 @@ import config from 'stonyx/config';
221
221
  import log from 'stonyx/log';
222
222
  import type { OrmRecord, AccessContext, AccessFunction, AccessMethod, AccessOperation } from './types/orm-types.js';
223
223
  import { isOrmRecord, NO_FREE_ID_ERROR } from './utils.js';
224
- import { interpretAccess, createLinkageFilter } from './access-verdict.js';
225
224
 
226
225
  interface OrmRequest$ extends Request {
227
226
  protocol?: string;
@@ -634,14 +633,7 @@ export default class OrmRequest extends Request {
634
633
  if (queryFilterPredicate) recordsToReturn = recordsToReturn.filter(queryFilterPredicate as (record: OrmRecord) => boolean);
635
634
 
636
635
  const baseUrl = getBaseUrl(request);
637
-
638
- // ONE filter per REQUEST, not one per record: it carries the per-type
639
- // verdict cache and the per-(type, id) decision cache, and both are
640
- // worthless if it is rebuilt inside the map. Measured on this exact
641
- // surface with no `include=`: 48 linkage entries collapse to 7 distinct
642
- // (type, id) pairs.
643
- const linkage = createLinkageFilter(request);
644
- const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl, linkage }));
636
+ const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl }));
645
637
 
646
638
  return buildResponse(data, request.query?.include, recordsToReturn, {
647
639
  links: { self: `${baseUrl}/${pluralizedModel}` },
@@ -661,14 +653,7 @@ export default class OrmRequest extends Request {
661
653
  const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
662
654
 
663
655
  const baseUrl = getBaseUrl(request);
664
- const linkage = createLinkageFilter(request);
665
-
666
- // `buildResponse` is deliberately NOT given the linkage filter. It builds
667
- // `included`, and WHETHER A RESOURCE APPEARS THERE AT ALL is membership,
668
- // which belongs to abofs/stonyx-orm#233 -- see this file's #234 note and
669
- // the ownership boundary in that issue. Only the PRIMARY document's
670
- // linkage is filtered here.
671
- return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
656
+ return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl }), request.query?.include, record, {
672
657
  links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
673
658
  baseUrl
674
659
  });
@@ -1298,21 +1283,14 @@ export default class OrmRequest extends Request {
1298
1283
  const relatedData = record.__relationships[relationshipName];
1299
1284
  const baseUrl = getBaseUrl(request);
1300
1285
 
1301
- // LINKAGE ONLY. This filter decides which ids the emitted documents may
1302
- // NAME in their own `relationships.*.data`; it does NOT decide whether
1303
- // the related records themselves are served -- that is the parent-only
1304
- // filtering this route has done since #190, and widening it to the
1305
- // related record is abofs/stonyx-orm#196.
1306
- const linkage = createLinkageFilter(request);
1307
-
1308
1286
  let data: unknown;
1309
1287
  if (info.isArray) {
1310
1288
  // hasMany - return array
1311
1289
  const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
1312
- data = related.map(r => r.toJSON?.({ baseUrl, linkage }));
1290
+ data = related.map(r => r.toJSON?.({ baseUrl }));
1313
1291
  } else {
1314
1292
  // belongsTo - return single or null
1315
- data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl, linkage }) : null;
1293
+ data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl }) : null;
1316
1294
  }
1317
1295
 
1318
1296
  return {
@@ -1411,10 +1389,53 @@ export default class OrmRequest extends Request {
1411
1389
  // src/types/orm-types.ts. Nothing is fetched at this point and adding a
1412
1390
  // lookup here would put a store read in the middle of an authorization
1413
1391
  // path. The function return shape below IS the per-record hook.
1392
+ //
1393
+ // -------------------------------------------------------------------------
1394
+ // #236 -- `recordId`, the DECODED route-parameter id, for the same reason.
1395
+ //
1396
+ // WHICH RECORD is the third structural fact the framework already holds and
1397
+ // the consumer was left to re-derive, and re-deriving it failed OPEN. The
1398
+ // documented sample compared `request.path` -- the RAW, undecoded pathname
1399
+ // -- against a literal `/archived`, while the router DECODES `:id`. So
1400
+ // `GET /owners/%61rchived` walked past the deny and was dispatched as the
1401
+ // record `archived`: 200 with the record in full, and DELETE answered 204
1402
+ // with the record destroyed, unauthenticated. Four spellings measured, all
1403
+ // four through; 255 non-canonical spellings of that 8-character id decode
1404
+ // to the same key, so this was never a deny-list of one.
1405
+ //
1406
+ // TWO CONSUMER-SIDE NORMALISATIONS WERE MEASURED WRONG IN OPPOSITE
1407
+ // DIRECTIONS, which is the argument for doing it once, here.
1408
+ // `.toLowerCase()` case-folds a route-parameter VALUE on the axis that
1409
+ // governs literal SEGMENTS: with a distinct owner seeded at `ARCHIVED`,
1410
+ // `GET /owners/ARCHIVED` was a false DENY on the wrong record and
1411
+ // `GET /owners/%41RCHIVED` a false ALLOW on that same one.
1412
+ // `decodeURIComponent(request.path)` decodes THEN splits while the router
1413
+ // splits THEN decodes, so it over-denied `/owners/archived%2fx` -- 403 for
1414
+ // a genuinely distinct record. Failing closed there was luck, not design.
1415
+ //
1416
+ // `getId(request.params)` AND NOT `request.params.id`, for exactly the
1417
+ // reason `operation` is a `methodAccessMap` lookup: it is the SAME single
1418
+ // coercion the store lookup one layer down performs, so the predicate and
1419
+ // the dispatch cannot disagree about which record a request addresses.
1420
+ // The raw string would reintroduce that divergence on hex-shaped ids --
1421
+ // `GET /animals/0x2391` looks up record `9105`.
1422
+ //
1423
+ // NOTHING HERE PARSES THE REQUEST TARGET EITHER. `request.params` is what
1424
+ // the router matched, so a mount prefix, an absolute-form target, a query
1425
+ // string or a case-varied mount cannot move this value -- the same
1426
+ // guarantee `model` carries, by the same means.
1427
+ //
1428
+ // `null` and not `undefined` on a collection route, so the KEY IS ALWAYS
1429
+ // PRESENT -- the rule `operation`'s own docblock already establishes. A
1430
+ // context reaching a predicate WITHOUT the key therefore did not come from
1431
+ // here; it was hand-assembled by a caller resolving the predicate through
1432
+ // `Orm.instance.getAccess()`, and that absence stays deniable only because
1433
+ // `auth()` never produces it.
1414
1434
  // -------------------------------------------------------------------------
1415
1435
  const context: AccessContext = {
1416
1436
  model: this.model,
1417
1437
  operation: methodAccessMap[request.method],
1438
+ recordId: request.params && 'id' in request.params ? getId(request.params) : null,
1418
1439
  };
1419
1440
 
1420
1441
  let access: AccessMethod;
@@ -1429,23 +1450,24 @@ export default class OrmRequest extends Request {
1429
1450
  return 403; // Forbidden
1430
1451
  }
1431
1452
 
1432
- // THE READING OF THE RETURN SHAPE LIVES IN ONE PLACE (#234).
1433
- //
1434
- // It used to be inline here, and it was the only copy, which was fine while
1435
- // `auth()` was the only thing that had to ask. It is not any more: the
1436
- // linkage path has to ask model X's predicate about model X's records while
1437
- // servicing a request routed to model Y, and a second inline copy of these
1438
- // six branches would be a second authorization vocabulary -- one that can
1439
- // drift, and that reviewers would have to notice had drifted. The branch
1440
- // order in `interpretAccess` is this block, moved, not rewritten.
1441
- const verdict = interpretAccess(access, methodAccessMap[request.method]);
1442
-
1443
- if (!verdict.granted) return 403;
1444
-
1445
- // The function return shape is the per-record hook, and `state` is the
1446
- // whole transport for it: @stonyx/rest-server memoises one state object per
1447
- // request and hands the same one to `auth()` and to the handler.
1448
- if (verdict.filter) state.filter = verdict.filter;
1453
+ if (!access) return 403;
1454
+ if (typeof access === 'function') {
1455
+ state.filter = access;
1456
+ return undefined;
1457
+ }
1458
+ if (access === true) return undefined;
1459
+
1460
+ // `AccessMethod` declares `string` legal and it fell through every branch
1461
+ // above, returning undefined -- i.e. FULL CRUD, no filter. `return 'read'`
1462
+ // is the natural reading of a type that lists `string` first, and it
1463
+ // granted DELETE. A bare string is one permission, not a grant of all four.
1464
+ const permitted = typeof access === 'string' ? [access] : access;
1465
+
1466
+ // Anything that is not a permission array by this point -- an object, a
1467
+ // number, a Symbol -- is a consumer mistake, and the only safe reading of a
1468
+ // shape the contract does not define is a denial. Fail CLOSED.
1469
+ if (!Array.isArray(permitted)) return 403;
1470
+ if (!permitted.includes(methodAccessMap[request.method])) return 403;
1449
1471
 
1450
1472
  return undefined;
1451
1473
  }
package/src/record.ts CHANGED
@@ -7,18 +7,6 @@ import type Serializer from './serializer.js';
7
7
  interface ToJSONOptions {
8
8
  fields?: Set<string>;
9
9
  baseUrl?: string;
10
- /**
11
- * An ALREADY-RESOLVED linkage decision, supplied by a caller that holds the
12
- * request (abofs/stonyx-orm#234). Returning `false` for a related record
13
- * drops that record's `{ type, id }` from `relationships.*.data`.
14
- *
15
- * This method APPLIES a verdict; it never RESOLVES one -- see
16
- * `src/access-verdict.ts` for the two measured reasons it cannot. ABSENT is
17
- * the default and the default is TODAY'S DOCUMENT, unchanged, because
18
- * `toJSON` is also the `JSON.stringify` hook and an implicit caller has no
19
- * syntactic place to pass this (abofs/stonyx-orm#230).
20
- */
21
- linkage?: (type: string, record: unknown) => boolean;
22
10
  }
23
11
 
24
12
  interface SerializeOptions {
@@ -128,13 +116,7 @@ export default class Record {
128
116
  toJSON(options: ToJSONOptions = {}): JSONAPIResult {
129
117
  if (!this.__serialized) throw new Error('Record must be serialized before being converted to JSON');
130
118
 
131
- // DESTRUCTURED FROM A VALUE THAT IS NOT ALWAYS AN OBJECT. `toJSON` is the
132
- // ECMAScript serialization hook, so `JSON.stringify({ data: record })`
133
- // arrives here as `toJSON('data')` -- a STRING in the options slot.
134
- // Destructuring a string yields `undefined` for every key, which is exactly
135
- // the no-argument default, so the implicit path keeps working and keeps
136
- // emitting today's document (abofs/stonyx-orm#230).
137
- const { fields, baseUrl, linkage } = options;
119
+ const { fields, baseUrl } = options;
138
120
  const { __data: data } = this;
139
121
  const modelName = this.__model.__name;
140
122
  const pluralizedModelName = getPluralName(modelName);
@@ -156,21 +138,9 @@ export default class Record {
156
138
  for (const [key, childRecord] of Object.entries(this.__relationships)) {
157
139
  if (fields && !fields.has(key)) continue;
158
140
 
159
- // The linkage decision is applied HERE, alongside the existing
160
- // `__model` liveness check, and it produces exactly the shapes that
161
- // check already produces: a dropped hasMany member leaves `data: []`,
162
- // a dropped belongsTo leaves `data: null`. Both already ship -- a
163
- // genuinely-empty hasMany emits `data: []` with links, and a cleaned
164
- // belongsTo emits `data: null` -- so a filtered relationship is
165
- // BYTE-IDENTICAL to an empty one and there is no new wire shape and no
166
- // oracle. It never throws: a throw here escapes the enclosing
167
- // `JSON.stringify` and takes `console.log` and `Orm.db.save()`'s
168
- // neighbours with it, which is a far worse failure mode than a status.
169
- const isLinkable = (r: Record) => !linkage || linkage(r.__model.__name, r);
170
-
171
141
  const relationshipData = Array.isArray(childRecord)
172
- ? childRecord.filter((r: Record) => r?.__model).filter(isLinkable).map((r: Record) => ({ type: r.__model.__name, id: r.id }))
173
- : (childRecord && (childRecord as Record).__model && isLinkable(childRecord as Record)) ? { type: (childRecord as Record).__model.__name, id: (childRecord as Record).id } : null;
142
+ ? childRecord.filter((r: Record) => r?.__model).map((r: Record) => ({ type: r.__model.__name, id: r.id }))
143
+ : (childRecord && (childRecord as Record).__model) ? { type: (childRecord as Record).__model.__name, id: (childRecord as Record).id } : null;
174
144
 
175
145
  // Dasherize the key for URL paths (e.g., accessLinks -> access-links)
176
146
  const dasherizedKey = camelCaseToKebabCase(key);
@@ -89,15 +89,7 @@ export interface OrmRecord {
89
89
  __model?: { __name: string };
90
90
  __data: Record<string, unknown> & { id?: string | number; __pendingSqlId?: boolean };
91
91
  __relationships: Record<string, unknown>;
92
- /**
93
- * `linkage` is an ALREADY-RESOLVED decision supplied by a caller that holds
94
- * the request (abofs/stonyx-orm#234): return `false` for a related record and
95
- * its `{ type, id }` is dropped from `relationships.*.data`. Omitting it is
96
- * the default, and the default is the pre-#234 document unchanged -- this
97
- * method is also the `JSON.stringify` hook, so an implicit caller has no
98
- * syntactic place to pass it (abofs/stonyx-orm#230).
99
- */
100
- toJSON?(options?: { fields?: Set<string>; baseUrl?: string; linkage?: (type: string, record: unknown) => boolean }): Record<string, unknown>;
92
+ toJSON?(options?: { fields?: Set<string>; baseUrl?: string }): Record<string, unknown>;
101
93
  [key: string]: unknown;
102
94
  }
103
95
 
@@ -260,6 +252,73 @@ export interface AccessContext {
260
252
  * from one that classified the request and found nothing.
261
253
  */
262
254
  operation: AccessOperation | undefined;
255
+
256
+ /**
257
+ * The record this route was addressed to, as the store key -- or `null` on a
258
+ * collection route, which is addressed to no record (abofs/stonyx-orm#236).
259
+ *
260
+ * IT IS ALREADY DECODED, AND THAT IS THE WHOLE POINT. Express decodes route
261
+ * PARAMETERS while leaving `request.path` raw, so a consumer comparing
262
+ * `request.path` against a literal compares an undecoded string against a
263
+ * decoded dispatch. `GET /owners/%61rchived` reached such a comparison as
264
+ * `/%61rchived`, walked past a `/archived` deny, and was dispatched as the
265
+ * record `archived` -- 200 with the record in full, and `DELETE` destroyed
266
+ * it, unauthenticated. 255 non-canonical spellings of an 8-character id
267
+ * decode to the same key, so a deny-list of spellings is the wrong shape.
268
+ *
269
+ * SO DO NOT NORMALISE THIS, AND DO NOT NORMALISE ANYTHING ELSE INSTEAD:
270
+ *
271
+ * - Do NOT decode it. Express decodes exactly ONCE, which is what a route
272
+ * parameter means. `GET /owners/%2561rchived` is the legitimate id
273
+ * `%61rchived`, not a second-order spelling of `archived`; a predicate that
274
+ * decoded until stable would deny a record it was never asked about.
275
+ * - Do NOT case-fold it. A record id is a VALUE, not a literal route segment,
276
+ * and express's `case sensitive routing` governs literal segments only.
277
+ * With a distinct owner seeded at `ARCHIVED`, `.toLowerCase()` was measured
278
+ * wrong in BOTH directions at once: `GET /owners/ARCHIVED` 403 (a false
279
+ * deny, on the wrong record) and `GET /owners/%41RCHIVED` 200 (a false
280
+ * allow, on that same record).
281
+ * - Do NOT derive it from `request.path` or the request target. Decoding the
282
+ * whole path decodes THEN splits, while the router splits THEN decodes, so
283
+ * `/owners/archived%2fx` -- a genuinely distinct record whose id is
284
+ * `archived/x` -- was measured over-denied 403.
285
+ *
286
+ * IT IS `getId(request.params)`, BYTE FOR BYTE -- the same single coercion
287
+ * the store lookup uses, exactly as `operation` is the same `methodAccessMap`
288
+ * lookup the permission-array branch uses. The predicate and the dispatch
289
+ * therefore cannot disagree about which record a request addresses. Handing
290
+ * over the raw `request.params.id` instead would reintroduce that divergence
291
+ * on hex-shaped ids: `GET /animals/0x2391` looks up record `9105`.
292
+ *
293
+ * It inherits abofs/stonyx-orm#209 along with that coercion -- on a model
294
+ * declaring `id = attr('string')`, `'9107'` arrives here as the number
295
+ * `9107`. That is consistency WITH THE LOOKUP, which is the property this key
296
+ * exists to buy; it is not a defect to repair here.
297
+ *
298
+ * `null`, not `undefined`, on a collection route -- and the KEY IS ALWAYS
299
+ * PRESENT, the same rule `operation` states above. `auth()` always sets it,
300
+ * so a context arriving WITHOUT the key did not come from `auth()`: it was
301
+ * hand-assembled by a caller resolving the predicate through
302
+ * `Orm.instance.getAccess()`. That absence stays a distinguishable, deniable
303
+ * signal only because the framework never produces it.
304
+ *
305
+ * IT IS THE ONE KEY THE HOOK VOCABULARY DOES *NOT* DISAGREE WITH.
306
+ * `HookContext.recordId` (`src/hooks.ts`) is an identically-named key on an
307
+ * identically-shaped context object, which is the exact configuration that
308
+ * makes `operation` fail-open shaped -- a hook sees `'get'` where `access()`
309
+ * sees `'read'`. Here they AGREE, and not by coincidence: `_withHooks` sets
310
+ * `context.recordId = getId(request.params)`, the same single coercion this
311
+ * key is built from. They differ in ONE way and it is the absence spelling --
312
+ * `HookContext.recordId` is optional and `undefined` when unset, while this
313
+ * key is always present and `null` on a collection route. A predicate must
314
+ * not read `undefined` here as "collection".
315
+ *
316
+ * IT NAMES WHICH RECORD, NOT WHICH SURFACE. `GET /owners/gina`,
317
+ * `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` all
318
+ * carry `recordId: 'gina'`; the related-resource gap is abofs/stonyx-orm#196
319
+ * and is untouched by this key.
320
+ */
321
+ recordId: string | number | null;
263
322
  }
264
323
 
265
324
  /**
@@ -1,57 +0,0 @@
1
- import type { AccessMethod, AccessOperation } from './types/orm-types.js';
2
- /**
3
- * The classified reading of one `access()` return value.
4
- *
5
- * `granted: false` is a total denial. `granted: true` with no `filter` is an
6
- * unconditional grant. `granted: true` WITH a filter means "grant, subject to
7
- * this per-record predicate" -- the function return shape, which is the
8
- * per-record hook `AccessContext` deliberately does not provide.
9
- */
10
- export interface AccessVerdict {
11
- granted: boolean;
12
- filter?: (record: unknown) => boolean;
13
- }
14
- /**
15
- * A resolved, request-scoped linkage decision: may `record` of model `type` be
16
- * NAMED, by id, inside another model's document?
17
- *
18
- * Arity is `(type, record)` and not `(type, id)` because the per-record filter
19
- * the consumer returns is handed the RECORD -- this repo's own fixture reads
20
- * `record.owner?.id`, not just `record.id`. The `(type, id)` pair is the CACHE
21
- * key, not the input.
22
- */
23
- export type LinkageFilter = (type: string, record: unknown) => boolean;
24
- /**
25
- * Classify one `access()` return value. Extracted verbatim from `auth()`, which
26
- * now calls this; the branch ORDER is load-bearing and is preserved exactly.
27
- *
28
- * `operation` is the verb being authorised. `undefined` -- reachable, because
29
- * express delivers HEAD to the GET handler and `methodAccessMap` has no entry
30
- * for it -- falls through `permitted.includes(undefined)` to a denial, which is
31
- * the same answer `auth()` gave before the extraction.
32
- */
33
- export declare function interpretAccess(access: AccessMethod, operation: AccessOperation | undefined): AccessVerdict;
34
- /**
35
- * Build a request-scoped linkage filter.
36
- *
37
- * TWO CACHES, AND BOTH ARE LOAD-BEARING RATHER THAN AN OPTIMISATION:
38
- *
39
- * - one verdict per TYPE. Resolving means CALLING the consumer's `access()`,
40
- * which is arbitrary code with arbitrary cost and which the module has
41
- * already had to guard for throwing.
42
- * - one decision per `(type, id)`. `included` is deduplicated by
43
- * `buildResponse`; LINKAGE is not deduplicated at all, so it re-asks once
44
- * per record. Measured on a bare `GET /animals` with no `include=`:
45
- * 48 linkage entries -> 7 distinct `(type, id)` pairs (owner 20, trait 28),
46
- * a 6.9x reduction and 41 predicate calls saved.
47
- *
48
- * The `(type, id)` cache is a `Map` per type keyed on the RAW id, not on a
49
- * template-string composite: `Map` compares with SameValueZero, so the numeric
50
- * id `1` and the string id `'1'` stay distinct, where `` `${type}:${id}` ``
51
- * would collapse them and let one model's verdict answer for another record.
52
- *
53
- * SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
54
- * it -- a verdict cached across requests would answer a second caller with the
55
- * first caller's authorization.
56
- */
57
- export declare function createLinkageFilter(request: unknown): LinkageFilter;
@@ -1,185 +0,0 @@
1
- /**
2
- * The shared access-verdict primitive (abofs/stonyx-orm#234).
3
- *
4
- * ---------------------------------------------------------------------------
5
- * WHY THIS FILE EXISTS: ONE INTERPRETER, NOT TWO
6
- * ---------------------------------------------------------------------------
7
- * A consumer `access()` may return six differently-shaped things -- `false`, a
8
- * bare permission string, a permission array, `true`, a per-record function, or
9
- * something the contract does not define at all -- and the reading of each one
10
- * is a security decision. `auth()` has held that reading inline since #190.
11
- * Every surface that needs to ask "may this caller see model X's record?" needs
12
- * the SAME reading, or the second copy becomes an unreviewed second
13
- * authorization vocabulary that answers differently about the same value.
14
- *
15
- * So `interpretAccess` is extracted here and `auth()` now calls it. It is the
16
- * only place a return shape is classified, and abofs/stonyx-orm#232 and #233
17
- * rebase onto it rather than re-deriving it.
18
- *
19
- * ---------------------------------------------------------------------------
20
- * WHAT A LINKAGE FILTER IS, AND WHY THE CALLER BUILDS IT
21
- * ---------------------------------------------------------------------------
22
- * `Record.toJSON()` APPLIES a verdict; it never RESOLVES one. That is not a
23
- * style choice, it is forced, and it was measured before it was decided:
24
- *
25
- * INPUT: origin/dev @ c5f7907, unpatched -> 967 pass / 0 fail
26
- * INPUT: same + fail-closed resolution INSIDE toJSON() -> 964 pass / 3 fail
27
- *
28
- * and all three reds were over-denial of PERMITTED records, not the leak. Two
29
- * independent reasons:
30
- *
31
- * 1. `toJSON()` has no request. The shipped, documented sample reads
32
- * `request.path` for its `/archived` sub-path rule -- the one read of
33
- * argument one the README sanctions -- and fail-closes when it is absent.
34
- * Measured against the live registry:
35
- *
36
- * getAccess('owner')(undefined, { model:'owner', operation:'read' }) -> false
37
- * getAccess('animal')(undefined,{ model:'animal', operation:'read' }) -> [Function]
38
- *
39
- * Same predicate object, two models, two different degradation modes,
40
- * chosen by the consumer. Without a request there is no trustworthy
41
- * answer to get.
42
- *
43
- * 2. `toJSON` is also the `JSON.stringify` hook, so `JSON.stringify({data:
44
- * record})` calls `record.toJSON('data')` -- a STRING in the options slot.
45
- * An implicit caller has no syntactic place to pass anything
46
- * (abofs/stonyx-orm#230). The no-argument document must therefore stay
47
- * byte-identical to what shipped, which also rules out fail-closed by
48
- * default: `Orm.instance.accessFunctions` is `{}` in any process that
49
- * never ran `setup-rest-server` (CLI, SQL-only, unit tests), so a
50
- * fail-closed default would empty every relationship on every document in
51
- * processes that have no REST surface to protect.
52
- *
53
- * The caller -- which still holds the request -- resolves the predicate,
54
- * interprets it here, caches the answer, and hands `toJSON()` an already-decided
55
- * `(type, record) => boolean`.
56
- */
57
- import Orm from '@stonyx/orm';
58
- import log from 'stonyx/log';
59
- const DENIED = Object.freeze({ granted: false });
60
- const GRANTED = Object.freeze({ granted: true });
61
- /**
62
- * Classify one `access()` return value. Extracted verbatim from `auth()`, which
63
- * now calls this; the branch ORDER is load-bearing and is preserved exactly.
64
- *
65
- * `operation` is the verb being authorised. `undefined` -- reachable, because
66
- * express delivers HEAD to the GET handler and `methodAccessMap` has no entry
67
- * for it -- falls through `permitted.includes(undefined)` to a denial, which is
68
- * the same answer `auth()` gave before the extraction.
69
- */
70
- export function interpretAccess(access, operation) {
71
- if (!access)
72
- return DENIED;
73
- // The function return shape IS the per-record hook. Grant the request and
74
- // carry the predicate; the caller applies it per record.
75
- if (typeof access === 'function')
76
- return { granted: true, filter: access };
77
- if (access === true)
78
- return GRANTED;
79
- // `AccessMethod` declares `string` legal and it fell through every branch
80
- // above. A bare string is ONE permission, not a grant of all four -- reading
81
- // it as a full grant is what once let `return 'read'` authorise DELETE.
82
- const permitted = typeof access === 'string' ? [access] : access;
83
- // Anything that is not a permission array by this point -- an object, a
84
- // number, a Symbol -- is a consumer mistake, and the only safe reading of a
85
- // shape the contract does not define is a denial. Fail CLOSED.
86
- if (!Array.isArray(permitted))
87
- return DENIED;
88
- if (!permitted.includes(operation))
89
- return DENIED;
90
- return GRANTED;
91
- }
92
- /**
93
- * Resolve model `type`'s verdict for a read, against the live `request`.
94
- *
95
- * Fails closed on both ambiguous inputs:
96
- *
97
- * - `getAccess(type)` -> `undefined`. That is NOT "this model is
98
- * unrestricted". `setup-rest-server` catches an access-class load failure,
99
- * warns, and publishes whatever PARTIAL map it had, so `undefined` covers
100
- * both "no access class claims this model" and "the class that claims it
101
- * failed to load" -- and the caller cannot tell them apart. Deny.
102
- * - the predicate THROWS. Same reading `auth()` and `isDenied` already use:
103
- * a throw is a denial, logged, never a 500 and never a grant.
104
- *
105
- * NOTE ON CROSS-MODEL ASKS. The predicate is asked about `type` while the
106
- * request in hand was dispatched to a DIFFERENT model's route. Since #222 this
107
- * repo's fixture reads `context.model` and answers correctly; a consumer's
108
- * arity-1 predicate does not, and there is no supported way to tell which kind
109
- * was resolved (the boot-time arity warning is abofs/stonyx-orm#213). A
110
- * consequence to expect rather than debug: the fixture's surviving `request.path`
111
- * read means asking the OWNER predicate on a request dispatched to
112
- * `GET /animals/archived` returns a bare `false`. That is a whole-request deny
113
- * bleeding across models -- harmless, because it is the fail-closed direction,
114
- * and it is treated as "deny this linkage", not as an error.
115
- */
116
- function resolveVerdict(request, type) {
117
- const predicate = Orm.instance?.getAccess?.(type);
118
- if (typeof predicate !== 'function')
119
- return DENIED;
120
- let access;
121
- try {
122
- access = predicate(request, { model: type, operation: 'read' });
123
- }
124
- catch (error) {
125
- log.error?.(`[@stonyx/orm] access() threw while resolving linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
126
- return DENIED;
127
- }
128
- return interpretAccess(access, 'read');
129
- }
130
- /**
131
- * Build a request-scoped linkage filter.
132
- *
133
- * TWO CACHES, AND BOTH ARE LOAD-BEARING RATHER THAN AN OPTIMISATION:
134
- *
135
- * - one verdict per TYPE. Resolving means CALLING the consumer's `access()`,
136
- * which is arbitrary code with arbitrary cost and which the module has
137
- * already had to guard for throwing.
138
- * - one decision per `(type, id)`. `included` is deduplicated by
139
- * `buildResponse`; LINKAGE is not deduplicated at all, so it re-asks once
140
- * per record. Measured on a bare `GET /animals` with no `include=`:
141
- * 48 linkage entries -> 7 distinct `(type, id)` pairs (owner 20, trait 28),
142
- * a 6.9x reduction and 41 predicate calls saved.
143
- *
144
- * The `(type, id)` cache is a `Map` per type keyed on the RAW id, not on a
145
- * template-string composite: `Map` compares with SameValueZero, so the numeric
146
- * id `1` and the string id `'1'` stay distinct, where `` `${type}:${id}` ``
147
- * would collapse them and let one model's verdict answer for another record.
148
- *
149
- * SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
150
- * it -- a verdict cached across requests would answer a second caller with the
151
- * first caller's authorization.
152
- */
153
- export function createLinkageFilter(request) {
154
- const byType = new Map();
155
- return function isLinkable(type, record) {
156
- let entry = byType.get(type);
157
- if (!entry) {
158
- entry = { verdict: resolveVerdict(request, type), decisions: new Map() };
159
- byType.set(type, entry);
160
- }
161
- const { verdict, decisions } = entry;
162
- if (!verdict.granted)
163
- return false;
164
- if (!verdict.filter)
165
- return true;
166
- const id = record?.id;
167
- const cached = decisions.get(id);
168
- if (cached !== undefined)
169
- return cached;
170
- let allowed;
171
- try {
172
- allowed = Boolean(verdict.filter(record));
173
- }
174
- catch (error) {
175
- // A predicate that throws is a denial -- the same reading `isDenied` uses
176
- // one layer down. Logged, because a predicate that throws on every record
177
- // empties every relationship and, silently, that is indistinguishable
178
- // from a database with no relationships in it.
179
- log.error?.(`[@stonyx/orm] access filter threw while filtering linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
180
- allowed = false;
181
- }
182
- decisions.set(id, allowed);
183
- return allowed;
184
- };
185
- }
@@ -1,222 +0,0 @@
1
- /**
2
- * The shared access-verdict primitive (abofs/stonyx-orm#234).
3
- *
4
- * ---------------------------------------------------------------------------
5
- * WHY THIS FILE EXISTS: ONE INTERPRETER, NOT TWO
6
- * ---------------------------------------------------------------------------
7
- * A consumer `access()` may return six differently-shaped things -- `false`, a
8
- * bare permission string, a permission array, `true`, a per-record function, or
9
- * something the contract does not define at all -- and the reading of each one
10
- * is a security decision. `auth()` has held that reading inline since #190.
11
- * Every surface that needs to ask "may this caller see model X's record?" needs
12
- * the SAME reading, or the second copy becomes an unreviewed second
13
- * authorization vocabulary that answers differently about the same value.
14
- *
15
- * So `interpretAccess` is extracted here and `auth()` now calls it. It is the
16
- * only place a return shape is classified, and abofs/stonyx-orm#232 and #233
17
- * rebase onto it rather than re-deriving it.
18
- *
19
- * ---------------------------------------------------------------------------
20
- * WHAT A LINKAGE FILTER IS, AND WHY THE CALLER BUILDS IT
21
- * ---------------------------------------------------------------------------
22
- * `Record.toJSON()` APPLIES a verdict; it never RESOLVES one. That is not a
23
- * style choice, it is forced, and it was measured before it was decided:
24
- *
25
- * INPUT: origin/dev @ c5f7907, unpatched -> 967 pass / 0 fail
26
- * INPUT: same + fail-closed resolution INSIDE toJSON() -> 964 pass / 3 fail
27
- *
28
- * and all three reds were over-denial of PERMITTED records, not the leak. Two
29
- * independent reasons:
30
- *
31
- * 1. `toJSON()` has no request. The shipped, documented sample reads
32
- * `request.path` for its `/archived` sub-path rule -- the one read of
33
- * argument one the README sanctions -- and fail-closes when it is absent.
34
- * Measured against the live registry:
35
- *
36
- * getAccess('owner')(undefined, { model:'owner', operation:'read' }) -> false
37
- * getAccess('animal')(undefined,{ model:'animal', operation:'read' }) -> [Function]
38
- *
39
- * Same predicate object, two models, two different degradation modes,
40
- * chosen by the consumer. Without a request there is no trustworthy
41
- * answer to get.
42
- *
43
- * 2. `toJSON` is also the `JSON.stringify` hook, so `JSON.stringify({data:
44
- * record})` calls `record.toJSON('data')` -- a STRING in the options slot.
45
- * An implicit caller has no syntactic place to pass anything
46
- * (abofs/stonyx-orm#230). The no-argument document must therefore stay
47
- * byte-identical to what shipped, which also rules out fail-closed by
48
- * default: `Orm.instance.accessFunctions` is `{}` in any process that
49
- * never ran `setup-rest-server` (CLI, SQL-only, unit tests), so a
50
- * fail-closed default would empty every relationship on every document in
51
- * processes that have no REST surface to protect.
52
- *
53
- * The caller -- which still holds the request -- resolves the predicate,
54
- * interprets it here, caches the answer, and hands `toJSON()` an already-decided
55
- * `(type, record) => boolean`.
56
- */
57
- import Orm from '@stonyx/orm';
58
- import log from 'stonyx/log';
59
- import type { AccessMethod, AccessOperation } from './types/orm-types.js';
60
-
61
- /**
62
- * The classified reading of one `access()` return value.
63
- *
64
- * `granted: false` is a total denial. `granted: true` with no `filter` is an
65
- * unconditional grant. `granted: true` WITH a filter means "grant, subject to
66
- * this per-record predicate" -- the function return shape, which is the
67
- * per-record hook `AccessContext` deliberately does not provide.
68
- */
69
- export interface AccessVerdict {
70
- granted: boolean;
71
- filter?: (record: unknown) => boolean;
72
- }
73
-
74
- /**
75
- * A resolved, request-scoped linkage decision: may `record` of model `type` be
76
- * NAMED, by id, inside another model's document?
77
- *
78
- * Arity is `(type, record)` and not `(type, id)` because the per-record filter
79
- * the consumer returns is handed the RECORD -- this repo's own fixture reads
80
- * `record.owner?.id`, not just `record.id`. The `(type, id)` pair is the CACHE
81
- * key, not the input.
82
- */
83
- export type LinkageFilter = (type: string, record: unknown) => boolean;
84
-
85
- const DENIED: AccessVerdict = Object.freeze({ granted: false });
86
- const GRANTED: AccessVerdict = Object.freeze({ granted: true });
87
-
88
- /**
89
- * Classify one `access()` return value. Extracted verbatim from `auth()`, which
90
- * now calls this; the branch ORDER is load-bearing and is preserved exactly.
91
- *
92
- * `operation` is the verb being authorised. `undefined` -- reachable, because
93
- * express delivers HEAD to the GET handler and `methodAccessMap` has no entry
94
- * for it -- falls through `permitted.includes(undefined)` to a denial, which is
95
- * the same answer `auth()` gave before the extraction.
96
- */
97
- export function interpretAccess(access: AccessMethod, operation: AccessOperation | undefined): AccessVerdict {
98
- if (!access) return DENIED;
99
-
100
- // The function return shape IS the per-record hook. Grant the request and
101
- // carry the predicate; the caller applies it per record.
102
- if (typeof access === 'function') return { granted: true, filter: access as (record: unknown) => boolean };
103
-
104
- if (access === true) return GRANTED;
105
-
106
- // `AccessMethod` declares `string` legal and it fell through every branch
107
- // above. A bare string is ONE permission, not a grant of all four -- reading
108
- // it as a full grant is what once let `return 'read'` authorise DELETE.
109
- const permitted = typeof access === 'string' ? [access] : access;
110
-
111
- // Anything that is not a permission array by this point -- an object, a
112
- // number, a Symbol -- is a consumer mistake, and the only safe reading of a
113
- // shape the contract does not define is a denial. Fail CLOSED.
114
- if (!Array.isArray(permitted)) return DENIED;
115
- if (!permitted.includes(operation as string)) return DENIED;
116
-
117
- return GRANTED;
118
- }
119
-
120
- /**
121
- * Resolve model `type`'s verdict for a read, against the live `request`.
122
- *
123
- * Fails closed on both ambiguous inputs:
124
- *
125
- * - `getAccess(type)` -> `undefined`. That is NOT "this model is
126
- * unrestricted". `setup-rest-server` catches an access-class load failure,
127
- * warns, and publishes whatever PARTIAL map it had, so `undefined` covers
128
- * both "no access class claims this model" and "the class that claims it
129
- * failed to load" -- and the caller cannot tell them apart. Deny.
130
- * - the predicate THROWS. Same reading `auth()` and `isDenied` already use:
131
- * a throw is a denial, logged, never a 500 and never a grant.
132
- *
133
- * NOTE ON CROSS-MODEL ASKS. The predicate is asked about `type` while the
134
- * request in hand was dispatched to a DIFFERENT model's route. Since #222 this
135
- * repo's fixture reads `context.model` and answers correctly; a consumer's
136
- * arity-1 predicate does not, and there is no supported way to tell which kind
137
- * was resolved (the boot-time arity warning is abofs/stonyx-orm#213). A
138
- * consequence to expect rather than debug: the fixture's surviving `request.path`
139
- * read means asking the OWNER predicate on a request dispatched to
140
- * `GET /animals/archived` returns a bare `false`. That is a whole-request deny
141
- * bleeding across models -- harmless, because it is the fail-closed direction,
142
- * and it is treated as "deny this linkage", not as an error.
143
- */
144
- function resolveVerdict(request: unknown, type: string): AccessVerdict {
145
- const predicate = Orm.instance?.getAccess?.(type);
146
- if (typeof predicate !== 'function') return DENIED;
147
-
148
- let access: AccessMethod;
149
-
150
- try {
151
- access = predicate(request, { model: type, operation: 'read' });
152
- } catch (error) {
153
- log.error?.(`[@stonyx/orm] access() threw while resolving linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
154
-
155
- return DENIED;
156
- }
157
-
158
- return interpretAccess(access, 'read');
159
- }
160
-
161
- /**
162
- * Build a request-scoped linkage filter.
163
- *
164
- * TWO CACHES, AND BOTH ARE LOAD-BEARING RATHER THAN AN OPTIMISATION:
165
- *
166
- * - one verdict per TYPE. Resolving means CALLING the consumer's `access()`,
167
- * which is arbitrary code with arbitrary cost and which the module has
168
- * already had to guard for throwing.
169
- * - one decision per `(type, id)`. `included` is deduplicated by
170
- * `buildResponse`; LINKAGE is not deduplicated at all, so it re-asks once
171
- * per record. Measured on a bare `GET /animals` with no `include=`:
172
- * 48 linkage entries -> 7 distinct `(type, id)` pairs (owner 20, trait 28),
173
- * a 6.9x reduction and 41 predicate calls saved.
174
- *
175
- * The `(type, id)` cache is a `Map` per type keyed on the RAW id, not on a
176
- * template-string composite: `Map` compares with SameValueZero, so the numeric
177
- * id `1` and the string id `'1'` stay distinct, where `` `${type}:${id}` ``
178
- * would collapse them and let one model's verdict answer for another record.
179
- *
180
- * SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
181
- * it -- a verdict cached across requests would answer a second caller with the
182
- * first caller's authorization.
183
- */
184
- export function createLinkageFilter(request: unknown): LinkageFilter {
185
- const byType = new Map<string, { verdict: AccessVerdict; decisions: Map<unknown, boolean> }>();
186
-
187
- return function isLinkable(type: string, record: unknown): boolean {
188
- let entry = byType.get(type);
189
-
190
- if (!entry) {
191
- entry = { verdict: resolveVerdict(request, type), decisions: new Map() };
192
- byType.set(type, entry);
193
- }
194
-
195
- const { verdict, decisions } = entry;
196
-
197
- if (!verdict.granted) return false;
198
- if (!verdict.filter) return true;
199
-
200
- const id = (record as { id?: unknown } | null)?.id;
201
- const cached = decisions.get(id);
202
- if (cached !== undefined) return cached;
203
-
204
- let allowed: boolean;
205
-
206
- try {
207
- allowed = Boolean(verdict.filter(record));
208
- } catch (error) {
209
- // A predicate that throws is a denial -- the same reading `isDenied` uses
210
- // one layer down. Logged, because a predicate that throws on every record
211
- // empties every relationship and, silently, that is indistinguishable
212
- // from a database with no relationships in it.
213
- log.error?.(`[@stonyx/orm] access filter threw while filtering linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
214
-
215
- allowed = false;
216
- }
217
-
218
- decisions.set(id, allowed);
219
-
220
- return allowed;
221
- };
222
- }