@stonyx/orm 0.3.2-beta.157 → 0.3.2-beta.158
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 +202 -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 +57 -23
- 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 +61 -22
- package/src/record.ts +176 -3
- package/src/types/orm-types.ts +28 -1
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',
|
|
@@ -436,6 +437,13 @@ function buildResponse(data, includeParam, recordOrRecords, options = {}) {
|
|
|
436
437
|
return response;
|
|
437
438
|
const includedRecords = collectIncludedRecords(recordOrRecords, includes);
|
|
438
439
|
if (includedRecords.length > 0) {
|
|
440
|
+
// NO `linkage` ARGUMENT, deliberately, and abofs/stonyx-orm#235 owns adding
|
|
441
|
+
// one. Until it does, a PERMITTED record here emits the full pre-#234
|
|
442
|
+
// document: `GET /animals/1?include=owner` filters the primary document's
|
|
443
|
+
// `owner.data` to `null` and then names `owner:angela` in `included`.
|
|
444
|
+
// Whether a resource reaches this array at all is a different question
|
|
445
|
+
// (membership, abofs/stonyx-orm#233) and closing that one does not close
|
|
446
|
+
// this one.
|
|
439
447
|
response.included = includedRecords.map(record => record.toJSON?.({ baseUrl }));
|
|
440
448
|
}
|
|
441
449
|
return response;
|
|
@@ -609,7 +617,13 @@ export default class OrmRequest extends Request {
|
|
|
609
617
|
if (queryFilterPredicate)
|
|
610
618
|
recordsToReturn = recordsToReturn.filter(queryFilterPredicate);
|
|
611
619
|
const baseUrl = getBaseUrl(request);
|
|
612
|
-
|
|
620
|
+
// ONE filter per REQUEST, not one per record: it carries the per-type
|
|
621
|
+
// verdict cache and the per-(type, id) decision cache, and both are
|
|
622
|
+
// worthless if it is rebuilt inside the map. Measured on this exact
|
|
623
|
+
// surface with no `include=`: 48 linkage entries collapse to 7 distinct
|
|
624
|
+
// (type, id) pairs.
|
|
625
|
+
const linkage = createLinkageFilter(request);
|
|
626
|
+
const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl, linkage }));
|
|
613
627
|
return buildResponse(data, request.query?.include, recordsToReturn, {
|
|
614
628
|
links: { self: `${baseUrl}/${pluralizedModel}` },
|
|
615
629
|
baseUrl
|
|
@@ -627,7 +641,24 @@ export default class OrmRequest extends Request {
|
|
|
627
641
|
const fieldsMap = parseFields(request.query);
|
|
628
642
|
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
629
643
|
const baseUrl = getBaseUrl(request);
|
|
630
|
-
|
|
644
|
+
const linkage = createLinkageFilter(request);
|
|
645
|
+
// `buildResponse` is deliberately NOT given the linkage filter, and the
|
|
646
|
+
// residual that leaves is NOT the one #233 owns. Two different questions:
|
|
647
|
+
//
|
|
648
|
+
// - WHETHER A RESOURCE APPEARS in `included` at all is MEMBERSHIP ->
|
|
649
|
+
// abofs/stonyx-orm#233.
|
|
650
|
+
// - What a record already IN `included` may NAME is LINKAGE -- the same
|
|
651
|
+
// question #234 answers for the primary document -- and it is
|
|
652
|
+
// abofs/stonyx-orm#235, which also owns createHandler/updateHandler.
|
|
653
|
+
//
|
|
654
|
+
// The residual, stated so the next reader does not have to derive it:
|
|
655
|
+
// `buildResponse` calls `record.toJSON?.({ baseUrl })` with no `linkage`
|
|
656
|
+
// argument, so a PERMITTED record in `included` emits the full pre-#234
|
|
657
|
+
// document. Measured: `GET /animals/1?include=owner` returns
|
|
658
|
+
// `owner.data: null` on the primary document and `owner:angela` in
|
|
659
|
+
// `included`. One query parameter deep. Only the PRIMARY document's
|
|
660
|
+
// linkage is filtered here.
|
|
661
|
+
return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
|
|
631
662
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
|
|
632
663
|
baseUrl
|
|
633
664
|
});
|
|
@@ -1214,15 +1245,21 @@ export default class OrmRequest extends Request {
|
|
|
1214
1245
|
return 404;
|
|
1215
1246
|
const relatedData = record.__relationships[relationshipName];
|
|
1216
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.
|
|
1253
|
+
const linkage = createLinkageFilter(request);
|
|
1217
1254
|
let data;
|
|
1218
1255
|
if (info.isArray) {
|
|
1219
1256
|
// hasMany - return array
|
|
1220
1257
|
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
1221
|
-
data = related.map(r => r.toJSON?.({ baseUrl }));
|
|
1258
|
+
data = related.map(r => r.toJSON?.({ baseUrl, linkage }));
|
|
1222
1259
|
}
|
|
1223
1260
|
else {
|
|
1224
1261
|
// belongsTo - return single or null
|
|
1225
|
-
data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl }) : null;
|
|
1262
|
+
data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl, linkage }) : null;
|
|
1226
1263
|
}
|
|
1227
1264
|
return {
|
|
1228
1265
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}/${dasherizedName}` },
|
|
@@ -1376,26 +1413,23 @@ export default class OrmRequest extends Request {
|
|
|
1376
1413
|
log.error?.(`[@stonyx/orm] access() threw for model "${this.model}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
|
|
1377
1414
|
return 403; // Forbidden
|
|
1378
1415
|
}
|
|
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]))
|
|
1416
|
+
// THE READING OF THE RETURN SHAPE LIVES IN ONE PLACE (#234).
|
|
1417
|
+
//
|
|
1418
|
+
// It used to be inline here, and it was the only copy, which was fine while
|
|
1419
|
+
// `auth()` was the only thing that had to ask. It is not any more: the
|
|
1420
|
+
// linkage path has to ask model X's predicate about model X's records while
|
|
1421
|
+
// servicing a request routed to model Y, and a second inline copy of these
|
|
1422
|
+
// six branches would be a second authorization vocabulary -- one that can
|
|
1423
|
+
// drift, and that reviewers would have to notice had drifted. The branch
|
|
1424
|
+
// order in `interpretAccess` is this block, moved, not rewritten.
|
|
1425
|
+
const verdict = interpretAccess(access, methodAccessMap[request.method]);
|
|
1426
|
+
if (!verdict.granted)
|
|
1398
1427
|
return 403;
|
|
1428
|
+
// The function return shape is the per-record hook, and `state` is the
|
|
1429
|
+
// whole transport for it: @stonyx/rest-server memoises one state object per
|
|
1430
|
+
// request and hands the same one to `auth()` and to the handler.
|
|
1431
|
+
if (verdict.filter)
|
|
1432
|
+
state.filter = verdict.filter;
|
|
1399
1433
|
return undefined;
|
|
1400
1434
|
}
|
|
1401
1435
|
}
|
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;
|