@stonyx/orm 0.3.2-beta.157 → 0.3.2-beta.159
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 +268 -1
- package/dist/access-verdict.d.ts +85 -0
- package/dist/access-verdict.js +284 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +8 -0
- package/dist/orm-request.js +182 -31
- package/dist/record.d.ts +16 -0
- package/dist/record.js +149 -3
- package/dist/types/orm-types.d.ts +27 -0
- package/package.json +1 -1
- package/src/access-verdict.ts +312 -0
- package/src/index.ts +9 -0
- package/src/orm-request.ts +188 -32
- package/src/record.ts +176 -3
- package/src/types/orm-types.ts +28 -1
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export { default } from './main.js';
|
|
|
10
10
|
export { store, relationships } from './main.js';
|
|
11
11
|
export type { PersistErrorDetail } from './main.js';
|
|
12
12
|
export type { AccessContext, AccessFunction, AccessMethod, AccessOperation } from './types/orm-types.js';
|
|
13
|
+
export type { LinkageFilter } from './types/orm-types.js';
|
|
14
|
+
export { createLinkageFilter } from './access-verdict.js';
|
|
13
15
|
export { Model, View, Serializer };
|
|
14
16
|
export { attr, belongsTo, hasMany, createRecord, updateRecord };
|
|
15
17
|
export { count, avg, sum, min, max };
|
package/dist/index.js
CHANGED
|
@@ -23,6 +23,14 @@ import { createRecord, updateRecord } from './manage-record.js';
|
|
|
23
23
|
import { count, avg, sum, min, max } from './aggregates.js';
|
|
24
24
|
export { default } from './main.js';
|
|
25
25
|
export { store, relationships } from './main.js';
|
|
26
|
+
// The request-scoped linkage-verdict factory (#234). PUBLIC on purpose: the
|
|
27
|
+
// README tells a consumer serializing a `Record` outside the REST layer to pass
|
|
28
|
+
// their own resolved `linkage` option, and without an exported factory the only
|
|
29
|
+
// way to follow that advice is to write a SECOND reading of `access()` in
|
|
30
|
+
// consumer code -- the exact "unreviewed second authorization vocabulary" that
|
|
31
|
+
// src/access-verdict.ts exists to prevent, reproduced where no reviewer sees it
|
|
32
|
+
// drift. Give them the one interpreter instead of an invitation to fork it.
|
|
33
|
+
export { createLinkageFilter } from './access-verdict.js';
|
|
26
34
|
export { Model, View, Serializer }; // base classes
|
|
27
35
|
export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
|
|
28
36
|
export { count, avg, sum, min, max }; // aggregate helpers
|
package/dist/orm-request.js
CHANGED
|
@@ -267,6 +267,7 @@ import { getBeforeHooks, getAfterHooks } from './hooks.js';
|
|
|
267
267
|
import config from 'stonyx/config';
|
|
268
268
|
import log from 'stonyx/log';
|
|
269
269
|
import { isOrmRecord, NO_FREE_ID_ERROR } from './utils.js';
|
|
270
|
+
import { interpretAccess, createLinkageFilter } from './access-verdict.js';
|
|
270
271
|
const methodAccessMap = {
|
|
271
272
|
GET: 'read',
|
|
272
273
|
POST: 'create',
|
|
@@ -423,7 +424,7 @@ function normalizeBodyId(id) {
|
|
|
423
424
|
return coerceId(id);
|
|
424
425
|
}
|
|
425
426
|
function buildResponse(data, includeParam, recordOrRecords, options = {}) {
|
|
426
|
-
const { links, baseUrl } = options;
|
|
427
|
+
const { links, baseUrl, linkage } = options;
|
|
427
428
|
const response = { data };
|
|
428
429
|
// Add top-level links
|
|
429
430
|
if (links) {
|
|
@@ -436,7 +437,53 @@ function buildResponse(data, includeParam, recordOrRecords, options = {}) {
|
|
|
436
437
|
return response;
|
|
437
438
|
const includedRecords = collectIncludedRecords(recordOrRecords, includes);
|
|
438
439
|
if (includedRecords.length > 0) {
|
|
439
|
-
|
|
440
|
+
// LINKAGE, NOT MEMBERSHIP -- and the distinction is the whole reason this
|
|
441
|
+
// line is one story's and the line above it is another's
|
|
442
|
+
// (abofs/stonyx-orm#235 and #233 respectively).
|
|
443
|
+
//
|
|
444
|
+
// - WHICH RESOURCES REACH THIS ARRAY is decided by
|
|
445
|
+
// `collectIncludedRecords` on the line above. That is MEMBERSHIP, it is
|
|
446
|
+
// #233's, and it is deliberately untouched here: a hidden owner is
|
|
447
|
+
// still a member of `included` after this change. Pinned green by
|
|
448
|
+
// `[GUARD] #235 X1` so that #235 cannot close #233 incidentally.
|
|
449
|
+
// - WHAT A RECORD ALREADY IN THIS ARRAY MAY NAME in its own
|
|
450
|
+
// `relationships.*.data` is LINKAGE -- the same question #234 answers
|
|
451
|
+
// for the primary document -- and that is what the `linkage` option
|
|
452
|
+
// below decides. Before it, `GET /animals/1?include=owner,owner.pets`
|
|
453
|
+
// filtered the primary document's `owner.data` to `null` and then
|
|
454
|
+
// handed back eight PERMITTED animals in `included` each naming
|
|
455
|
+
// `{"type":"owner","id":"angela"}` -- angela's whole `pets` set,
|
|
456
|
+
// `[1, 3, 7, 10, 11, 15, 17, 20]`. `included` itself is NINE
|
|
457
|
+
// resources there: those eight animals plus the hidden owner, whose
|
|
458
|
+
// membership is #233's and not an animal. Neither #233 nor #234
|
|
459
|
+
// closes that.
|
|
460
|
+
//
|
|
461
|
+
// THE FILTER IS THE CALLER'S, PASSED IN, NOT BUILT HERE. Both call sites
|
|
462
|
+
// already hold one for the primary document, and sharing it is what keeps
|
|
463
|
+
// the per-type verdict cache and the per-(type, id) decision cache alive
|
|
464
|
+
// across the primary document AND the sideload -- one verdict resolution
|
|
465
|
+
// per type for the whole response, pinned by `[GUARD] #235 C1`. Building a
|
|
466
|
+
// fresh filter here would resolve the consumer's `access()` once per
|
|
467
|
+
// included record instead.
|
|
468
|
+
//
|
|
469
|
+
// `linkage` IS OPTIONAL IN THE TYPE AND IS NOT OPTIONAL IN PRACTICE.
|
|
470
|
+
// Stating it precisely because the opposite claim stood here in an earlier
|
|
471
|
+
// draft of this change: BOTH of this function's callers supply a filter
|
|
472
|
+
// (`getCollectionHandler` and `getSingleHandler`, the only two), so the
|
|
473
|
+
// `undefined` branch has no live caller in this module today. It is
|
|
474
|
+
// optional so that omitting it degrades to the PRE-#234 document rather
|
|
475
|
+
// than to a denial -- `Record.toJSON` reads an ABSENT option as "no verdict
|
|
476
|
+
// was supplied" and emits linkage in full.
|
|
477
|
+
//
|
|
478
|
+
// WHAT IT MUST NEVER BE HANDED IS A NON-FUNCTION. `toJSON` does NOT read a
|
|
479
|
+
// non-function as absent: `Object.prototype.toString.call(linkage)` must be
|
|
480
|
+
// `'[object Function]'`, and anything else -- `null`, an `AsyncFunction`,
|
|
481
|
+
// and INCLUDING the primitive `true` -- DENIES every relationship on the
|
|
482
|
+
// document and logs once. `toJSON({ linkage: true })` emits `null` linkage.
|
|
483
|
+
// So do not "simplify" this to a boolean, and do not make it default to
|
|
484
|
+
// `true`: both spellings look like "allow everything" and mean the exact
|
|
485
|
+
// opposite (abofs/stonyx-orm#224).
|
|
486
|
+
response.included = includedRecords.map(record => record.toJSON?.({ baseUrl, linkage }));
|
|
440
487
|
}
|
|
441
488
|
return response;
|
|
442
489
|
}
|
|
@@ -609,10 +656,20 @@ export default class OrmRequest extends Request {
|
|
|
609
656
|
if (queryFilterPredicate)
|
|
610
657
|
recordsToReturn = recordsToReturn.filter(queryFilterPredicate);
|
|
611
658
|
const baseUrl = getBaseUrl(request);
|
|
612
|
-
|
|
659
|
+
// ONE filter per REQUEST, not one per record: it carries the per-type
|
|
660
|
+
// verdict cache and the per-(type, id) decision cache, and both are
|
|
661
|
+
// worthless if it is rebuilt inside the map. Measured on this exact
|
|
662
|
+
// surface with no `include=`: 48 linkage entries collapse to 7 distinct
|
|
663
|
+
// (type, id) pairs.
|
|
664
|
+
const linkage = createLinkageFilter(request);
|
|
665
|
+
const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl, linkage }));
|
|
613
666
|
return buildResponse(data, request.query?.include, recordsToReturn, {
|
|
614
667
|
links: { self: `${baseUrl}/${pluralizedModel}` },
|
|
615
|
-
baseUrl
|
|
668
|
+
baseUrl,
|
|
669
|
+
// THE SAME filter object the primary documents above were serialized
|
|
670
|
+
// with, deliberately: it carries the caches, and rebuilding one here
|
|
671
|
+
// would re-resolve every type (abofs/stonyx-orm#235).
|
|
672
|
+
linkage
|
|
616
673
|
});
|
|
617
674
|
};
|
|
618
675
|
const getSingleHandler = async (request, { filter }) => {
|
|
@@ -627,12 +684,29 @@ export default class OrmRequest extends Request {
|
|
|
627
684
|
const fieldsMap = parseFields(request.query);
|
|
628
685
|
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
629
686
|
const baseUrl = getBaseUrl(request);
|
|
630
|
-
|
|
687
|
+
const linkage = createLinkageFilter(request);
|
|
688
|
+
// `buildResponse` IS given the filter now (abofs/stonyx-orm#235), and it
|
|
689
|
+
// is the SAME object the primary document is serialized with -- one
|
|
690
|
+
// verdict per type for the whole response, sideload included.
|
|
691
|
+
//
|
|
692
|
+
// The boundary that remains, so the next reader does not have to derive
|
|
693
|
+
// it: this closes what a record already in `included` may NAME. WHETHER a
|
|
694
|
+
// resource appears in `included` at all is MEMBERSHIP and it is
|
|
695
|
+
// abofs/stonyx-orm#233's -- a hidden owner is still a member here.
|
|
696
|
+
// Neither question closes the other.
|
|
697
|
+
return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
|
|
631
698
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
|
|
632
|
-
baseUrl
|
|
699
|
+
baseUrl,
|
|
700
|
+
linkage
|
|
633
701
|
});
|
|
634
702
|
};
|
|
635
|
-
const createHandler = async (
|
|
703
|
+
const createHandler = async (request, { filter }) => {
|
|
704
|
+
// BOUND, not destructured (abofs/stonyx-orm#235). `HandlerFn` has always
|
|
705
|
+
// delivered the request as argument one; this handler simply discarded
|
|
706
|
+
// the binding, which is why its response document named ids every read
|
|
707
|
+
// surface withholds. `createLinkageFilter` needs the live request and
|
|
708
|
+
// there is no signature change involved in giving it one.
|
|
709
|
+
const { body, query } = request;
|
|
636
710
|
const { type, id, attributes, relationships: rels } = (body?.data || {});
|
|
637
711
|
if (!type)
|
|
638
712
|
return 400; // Bad request
|
|
@@ -871,9 +945,28 @@ export default class OrmRequest extends Request {
|
|
|
871
945
|
}
|
|
872
946
|
return 403;
|
|
873
947
|
}
|
|
874
|
-
|
|
948
|
+
// The filter is built HERE, per invocation, and never hoisted into the
|
|
949
|
+
// OrmRequest constructor where the other per-mount values live: a verdict
|
|
950
|
+
// cached across requests answers a second caller with the first caller's
|
|
951
|
+
// authorization (src/access-verdict.ts says so at the constructor an
|
|
952
|
+
// implementer would reach for).
|
|
953
|
+
//
|
|
954
|
+
// AND IT IS BUILT AFTER `createRecord`, AFTER THE ROLLBACK WINDOW AND
|
|
955
|
+
// AFTER `isDenied`, so the record is in its final form at the call. The
|
|
956
|
+
// filter is lazy per type and per (type, id), so it cannot observe a
|
|
957
|
+
// pre-write state even if it were built earlier.
|
|
958
|
+
//
|
|
959
|
+
// `fields` is passed here and NOT in `updateHandler`: the two handlers
|
|
960
|
+
// are asymmetric on purpose (`updateHandler` has no `fieldsMap` in
|
|
961
|
+
// scope), and a single copy-pasted wiring would drop it from one of them.
|
|
962
|
+
return { data: record.toJSON?.({ fields: modelFields, linkage: createLinkageFilter(request) }) };
|
|
875
963
|
};
|
|
876
|
-
const updateHandler = async (
|
|
964
|
+
const updateHandler = async (request, { filter }) => {
|
|
965
|
+
// Bound rather than destructured, for the reason given in
|
|
966
|
+
// `createHandler` above (abofs/stonyx-orm#235). `PATCH /animals/1`
|
|
967
|
+
// returned 200 naming angela seconds after `GET /animals/1` returned
|
|
968
|
+
// `owner.data: null` for the same record -- one HTTP verb apart.
|
|
969
|
+
const { body, params } = request;
|
|
877
970
|
const found = await store.find(model, getId(params));
|
|
878
971
|
if (!found || !isOrmRecord(found))
|
|
879
972
|
return 404;
|
|
@@ -930,7 +1023,14 @@ export default class OrmRequest extends Request {
|
|
|
930
1023
|
updateRecord(record, relUpdates, { _skipAutoPersist: true });
|
|
931
1024
|
}
|
|
932
1025
|
}
|
|
933
|
-
|
|
1026
|
+
// No `fields` and no `baseUrl`, both unchanged: `updateHandler` has no
|
|
1027
|
+
// `fieldsMap` in scope, and adding `baseUrl` would put `links` on a
|
|
1028
|
+
// document that has never carried them -- an unrelated behaviour change.
|
|
1029
|
+
// #224 AC6's "emits `data: []` WITH links" is a statement about the READ
|
|
1030
|
+
// surfaces; on these two handlers a filtered relationship and a
|
|
1031
|
+
// genuinely-empty one are both a bare `{ data }`, which is what makes
|
|
1032
|
+
// them indistinguishable here too.
|
|
1033
|
+
return { data: record.toJSON?.({ linkage: createLinkageFilter(request) }) };
|
|
934
1034
|
};
|
|
935
1035
|
const deleteHandler = async ({ params }, { filter }) => {
|
|
936
1036
|
// Coerced ONCE. `getId(params)` was evaluated twice here -- once to find
|
|
@@ -1214,15 +1314,21 @@ export default class OrmRequest extends Request {
|
|
|
1214
1314
|
return 404;
|
|
1215
1315
|
const relatedData = record.__relationships[relationshipName];
|
|
1216
1316
|
const baseUrl = getBaseUrl(request);
|
|
1317
|
+
// LINKAGE ONLY. This filter decides which ids the emitted documents may
|
|
1318
|
+
// NAME in their own `relationships.*.data`; it does NOT decide whether
|
|
1319
|
+
// the related records themselves are served -- that is the parent-only
|
|
1320
|
+
// filtering this route has done since #190, and widening it to the
|
|
1321
|
+
// related record is abofs/stonyx-orm#196.
|
|
1322
|
+
const linkage = createLinkageFilter(request);
|
|
1217
1323
|
let data;
|
|
1218
1324
|
if (info.isArray) {
|
|
1219
1325
|
// hasMany - return array
|
|
1220
1326
|
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
1221
|
-
data = related.map(r => r.toJSON?.({ baseUrl }));
|
|
1327
|
+
data = related.map(r => r.toJSON?.({ baseUrl, linkage }));
|
|
1222
1328
|
}
|
|
1223
1329
|
else {
|
|
1224
1330
|
// belongsTo - return single or null
|
|
1225
|
-
data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl }) : null;
|
|
1331
|
+
data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl, linkage }) : null;
|
|
1226
1332
|
}
|
|
1227
1333
|
return {
|
|
1228
1334
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}/${dasherizedName}` },
|
|
@@ -1230,6 +1336,54 @@ export default class OrmRequest extends Request {
|
|
|
1230
1336
|
};
|
|
1231
1337
|
};
|
|
1232
1338
|
// Relationship linkage route: GET /:id/relationships/{relationship}
|
|
1339
|
+
//
|
|
1340
|
+
// NO `linkage` FILTER FROM abofs/stonyx-orm#235, AND THAT IS A SCOPE
|
|
1341
|
+
// BOUNDARY RATHER THAN AN OVERSIGHT -- abofs/stonyx-orm#232 OWNS THIS
|
|
1342
|
+
// ROUTE, and PR #247 is IN FLIGHT against it in this same sprint. If you
|
|
1343
|
+
// are reading this after #247 landed, the filtering below is #232's and
|
|
1344
|
+
// this note records why it was never #235's to add.
|
|
1345
|
+
//
|
|
1346
|
+
// The three sites #235 does own -- `buildResponse`'s `included`, and the
|
|
1347
|
+
// two write handlers, `POST /:models` and `PATCH /:models/:id` -- all
|
|
1348
|
+
// reach the filter through `record.toJSON()`, which is where the
|
|
1349
|
+
// `linkage` OPTION is applied.
|
|
1350
|
+
//
|
|
1351
|
+
// The related-resource branch above ALSO passes a `linkage` filter, and
|
|
1352
|
+
// it is NOT one of those three: it is abofs/stonyx-orm#234's code and
|
|
1353
|
+
// predates this change. `git diff 8dda5d6..HEAD -- src/orm-request.ts`
|
|
1354
|
+
// leaves that branch byte-unchanged.
|
|
1355
|
+
//
|
|
1356
|
+
// This branch builds its `{ type, id }` objects BY
|
|
1357
|
+
// HAND and never calls `toJSON` at all, so the `linkage` option cannot
|
|
1358
|
+
// reach it -- whatever this route filters, it has to filter itself, which
|
|
1359
|
+
// is precisely why doing so is a separate change with a separate owner.
|
|
1360
|
+
//
|
|
1361
|
+
// It is also a DIFFERENT QUESTION. Everywhere #235 touches, linkage is
|
|
1362
|
+
// metadata ABOUT a document. Here the linkage IS the primary data, so
|
|
1363
|
+
// dropping an entry is a MEMBERSHIP decision about what this route
|
|
1364
|
+
// serves -- the same class as abofs/stonyx-orm#233 and #196, not the
|
|
1365
|
+
// class #234/#235 close. That is why it is absent from #224 §2a's
|
|
1366
|
+
// seven-site inventory.
|
|
1367
|
+
//
|
|
1368
|
+
// MEASURED, so the next person does not re-derive it. Against this
|
|
1369
|
+
// branch's baseline of 1011/0, wiring `createLinkageFilter` into the
|
|
1370
|
+
// belongsTo branch below takes the suite to 1009/2, reddening
|
|
1371
|
+
// `[GUARD] #235 X2` and the
|
|
1372
|
+
// `GET /animals/:id/relationships/owner returns relationship linkage`
|
|
1373
|
+
// test -- the latter is #232's own reproduction, not a regression.
|
|
1374
|
+
//
|
|
1375
|
+
// THE BASELINE IS QUOTED WITH THE RESULT BECAUSE AN EARLIER REVISION OF
|
|
1376
|
+
// THIS COMMENT SAID 993/2 AND SHIPPED IT. This file lands in consumers'
|
|
1377
|
+
// `node_modules`, so a wrong number here is a wrong number in the
|
|
1378
|
+
// published package. 993+2 = 995 is the DEV baseline, carried over from
|
|
1379
|
+
// a branch on which `[GUARD] #235 X2` does not exist. A pass/fail pair
|
|
1380
|
+
// with no baseline beside it cannot be checked by reading, which is how
|
|
1381
|
+
// it survived three artifacts and a review; the qualitative claim was
|
|
1382
|
+
// right the whole time and only the count was wrong.
|
|
1383
|
+
//
|
|
1384
|
+
// `[GUARD] #235 X2` in test/integration/orm-test.ts pins the OWNERSHIP
|
|
1385
|
+
// BOUNDARY here rather than this route's current answer, so that it
|
|
1386
|
+
// survives #247 landing. Read its comment before changing it.
|
|
1233
1387
|
routes[`/:id/relationships/${dasherizedName}`] = async (request, { filter } = {}) => {
|
|
1234
1388
|
const record = await store.find(model, getId(request.params));
|
|
1235
1389
|
if (!record)
|
|
@@ -1376,26 +1530,23 @@ export default class OrmRequest extends Request {
|
|
|
1376
1530
|
log.error?.(`[@stonyx/orm] access() threw for model "${this.model}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
|
|
1377
1531
|
return 403; // Forbidden
|
|
1378
1532
|
}
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
//
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
// granted DELETE. A bare string is one permission, not a grant of all four.
|
|
1391
|
-
const permitted = typeof access === 'string' ? [access] : access;
|
|
1392
|
-
// Anything that is not a permission array by this point -- an object, a
|
|
1393
|
-
// number, a Symbol -- is a consumer mistake, and the only safe reading of a
|
|
1394
|
-
// shape the contract does not define is a denial. Fail CLOSED.
|
|
1395
|
-
if (!Array.isArray(permitted))
|
|
1396
|
-
return 403;
|
|
1397
|
-
if (!permitted.includes(methodAccessMap[request.method]))
|
|
1533
|
+
// THE READING OF THE RETURN SHAPE LIVES IN ONE PLACE (#234).
|
|
1534
|
+
//
|
|
1535
|
+
// It used to be inline here, and it was the only copy, which was fine while
|
|
1536
|
+
// `auth()` was the only thing that had to ask. It is not any more: the
|
|
1537
|
+
// linkage path has to ask model X's predicate about model X's records while
|
|
1538
|
+
// servicing a request routed to model Y, and a second inline copy of these
|
|
1539
|
+
// six branches would be a second authorization vocabulary -- one that can
|
|
1540
|
+
// drift, and that reviewers would have to notice had drifted. The branch
|
|
1541
|
+
// order in `interpretAccess` is this block, moved, not rewritten.
|
|
1542
|
+
const verdict = interpretAccess(access, methodAccessMap[request.method]);
|
|
1543
|
+
if (!verdict.granted)
|
|
1398
1544
|
return 403;
|
|
1545
|
+
// The function return shape is the per-record hook, and `state` is the
|
|
1546
|
+
// whole transport for it: @stonyx/rest-server memoises one state object per
|
|
1547
|
+
// request and hands the same one to `auth()` and to the handler.
|
|
1548
|
+
if (verdict.filter)
|
|
1549
|
+
state.filter = verdict.filter;
|
|
1399
1550
|
return undefined;
|
|
1400
1551
|
}
|
|
1401
1552
|
}
|
package/dist/record.d.ts
CHANGED
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
import type Serializer from './serializer.js';
|
|
2
|
+
import type { LinkageFilter } from './types/orm-types.js';
|
|
2
3
|
interface ToJSONOptions {
|
|
3
4
|
fields?: Set<string>;
|
|
4
5
|
baseUrl?: string;
|
|
6
|
+
/**
|
|
7
|
+
* An ALREADY-RESOLVED linkage decision, supplied by a caller that holds the
|
|
8
|
+
* request (abofs/stonyx-orm#234). Returning `false` for a related record
|
|
9
|
+
* drops that record's `{ type, id }` from `relationships.*.data`.
|
|
10
|
+
*
|
|
11
|
+
* This method APPLIES a verdict; it never RESOLVES one -- see
|
|
12
|
+
* `src/access-verdict.ts` for the two measured reasons it cannot. ABSENT is
|
|
13
|
+
* the default and the default is TODAY'S DOCUMENT, unchanged, because
|
|
14
|
+
* `toJSON` is also the `JSON.stringify` hook and an implicit caller has no
|
|
15
|
+
* syntactic place to pass this (abofs/stonyx-orm#230).
|
|
16
|
+
*
|
|
17
|
+
* ABSENT and UNUSABLE are read differently, and the difference is a security
|
|
18
|
+
* decision -- see the three-way reading at the call site below.
|
|
19
|
+
*/
|
|
20
|
+
linkage?: LinkageFilter;
|
|
5
21
|
}
|
|
6
22
|
interface SerializeOptions {
|
|
7
23
|
update?: boolean;
|
package/dist/record.js
CHANGED
|
@@ -1,7 +1,26 @@
|
|
|
1
1
|
import { store } from '@stonyx/orm';
|
|
2
|
+
import log from 'stonyx/log';
|
|
2
3
|
import { getComputedProperties } from "./serializer.js";
|
|
3
4
|
import { camelCaseToKebabCase } from '@stonyx/utils/string';
|
|
4
5
|
import { getPluralName } from './plural-registry.js';
|
|
6
|
+
/**
|
|
7
|
+
* Name a non-boolean `linkage` return for the one log line that reports it.
|
|
8
|
+
*
|
|
9
|
+
* A thenable is called out BY NAME because it is the shape a consumer produces
|
|
10
|
+
* by accident -- an `async` resolver, or one that returns the promise of an
|
|
11
|
+
* authorization lookup -- and the one whose truthiness silently GRANTED every
|
|
12
|
+
* relationship before the ANSWER was checked (abofs/stonyx-orm#234).
|
|
13
|
+
*/
|
|
14
|
+
function describeNonVerdict(verdict) {
|
|
15
|
+
if (verdict === null)
|
|
16
|
+
return 'null';
|
|
17
|
+
if (Array.isArray(verdict))
|
|
18
|
+
return 'an array';
|
|
19
|
+
if ((typeof verdict === 'object' || typeof verdict === 'function')
|
|
20
|
+
&& typeof verdict.then === 'function')
|
|
21
|
+
return 'a Promise (or other thenable)';
|
|
22
|
+
return `a value of type ${typeof verdict}`;
|
|
23
|
+
}
|
|
5
24
|
export default class Record {
|
|
6
25
|
/** @private */
|
|
7
26
|
__data = {};
|
|
@@ -65,7 +84,13 @@ export default class Record {
|
|
|
65
84
|
toJSON(options = {}) {
|
|
66
85
|
if (!this.__serialized)
|
|
67
86
|
throw new Error('Record must be serialized before being converted to JSON');
|
|
68
|
-
|
|
87
|
+
// DESTRUCTURED FROM A VALUE THAT IS NOT ALWAYS AN OBJECT. `toJSON` is the
|
|
88
|
+
// ECMAScript serialization hook, so `JSON.stringify({ data: record })`
|
|
89
|
+
// arrives here as `toJSON('data')` -- a STRING in the options slot.
|
|
90
|
+
// Destructuring a string yields `undefined` for every key, which is exactly
|
|
91
|
+
// the no-argument default, so the implicit path keeps working and keeps
|
|
92
|
+
// emitting today's document (abofs/stonyx-orm#230).
|
|
93
|
+
const { fields, baseUrl, linkage } = options;
|
|
69
94
|
const { __data: data } = this;
|
|
70
95
|
const modelName = this.__model.__name;
|
|
71
96
|
const pluralizedModelName = getPluralName(modelName);
|
|
@@ -84,12 +109,133 @@ export default class Record {
|
|
|
84
109
|
continue;
|
|
85
110
|
attributes[key] = getter.call(this);
|
|
86
111
|
}
|
|
112
|
+
// `linkage` is a PUBLIC option -- it is on `OrmRecord.toJSON`
|
|
113
|
+
// (src/types/orm-types.ts) and the README tells consumers to pass one -- so
|
|
114
|
+
// it arrives from outside this package, may be ANY value, and whatever it
|
|
115
|
+
// is, it gets INVOKED here. That makes this the trust boundary, and it was
|
|
116
|
+
// the LAX side of one: the internal `createLinkageFilter` coerces and
|
|
117
|
+
// try/catches the consumer predicate it wraps, while this -- the site that
|
|
118
|
+
// consumes the PUBLIC option -- did neither.
|
|
119
|
+
//
|
|
120
|
+
// THREE QUESTIONS. Every wrong answer below was measured, on a two-
|
|
121
|
+
// relationship record, emitting the full pre-#234 document or throwing out
|
|
122
|
+
// of `JSON.stringify`.
|
|
123
|
+
//
|
|
124
|
+
// 1. IS IT SUPPLIED? ABSENT (`undefined`) means no verdict was supplied:
|
|
125
|
+
// emit today's document. Load-bearing and asserted (AC5/AC5b) --
|
|
126
|
+
// `toJSON` is also the `JSON.stringify` hook, so the implicit caller
|
|
127
|
+
// arrives as `toJSON('data')`, a STRING, which destructures to
|
|
128
|
+
// `undefined` here (abofs/stonyx-orm#230).
|
|
129
|
+
//
|
|
130
|
+
// 2. IS ITS SHAPE USABLE? `[object Function]` only, because
|
|
131
|
+
// `typeof x === 'function'` is NOT the question "can this answer a
|
|
132
|
+
// synchronous boolean".
|
|
133
|
+
//
|
|
134
|
+
// A NON-FUNCTION denies. Reading it as absent is what `!linkage ||`
|
|
135
|
+
// did, and a resolver returning `null` because it could not resolve a
|
|
136
|
+
// session is the natural shape of that value and the fail-closed
|
|
137
|
+
// INTENT -- measured, `toJSON({ linkage: null })` emitted the full
|
|
138
|
+
// pre-#234 linkage with no signal, byte-identical to unpatched dev.
|
|
139
|
+
//
|
|
140
|
+
// AN `AsyncFunction`, `GeneratorFunction` or `AsyncGeneratorFunction`
|
|
141
|
+
// denies for that SAME reason, one branch over -- and a `typeof`-only
|
|
142
|
+
// check left the whole defect standing there. `async (type, r) =>
|
|
143
|
+
// false` returns a PROMISE, a promise is TRUTHY, so every relationship
|
|
144
|
+
// was emitted in full with ZERO log, again byte-identical to unpatched
|
|
145
|
+
// dev. An awaited authorization lookup is at least as natural a
|
|
146
|
+
// resolver as a nullish one -- the README's own Consumer Contracts
|
|
147
|
+
// section points consumers at queue payloads and websocket frames,
|
|
148
|
+
// where lookups are routinely awaited -- and it landed on the GRANT
|
|
149
|
+
// side of the same branch the `null` reading closed.
|
|
150
|
+
//
|
|
151
|
+
// 3. IS ITS ANSWER A VERDICT? It must BE a boolean, not merely coerce to
|
|
152
|
+
// one. `Boolean(...)` -- the coercion `createLinkageFilter` applies to
|
|
153
|
+
// a consumer `access()` predicate, whose truthy contract predates this
|
|
154
|
+
// option and is deliberately NOT changed -- is not enough here, and
|
|
155
|
+
// was measured not to be: with `Boolean(...)` plus a try/catch in
|
|
156
|
+
// place, `async () => false`, `function* () {}`,
|
|
157
|
+
// `() => Promise.resolve(false)`, `() => ({})` and `() => 'no'` ALL
|
|
158
|
+
// still emitted the full pre-#234 linkage with no log, because
|
|
159
|
+
// truthiness is what they already had. A non-boolean is a resolver
|
|
160
|
+
// that did not answer, and the only safe reading of a non-answer is a
|
|
161
|
+
// denial.
|
|
162
|
+
//
|
|
163
|
+
// AND IT NEVER THROWS -- which is now true rather than only written down.
|
|
164
|
+
// A throw here escapes the enclosing `JSON.stringify` and takes
|
|
165
|
+
// `console.log` and `Orm.db.save()`'s neighbours with it, a far worse
|
|
166
|
+
// failure mode than a status. `class Klass {}`, `Klass.bind(null)` and any
|
|
167
|
+
// predicate that dereferences something undefined were all measured raising
|
|
168
|
+
// out of the `stringify`; all three are caught and denied.
|
|
169
|
+
//
|
|
170
|
+
// Logged once per DOCUMENT, not once per relationship key or per related
|
|
171
|
+
// record: an emptied relationship is deliberately indistinguishable from a
|
|
172
|
+
// genuinely empty one on the wire, so the log is the ONLY signal a consumer
|
|
173
|
+
// whose resolver quietly returned `null`, or a promise, will ever get.
|
|
174
|
+
const linkageSupplied = linkage !== undefined;
|
|
175
|
+
// Read the tag DEFENSIVELY. `Object.prototype.toString` consults
|
|
176
|
+
// `Symbol.toStringTag`, so a Proxy with a throwing `get` trap would throw
|
|
177
|
+
// out of the validation whose entire job is that nothing throws.
|
|
178
|
+
let linkageShape = 'a non-function';
|
|
179
|
+
if (typeof linkage === 'function') {
|
|
180
|
+
try {
|
|
181
|
+
linkageShape = Object.prototype.toString.call(linkage);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
linkageShape = '[object Unreadable]';
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const linkageUsable = linkageShape === '[object Function]';
|
|
188
|
+
let linkageReported = false;
|
|
189
|
+
const denyAllLinkage = (reason) => {
|
|
190
|
+
if (linkageReported)
|
|
191
|
+
return;
|
|
192
|
+
linkageReported = true;
|
|
193
|
+
log.error?.(`[@stonyx/orm] toJSON() received an unusable \`linkage\` option -- ${reason}, so ALL relationship linkage on this \`${modelName}\` document is denied.`);
|
|
194
|
+
};
|
|
195
|
+
if (linkageSupplied && !linkageUsable) {
|
|
196
|
+
denyAllLinkage(typeof linkage !== 'function'
|
|
197
|
+
? `it is of type ${linkage === null ? 'null' : typeof linkage} and it must be a function`
|
|
198
|
+
: `it is ${linkageShape} and it must be a SYNCHRONOUS function -- \`toJSON\` is the \`JSON.stringify\` hook and cannot await a verdict`);
|
|
199
|
+
}
|
|
200
|
+
const linkageVerdict = !linkageSupplied
|
|
201
|
+
? undefined
|
|
202
|
+
: linkageUsable ? linkage : () => false;
|
|
203
|
+
// Applied per related record, alongside the existing `__model` liveness
|
|
204
|
+
// check, and producing exactly the shapes that check already produces: a
|
|
205
|
+
// dropped hasMany member leaves `data: []`, a dropped belongsTo leaves
|
|
206
|
+
// `data: null`. Both already ship -- a genuinely-empty hasMany emits
|
|
207
|
+
// `data: []` with links, and a cleaned belongsTo emits `data: null` -- so a
|
|
208
|
+
// filtered relationship is BYTE-IDENTICAL to an empty one and there is no
|
|
209
|
+
// new wire shape and no oracle.
|
|
210
|
+
const isLinkable = (r) => {
|
|
211
|
+
if (!linkageVerdict)
|
|
212
|
+
return true;
|
|
213
|
+
try {
|
|
214
|
+
const verdict = linkageVerdict(r.__model.__name, r);
|
|
215
|
+
if (typeof verdict === 'boolean')
|
|
216
|
+
return verdict;
|
|
217
|
+
denyAllLinkage(`it answered with ${describeNonVerdict(verdict)} rather than a boolean`);
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
// Building the report is itself a throw site -- `throw Symbol('x')`
|
|
221
|
+
// makes `String(error)` throw, and a getter on `.message` can throw --
|
|
222
|
+
// and a throw from the reporter would escape the catch that exists so
|
|
223
|
+
// that nothing escapes.
|
|
224
|
+
let detail = 'a value that could not be described';
|
|
225
|
+
try {
|
|
226
|
+
detail = error instanceof Error ? error.message : String(error);
|
|
227
|
+
}
|
|
228
|
+
catch { /* keep the fallback -- the denial matters, the text does not */ }
|
|
229
|
+
denyAllLinkage(`it threw (${detail})`);
|
|
230
|
+
}
|
|
231
|
+
return false;
|
|
232
|
+
};
|
|
87
233
|
for (const [key, childRecord] of Object.entries(this.__relationships)) {
|
|
88
234
|
if (fields && !fields.has(key))
|
|
89
235
|
continue;
|
|
90
236
|
const relationshipData = Array.isArray(childRecord)
|
|
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;
|
|
237
|
+
? childRecord.filter((r) => r?.__model).filter(isLinkable).map((r) => ({ type: r.__model.__name, id: r.id }))
|
|
238
|
+
: (childRecord && childRecord.__model && isLinkable(childRecord)) ? { type: childRecord.__model.__name, id: childRecord.id } : null;
|
|
93
239
|
// Dasherize the key for URL paths (e.g., accessLinks -> access-links)
|
|
94
240
|
const dasherizedKey = camelCaseToKebabCase(key);
|
|
95
241
|
relationships[dasherizedKey] = { data: relationshipData };
|
|
@@ -87,9 +87,18 @@ 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
|
+
*/
|
|
90
98
|
toJSON?(options?: {
|
|
91
99
|
fields?: Set<string>;
|
|
92
100
|
baseUrl?: string;
|
|
101
|
+
linkage?: LinkageFilter;
|
|
93
102
|
}): Record<string, unknown>;
|
|
94
103
|
[key: string]: unknown;
|
|
95
104
|
}
|
|
@@ -358,3 +367,21 @@ export interface AccessContext {
|
|
|
358
367
|
* context gets `TS2554: Expected 2 arguments, but got 1`.
|
|
359
368
|
*/
|
|
360
369
|
export type AccessFunction = (request: unknown, context: AccessContext) => AccessMethod;
|
|
370
|
+
/**
|
|
371
|
+
* A resolved, request-scoped linkage decision: may `record` of model `type` be
|
|
372
|
+
* NAMED, by id, inside another model's document (abofs/stonyx-orm#234)?
|
|
373
|
+
*
|
|
374
|
+
* Arity is `(type, record)` and not `(type, id)` because the per-record filter
|
|
375
|
+
* a consumer returns is handed the RECORD -- this repo's own fixture reads
|
|
376
|
+
* `record.owner?.id`, not just `record.id`. The `(type, id)` pair is the CACHE
|
|
377
|
+
* key inside `createLinkageFilter`, not the input.
|
|
378
|
+
*
|
|
379
|
+
* DECLARED HERE, with the rest of the access vocabulary, and imported by every
|
|
380
|
+
* site that names it. It had three structurally-identical hand-written copies
|
|
381
|
+
* (`access-verdict.ts`, `record.ts`, `OrmRecord.toJSON` below) bridged to each
|
|
382
|
+
* other by nothing, so a drift in nullability or a widening of `type` would
|
|
383
|
+
* have landed on one and not the others -- which is the same "second,
|
|
384
|
+
* unreviewed vocabulary" failure `src/access-verdict.ts` exists to prevent, one
|
|
385
|
+
* level up in the type system.
|
|
386
|
+
*/
|
|
387
|
+
export type LinkageFilter = (type: string, record: unknown) => boolean;
|