@stonyx/orm 0.3.2-alpha.73 → 0.3.2-alpha.75

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
@@ -452,7 +452,11 @@ export default class GlobalAccess {
452
452
  // inert. Deliberately NO `?? record.owner` fallback: accepting the raw
453
453
  // shape as well as the resolved one would absorb a resolution regression
454
454
  // silently, which is exactly what blinded this fixture before.
455
- if (model === 'animal') return record => record.owner?.id !== 'restricted';
455
+ // `record.id !== 18` hides one animal whose OWNER is permitted. It is the
456
+ // fixture that makes the `hasMany` half of the relationship-route rules
457
+ // observable: gina is served, animal 18 is not, and every surface that
458
+ // names gina's pets has to drop it.
459
+ if (model === 'animal') return record => record.owner?.id !== 'restricted' && record.id !== 18;
456
460
 
457
461
  // Allows full access to all calls that don't match any of the above conditions
458
462
  return ['read', 'create', 'update', 'delete'];
@@ -921,15 +925,67 @@ per-record filter. An input you cannot identify must **deny**.
921
925
  operation and which record the request addresses. The five variants above are
922
926
  the five ways that has been observed to fail open so far. Tracked as
923
927
  [#202](https://github.com/abofs/stonyx-orm/issues/202).
924
- - **Related and included records are not filtered.** The predicate is evaluated
925
- against the record the route is *addressed to*. `GET /animals/1/owner`,
926
- `GET /animals/1/relationships/owner` and `?include=owner` all serialize the
927
- related record without resolving that model's own access class, so a filter on
928
- `/owners` does not hide an owner reached through `/animals`. Tracked as
929
- [#196](https://github.com/abofs/stonyx-orm/issues/196), which covers
930
- `include=`, related-resource routes and relationship-linkage routes. This is
931
- **membership** whether the related resource is served at all — and it is a
932
- different question from which ids a document may *name*, immediately below.
928
+ - **The two relationship route families now resolve the *related* model's own
929
+ access class `GET /:models/:id/{relationship}` and
930
+ `GET /:models/:id/relationships/{relationship}`**
931
+ ([#232](https://github.com/abofs/stonyx-orm/issues/232)). This is
932
+ **membership**: the related resource is the route's *primary* data, so the
933
+ filter decides whether it is served at all, not merely which ids a document
934
+ may name. A denied `hasMany` member is **dropped from the array** — the result
935
+ is shaped exactly like a genuinely empty relationship, `links` intact, no
936
+ `errors` member, same status. A denied `belongsTo` target answers **404**, the
937
+ same status the route already answered for a denied parent and for a parent
938
+ that does not exist. The `/relationships/` family built its `{type, id}` by
939
+ hand rather than through `toJSON()`, which is why the linkage filter shipped in
940
+ [#234](https://github.com/abofs/stonyx-orm/issues/234) did not reach it.
941
+
942
+ Before this, both families served a record hidden on every one of its own
943
+ surfaces, in full, from another model's route, at **zero query parameters**.
944
+ The severe case is a model **claimed by no access class**: `getAccess()`
945
+ returns `undefined`, no route is mounted for it at all, and it was still
946
+ readable as a related resource — a collection the consumer deliberately never
947
+ exposed.
948
+
949
+ **Residual on the `belongsTo` shape, stated rather than left to be found:** a
950
+ denied target answers 404 while a genuinely *absent* one answers 200 with
951
+ `data: null`, so those two cases are distinguishable. That asymmetry is
952
+ inherited — a denied *parent* has always answered 404 while an existing parent
953
+ with an empty relationship answers 200 — and changing it is a change to this
954
+ module's whole spelling of denial, not to these two routes.
955
+
956
+ **Per-record denies for a related resource are not expressible.** A predicate resolved for a
957
+ related resource on these routes receives `recordId: null` and a `request`
958
+ whose `params` name a record of a **different model**. So the inputs it has
959
+ are the model name, the operation and the request — and **a rule that needs to
960
+ know *which* related record it is being asked about cannot be written**.
961
+ Model-level denies (`return false` for a model) work. Request-level denies (a
962
+ rule reading a header, a tenant, the method) work. The per-record **filter**
963
+ shape works too — `access()` may return a function, and that function receives
964
+ the whole record, id included. What does not work is branching on the record's
965
+ identity *before* returning, because `access()` is not told it.
966
+
967
+ This is not an oversight and it is not closed here. The verdict is resolved
968
+ **once per type**, cached, before any record has been examined — a `hasMany`
969
+ related-resource route returns many records of one type, so seeding `recordId`
970
+ from a record would let the first one decide the context for all of them. The
971
+ rule the framework holds to is: **`recordId` may name a record only where the
972
+ route addresses exactly one record of the model being asked about.** That is
973
+ true for `GET /owners/{id}`, false for linkage, and false for a `hasMany`
974
+ related-resource route.
975
+
976
+ - **`?include=` records are still not filtered — the relationship routes now
977
+ are.** *Re-specified by [#232](https://github.com/abofs/stonyx-orm/issues/232);
978
+ the sentence this replaces said all three surfaces were unfiltered, and two of
979
+ them no longer are.* `GET /animals/1/owner` and
980
+ `GET /animals/1/relationships/owner` resolve the related model's own access
981
+ class (see the bullet above). **`?include=owner` still does not**: it
982
+ serializes the related record without resolving that class, so a filter on
983
+ `/owners` does not hide an owner reached through `?include=` on `/animals`.
984
+ Tracked as [#233](https://github.com/abofs/stonyx-orm/issues/233), the
985
+ remaining child of [#196](https://github.com/abofs/stonyx-orm/issues/196).
986
+ This is **membership** — whether the related resource is served at all — and
987
+ it is a different question from which ids a document may *name*, immediately
988
+ below.
933
989
  - **Relationship linkage is filtered on the four request-bound read surfaces,
934
990
  and only there.** A document's `relationships.*.data` used to publish the id
935
991
  of every related record unconditionally, so a record hidden on every one of
@@ -988,10 +1044,14 @@ per-record filter. An input you cannot identify must **deny**.
988
1044
  angela**, seconds apart, with no query string and no relationship route. Any
989
1045
  caller who can read a record can also write it and be handed the id the read
990
1046
  withheld.
991
- - **`GET /:models/:id/relationships/{relationship}`**, whose *primary data* is
992
- linkage, so filtering it is a **membership** decision —
993
- [#232](https://github.com/abofs/stonyx-orm/issues/232), the filed child of
994
- [#196](https://github.com/abofs/stonyx-orm/issues/196).
1047
+ - ~~**`GET /:models/:id/relationships/{relationship}`**~~ **covered as of
1048
+ [#232](https://github.com/abofs/stonyx-orm/issues/232)**, together with
1049
+ `GET /:models/:id/{relationship}`. Left in place rather than deleted because
1050
+ this list is what a reader consults to find out what is *not* covered, and a
1051
+ silently vanishing entry reads as an entry that was never there. Its
1052
+ *primary data* is linkage, so filtering it was a **membership** decision —
1053
+ which is why it belonged to #232 and not to
1054
+ [#234](https://github.com/abofs/stonyx-orm/issues/234).
995
1055
  - **A bare `toJSON()` still emits unfiltered linkage, and that is deliberate.**
996
1056
  `Record.toJSON()` **applies** a verdict; it never **resolves** one. It has no
997
1057
  request, and the documented `access()` contract permits a predicate to read
@@ -1245,21 +1245,77 @@ export default class OrmRequest extends Request {
1245
1245
  return 404;
1246
1246
  const relatedData = record.__relationships[relationshipName];
1247
1247
  const baseUrl = getBaseUrl(request);
1248
- // LINKAGE ONLY. This filter decides which ids the emitted documents may
1249
- // NAME in their own `relationships.*.data`; it does NOT decide whether
1250
- // the related records themselves are served -- that is the parent-only
1251
- // filtering this route has done since #190, and widening it to the
1252
- // related record is abofs/stonyx-orm#196.
1248
+ // ONE FILTER, TWO JOBS, AND abofs/stonyx-orm#232 IS THE SECOND ONE.
1249
+ //
1250
+ // As LINKAGE (#234) it decides which ids the emitted documents may NAME
1251
+ // in their own `relationships.*.data`. As MEMBERSHIP (this issue) it
1252
+ // decides whether the related record is served here AT ALL -- the
1253
+ // related resource is PRIMARY data on this route, so there is no
1254
+ // linkage-consistency question to answer separately.
1255
+ //
1256
+ // Until #232 this route filtered only the PARENT, so a record its own
1257
+ // model's predicate hides was served in full from another model's
1258
+ // route, at ZERO query parameters. Measured on dev @ 8dda5d6:
1259
+ //
1260
+ // GET /owners/angela -> 404
1261
+ // GET /animals/1/owner -> 200, owner:angela, full attributes
1262
+ // GET /traits/2/tag -> 200, a model NO access class
1263
+ // claims, on a collection that has
1264
+ // no mounted route at all
1265
+ //
1266
+ // ARGUMENT ONE IS THE LIVE REQUEST, NOT A DERIVED ONE. A fabricated
1267
+ // request addressing the RELATED resource was the original design and
1268
+ // it is dropped: #241 removed the shipped fixture's read of argument
1269
+ // one, so a fabricated value changes nothing it could observe, and
1270
+ // test/unit/linkage-verdict-test.ts:484 pins the predicate as receiving
1271
+ // the live request BY IDENTITY. `createLinkageFilter` is also a
1272
+ // published public export (src/index.ts) whose resolution granularity
1273
+ // is per TYPE; supplying a per-RECORD request would mean widening it,
1274
+ // which takes a consumer `access()` from ~2 calls to ~7 on a plain
1275
+ // `GET /animals`. That is a separate, consumer-visible story.
1276
+ //
1277
+ // THE RESIDUAL THAT FOLLOWS FROM THAT IS DISCLOSED, NOT PAPERED OVER.
1278
+ // `recordId` is `null` here and the request names a record of a
1279
+ // DIFFERENT model, so a consumer predicate can express a model-level or
1280
+ // a request-level deny for a related resource, but NOT a per-record
1281
+ // one. README.md and docs/usage-patterns.md say so; a ledger assertion
1282
+ // in test/unit/relationship-route-access-test.ts keeps them saying it.
1253
1283
  const linkage = createLinkageFilter(request);
1284
+ // FAIL CLOSED ON A RECORD WHOSE TYPE CANNOT BE NAMED. `isLinkable` is
1285
+ // keyed on the model name; without one there is no predicate to ask,
1286
+ // and an unidentifiable input must never be the permissive path.
1287
+ const isLinkable = (r) => {
1288
+ const type = r.__model?.__name;
1289
+ return typeof type === 'string' && type !== '' && linkage(type, r);
1290
+ };
1254
1291
  let data;
1255
1292
  if (info.isArray) {
1256
- // hasMany - return array
1293
+ // hasMany - return array, MINUS the members this caller may not see.
1294
+ // Dropped, never errored: the result is byte-identical to a genuinely
1295
+ // empty relationship, so this route is not an existence oracle.
1257
1296
  const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
1258
- data = related.map(r => r.toJSON?.({ baseUrl, linkage }));
1297
+ data = related.filter(isLinkable).map(r => r.toJSON?.({ baseUrl, linkage }));
1259
1298
  }
1260
1299
  else {
1261
- // belongsTo - return single or null
1262
- data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl, linkage }) : null;
1300
+ // belongsTo - return single or null. A DENIED target is 404, the same
1301
+ // status this route already returns for a denied PARENT and for a
1302
+ // parent that does not exist, and the same status the related
1303
+ // record's own route answers.
1304
+ //
1305
+ // KNOWN RESIDUAL, stated here because it is the one place a reader
1306
+ // will look: 404 is distinguishable from a relationship that is
1307
+ // genuinely EMPTY, which answers 200 with `data: null`. So a caller
1308
+ // can tell "there is a target you may not see" from "there is no
1309
+ // target". That asymmetry is inherited -- a denied PARENT has always
1310
+ // answered 404 while an existing parent with an empty relationship
1311
+ // answers 200 -- and closing it is a change to the module's whole
1312
+ // denial spelling, not to this route.
1313
+ if (!isOrmRecord(relatedData))
1314
+ data = null;
1315
+ else if (!isLinkable(relatedData))
1316
+ return 404;
1317
+ else
1318
+ data = relatedData.toJSON?.({ baseUrl, linkage });
1263
1319
  }
1264
1320
  return {
1265
1321
  links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}/${dasherizedName}` },
@@ -1275,17 +1331,35 @@ export default class OrmRequest extends Request {
1275
1331
  return 404;
1276
1332
  const relatedData = record.__relationships[relationshipName];
1277
1333
  const baseUrl = getBaseUrl(request);
1334
+ // THE ONLY ONE OF THE FOUR READ SURFACES THAT DOES NOT GO THROUGH
1335
+ // `toJSON()`. It builds `{ type, id }` BY HAND, which is why #234's
1336
+ // linkage filter never reached it and why this half belongs to
1337
+ // abofs/stonyx-orm#232 rather than to #234: on this route the linkage
1338
+ // IS the primary data of an opt-in request, so filtering it changes the
1339
+ // route's MEMBERSHIP semantics, not the ids named inside somebody
1340
+ // else's document.
1341
+ //
1342
+ // Same filter, same argument-one decision, same residual as
1343
+ // `/:id/{relationship}` above -- read the block there.
1344
+ const linkage = createLinkageFilter(request);
1345
+ const isLinkable = (r) => {
1346
+ const type = r.__model?.__name;
1347
+ return typeof type === 'string' && type !== '' && linkage(type, r);
1348
+ };
1278
1349
  let data;
1279
1350
  if (info.isArray) {
1280
1351
  // hasMany - return array of linkage objects
1281
1352
  const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
1282
1353
  data = related
1283
1354
  .filter((r) => Boolean(r.__model))
1355
+ .filter(isLinkable)
1284
1356
  .map(r => ({ type: r.__model.__name, id: r.id }));
1285
1357
  }
1286
1358
  else {
1287
1359
  // belongsTo - return single linkage or null
1288
1360
  if (isOrmRecord(relatedData) && relatedData.__model) {
1361
+ if (!isLinkable(relatedData))
1362
+ return 404;
1289
1363
  data = { type: relatedData.__model.__name, id: relatedData.id };
1290
1364
  }
1291
1365
  else {
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-alpha.73",
7
+ "version": "0.3.2-alpha.75",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -1364,21 +1364,76 @@ export default class OrmRequest extends Request {
1364
1364
  const relatedData = record.__relationships[relationshipName];
1365
1365
  const baseUrl = getBaseUrl(request);
1366
1366
 
1367
- // LINKAGE ONLY. This filter decides which ids the emitted documents may
1368
- // NAME in their own `relationships.*.data`; it does NOT decide whether
1369
- // the related records themselves are served -- that is the parent-only
1370
- // filtering this route has done since #190, and widening it to the
1371
- // related record is abofs/stonyx-orm#196.
1367
+ // ONE FILTER, TWO JOBS, AND abofs/stonyx-orm#232 IS THE SECOND ONE.
1368
+ //
1369
+ // As LINKAGE (#234) it decides which ids the emitted documents may NAME
1370
+ // in their own `relationships.*.data`. As MEMBERSHIP (this issue) it
1371
+ // decides whether the related record is served here AT ALL -- the
1372
+ // related resource is PRIMARY data on this route, so there is no
1373
+ // linkage-consistency question to answer separately.
1374
+ //
1375
+ // Until #232 this route filtered only the PARENT, so a record its own
1376
+ // model's predicate hides was served in full from another model's
1377
+ // route, at ZERO query parameters. Measured on dev @ 8dda5d6:
1378
+ //
1379
+ // GET /owners/angela -> 404
1380
+ // GET /animals/1/owner -> 200, owner:angela, full attributes
1381
+ // GET /traits/2/tag -> 200, a model NO access class
1382
+ // claims, on a collection that has
1383
+ // no mounted route at all
1384
+ //
1385
+ // ARGUMENT ONE IS THE LIVE REQUEST, NOT A DERIVED ONE. A fabricated
1386
+ // request addressing the RELATED resource was the original design and
1387
+ // it is dropped: #241 removed the shipped fixture's read of argument
1388
+ // one, so a fabricated value changes nothing it could observe, and
1389
+ // test/unit/linkage-verdict-test.ts:484 pins the predicate as receiving
1390
+ // the live request BY IDENTITY. `createLinkageFilter` is also a
1391
+ // published public export (src/index.ts) whose resolution granularity
1392
+ // is per TYPE; supplying a per-RECORD request would mean widening it,
1393
+ // which takes a consumer `access()` from ~2 calls to ~7 on a plain
1394
+ // `GET /animals`. That is a separate, consumer-visible story.
1395
+ //
1396
+ // THE RESIDUAL THAT FOLLOWS FROM THAT IS DISCLOSED, NOT PAPERED OVER.
1397
+ // `recordId` is `null` here and the request names a record of a
1398
+ // DIFFERENT model, so a consumer predicate can express a model-level or
1399
+ // a request-level deny for a related resource, but NOT a per-record
1400
+ // one. README.md and docs/usage-patterns.md say so; a ledger assertion
1401
+ // in test/unit/relationship-route-access-test.ts keeps them saying it.
1372
1402
  const linkage = createLinkageFilter(request);
1373
1403
 
1404
+ // FAIL CLOSED ON A RECORD WHOSE TYPE CANNOT BE NAMED. `isLinkable` is
1405
+ // keyed on the model name; without one there is no predicate to ask,
1406
+ // and an unidentifiable input must never be the permissive path.
1407
+ const isLinkable = (r: OrmRecord) => {
1408
+ const type = (r as { __model?: { __name?: string } }).__model?.__name;
1409
+
1410
+ return typeof type === 'string' && type !== '' && linkage(type, r);
1411
+ };
1412
+
1374
1413
  let data: unknown;
1375
1414
  if (info.isArray) {
1376
- // hasMany - return array
1415
+ // hasMany - return array, MINUS the members this caller may not see.
1416
+ // Dropped, never errored: the result is byte-identical to a genuinely
1417
+ // empty relationship, so this route is not an existence oracle.
1377
1418
  const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
1378
- data = related.map(r => r.toJSON?.({ baseUrl, linkage }));
1419
+ data = related.filter(isLinkable).map(r => r.toJSON?.({ baseUrl, linkage }));
1379
1420
  } else {
1380
- // belongsTo - return single or null
1381
- data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl, linkage }) : null;
1421
+ // belongsTo - return single or null. A DENIED target is 404, the same
1422
+ // status this route already returns for a denied PARENT and for a
1423
+ // parent that does not exist, and the same status the related
1424
+ // record's own route answers.
1425
+ //
1426
+ // KNOWN RESIDUAL, stated here because it is the one place a reader
1427
+ // will look: 404 is distinguishable from a relationship that is
1428
+ // genuinely EMPTY, which answers 200 with `data: null`. So a caller
1429
+ // can tell "there is a target you may not see" from "there is no
1430
+ // target". That asymmetry is inherited -- a denied PARENT has always
1431
+ // answered 404 while an existing parent with an empty relationship
1432
+ // answers 200 -- and closing it is a change to the module's whole
1433
+ // denial spelling, not to this route.
1434
+ if (!isOrmRecord(relatedData)) data = null;
1435
+ else if (!isLinkable(relatedData)) return 404;
1436
+ else data = relatedData.toJSON?.({ baseUrl, linkage });
1382
1437
  }
1383
1438
 
1384
1439
  return {
@@ -1396,16 +1451,36 @@ export default class OrmRequest extends Request {
1396
1451
  const relatedData = record.__relationships[relationshipName];
1397
1452
  const baseUrl = getBaseUrl(request);
1398
1453
 
1454
+ // THE ONLY ONE OF THE FOUR READ SURFACES THAT DOES NOT GO THROUGH
1455
+ // `toJSON()`. It builds `{ type, id }` BY HAND, which is why #234's
1456
+ // linkage filter never reached it and why this half belongs to
1457
+ // abofs/stonyx-orm#232 rather than to #234: on this route the linkage
1458
+ // IS the primary data of an opt-in request, so filtering it changes the
1459
+ // route's MEMBERSHIP semantics, not the ids named inside somebody
1460
+ // else's document.
1461
+ //
1462
+ // Same filter, same argument-one decision, same residual as
1463
+ // `/:id/{relationship}` above -- read the block there.
1464
+ const linkage = createLinkageFilter(request);
1465
+ const isLinkable = (r: OrmRecord) => {
1466
+ const type = (r as { __model?: { __name?: string } }).__model?.__name;
1467
+
1468
+ return typeof type === 'string' && type !== '' && linkage(type, r);
1469
+ };
1470
+
1399
1471
  let data: unknown;
1400
1472
  if (info.isArray) {
1401
1473
  // hasMany - return array of linkage objects
1402
1474
  const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
1403
1475
  data = related
1404
1476
  .filter((r): r is OrmRecord & { __model: { __name: string } } => Boolean(r.__model))
1477
+ .filter(isLinkable)
1405
1478
  .map(r => ({ type: r.__model.__name, id: r.id }));
1406
1479
  } else {
1407
1480
  // belongsTo - return single linkage or null
1408
1481
  if (isOrmRecord(relatedData) && relatedData.__model) {
1482
+ if (!isLinkable(relatedData)) return 404;
1483
+
1409
1484
  data = { type: relatedData.__model.__name, id: relatedData.id };
1410
1485
  } else {
1411
1486
  data = null;