@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
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared access-verdict primitive (abofs/stonyx-orm#234).
|
|
3
|
+
*
|
|
4
|
+
* ---------------------------------------------------------------------------
|
|
5
|
+
* WHY THIS FILE EXISTS: ONE INTERPRETER, NOT TWO
|
|
6
|
+
* ---------------------------------------------------------------------------
|
|
7
|
+
* A consumer `access()` may return six differently-shaped things -- `false`, a
|
|
8
|
+
* bare permission string, a permission array, `true`, a per-record function, or
|
|
9
|
+
* something the contract does not define at all -- and the reading of each one
|
|
10
|
+
* is a security decision. `auth()` has held that reading inline since #190.
|
|
11
|
+
* Every surface that needs to ask "may this caller see model X's record?" needs
|
|
12
|
+
* the SAME reading, or the second copy becomes an unreviewed second
|
|
13
|
+
* authorization vocabulary that answers differently about the same value.
|
|
14
|
+
*
|
|
15
|
+
* So `interpretAccess` is extracted here and `auth()` now calls it. It is the
|
|
16
|
+
* only place a return shape is classified, and abofs/stonyx-orm#232 and #233
|
|
17
|
+
* rebase onto it rather than re-deriving it.
|
|
18
|
+
*
|
|
19
|
+
* ---------------------------------------------------------------------------
|
|
20
|
+
* WHAT A LINKAGE FILTER IS, AND WHY THE CALLER BUILDS IT
|
|
21
|
+
* ---------------------------------------------------------------------------
|
|
22
|
+
* `Record.toJSON()` APPLIES a verdict; it never RESOLVES one. That is not a
|
|
23
|
+
* style choice, it is forced, and it was measured before it was decided:
|
|
24
|
+
*
|
|
25
|
+
* INPUT: origin/dev @ c5f7907, unpatched -> 967 pass / 0 fail
|
|
26
|
+
* INPUT: same + fail-closed resolution INSIDE toJSON() -> 964 pass / 3 fail
|
|
27
|
+
*
|
|
28
|
+
* and all three reds were over-denial of PERMITTED records, not the leak. Two
|
|
29
|
+
* independent reasons:
|
|
30
|
+
*
|
|
31
|
+
* 1. `toJSON()` has no request. The shipped, documented sample reads
|
|
32
|
+
* `request.path` for its `/archived` sub-path rule -- the one read of
|
|
33
|
+
* argument one the README sanctions -- and fail-closes when it is absent.
|
|
34
|
+
* Measured against the live registry:
|
|
35
|
+
*
|
|
36
|
+
* getAccess('owner')(undefined, { model:'owner', operation:'read' }) -> false
|
|
37
|
+
* getAccess('animal')(undefined,{ model:'animal', operation:'read' }) -> [Function]
|
|
38
|
+
*
|
|
39
|
+
* Same predicate object, two models, two different degradation modes,
|
|
40
|
+
* chosen by the consumer. Without a request there is no trustworthy
|
|
41
|
+
* answer to get.
|
|
42
|
+
*
|
|
43
|
+
* 2. `toJSON` is also the `JSON.stringify` hook, so `JSON.stringify({data:
|
|
44
|
+
* record})` calls `record.toJSON('data')` -- a STRING in the options slot.
|
|
45
|
+
* An implicit caller has no syntactic place to pass anything
|
|
46
|
+
* (abofs/stonyx-orm#230). The no-argument document must therefore stay
|
|
47
|
+
* byte-identical to what shipped, which also rules out fail-closed by
|
|
48
|
+
* default: `Orm.instance.accessFunctions` is `{}` in any process that
|
|
49
|
+
* never ran `setup-rest-server` (CLI, SQL-only, unit tests), so a
|
|
50
|
+
* fail-closed default would empty every relationship on every document in
|
|
51
|
+
* processes that have no REST surface to protect.
|
|
52
|
+
*
|
|
53
|
+
* The caller -- which still holds the request -- resolves the predicate,
|
|
54
|
+
* interprets it here, caches the answer, and hands `toJSON()` an already-decided
|
|
55
|
+
* `(type, record) => boolean`.
|
|
56
|
+
*/
|
|
57
|
+
import Orm from '@stonyx/orm';
|
|
58
|
+
import log from 'stonyx/log';
|
|
59
|
+
const DENIED = Object.freeze({ granted: false });
|
|
60
|
+
const GRANTED = Object.freeze({ granted: true });
|
|
61
|
+
/**
|
|
62
|
+
* Classify one `access()` return value. Extracted verbatim from `auth()`, which
|
|
63
|
+
* now calls this; the branch ORDER is load-bearing and is preserved exactly.
|
|
64
|
+
*
|
|
65
|
+
* `operation` is the verb being authorised. `undefined` -- reachable, because
|
|
66
|
+
* express delivers HEAD to the GET handler and `methodAccessMap` has no entry
|
|
67
|
+
* for it -- falls through `permitted.includes(undefined)` to a denial, which is
|
|
68
|
+
* the same answer `auth()` gave before the extraction.
|
|
69
|
+
*/
|
|
70
|
+
export function interpretAccess(access, operation) {
|
|
71
|
+
if (!access)
|
|
72
|
+
return DENIED;
|
|
73
|
+
// The function return shape IS the per-record hook. Grant the request and
|
|
74
|
+
// carry the predicate; the caller applies it per record.
|
|
75
|
+
if (typeof access === 'function')
|
|
76
|
+
return { granted: true, filter: access };
|
|
77
|
+
if (access === true)
|
|
78
|
+
return GRANTED;
|
|
79
|
+
// `AccessMethod` declares `string` legal and it fell through every branch
|
|
80
|
+
// above. A bare string is ONE permission, not a grant of all four -- reading
|
|
81
|
+
// it as a full grant is what once let `return 'read'` authorise DELETE.
|
|
82
|
+
const permitted = typeof access === 'string' ? [access] : access;
|
|
83
|
+
// Anything that is not a permission array by this point -- an object, a
|
|
84
|
+
// number, a Symbol -- is a consumer mistake, and the only safe reading of a
|
|
85
|
+
// shape the contract does not define is a denial. Fail CLOSED.
|
|
86
|
+
if (!Array.isArray(permitted))
|
|
87
|
+
return DENIED;
|
|
88
|
+
if (!permitted.includes(operation))
|
|
89
|
+
return DENIED;
|
|
90
|
+
return GRANTED;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Resolve model `type`'s verdict for a read, against the live `request`.
|
|
94
|
+
*
|
|
95
|
+
* Fails closed on both ambiguous inputs:
|
|
96
|
+
*
|
|
97
|
+
* - `getAccess(type)` -> `undefined`. That is NOT "this model is
|
|
98
|
+
* unrestricted". `setup-rest-server` catches an access-class load failure,
|
|
99
|
+
* warns, and publishes whatever PARTIAL map it had, so `undefined` covers
|
|
100
|
+
* both "no access class claims this model" and "the class that claims it
|
|
101
|
+
* failed to load" -- and the caller cannot tell them apart. Deny.
|
|
102
|
+
* - the predicate THROWS. Same reading `auth()` and `isDenied` already use:
|
|
103
|
+
* a throw is a denial, logged, never a 500 and never a grant.
|
|
104
|
+
*
|
|
105
|
+
* NOTE ON CROSS-MODEL ASKS -- READ THIS BEFORE REBASING #232 OR #233 ONTO IT.
|
|
106
|
+
* The predicate is asked about `type` while the request in hand was dispatched
|
|
107
|
+
* to a DIFFERENT model's route. This function makes another model's class
|
|
108
|
+
* REACHABLE and asks it the model-correct question (`{ model: type }`); whether
|
|
109
|
+
* the ANSWER is model-correct is the CONSUMER's, because only a predicate that
|
|
110
|
+
* READS `context.model` can give one. Since #222 this repo's fixture does. A
|
|
111
|
+
* consumer's arity-1 predicate does not, and there is no supported way to tell
|
|
112
|
+
* which kind was resolved (the boot-time arity warning is
|
|
113
|
+
* abofs/stonyx-orm#213/#221, unshipped).
|
|
114
|
+
*
|
|
115
|
+
* BOTH DEGRADATION DIRECTIONS ARE REACHABLE, AND THE SECOND ONE GRANTS. This is
|
|
116
|
+
* measured, not reasoned:
|
|
117
|
+
*
|
|
118
|
+
* - CLOSED. The migrated fixture's surviving `request.path` read means asking
|
|
119
|
+
* the OWNER predicate on a request dispatched to `GET /animals/archived`
|
|
120
|
+
* returns a bare `false` -- a whole-request deny bleeding across models,
|
|
121
|
+
* treated here as "deny this linkage", not as an error. That over-denies a
|
|
122
|
+
* PERMITTED record.
|
|
123
|
+
* - OPEN. An arity-1 predicate -- the shape `setup-rest-server.ts:15-18`
|
|
124
|
+
* still declares valid and the README calls the default in every consumer
|
|
125
|
+
* tree -- identifies its collection from the request, so asked about
|
|
126
|
+
* `owner` on a request dispatched to `/animals` it answers about ANIMALS.
|
|
127
|
+
* Measured against this repo's own fixture with `reg.owner` replaced by an
|
|
128
|
+
* arity-1 predicate that hides angela on `/owners`:
|
|
129
|
+
*
|
|
130
|
+
* GET /owners -> ["gina","michael","bob"] angela hidden, correctly
|
|
131
|
+
* GET /animals -> owners named: [angela, ...] LEAK
|
|
132
|
+
* GET /animals/1 -> owner.data {"type":"owner","id":"angela"}
|
|
133
|
+
*
|
|
134
|
+
* That is byte-for-byte the abofs/stonyx-orm#234 defect, on the surface
|
|
135
|
+
* #234 was filed for, AFTER this fix. It is not a regression -- dev
|
|
136
|
+
* published the same id unconditionally -- and this file cannot close it,
|
|
137
|
+
* because the arity signal is #213/#221. Do NOT write, here or anywhere
|
|
138
|
+
* else, that the cross-model ask degrades closed. The standing rule this
|
|
139
|
+
* paragraph is held to is in docs/project-structure.md.
|
|
140
|
+
*/
|
|
141
|
+
function resolveVerdict(request, type) {
|
|
142
|
+
const predicate = Orm.instance?.getAccess?.(type);
|
|
143
|
+
if (typeof predicate !== 'function')
|
|
144
|
+
return DENIED;
|
|
145
|
+
let access;
|
|
146
|
+
try {
|
|
147
|
+
access = predicate(request, { model: type, operation: 'read' });
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
log.error?.(`[@stonyx/orm] access() threw while resolving linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
|
|
151
|
+
return DENIED;
|
|
152
|
+
}
|
|
153
|
+
return interpretAccess(access, 'read');
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Build a request-scoped linkage filter.
|
|
157
|
+
*
|
|
158
|
+
* TWO CACHES, AND BOTH ARE LOAD-BEARING RATHER THAN AN OPTIMISATION:
|
|
159
|
+
*
|
|
160
|
+
* - one verdict per TYPE. Resolving means CALLING the consumer's `access()`,
|
|
161
|
+
* which is arbitrary code with arbitrary cost and which the module has
|
|
162
|
+
* already had to guard for throwing.
|
|
163
|
+
* - one decision per `(type, id)`. `included` is deduplicated by
|
|
164
|
+
* `buildResponse`; LINKAGE is not deduplicated at all, so it re-asks once
|
|
165
|
+
* per record. Measured on a bare `GET /animals` with no `include=`:
|
|
166
|
+
* 48 linkage entries -> 7 distinct `(type, id)` pairs (owner 20, trait 28),
|
|
167
|
+
* a 6.9x reduction and 41 predicate calls saved.
|
|
168
|
+
*
|
|
169
|
+
* The `(type, id)` cache is a `Map` per type keyed on the RAW id, not on a
|
|
170
|
+
* template-string composite. `Map` compares with SameValueZero, so the numeric
|
|
171
|
+
* id `1` and the string id `'1'` stay DISTINCT, where `` `${type}:${id}` `` --
|
|
172
|
+
* or a bare `String(id)` -- collapses them onto one entry and answers the second
|
|
173
|
+
* record with the first record's verdict.
|
|
174
|
+
*
|
|
175
|
+
* WHAT THAT DOES AND DOES NOT PROTECT. It cannot cross MODELS. `decisions` is
|
|
176
|
+
* already partitioned per type by `byType`, so a composite key inside a per-type
|
|
177
|
+
* map is one-to-one with the raw one and no owner's verdict could ever answer
|
|
178
|
+
* for an animal -- the claim that once stood here. The real exposure is narrower
|
|
179
|
+
* and entirely WITHIN one model: two records of the same type whose ids differ
|
|
180
|
+
* only by JavaScript type, which a per-record predicate may legitimately answer
|
|
181
|
+
* differently about (an id read off a JSON body is a string; the same id
|
|
182
|
+
* assigned by the server is a number). Pinned by unit assertion, because this
|
|
183
|
+
* fixture cannot produce the collision on its own -- `owner` ids are strings and
|
|
184
|
+
* `animal` ids are numbers.
|
|
185
|
+
*
|
|
186
|
+
* SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
|
|
187
|
+
* it -- a verdict cached across requests would answer a second caller with the
|
|
188
|
+
* first caller's authorization.
|
|
189
|
+
*/
|
|
190
|
+
export function createLinkageFilter(request) {
|
|
191
|
+
const byType = new Map();
|
|
192
|
+
return function isLinkable(type, record) {
|
|
193
|
+
let entry = byType.get(type);
|
|
194
|
+
if (!entry) {
|
|
195
|
+
entry = { verdict: resolveVerdict(request, type), decisions: new Map() };
|
|
196
|
+
byType.set(type, entry);
|
|
197
|
+
}
|
|
198
|
+
const { verdict, decisions } = entry;
|
|
199
|
+
if (!verdict.granted)
|
|
200
|
+
return false;
|
|
201
|
+
if (!verdict.filter)
|
|
202
|
+
return true;
|
|
203
|
+
const id = record?.id;
|
|
204
|
+
const cached = decisions.get(id);
|
|
205
|
+
if (cached !== undefined)
|
|
206
|
+
return cached;
|
|
207
|
+
let allowed;
|
|
208
|
+
try {
|
|
209
|
+
allowed = Boolean(verdict.filter(record));
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
// A predicate that throws is a denial -- the same reading `isDenied` uses
|
|
213
|
+
// one layer down. Logged, because a predicate that throws on every record
|
|
214
|
+
// empties every relationship and, silently, that is indistinguishable
|
|
215
|
+
// from a database with no relationships in it.
|
|
216
|
+
log.error?.(`[@stonyx/orm] access filter threw while filtering linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
|
|
217
|
+
allowed = false;
|
|
218
|
+
}
|
|
219
|
+
decisions.set(id, allowed);
|
|
220
|
+
return allowed;
|
|
221
|
+
};
|
|
222
|
+
}
|
package/dist/hooks.d.ts
CHANGED
|
@@ -20,21 +20,7 @@ export interface HookContext {
|
|
|
20
20
|
state?: Record<string, unknown>;
|
|
21
21
|
/** Previous record state (available in update hooks). */
|
|
22
22
|
oldState?: unknown;
|
|
23
|
-
/**
|
|
24
|
-
* Target record ID for single-record operations.
|
|
25
|
-
*
|
|
26
|
-
* SET ONLY UNDER `delete`. `_withHooks` assigns this key in the two
|
|
27
|
-
* `operation === 'delete'` branches and nowhere else, so on `get`, `list`,
|
|
28
|
-
* `create` and `update` the key is ABSENT -- not `undefined`-valued, absent.
|
|
29
|
-
* A hook rule written as `ctx.recordId === '<id>'` never fires on an update;
|
|
30
|
-
* the addressed id is in `ctx.params`. Tracked as abofs/stonyx-orm#242.
|
|
31
|
-
*
|
|
32
|
-
* @see AccessContext.recordId in ./types/orm-types.ts -- an identically-named
|
|
33
|
-
* key on an identically-shaped context object, and NOT interchangeable with
|
|
34
|
-
* this one: it is present on every route `auth()` classifies, and spells
|
|
35
|
-
* absence as `null` rather than `undefined`. They differ in coverage on four
|
|
36
|
-
* of five operations, not only in the absence spelling.
|
|
37
|
-
*/
|
|
23
|
+
/** Target record ID for single-record operations. */
|
|
38
24
|
recordId?: string | number;
|
|
39
25
|
/** Response data (available in after hooks). */
|
|
40
26
|
response?: unknown;
|
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.d.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.
|
package/dist/orm-request.js
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.
|
|
@@ -267,6 +219,7 @@ import { getBeforeHooks, getAfterHooks } from './hooks.js';
|
|
|
267
219
|
import config from 'stonyx/config';
|
|
268
220
|
import log from 'stonyx/log';
|
|
269
221
|
import { isOrmRecord, NO_FREE_ID_ERROR } from './utils.js';
|
|
222
|
+
import { interpretAccess, createLinkageFilter } from './access-verdict.js';
|
|
270
223
|
const methodAccessMap = {
|
|
271
224
|
GET: 'read',
|
|
272
225
|
POST: 'create',
|
|
@@ -436,6 +389,13 @@ function buildResponse(data, includeParam, recordOrRecords, options = {}) {
|
|
|
436
389
|
return response;
|
|
437
390
|
const includedRecords = collectIncludedRecords(recordOrRecords, includes);
|
|
438
391
|
if (includedRecords.length > 0) {
|
|
392
|
+
// NO `linkage` ARGUMENT, deliberately, and abofs/stonyx-orm#235 owns adding
|
|
393
|
+
// one. Until it does, a PERMITTED record here emits the full pre-#234
|
|
394
|
+
// document: `GET /animals/1?include=owner` filters the primary document's
|
|
395
|
+
// `owner.data` to `null` and then names `owner:angela` in `included`.
|
|
396
|
+
// Whether a resource reaches this array at all is a different question
|
|
397
|
+
// (membership, abofs/stonyx-orm#233) and closing that one does not close
|
|
398
|
+
// this one.
|
|
439
399
|
response.included = includedRecords.map(record => record.toJSON?.({ baseUrl }));
|
|
440
400
|
}
|
|
441
401
|
return response;
|
|
@@ -609,7 +569,13 @@ export default class OrmRequest extends Request {
|
|
|
609
569
|
if (queryFilterPredicate)
|
|
610
570
|
recordsToReturn = recordsToReturn.filter(queryFilterPredicate);
|
|
611
571
|
const baseUrl = getBaseUrl(request);
|
|
612
|
-
|
|
572
|
+
// ONE filter per REQUEST, not one per record: it carries the per-type
|
|
573
|
+
// verdict cache and the per-(type, id) decision cache, and both are
|
|
574
|
+
// worthless if it is rebuilt inside the map. Measured on this exact
|
|
575
|
+
// surface with no `include=`: 48 linkage entries collapse to 7 distinct
|
|
576
|
+
// (type, id) pairs.
|
|
577
|
+
const linkage = createLinkageFilter(request);
|
|
578
|
+
const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl, linkage }));
|
|
613
579
|
return buildResponse(data, request.query?.include, recordsToReturn, {
|
|
614
580
|
links: { self: `${baseUrl}/${pluralizedModel}` },
|
|
615
581
|
baseUrl
|
|
@@ -627,7 +593,24 @@ export default class OrmRequest extends Request {
|
|
|
627
593
|
const fieldsMap = parseFields(request.query);
|
|
628
594
|
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
629
595
|
const baseUrl = getBaseUrl(request);
|
|
630
|
-
|
|
596
|
+
const linkage = createLinkageFilter(request);
|
|
597
|
+
// `buildResponse` is deliberately NOT given the linkage filter, and the
|
|
598
|
+
// residual that leaves is NOT the one #233 owns. Two different questions:
|
|
599
|
+
//
|
|
600
|
+
// - WHETHER A RESOURCE APPEARS in `included` at all is MEMBERSHIP ->
|
|
601
|
+
// abofs/stonyx-orm#233.
|
|
602
|
+
// - What a record already IN `included` may NAME is LINKAGE -- the same
|
|
603
|
+
// question #234 answers for the primary document -- and it is
|
|
604
|
+
// abofs/stonyx-orm#235, which also owns createHandler/updateHandler.
|
|
605
|
+
//
|
|
606
|
+
// The residual, stated so the next reader does not have to derive it:
|
|
607
|
+
// `buildResponse` calls `record.toJSON?.({ baseUrl })` with no `linkage`
|
|
608
|
+
// argument, so a PERMITTED record in `included` emits the full pre-#234
|
|
609
|
+
// document. Measured: `GET /animals/1?include=owner` returns
|
|
610
|
+
// `owner.data: null` on the primary document and `owner:angela` in
|
|
611
|
+
// `included`. One query parameter deep. Only the PRIMARY document's
|
|
612
|
+
// linkage is filtered here.
|
|
613
|
+
return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
|
|
631
614
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
|
|
632
615
|
baseUrl
|
|
633
616
|
});
|
|
@@ -1214,15 +1197,21 @@ export default class OrmRequest extends Request {
|
|
|
1214
1197
|
return 404;
|
|
1215
1198
|
const relatedData = record.__relationships[relationshipName];
|
|
1216
1199
|
const baseUrl = getBaseUrl(request);
|
|
1200
|
+
// LINKAGE ONLY. This filter decides which ids the emitted documents may
|
|
1201
|
+
// NAME in their own `relationships.*.data`; it does NOT decide whether
|
|
1202
|
+
// the related records themselves are served -- that is the parent-only
|
|
1203
|
+
// filtering this route has done since #190, and widening it to the
|
|
1204
|
+
// related record is abofs/stonyx-orm#196.
|
|
1205
|
+
const linkage = createLinkageFilter(request);
|
|
1217
1206
|
let data;
|
|
1218
1207
|
if (info.isArray) {
|
|
1219
1208
|
// hasMany - return array
|
|
1220
1209
|
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
1221
|
-
data = related.map(r => r.toJSON?.({ baseUrl }));
|
|
1210
|
+
data = related.map(r => r.toJSON?.({ baseUrl, linkage }));
|
|
1222
1211
|
}
|
|
1223
1212
|
else {
|
|
1224
1213
|
// belongsTo - return single or null
|
|
1225
|
-
data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl }) : null;
|
|
1214
|
+
data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl, linkage }) : null;
|
|
1226
1215
|
}
|
|
1227
1216
|
return {
|
|
1228
1217
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}/${dasherizedName}` },
|
|
@@ -1317,53 +1306,10 @@ export default class OrmRequest extends Request {
|
|
|
1317
1306
|
// src/types/orm-types.ts. Nothing is fetched at this point and adding a
|
|
1318
1307
|
// lookup here would put a store read in the middle of an authorization
|
|
1319
1308
|
// path. The function return shape below IS the per-record hook.
|
|
1320
|
-
//
|
|
1321
|
-
// -------------------------------------------------------------------------
|
|
1322
|
-
// #236 -- `recordId`, the DECODED route-parameter id, for the same reason.
|
|
1323
|
-
//
|
|
1324
|
-
// WHICH RECORD is the third structural fact the framework already holds and
|
|
1325
|
-
// the consumer was left to re-derive, and re-deriving it failed OPEN. The
|
|
1326
|
-
// documented sample compared `request.path` -- the RAW, undecoded pathname
|
|
1327
|
-
// -- against a literal `/archived`, while the router DECODES `:id`. So
|
|
1328
|
-
// `GET /owners/%61rchived` walked past the deny and was dispatched as the
|
|
1329
|
-
// record `archived`: 200 with the record in full, and DELETE answered 204
|
|
1330
|
-
// with the record destroyed, unauthenticated. Four spellings measured, all
|
|
1331
|
-
// four through; 255 non-canonical spellings of that 8-character id decode
|
|
1332
|
-
// to the same key, so this was never a deny-list of one.
|
|
1333
|
-
//
|
|
1334
|
-
// TWO CONSUMER-SIDE NORMALISATIONS WERE MEASURED WRONG IN OPPOSITE
|
|
1335
|
-
// DIRECTIONS, which is the argument for doing it once, here.
|
|
1336
|
-
// `.toLowerCase()` case-folds a route-parameter VALUE on the axis that
|
|
1337
|
-
// governs literal SEGMENTS: with a distinct owner seeded at `ARCHIVED`,
|
|
1338
|
-
// `GET /owners/ARCHIVED` was a false DENY on the wrong record and
|
|
1339
|
-
// `GET /owners/%41RCHIVED` a false ALLOW on that same one.
|
|
1340
|
-
// `decodeURIComponent(request.path)` decodes THEN splits while the router
|
|
1341
|
-
// splits THEN decodes, so it over-denied `/owners/archived%2fx` -- 403 for
|
|
1342
|
-
// a genuinely distinct record. Failing closed there was luck, not design.
|
|
1343
|
-
//
|
|
1344
|
-
// `getId(request.params)` AND NOT `request.params.id`, for exactly the
|
|
1345
|
-
// reason `operation` is a `methodAccessMap` lookup: it is the SAME single
|
|
1346
|
-
// coercion the store lookup one layer down performs, so the predicate and
|
|
1347
|
-
// the dispatch cannot disagree about which record a request addresses.
|
|
1348
|
-
// The raw string would reintroduce that divergence on hex-shaped ids --
|
|
1349
|
-
// `GET /animals/0x2391` looks up record `9105`.
|
|
1350
|
-
//
|
|
1351
|
-
// NOTHING HERE PARSES THE REQUEST TARGET EITHER. `request.params` is what
|
|
1352
|
-
// the router matched, so a mount prefix, an absolute-form target, a query
|
|
1353
|
-
// string or a case-varied mount cannot move this value -- the same
|
|
1354
|
-
// guarantee `model` carries, by the same means.
|
|
1355
|
-
//
|
|
1356
|
-
// `null` and not `undefined` on a collection route, so the KEY IS ALWAYS
|
|
1357
|
-
// PRESENT -- the rule `operation`'s own docblock already establishes. A
|
|
1358
|
-
// context reaching a predicate WITHOUT the key therefore did not come from
|
|
1359
|
-
// here; it was hand-assembled by a caller resolving the predicate through
|
|
1360
|
-
// `Orm.instance.getAccess()`, and that absence stays deniable only because
|
|
1361
|
-
// `auth()` never produces it.
|
|
1362
1309
|
// -------------------------------------------------------------------------
|
|
1363
1310
|
const context = {
|
|
1364
1311
|
model: this.model,
|
|
1365
1312
|
operation: methodAccessMap[request.method],
|
|
1366
|
-
recordId: request.params && 'id' in request.params ? getId(request.params) : null,
|
|
1367
1313
|
};
|
|
1368
1314
|
let access;
|
|
1369
1315
|
try {
|
|
@@ -1376,26 +1322,23 @@ export default class OrmRequest extends Request {
|
|
|
1376
1322
|
log.error?.(`[@stonyx/orm] access() threw for model "${this.model}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
|
|
1377
1323
|
return 403; // Forbidden
|
|
1378
1324
|
}
|
|
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]))
|
|
1325
|
+
// THE READING OF THE RETURN SHAPE LIVES IN ONE PLACE (#234).
|
|
1326
|
+
//
|
|
1327
|
+
// It used to be inline here, and it was the only copy, which was fine while
|
|
1328
|
+
// `auth()` was the only thing that had to ask. It is not any more: the
|
|
1329
|
+
// linkage path has to ask model X's predicate about model X's records while
|
|
1330
|
+
// servicing a request routed to model Y, and a second inline copy of these
|
|
1331
|
+
// six branches would be a second authorization vocabulary -- one that can
|
|
1332
|
+
// drift, and that reviewers would have to notice had drifted. The branch
|
|
1333
|
+
// order in `interpretAccess` is this block, moved, not rewritten.
|
|
1334
|
+
const verdict = interpretAccess(access, methodAccessMap[request.method]);
|
|
1335
|
+
if (!verdict.granted)
|
|
1398
1336
|
return 403;
|
|
1337
|
+
// The function return shape is the per-record hook, and `state` is the
|
|
1338
|
+
// whole transport for it: @stonyx/rest-server memoises one state object per
|
|
1339
|
+
// request and hands the same one to `auth()` and to the handler.
|
|
1340
|
+
if (verdict.filter)
|
|
1341
|
+
state.filter = verdict.filter;
|
|
1399
1342
|
return undefined;
|
|
1400
1343
|
}
|
|
1401
1344
|
}
|
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;
|