@stonyx/orm 0.3.2-alpha.70 → 0.3.2-alpha.71
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 +218 -120
- package/dist/access-verdict.d.ts +59 -0
- package/dist/access-verdict.js +222 -0
- package/dist/hooks.d.ts +1 -15
- package/dist/index.d.ts +2 -0
- package/dist/index.js +8 -0
- package/dist/orm-request.d.ts +0 -48
- package/dist/orm-request.js +57 -114
- package/dist/record.d.ts +16 -0
- package/dist/record.js +57 -3
- package/dist/types/orm-types.d.ts +27 -95
- package/package.json +1 -1
- package/src/access-verdict.ts +248 -0
- package/src/hooks.ts +1 -15
- package/src/index.ts +9 -0
- package/src/orm-request.ts +61 -113
- package/src/record.ts +76 -3
- package/src/types/orm-types.ts +28 -97
package/src/orm-request.ts
CHANGED
|
@@ -69,15 +69,6 @@
|
|
|
69
69
|
* records under `model: 'owner'`, and the context gives no signal of that
|
|
70
70
|
* (abofs/stonyx-orm#196).
|
|
71
71
|
*
|
|
72
|
-
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237, AND KEPT FOR THE
|
|
73
|
-
* CONSTRAINT IT STATES RATHER THAN AS A DESCRIPTION OF THE CODE. The context
|
|
74
|
-
* now also carries `recordId` -- the DECODED route-parameter id, see
|
|
75
|
-
* `AccessContext.recordId` in ./types/orm-types.ts -- so the fixture's
|
|
76
|
-
* `/archived` deny IS expressible from the context alone, and the shipped
|
|
77
|
-
* sample no longer reads `request.path` at all. Retiring this wording WITH the
|
|
78
|
-
* measurement that retires it, rather than by deletion, is
|
|
79
|
-
* abofs/stonyx-orm#238.
|
|
80
|
-
*
|
|
81
72
|
* `record` IS NOT IN THIS CONTEXT, deliberately. `auth()` runs after route
|
|
82
73
|
* matching but BEFORE any handler executes (`@stonyx/rest-server`
|
|
83
74
|
* `src/request.ts:58-60`), so nothing has been fetched yet -- supplying a
|
|
@@ -167,11 +158,6 @@
|
|
|
167
158
|
* `/archived` SUB-PATH rule is still a string match, and abofs/stonyx-orm#228 is
|
|
168
159
|
* a sixth spelling that gets past it.
|
|
169
160
|
*
|
|
170
|
-
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237: the `/archived` rule is
|
|
171
|
-
* no longer a string match against the request target -- it compares the
|
|
172
|
-
* decoded `recordId` the framework supplies -- and abofs/stonyx-orm#228 is
|
|
173
|
-
* CLOSED. Retirement of this wording: abofs/stonyx-orm#238.
|
|
174
|
-
*
|
|
175
161
|
* An intermediate revision of the sample read `request.baseUrl` -- the mount
|
|
176
162
|
* Express ACTUALLY MATCHED. That closed all five variants (no query string,
|
|
177
163
|
* not mount-relative, unaffected by absolute-form, already carrying the
|
|
@@ -187,26 +173,12 @@
|
|
|
187
173
|
* does not decode, so `GET /owners/%61rchived` steps past it. See the
|
|
188
174
|
* normalisation paragraph below and abofs/stonyx-orm#228.
|
|
189
175
|
*
|
|
190
|
-
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237. Variant 3 lived in that
|
|
191
|
-
* one string comparison, and the comparison is gone: the sample compares the
|
|
192
|
-
* decoded `recordId`. Left standing rather than edited because the same
|
|
193
|
-
* "variant 3 survives" wording sits at four sites -- this header, README.md
|
|
194
|
-
* twice, and test/sample/access/global-access.ts -- three of which SHIP, so
|
|
195
|
-
* retiring one of four leaves the shipped copies contradicting each other.
|
|
196
|
-
* Retiring all four WITH their measurement is abofs/stonyx-orm#238.
|
|
197
|
-
*
|
|
198
176
|
* ONE READ OF ARGUMENT ONE SURVIVES, AND IT MUST: `request.path`. It is
|
|
199
177
|
* mount-relative and query-free, and it is for rules that distinguish SUB-PATHS
|
|
200
178
|
* beneath the mount. The context names which model and which verb, NOT which
|
|
201
179
|
* route, so the sample's `/archived` deny cannot be expressed from the context
|
|
202
180
|
* alone and a context-ONLY rewrite would silently turn that deny into an allow.
|
|
203
181
|
*
|
|
204
|
-
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237: NO read of argument one
|
|
205
|
-
* survives in the shipped sample. `recordId` names WHICH RECORD the route was
|
|
206
|
-
* addressed to, so the `/archived` deny is expressible from the context alone
|
|
207
|
-
* -- and it still must not be dropped; expressible is not optional. Retirement
|
|
208
|
-
* of this wording: abofs/stonyx-orm#238.
|
|
209
|
-
*
|
|
210
182
|
* NORMALISE THE WAY THE ROUTER DOES, AND CASE-FOLDING ALONE IS NOT THAT. The
|
|
211
183
|
* sample lower-cases before comparing, because a matcher stricter than the
|
|
212
184
|
* case-insensitive router can be stepped around. That closes the case gap only.
|
|
@@ -216,20 +188,6 @@
|
|
|
216
188
|
* sample and is tracked as abofs/stonyx-orm#228; the `.toLowerCase()` is not a
|
|
217
189
|
* complete normalisation recipe. Compare record ids at their real case.
|
|
218
190
|
*
|
|
219
|
-
* DO NOT FOLLOW THE PARAGRAPH ABOVE. SUPERSEDED 2026-09-01 BY
|
|
220
|
-
* abofs/stonyx-orm#236/#237, and flagged here rather than merely dated because
|
|
221
|
-
* it is an INSTRUCTION, not a stale observation. `.toLowerCase()` on the access
|
|
222
|
-
* path was measured WRONG IN BOTH DIRECTIONS AT ONCE: with a distinct owner
|
|
223
|
-
* seeded at `ARCHIVED`, `GET /owners/ARCHIVED` was a false DENY on the wrong
|
|
224
|
-
* record and `GET /owners/%41RCHIVED` a false ALLOW on that same record. A
|
|
225
|
-
* record id is a VALUE, not a literal route segment, and express's
|
|
226
|
-
* `case sensitive routing` governs literal segments only. Compare
|
|
227
|
-
* `context.recordId` AS IT ARRIVES: do not case-fold it, do not decode it, do
|
|
228
|
-
* not derive it from `request.path`. `AccessContext.recordId` in
|
|
229
|
-
* ./types/orm-types.ts is the contract and says "Do NOT case-fold it"; the same
|
|
230
|
-
* published tarball ships both files, and THIS paragraph is the one that is
|
|
231
|
-
* wrong. Retiring it WITH its measurement is abofs/stonyx-orm#238.
|
|
232
|
-
*
|
|
233
191
|
* `?? ''` is not a defence. It converts an absent request target into an empty
|
|
234
192
|
* string, which matches no collection, which falls through to the permission
|
|
235
193
|
* array -- a total grant. An input you cannot identify must DENY, and that
|
|
@@ -238,12 +196,6 @@
|
|
|
238
196
|
* argument one. The sample returns `false` for an absent `model` AND for an
|
|
239
197
|
* absent or non-string `request.path`, rather than falling through either way.
|
|
240
198
|
*
|
|
241
|
-
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237 as to WHAT is guarded --
|
|
242
|
-
* the principle is unchanged. The sample no longer reads `request.path`, so it
|
|
243
|
-
* returns `false` for an absent `model` AND for an absent `recordId`
|
|
244
|
-
* (`undefined`, the one spelling `auth()` never produces). Retirement of this
|
|
245
|
-
* wording: abofs/stonyx-orm#238.
|
|
246
|
-
*
|
|
247
199
|
* THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
|
|
248
200
|
* the operation and the record. Prefer the array shape (`['read']`) or `false`
|
|
249
201
|
* until #202 lands; the function shape is what requires any matching at all.
|
|
@@ -269,6 +221,7 @@ import config from 'stonyx/config';
|
|
|
269
221
|
import log from 'stonyx/log';
|
|
270
222
|
import type { OrmRecord, AccessContext, AccessFunction, AccessMethod, AccessOperation } from './types/orm-types.js';
|
|
271
223
|
import { isOrmRecord, NO_FREE_ID_ERROR } from './utils.js';
|
|
224
|
+
import { interpretAccess, createLinkageFilter } from './access-verdict.js';
|
|
272
225
|
|
|
273
226
|
interface OrmRequest$ extends Request {
|
|
274
227
|
protocol?: string;
|
|
@@ -481,6 +434,13 @@ function buildResponse(
|
|
|
481
434
|
|
|
482
435
|
const includedRecords = collectIncludedRecords(recordOrRecords, includes);
|
|
483
436
|
if (includedRecords.length > 0) {
|
|
437
|
+
// NO `linkage` ARGUMENT, deliberately, and abofs/stonyx-orm#235 owns adding
|
|
438
|
+
// one. Until it does, a PERMITTED record here emits the full pre-#234
|
|
439
|
+
// document: `GET /animals/1?include=owner` filters the primary document's
|
|
440
|
+
// `owner.data` to `null` and then names `owner:angela` in `included`.
|
|
441
|
+
// Whether a resource reaches this array at all is a different question
|
|
442
|
+
// (membership, abofs/stonyx-orm#233) and closing that one does not close
|
|
443
|
+
// this one.
|
|
484
444
|
response.included = includedRecords.map(record => record.toJSON?.({ baseUrl }));
|
|
485
445
|
}
|
|
486
446
|
|
|
@@ -681,7 +641,14 @@ export default class OrmRequest extends Request {
|
|
|
681
641
|
if (queryFilterPredicate) recordsToReturn = recordsToReturn.filter(queryFilterPredicate as (record: OrmRecord) => boolean);
|
|
682
642
|
|
|
683
643
|
const baseUrl = getBaseUrl(request);
|
|
684
|
-
|
|
644
|
+
|
|
645
|
+
// ONE filter per REQUEST, not one per record: it carries the per-type
|
|
646
|
+
// verdict cache and the per-(type, id) decision cache, and both are
|
|
647
|
+
// worthless if it is rebuilt inside the map. Measured on this exact
|
|
648
|
+
// surface with no `include=`: 48 linkage entries collapse to 7 distinct
|
|
649
|
+
// (type, id) pairs.
|
|
650
|
+
const linkage = createLinkageFilter(request);
|
|
651
|
+
const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl, linkage }));
|
|
685
652
|
|
|
686
653
|
return buildResponse(data, request.query?.include, recordsToReturn, {
|
|
687
654
|
links: { self: `${baseUrl}/${pluralizedModel}` },
|
|
@@ -701,7 +668,25 @@ export default class OrmRequest extends Request {
|
|
|
701
668
|
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
702
669
|
|
|
703
670
|
const baseUrl = getBaseUrl(request);
|
|
704
|
-
|
|
671
|
+
const linkage = createLinkageFilter(request);
|
|
672
|
+
|
|
673
|
+
// `buildResponse` is deliberately NOT given the linkage filter, and the
|
|
674
|
+
// residual that leaves is NOT the one #233 owns. Two different questions:
|
|
675
|
+
//
|
|
676
|
+
// - WHETHER A RESOURCE APPEARS in `included` at all is MEMBERSHIP ->
|
|
677
|
+
// abofs/stonyx-orm#233.
|
|
678
|
+
// - What a record already IN `included` may NAME is LINKAGE -- the same
|
|
679
|
+
// question #234 answers for the primary document -- and it is
|
|
680
|
+
// abofs/stonyx-orm#235, which also owns createHandler/updateHandler.
|
|
681
|
+
//
|
|
682
|
+
// The residual, stated so the next reader does not have to derive it:
|
|
683
|
+
// `buildResponse` calls `record.toJSON?.({ baseUrl })` with no `linkage`
|
|
684
|
+
// argument, so a PERMITTED record in `included` emits the full pre-#234
|
|
685
|
+
// document. Measured: `GET /animals/1?include=owner` returns
|
|
686
|
+
// `owner.data: null` on the primary document and `owner:angela` in
|
|
687
|
+
// `included`. One query parameter deep. Only the PRIMARY document's
|
|
688
|
+
// linkage is filtered here.
|
|
689
|
+
return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
|
|
705
690
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
|
|
706
691
|
baseUrl
|
|
707
692
|
});
|
|
@@ -1331,14 +1316,21 @@ export default class OrmRequest extends Request {
|
|
|
1331
1316
|
const relatedData = record.__relationships[relationshipName];
|
|
1332
1317
|
const baseUrl = getBaseUrl(request);
|
|
1333
1318
|
|
|
1319
|
+
// LINKAGE ONLY. This filter decides which ids the emitted documents may
|
|
1320
|
+
// NAME in their own `relationships.*.data`; it does NOT decide whether
|
|
1321
|
+
// the related records themselves are served -- that is the parent-only
|
|
1322
|
+
// filtering this route has done since #190, and widening it to the
|
|
1323
|
+
// related record is abofs/stonyx-orm#196.
|
|
1324
|
+
const linkage = createLinkageFilter(request);
|
|
1325
|
+
|
|
1334
1326
|
let data: unknown;
|
|
1335
1327
|
if (info.isArray) {
|
|
1336
1328
|
// hasMany - return array
|
|
1337
1329
|
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
1338
|
-
data = related.map(r => r.toJSON?.({ baseUrl }));
|
|
1330
|
+
data = related.map(r => r.toJSON?.({ baseUrl, linkage }));
|
|
1339
1331
|
} else {
|
|
1340
1332
|
// belongsTo - return single or null
|
|
1341
|
-
data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl }) : null;
|
|
1333
|
+
data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl, linkage }) : null;
|
|
1342
1334
|
}
|
|
1343
1335
|
|
|
1344
1336
|
return {
|
|
@@ -1437,53 +1429,10 @@ export default class OrmRequest extends Request {
|
|
|
1437
1429
|
// src/types/orm-types.ts. Nothing is fetched at this point and adding a
|
|
1438
1430
|
// lookup here would put a store read in the middle of an authorization
|
|
1439
1431
|
// path. The function return shape below IS the per-record hook.
|
|
1440
|
-
//
|
|
1441
|
-
// -------------------------------------------------------------------------
|
|
1442
|
-
// #236 -- `recordId`, the DECODED route-parameter id, for the same reason.
|
|
1443
|
-
//
|
|
1444
|
-
// WHICH RECORD is the third structural fact the framework already holds and
|
|
1445
|
-
// the consumer was left to re-derive, and re-deriving it failed OPEN. The
|
|
1446
|
-
// documented sample compared `request.path` -- the RAW, undecoded pathname
|
|
1447
|
-
// -- against a literal `/archived`, while the router DECODES `:id`. So
|
|
1448
|
-
// `GET /owners/%61rchived` walked past the deny and was dispatched as the
|
|
1449
|
-
// record `archived`: 200 with the record in full, and DELETE answered 204
|
|
1450
|
-
// with the record destroyed, unauthenticated. Four spellings measured, all
|
|
1451
|
-
// four through; 255 non-canonical spellings of that 8-character id decode
|
|
1452
|
-
// to the same key, so this was never a deny-list of one.
|
|
1453
|
-
//
|
|
1454
|
-
// TWO CONSUMER-SIDE NORMALISATIONS WERE MEASURED WRONG IN OPPOSITE
|
|
1455
|
-
// DIRECTIONS, which is the argument for doing it once, here.
|
|
1456
|
-
// `.toLowerCase()` case-folds a route-parameter VALUE on the axis that
|
|
1457
|
-
// governs literal SEGMENTS: with a distinct owner seeded at `ARCHIVED`,
|
|
1458
|
-
// `GET /owners/ARCHIVED` was a false DENY on the wrong record and
|
|
1459
|
-
// `GET /owners/%41RCHIVED` a false ALLOW on that same one.
|
|
1460
|
-
// `decodeURIComponent(request.path)` decodes THEN splits while the router
|
|
1461
|
-
// splits THEN decodes, so it over-denied `/owners/archived%2fx` -- 403 for
|
|
1462
|
-
// a genuinely distinct record. Failing closed there was luck, not design.
|
|
1463
|
-
//
|
|
1464
|
-
// `getId(request.params)` AND NOT `request.params.id`, for exactly the
|
|
1465
|
-
// reason `operation` is a `methodAccessMap` lookup: it is the SAME single
|
|
1466
|
-
// coercion the store lookup one layer down performs, so the predicate and
|
|
1467
|
-
// the dispatch cannot disagree about which record a request addresses.
|
|
1468
|
-
// The raw string would reintroduce that divergence on hex-shaped ids --
|
|
1469
|
-
// `GET /animals/0x2391` looks up record `9105`.
|
|
1470
|
-
//
|
|
1471
|
-
// NOTHING HERE PARSES THE REQUEST TARGET EITHER. `request.params` is what
|
|
1472
|
-
// the router matched, so a mount prefix, an absolute-form target, a query
|
|
1473
|
-
// string or a case-varied mount cannot move this value -- the same
|
|
1474
|
-
// guarantee `model` carries, by the same means.
|
|
1475
|
-
//
|
|
1476
|
-
// `null` and not `undefined` on a collection route, so the KEY IS ALWAYS
|
|
1477
|
-
// PRESENT -- the rule `operation`'s own docblock already establishes. A
|
|
1478
|
-
// context reaching a predicate WITHOUT the key therefore did not come from
|
|
1479
|
-
// here; it was hand-assembled by a caller resolving the predicate through
|
|
1480
|
-
// `Orm.instance.getAccess()`, and that absence stays deniable only because
|
|
1481
|
-
// `auth()` never produces it.
|
|
1482
1432
|
// -------------------------------------------------------------------------
|
|
1483
1433
|
const context: AccessContext = {
|
|
1484
1434
|
model: this.model,
|
|
1485
1435
|
operation: methodAccessMap[request.method],
|
|
1486
|
-
recordId: request.params && 'id' in request.params ? getId(request.params) : null,
|
|
1487
1436
|
};
|
|
1488
1437
|
|
|
1489
1438
|
let access: AccessMethod;
|
|
@@ -1498,24 +1447,23 @@ export default class OrmRequest extends Request {
|
|
|
1498
1447
|
return 403; // Forbidden
|
|
1499
1448
|
}
|
|
1500
1449
|
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
//
|
|
1509
|
-
//
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
//
|
|
1515
|
-
//
|
|
1516
|
-
//
|
|
1517
|
-
if (
|
|
1518
|
-
if (!permitted.includes(methodAccessMap[request.method])) return 403;
|
|
1450
|
+
// THE READING OF THE RETURN SHAPE LIVES IN ONE PLACE (#234).
|
|
1451
|
+
//
|
|
1452
|
+
// It used to be inline here, and it was the only copy, which was fine while
|
|
1453
|
+
// `auth()` was the only thing that had to ask. It is not any more: the
|
|
1454
|
+
// linkage path has to ask model X's predicate about model X's records while
|
|
1455
|
+
// servicing a request routed to model Y, and a second inline copy of these
|
|
1456
|
+
// six branches would be a second authorization vocabulary -- one that can
|
|
1457
|
+
// drift, and that reviewers would have to notice had drifted. The branch
|
|
1458
|
+
// order in `interpretAccess` is this block, moved, not rewritten.
|
|
1459
|
+
const verdict = interpretAccess(access, methodAccessMap[request.method]);
|
|
1460
|
+
|
|
1461
|
+
if (!verdict.granted) return 403;
|
|
1462
|
+
|
|
1463
|
+
// The function return shape is the per-record hook, and `state` is the
|
|
1464
|
+
// whole transport for it: @stonyx/rest-server memoises one state object per
|
|
1465
|
+
// request and hands the same one to `auth()` and to the handler.
|
|
1466
|
+
if (verdict.filter) state.filter = verdict.filter;
|
|
1519
1467
|
|
|
1520
1468
|
return undefined;
|
|
1521
1469
|
}
|
package/src/record.ts
CHANGED
|
@@ -1,12 +1,29 @@
|
|
|
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';
|
|
5
6
|
import type Serializer from './serializer.js';
|
|
7
|
+
import type { LinkageFilter } from './types/orm-types.js';
|
|
6
8
|
|
|
7
9
|
interface ToJSONOptions {
|
|
8
10
|
fields?: Set<string>;
|
|
9
11
|
baseUrl?: string;
|
|
12
|
+
/**
|
|
13
|
+
* An ALREADY-RESOLVED linkage decision, supplied by a caller that holds the
|
|
14
|
+
* request (abofs/stonyx-orm#234). Returning `false` for a related record
|
|
15
|
+
* drops that record's `{ type, id }` from `relationships.*.data`.
|
|
16
|
+
*
|
|
17
|
+
* This method APPLIES a verdict; it never RESOLVES one -- see
|
|
18
|
+
* `src/access-verdict.ts` for the two measured reasons it cannot. ABSENT is
|
|
19
|
+
* the default and the default is TODAY'S DOCUMENT, unchanged, because
|
|
20
|
+
* `toJSON` is also the `JSON.stringify` hook and an implicit caller has no
|
|
21
|
+
* syntactic place to pass this (abofs/stonyx-orm#230).
|
|
22
|
+
*
|
|
23
|
+
* ABSENT and UNUSABLE are read differently, and the difference is a security
|
|
24
|
+
* decision -- see the three-way reading at the call site below.
|
|
25
|
+
*/
|
|
26
|
+
linkage?: LinkageFilter;
|
|
10
27
|
}
|
|
11
28
|
|
|
12
29
|
interface SerializeOptions {
|
|
@@ -116,7 +133,13 @@ export default class Record {
|
|
|
116
133
|
toJSON(options: ToJSONOptions = {}): JSONAPIResult {
|
|
117
134
|
if (!this.__serialized) throw new Error('Record must be serialized before being converted to JSON');
|
|
118
135
|
|
|
119
|
-
|
|
136
|
+
// DESTRUCTURED FROM A VALUE THAT IS NOT ALWAYS AN OBJECT. `toJSON` is the
|
|
137
|
+
// ECMAScript serialization hook, so `JSON.stringify({ data: record })`
|
|
138
|
+
// arrives here as `toJSON('data')` -- a STRING in the options slot.
|
|
139
|
+
// Destructuring a string yields `undefined` for every key, which is exactly
|
|
140
|
+
// the no-argument default, so the implicit path keeps working and keeps
|
|
141
|
+
// emitting today's document (abofs/stonyx-orm#230).
|
|
142
|
+
const { fields, baseUrl, linkage } = options;
|
|
120
143
|
const { __data: data } = this;
|
|
121
144
|
const modelName = this.__model.__name;
|
|
122
145
|
const pluralizedModelName = getPluralName(modelName);
|
|
@@ -135,12 +158,62 @@ export default class Record {
|
|
|
135
158
|
attributes[key] = (getter as () => unknown).call(this);
|
|
136
159
|
}
|
|
137
160
|
|
|
161
|
+
// `linkage` is a PUBLIC option -- it is on `OrmRecord.toJSON`
|
|
162
|
+
// (src/types/orm-types.ts) and the README tells consumers to pass one -- so
|
|
163
|
+
// it arrives from outside this package and may be ANY value. Three
|
|
164
|
+
// readings, and the difference between the second and the third is a
|
|
165
|
+
// security decision:
|
|
166
|
+
//
|
|
167
|
+
// ABSENT (`undefined`). No verdict was supplied. Emit today's document.
|
|
168
|
+
// Load-bearing and asserted (AC5/AC5b): `toJSON` is also the
|
|
169
|
+
// `JSON.stringify` hook, so the implicit caller arrives as
|
|
170
|
+
// `toJSON('data')` -- a STRING, which destructures to `undefined` here
|
|
171
|
+
// (abofs/stonyx-orm#230).
|
|
172
|
+
//
|
|
173
|
+
// A FUNCTION. Apply it per related record.
|
|
174
|
+
//
|
|
175
|
+
// ANYTHING ELSE -- `null`, `0`, `false`, `''`, `true`, a string, an
|
|
176
|
+
// object. DENY, and say so. Neither of the two obvious alternatives is
|
|
177
|
+
// available. Reading it as absent is what `!linkage ||` did, and a
|
|
178
|
+
// resolver returning `null` because it could not resolve a session is the
|
|
179
|
+
// natural shape of that value and the fail-closed INTENT -- measured,
|
|
180
|
+
// `toJSON({ linkage: null })` emitted the full pre-#234 linkage with no
|
|
181
|
+
// signal, byte-identical to unpatched dev. Reading it as a function
|
|
182
|
+
// raises `TypeError: linkage is not a function` out of the enclosing
|
|
183
|
+
// `JSON.stringify` -- measured on `true`, `'x'` and `{}` -- which is
|
|
184
|
+
// exactly the outcome the comment below promises cannot happen.
|
|
185
|
+
//
|
|
186
|
+
// Logged once per DOCUMENT, not once per relationship key or per related
|
|
187
|
+
// record: an emptied relationship is deliberately indistinguishable from a
|
|
188
|
+
// genuinely empty one on the wire, so the log is the only signal a consumer
|
|
189
|
+
// whose resolver silently returned `null` will ever get.
|
|
190
|
+
const linkageSupplied = linkage !== undefined;
|
|
191
|
+
const linkageVerdict: LinkageFilter | undefined = !linkageSupplied
|
|
192
|
+
? undefined
|
|
193
|
+
: typeof linkage === 'function' ? linkage : () => false;
|
|
194
|
+
|
|
195
|
+
if (linkageSupplied && typeof linkage !== 'function') {
|
|
196
|
+
log.error?.(`[@stonyx/orm] toJSON() received a \`linkage\` option of type ${linkage === null ? 'null' : typeof linkage} -- it must be a function, so ALL relationship linkage on this \`${modelName}\` document is denied.`);
|
|
197
|
+
}
|
|
198
|
+
|
|
138
199
|
for (const [key, childRecord] of Object.entries(this.__relationships)) {
|
|
139
200
|
if (fields && !fields.has(key)) continue;
|
|
140
201
|
|
|
202
|
+
// The linkage decision is applied HERE, alongside the existing
|
|
203
|
+
// `__model` liveness check, and it produces exactly the shapes that
|
|
204
|
+
// check already produces: a dropped hasMany member leaves `data: []`,
|
|
205
|
+
// a dropped belongsTo leaves `data: null`. Both already ship -- a
|
|
206
|
+
// genuinely-empty hasMany emits `data: []` with links, and a cleaned
|
|
207
|
+
// belongsTo emits `data: null` -- so a filtered relationship is
|
|
208
|
+
// BYTE-IDENTICAL to an empty one and there is no new wire shape and no
|
|
209
|
+
// oracle. It never throws: a throw here escapes the enclosing
|
|
210
|
+
// `JSON.stringify` and takes `console.log` and `Orm.db.save()`'s
|
|
211
|
+
// neighbours with it, which is a far worse failure mode than a status.
|
|
212
|
+
const isLinkable = (r: Record) => !linkageVerdict || linkageVerdict(r.__model.__name, r);
|
|
213
|
+
|
|
141
214
|
const relationshipData = Array.isArray(childRecord)
|
|
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;
|
|
215
|
+
? childRecord.filter((r: Record) => r?.__model).filter(isLinkable).map((r: Record) => ({ type: r.__model.__name, id: r.id }))
|
|
216
|
+
: (childRecord && (childRecord as Record).__model && isLinkable(childRecord as Record)) ? { type: (childRecord as Record).__model.__name, id: (childRecord as Record).id } : null;
|
|
144
217
|
|
|
145
218
|
// Dasherize the key for URL paths (e.g., accessLinks -> access-links)
|
|
146
219
|
const dasherizedKey = camelCaseToKebabCase(key);
|
package/src/types/orm-types.ts
CHANGED
|
@@ -89,7 +89,15 @@ 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
|
-
|
|
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?: LinkageFilter }): Record<string, unknown>;
|
|
93
101
|
[key: string]: unknown;
|
|
94
102
|
}
|
|
95
103
|
|
|
@@ -252,102 +260,6 @@ export interface AccessContext {
|
|
|
252
260
|
* from one that classified the request and found nothing.
|
|
253
261
|
*/
|
|
254
262
|
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 DISAGREES WITH THE HOOK VOCABULARY, AND NOT ONLY ON THE ABSENCE
|
|
306
|
-
* SPELLING. `HookContext.recordId` (`src/hooks.ts`) is an identically-named
|
|
307
|
-
* key on an identically-shaped context object, which is the exact
|
|
308
|
-
* configuration that makes `operation` fail-open shaped -- a hook sees
|
|
309
|
-
* `'get'` where `access()` sees `'read'`. An earlier revision of THIS
|
|
310
|
-
* docblock asserted the opposite ("here they AGREE... they differ in ONE way
|
|
311
|
-
* and it is the absence spelling"). That was measured false, in the fail-open
|
|
312
|
-
* direction, and it is corrected here rather than deleted.
|
|
313
|
-
*
|
|
314
|
-
* MEASURED over the live dispatch, before-hooks registered for all five
|
|
315
|
-
* operations on one model:
|
|
316
|
-
*
|
|
317
|
-
* before:list key ABSENT ('recordId' in context === false)
|
|
318
|
-
* before:get key ABSENT params={"id":"visible1"}
|
|
319
|
-
* before:create key ABSENT
|
|
320
|
-
* before:update key ABSENT params={"id":"visible2"}
|
|
321
|
-
* before:delete recordId="visible3"
|
|
322
|
-
* after:delete recordId="visible3"
|
|
323
|
-
*
|
|
324
|
-
* `_withHooks` assigns `context.recordId` at exactly TWO sites in
|
|
325
|
-
* `src/orm-request.ts`, and BOTH sit inside an `operation === 'delete'`
|
|
326
|
-
* branch. So the two keys differ in COVERAGE, on four of five operations: on
|
|
327
|
-
* a hook context the key is absent for get, list, create and update, while
|
|
328
|
-
* this key is present on every route `auth()` classifies. The absence
|
|
329
|
-
* spelling is the smaller half of the difference, not the whole of it.
|
|
330
|
-
*
|
|
331
|
-
* AND THAT INVERTS THE ARGUMENT ABOVE WHEN IT IS READ ACROSS THE TWO. Here,
|
|
332
|
-
* a missing `recordId` means "did not come from `auth()`" and is deniable.
|
|
333
|
-
* On a hook context it means "this is a get / list / create / update" -- an
|
|
334
|
-
* ordinary request. A consumer who writes the hook-side half of the same
|
|
335
|
-
* rule --
|
|
336
|
-
*
|
|
337
|
-
* beforeHook('update', 'owner', ctx => ctx.recordId === 'archived' ? 403 : undefined)
|
|
338
|
-
*
|
|
339
|
-
* -- gets a deny that NEVER FIRES: measured, `PATCH /owners/visible2` -> 200,
|
|
340
|
-
* with `ctx.recordId === undefined` while the addressed record sits in
|
|
341
|
-
* `ctx.params`. The hook side is abofs/stonyx-orm#242 and is deliberately not
|
|
342
|
-
* repaired here. A predicate must not read `undefined` here as "collection",
|
|
343
|
-
* and nothing in this contract makes it safe to read the two keys as one key.
|
|
344
|
-
*
|
|
345
|
-
* IT NAMES WHICH RECORD, NOT WHICH SURFACE. `GET /owners/gina`,
|
|
346
|
-
* `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` all
|
|
347
|
-
* carry `recordId: 'gina'`; the related-resource gap is abofs/stonyx-orm#196
|
|
348
|
-
* and is untouched by this key.
|
|
349
|
-
*/
|
|
350
|
-
recordId: string | number | null;
|
|
351
263
|
}
|
|
352
264
|
|
|
353
265
|
/**
|
|
@@ -370,3 +282,22 @@ export interface AccessContext {
|
|
|
370
282
|
* context gets `TS2554: Expected 2 arguments, but got 1`.
|
|
371
283
|
*/
|
|
372
284
|
export type AccessFunction = (request: unknown, context: AccessContext) => AccessMethod;
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* A resolved, request-scoped linkage decision: may `record` of model `type` be
|
|
288
|
+
* NAMED, by id, inside another model's document (abofs/stonyx-orm#234)?
|
|
289
|
+
*
|
|
290
|
+
* Arity is `(type, record)` and not `(type, id)` because the per-record filter
|
|
291
|
+
* a consumer returns is handed the RECORD -- this repo's own fixture reads
|
|
292
|
+
* `record.owner?.id`, not just `record.id`. The `(type, id)` pair is the CACHE
|
|
293
|
+
* key inside `createLinkageFilter`, not the input.
|
|
294
|
+
*
|
|
295
|
+
* DECLARED HERE, with the rest of the access vocabulary, and imported by every
|
|
296
|
+
* site that names it. It had three structurally-identical hand-written copies
|
|
297
|
+
* (`access-verdict.ts`, `record.ts`, `OrmRecord.toJSON` below) bridged to each
|
|
298
|
+
* other by nothing, so a drift in nullability or a widening of `type` would
|
|
299
|
+
* have landed on one and not the others -- which is the same "second,
|
|
300
|
+
* unreviewed vocabulary" failure `src/access-verdict.ts` exists to prevent, one
|
|
301
|
+
* level up in the type system.
|
|
302
|
+
*/
|
|
303
|
+
export type LinkageFilter = (type: string, record: unknown) => boolean;
|