@stonyx/orm 0.3.2-beta.157 → 0.3.2-beta.159
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +268 -1
- package/dist/access-verdict.d.ts +85 -0
- package/dist/access-verdict.js +284 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +8 -0
- package/dist/orm-request.js +182 -31
- package/dist/record.d.ts +16 -0
- package/dist/record.js +149 -3
- package/dist/types/orm-types.d.ts +27 -0
- package/package.json +1 -1
- package/src/access-verdict.ts +312 -0
- package/src/index.ts +9 -0
- package/src/orm-request.ts +188 -32
- package/src/record.ts +176 -3
- package/src/types/orm-types.ts +28 -1
|
@@ -0,0 +1,312 @@
|
|
|
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
|
+
import type { AccessMethod, AccessOperation, LinkageFilter } from './types/orm-types.js';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The classified reading of one `access()` return value.
|
|
63
|
+
*
|
|
64
|
+
* `granted: false` is a total denial. `granted: true` with no `filter` is an
|
|
65
|
+
* unconditional grant. `granted: true` WITH a filter means "grant, subject to
|
|
66
|
+
* this per-record predicate" -- the function return shape, which is the
|
|
67
|
+
* per-record hook `AccessContext` deliberately does not provide.
|
|
68
|
+
*/
|
|
69
|
+
export interface AccessVerdict {
|
|
70
|
+
granted: boolean;
|
|
71
|
+
filter?: (record: unknown) => boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const DENIED: AccessVerdict = Object.freeze({ granted: false });
|
|
75
|
+
const GRANTED: AccessVerdict = Object.freeze({ granted: true });
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Classify one `access()` return value. Extracted verbatim from `auth()`, which
|
|
79
|
+
* now calls this; the branch ORDER is load-bearing and is preserved exactly.
|
|
80
|
+
*
|
|
81
|
+
* `operation` is the verb being authorised. `undefined` -- reachable, because
|
|
82
|
+
* express delivers HEAD to the GET handler and `methodAccessMap` has no entry
|
|
83
|
+
* for it -- falls through `permitted.includes(undefined)` to a denial, which is
|
|
84
|
+
* the same answer `auth()` gave before the extraction.
|
|
85
|
+
*/
|
|
86
|
+
export function interpretAccess(access: AccessMethod, operation: AccessOperation | undefined): AccessVerdict {
|
|
87
|
+
if (!access) return DENIED;
|
|
88
|
+
|
|
89
|
+
// The function return shape IS the per-record hook. Grant the request and
|
|
90
|
+
// carry the predicate; the caller applies it per record.
|
|
91
|
+
if (typeof access === 'function') return { granted: true, filter: access as (record: unknown) => boolean };
|
|
92
|
+
|
|
93
|
+
if (access === true) return GRANTED;
|
|
94
|
+
|
|
95
|
+
// `AccessMethod` declares `string` legal and it fell through every branch
|
|
96
|
+
// above. A bare string is ONE permission, not a grant of all four -- reading
|
|
97
|
+
// it as a full grant is what once let `return 'read'` authorise DELETE.
|
|
98
|
+
const permitted = typeof access === 'string' ? [access] : access;
|
|
99
|
+
|
|
100
|
+
// Anything that is not a permission array by this point -- an object, a
|
|
101
|
+
// number, a Symbol -- is a consumer mistake, and the only safe reading of a
|
|
102
|
+
// shape the contract does not define is a denial. Fail CLOSED.
|
|
103
|
+
if (!Array.isArray(permitted)) return DENIED;
|
|
104
|
+
if (!permitted.includes(operation as string)) return DENIED;
|
|
105
|
+
|
|
106
|
+
return GRANTED;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve model `type`'s verdict for a read, against the live `request`.
|
|
111
|
+
*
|
|
112
|
+
* Fails closed on both ambiguous inputs:
|
|
113
|
+
*
|
|
114
|
+
* - `getAccess(type)` -> `undefined`. That is NOT "this model is
|
|
115
|
+
* unrestricted". `setup-rest-server` catches an access-class load failure,
|
|
116
|
+
* warns, and publishes whatever PARTIAL map it had, so `undefined` covers
|
|
117
|
+
* both "no access class claims this model" and "the class that claims it
|
|
118
|
+
* failed to load" -- and the caller cannot tell them apart. Deny.
|
|
119
|
+
* - the predicate THROWS. Same reading `auth()` and `isDenied` already use:
|
|
120
|
+
* a throw is a denial, logged, never a 500 and never a grant.
|
|
121
|
+
*
|
|
122
|
+
* NOTE ON CROSS-MODEL ASKS -- READ THIS BEFORE REBASING #232 OR #233 ONTO IT.
|
|
123
|
+
* The predicate is asked about `type` while the request in hand was dispatched
|
|
124
|
+
* to a DIFFERENT model's route. This function makes another model's class
|
|
125
|
+
* REACHABLE and asks it the model-correct question (`{ model: type }`); whether
|
|
126
|
+
* the ANSWER is model-correct is the CONSUMER's, because only a predicate that
|
|
127
|
+
* READS `context.model` can give one. Since #222 this repo's fixture does. A
|
|
128
|
+
* consumer's arity-1 predicate does not, and there is no supported way to tell
|
|
129
|
+
* which kind was resolved (the boot-time arity warning is
|
|
130
|
+
* abofs/stonyx-orm#213/#221, unshipped).
|
|
131
|
+
*
|
|
132
|
+
* BOTH DEGRADATION DIRECTIONS ARE REACHABLE, AND THE SECOND ONE GRANTS. This is
|
|
133
|
+
* measured, not reasoned:
|
|
134
|
+
*
|
|
135
|
+
* - CLOSED. The migrated fixture's surviving `request.path` read means asking
|
|
136
|
+
* the OWNER predicate on a request dispatched to `GET /animals/archived`
|
|
137
|
+
* returns a bare `false` -- a whole-request deny bleeding across models,
|
|
138
|
+
* treated here as "deny this linkage", not as an error. That over-denies a
|
|
139
|
+
* PERMITTED record.
|
|
140
|
+
* - OPEN. An arity-1 predicate -- the shape `setup-rest-server.ts:15-18`
|
|
141
|
+
* still declares valid and the README calls the default in every consumer
|
|
142
|
+
* tree -- identifies its collection from the request, so asked about
|
|
143
|
+
* `owner` on a request dispatched to `/animals` it answers about ANIMALS.
|
|
144
|
+
* Measured against this repo's own fixture with `reg.owner` replaced by an
|
|
145
|
+
* arity-1 predicate that hides angela on `/owners`:
|
|
146
|
+
*
|
|
147
|
+
* GET /owners -> ["gina","michael","bob"] angela hidden, correctly
|
|
148
|
+
* GET /animals -> owners named: [angela, ...] LEAK
|
|
149
|
+
* GET /animals/1 -> owner.data {"type":"owner","id":"angela"}
|
|
150
|
+
*
|
|
151
|
+
* That is byte-for-byte the abofs/stonyx-orm#234 defect, on the surface
|
|
152
|
+
* #234 was filed for, AFTER this fix. It is not a regression -- dev
|
|
153
|
+
* published the same id unconditionally -- and this file cannot close it,
|
|
154
|
+
* because the arity signal is #213/#221. Do NOT write, here or anywhere
|
|
155
|
+
* else, that the cross-model ask degrades closed. The standing rule this
|
|
156
|
+
* paragraph is held to is in docs/project-structure.md.
|
|
157
|
+
*/
|
|
158
|
+
function resolveVerdict(request: unknown, type: string): AccessVerdict {
|
|
159
|
+
const predicate = Orm.instance?.getAccess?.(type);
|
|
160
|
+
if (typeof predicate !== 'function') return DENIED;
|
|
161
|
+
|
|
162
|
+
let access: AccessMethod;
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
// `recordId: null`, AND NOT `request.params.id`. THE TEMPTING WRONG ANSWER
|
|
166
|
+
// IS RIGHT THERE, so this is pinned by assertion as well as by comment --
|
|
167
|
+
// test/unit/linkage-verdict-test.ts, `#234 + #241 -- recordId is null`.
|
|
168
|
+
//
|
|
169
|
+
// `AccessContext.recordId` (src/types/orm-types.ts, abofs/stonyx-orm#236 /
|
|
170
|
+
// #241) means "the record THIS ROUTE WAS ADDRESSED TO, as the store key of
|
|
171
|
+
// the model being authorised", and `null` means "addressed to no record".
|
|
172
|
+
// The id sitting on the request in hand names the PRIMARY record, which
|
|
173
|
+
// belongs to a DIFFERENT model -- `GET /owners/gina` carries
|
|
174
|
+
// `params.id === 'gina'`, and the ask being made HERE is about `animal` or
|
|
175
|
+
// `trait`. Filling this in from the request would hand the related model's
|
|
176
|
+
// predicate an id belonging to another model, which is byte-for-byte the
|
|
177
|
+
// cross-model confusion abofs/stonyx-orm#202 introduced this context to
|
|
178
|
+
// eliminate: the predicate would compare an owner's id against its own
|
|
179
|
+
// records and answer a question nobody asked. There is no record of THIS
|
|
180
|
+
// model addressed by this request, so `null` is the honest value -- the
|
|
181
|
+
// same spelling `auth()` uses for a collection route.
|
|
182
|
+
//
|
|
183
|
+
// NOR ANY RECORD'S OWN ID, WHICH IS THE SECOND-MOST TEMPTING ANSWER. This
|
|
184
|
+
// verdict is resolved ONCE PER TYPE and cached in `byType` below, before
|
|
185
|
+
// any record has been looked at; there is no per-record `AccessContext`
|
|
186
|
+
// built anywhere on this path. Seeding it from the first record of a type
|
|
187
|
+
// would let that record's identity answer for every later record of the
|
|
188
|
+
// same type -- the same "one record's verdict answers for another" defect
|
|
189
|
+
// the `decisions` raw-key argument below exists to prevent, just one level
|
|
190
|
+
// coarser. And it is unnecessary: `AccessContext` deliberately carries no
|
|
191
|
+
// `record` because auth-time and record-time are separate decision points,
|
|
192
|
+
// and the per-record point already receives the WHOLE record, id included,
|
|
193
|
+
// through `verdict.filter(record)`. A predicate that wants a record's id
|
|
194
|
+
// has the contract's own channel for it.
|
|
195
|
+
access = predicate(request, { model: type, operation: 'read', recordId: null });
|
|
196
|
+
} catch (error) {
|
|
197
|
+
log.error?.(`[@stonyx/orm] access() threw while resolving linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
|
|
198
|
+
|
|
199
|
+
return DENIED;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return interpretAccess(access, 'read');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Build a request-scoped linkage filter.
|
|
207
|
+
*
|
|
208
|
+
* TWO CACHES, AND BOTH ARE LOAD-BEARING RATHER THAN AN OPTIMISATION:
|
|
209
|
+
*
|
|
210
|
+
* - one verdict per TYPE. Resolving means CALLING the consumer's `access()`,
|
|
211
|
+
* which is arbitrary code with arbitrary cost and which the module has
|
|
212
|
+
* already had to guard for throwing.
|
|
213
|
+
* - one decision per `(type, id)`. `included` is deduplicated by
|
|
214
|
+
* `buildResponse`; LINKAGE is not deduplicated at all, so it re-asks once
|
|
215
|
+
* per record. Measured on a bare `GET /animals` with no `include=`:
|
|
216
|
+
* 48 linkage entries -> 7 distinct `(type, id)` pairs (owner 20, trait 28),
|
|
217
|
+
* a 6.9x reduction and 41 predicate calls saved.
|
|
218
|
+
*
|
|
219
|
+
* The `(type, id)` cache is a `Map` per type keyed on the RAW id, not on a
|
|
220
|
+
* template-string composite. `Map` compares with SameValueZero, so the numeric
|
|
221
|
+
* id `1` and the string id `'1'` stay DISTINCT, where `` `${type}:${id}` `` --
|
|
222
|
+
* or a bare `String(id)` -- collapses them onto one entry and answers the second
|
|
223
|
+
* record with the first record's verdict.
|
|
224
|
+
*
|
|
225
|
+
* WHAT THAT DOES AND DOES NOT PROTECT. It cannot cross MODELS. `decisions` is
|
|
226
|
+
* already partitioned per type by `byType`, so a composite key inside a per-type
|
|
227
|
+
* map is one-to-one with the raw one and no owner's verdict could ever answer
|
|
228
|
+
* for an animal -- the claim that once stood here. The real exposure is narrower
|
|
229
|
+
* and entirely WITHIN one model: two records of the same type whose ids differ
|
|
230
|
+
* only by JavaScript type, which a per-record predicate may legitimately answer
|
|
231
|
+
* differently about (an id read off a JSON body is a string; the same id
|
|
232
|
+
* assigned by the server is a number). Pinned by unit assertion, because this
|
|
233
|
+
* fixture cannot produce the collision on its own -- `owner` ids are strings and
|
|
234
|
+
* `animal` ids are numbers.
|
|
235
|
+
*
|
|
236
|
+
* SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
|
|
237
|
+
* it -- a verdict cached across requests would answer a second caller with the
|
|
238
|
+
* first caller's authorization.
|
|
239
|
+
*
|
|
240
|
+
* A REQUEST IS REQUIRED, AND ITS ABSENCE IS CHECKED HERE RATHER THAN DELEGATED.
|
|
241
|
+
* This function is EXPORTED (src/index.ts), and the README's Consumer Contracts
|
|
242
|
+
* section points consumers at exactly the contexts that have no live request --
|
|
243
|
+
* a queue payload, a websocket frame, a custom route. Without one there is no
|
|
244
|
+
* caller to authorise against, and this file's header already says so: the
|
|
245
|
+
* shipped sample reads `request.path` and fail-closes when it is absent, so
|
|
246
|
+
* `getAccess('owner')(undefined, ...)` is `false`, while
|
|
247
|
+
* `getAccess('animal')(undefined, ...)` returns a per-record predicate and
|
|
248
|
+
* GRANTS. Measured on this repo's own fixture before this guard existed:
|
|
249
|
+
*
|
|
250
|
+
* createLinkageFilter(undefined | null | {} | 'x' | 0)
|
|
251
|
+
* -> owner=false animal=TRUE trait=TRUE category=TRUE phone-number=TRUE
|
|
252
|
+
*
|
|
253
|
+
* Four of five claimed models granted, with no log, because whether an absent
|
|
254
|
+
* request fails closed was left ENTIRELY to consumer predicates -- and a
|
|
255
|
+
* predicate that ignores its request cannot fail closed on one that is missing.
|
|
256
|
+
* A nullish or primitive `request` therefore denies every model outright and
|
|
257
|
+
* says so once, at construction, so the signal exists even for a caller that
|
|
258
|
+
* goes on to serialize nothing.
|
|
259
|
+
*
|
|
260
|
+
* WHAT THIS CANNOT CHECK: `{}` is an object and passes. There is no request
|
|
261
|
+
* contract this module owns -- `auth()` reads `.method`, the shipped sample
|
|
262
|
+
* reads `.path`, a consumer's reads whatever it likes -- so anything past
|
|
263
|
+
* "is it an object" would be this module inventing a shape for someone else's
|
|
264
|
+
* framework. The residual is documented in the README under Consumer Contracts.
|
|
265
|
+
*/
|
|
266
|
+
export function createLinkageFilter(request: unknown): LinkageFilter {
|
|
267
|
+
if (typeof request !== 'object' || request === null) {
|
|
268
|
+
log.error?.(`[@stonyx/orm] createLinkageFilter() was called with no request (received ${request === null ? 'null' : typeof request}) -- there is no caller to authorise against, so ALL relationship linkage it is asked about is denied.`);
|
|
269
|
+
|
|
270
|
+
return function isLinkable(_type: string, _record: unknown): boolean {
|
|
271
|
+
return false;
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const byType = new Map<string, { verdict: AccessVerdict; decisions: Map<unknown, boolean> }>();
|
|
276
|
+
|
|
277
|
+
return function isLinkable(type: string, record: unknown): boolean {
|
|
278
|
+
let entry = byType.get(type);
|
|
279
|
+
|
|
280
|
+
if (!entry) {
|
|
281
|
+
entry = { verdict: resolveVerdict(request, type), decisions: new Map() };
|
|
282
|
+
byType.set(type, entry);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const { verdict, decisions } = entry;
|
|
286
|
+
|
|
287
|
+
if (!verdict.granted) return false;
|
|
288
|
+
if (!verdict.filter) return true;
|
|
289
|
+
|
|
290
|
+
const id = (record as { id?: unknown } | null)?.id;
|
|
291
|
+
const cached = decisions.get(id);
|
|
292
|
+
if (cached !== undefined) return cached;
|
|
293
|
+
|
|
294
|
+
let allowed: boolean;
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
allowed = Boolean(verdict.filter(record));
|
|
298
|
+
} catch (error) {
|
|
299
|
+
// A predicate that throws is a denial -- the same reading `isDenied` uses
|
|
300
|
+
// one layer down. Logged, because a predicate that throws on every record
|
|
301
|
+
// empties every relationship and, silently, that is indistinguishable
|
|
302
|
+
// from a database with no relationships in it.
|
|
303
|
+
log.error?.(`[@stonyx/orm] access filter threw while filtering linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
|
|
304
|
+
|
|
305
|
+
allowed = false;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
decisions.set(id, allowed);
|
|
309
|
+
|
|
310
|
+
return allowed;
|
|
311
|
+
};
|
|
312
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -28,6 +28,15 @@ export { default } from './main.js';
|
|
|
28
28
|
export { store, relationships } from './main.js';
|
|
29
29
|
export type { PersistErrorDetail } from './main.js';
|
|
30
30
|
export type { AccessContext, AccessFunction, AccessMethod, AccessOperation } from './types/orm-types.js'; // access() contract (#202)
|
|
31
|
+
export type { LinkageFilter } from './types/orm-types.js'; // linkage verdict contract (#234)
|
|
32
|
+
// The request-scoped linkage-verdict factory (#234). PUBLIC on purpose: the
|
|
33
|
+
// README tells a consumer serializing a `Record` outside the REST layer to pass
|
|
34
|
+
// their own resolved `linkage` option, and without an exported factory the only
|
|
35
|
+
// way to follow that advice is to write a SECOND reading of `access()` in
|
|
36
|
+
// consumer code -- the exact "unreviewed second authorization vocabulary" that
|
|
37
|
+
// src/access-verdict.ts exists to prevent, reproduced where no reviewer sees it
|
|
38
|
+
// drift. Give them the one interpreter instead of an invitation to fork it.
|
|
39
|
+
export { createLinkageFilter } from './access-verdict.js';
|
|
31
40
|
export { Model, View, Serializer }; // base classes
|
|
32
41
|
export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
|
|
33
42
|
export { count, avg, sum, min, max }; // aggregate helpers
|
package/src/orm-request.ts
CHANGED
|
@@ -267,8 +267,9 @@ import { getBeforeHooks, getAfterHooks } from './hooks.js';
|
|
|
267
267
|
import type { HookContext } from './hooks.js';
|
|
268
268
|
import config from 'stonyx/config';
|
|
269
269
|
import log from 'stonyx/log';
|
|
270
|
-
import type { OrmRecord, AccessContext, AccessFunction, AccessMethod, AccessOperation } from './types/orm-types.js';
|
|
270
|
+
import type { OrmRecord, AccessContext, AccessFunction, AccessMethod, AccessOperation, LinkageFilter } from './types/orm-types.js';
|
|
271
271
|
import { isOrmRecord, NO_FREE_ID_ERROR } from './utils.js';
|
|
272
|
+
import { interpretAccess, createLinkageFilter } from './access-verdict.js';
|
|
272
273
|
|
|
273
274
|
interface OrmRequest$ extends Request {
|
|
274
275
|
protocol?: string;
|
|
@@ -464,9 +465,9 @@ function buildResponse(
|
|
|
464
465
|
data: unknown,
|
|
465
466
|
includeParam: string | undefined,
|
|
466
467
|
recordOrRecords: OrmRecord | OrmRecord[],
|
|
467
|
-
options: { links?: { [key: string]: string }; baseUrl?: string } = {}
|
|
468
|
+
options: { links?: { [key: string]: string }; baseUrl?: string; linkage?: LinkageFilter } = {}
|
|
468
469
|
): JsonApiResponse {
|
|
469
|
-
const { links, baseUrl } = options;
|
|
470
|
+
const { links, baseUrl, linkage } = options;
|
|
470
471
|
const response: JsonApiResponse = { data };
|
|
471
472
|
|
|
472
473
|
// Add top-level links
|
|
@@ -481,7 +482,53 @@ function buildResponse(
|
|
|
481
482
|
|
|
482
483
|
const includedRecords = collectIncludedRecords(recordOrRecords, includes);
|
|
483
484
|
if (includedRecords.length > 0) {
|
|
484
|
-
|
|
485
|
+
// LINKAGE, NOT MEMBERSHIP -- and the distinction is the whole reason this
|
|
486
|
+
// line is one story's and the line above it is another's
|
|
487
|
+
// (abofs/stonyx-orm#235 and #233 respectively).
|
|
488
|
+
//
|
|
489
|
+
// - WHICH RESOURCES REACH THIS ARRAY is decided by
|
|
490
|
+
// `collectIncludedRecords` on the line above. That is MEMBERSHIP, it is
|
|
491
|
+
// #233's, and it is deliberately untouched here: a hidden owner is
|
|
492
|
+
// still a member of `included` after this change. Pinned green by
|
|
493
|
+
// `[GUARD] #235 X1` so that #235 cannot close #233 incidentally.
|
|
494
|
+
// - WHAT A RECORD ALREADY IN THIS ARRAY MAY NAME in its own
|
|
495
|
+
// `relationships.*.data` is LINKAGE -- the same question #234 answers
|
|
496
|
+
// for the primary document -- and that is what the `linkage` option
|
|
497
|
+
// below decides. Before it, `GET /animals/1?include=owner,owner.pets`
|
|
498
|
+
// filtered the primary document's `owner.data` to `null` and then
|
|
499
|
+
// handed back eight PERMITTED animals in `included` each naming
|
|
500
|
+
// `{"type":"owner","id":"angela"}` -- angela's whole `pets` set,
|
|
501
|
+
// `[1, 3, 7, 10, 11, 15, 17, 20]`. `included` itself is NINE
|
|
502
|
+
// resources there: those eight animals plus the hidden owner, whose
|
|
503
|
+
// membership is #233's and not an animal. Neither #233 nor #234
|
|
504
|
+
// closes that.
|
|
505
|
+
//
|
|
506
|
+
// THE FILTER IS THE CALLER'S, PASSED IN, NOT BUILT HERE. Both call sites
|
|
507
|
+
// already hold one for the primary document, and sharing it is what keeps
|
|
508
|
+
// the per-type verdict cache and the per-(type, id) decision cache alive
|
|
509
|
+
// across the primary document AND the sideload -- one verdict resolution
|
|
510
|
+
// per type for the whole response, pinned by `[GUARD] #235 C1`. Building a
|
|
511
|
+
// fresh filter here would resolve the consumer's `access()` once per
|
|
512
|
+
// included record instead.
|
|
513
|
+
//
|
|
514
|
+
// `linkage` IS OPTIONAL IN THE TYPE AND IS NOT OPTIONAL IN PRACTICE.
|
|
515
|
+
// Stating it precisely because the opposite claim stood here in an earlier
|
|
516
|
+
// draft of this change: BOTH of this function's callers supply a filter
|
|
517
|
+
// (`getCollectionHandler` and `getSingleHandler`, the only two), so the
|
|
518
|
+
// `undefined` branch has no live caller in this module today. It is
|
|
519
|
+
// optional so that omitting it degrades to the PRE-#234 document rather
|
|
520
|
+
// than to a denial -- `Record.toJSON` reads an ABSENT option as "no verdict
|
|
521
|
+
// was supplied" and emits linkage in full.
|
|
522
|
+
//
|
|
523
|
+
// WHAT IT MUST NEVER BE HANDED IS A NON-FUNCTION. `toJSON` does NOT read a
|
|
524
|
+
// non-function as absent: `Object.prototype.toString.call(linkage)` must be
|
|
525
|
+
// `'[object Function]'`, and anything else -- `null`, an `AsyncFunction`,
|
|
526
|
+
// and INCLUDING the primitive `true` -- DENIES every relationship on the
|
|
527
|
+
// document and logs once. `toJSON({ linkage: true })` emits `null` linkage.
|
|
528
|
+
// So do not "simplify" this to a boolean, and do not make it default to
|
|
529
|
+
// `true`: both spellings look like "allow everything" and mean the exact
|
|
530
|
+
// opposite (abofs/stonyx-orm#224).
|
|
531
|
+
response.included = includedRecords.map(record => record.toJSON?.({ baseUrl, linkage }));
|
|
485
532
|
}
|
|
486
533
|
|
|
487
534
|
return response;
|
|
@@ -681,11 +728,22 @@ export default class OrmRequest extends Request {
|
|
|
681
728
|
if (queryFilterPredicate) recordsToReturn = recordsToReturn.filter(queryFilterPredicate as (record: OrmRecord) => boolean);
|
|
682
729
|
|
|
683
730
|
const baseUrl = getBaseUrl(request);
|
|
684
|
-
|
|
731
|
+
|
|
732
|
+
// ONE filter per REQUEST, not one per record: it carries the per-type
|
|
733
|
+
// verdict cache and the per-(type, id) decision cache, and both are
|
|
734
|
+
// worthless if it is rebuilt inside the map. Measured on this exact
|
|
735
|
+
// surface with no `include=`: 48 linkage entries collapse to 7 distinct
|
|
736
|
+
// (type, id) pairs.
|
|
737
|
+
const linkage = createLinkageFilter(request);
|
|
738
|
+
const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl, linkage }));
|
|
685
739
|
|
|
686
740
|
return buildResponse(data, request.query?.include, recordsToReturn, {
|
|
687
741
|
links: { self: `${baseUrl}/${pluralizedModel}` },
|
|
688
|
-
baseUrl
|
|
742
|
+
baseUrl,
|
|
743
|
+
// THE SAME filter object the primary documents above were serialized
|
|
744
|
+
// with, deliberately: it carries the caches, and rebuilding one here
|
|
745
|
+
// would re-resolve every type (abofs/stonyx-orm#235).
|
|
746
|
+
linkage
|
|
689
747
|
});
|
|
690
748
|
};
|
|
691
749
|
|
|
@@ -701,13 +759,31 @@ export default class OrmRequest extends Request {
|
|
|
701
759
|
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
702
760
|
|
|
703
761
|
const baseUrl = getBaseUrl(request);
|
|
704
|
-
|
|
762
|
+
const linkage = createLinkageFilter(request);
|
|
763
|
+
|
|
764
|
+
// `buildResponse` IS given the filter now (abofs/stonyx-orm#235), and it
|
|
765
|
+
// is the SAME object the primary document is serialized with -- one
|
|
766
|
+
// verdict per type for the whole response, sideload included.
|
|
767
|
+
//
|
|
768
|
+
// The boundary that remains, so the next reader does not have to derive
|
|
769
|
+
// it: this closes what a record already in `included` may NAME. WHETHER a
|
|
770
|
+
// resource appears in `included` at all is MEMBERSHIP and it is
|
|
771
|
+
// abofs/stonyx-orm#233's -- a hidden owner is still a member here.
|
|
772
|
+
// Neither question closes the other.
|
|
773
|
+
return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
|
|
705
774
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
|
|
706
|
-
baseUrl
|
|
775
|
+
baseUrl,
|
|
776
|
+
linkage
|
|
707
777
|
});
|
|
708
778
|
};
|
|
709
779
|
|
|
710
|
-
const createHandler: HandlerFn = async (
|
|
780
|
+
const createHandler: HandlerFn = async (request, { filter }) => {
|
|
781
|
+
// BOUND, not destructured (abofs/stonyx-orm#235). `HandlerFn` has always
|
|
782
|
+
// delivered the request as argument one; this handler simply discarded
|
|
783
|
+
// the binding, which is why its response document named ids every read
|
|
784
|
+
// surface withholds. `createLinkageFilter` needs the live request and
|
|
785
|
+
// there is no signature change involved in giving it one.
|
|
786
|
+
const { body, query } = request;
|
|
711
787
|
const { type, id, attributes, relationships: rels } = (body?.data || {}) as {
|
|
712
788
|
type?: string;
|
|
713
789
|
id?: string | number;
|
|
@@ -962,10 +1038,29 @@ export default class OrmRequest extends Request {
|
|
|
962
1038
|
return 403;
|
|
963
1039
|
}
|
|
964
1040
|
|
|
965
|
-
|
|
1041
|
+
// The filter is built HERE, per invocation, and never hoisted into the
|
|
1042
|
+
// OrmRequest constructor where the other per-mount values live: a verdict
|
|
1043
|
+
// cached across requests answers a second caller with the first caller's
|
|
1044
|
+
// authorization (src/access-verdict.ts says so at the constructor an
|
|
1045
|
+
// implementer would reach for).
|
|
1046
|
+
//
|
|
1047
|
+
// AND IT IS BUILT AFTER `createRecord`, AFTER THE ROLLBACK WINDOW AND
|
|
1048
|
+
// AFTER `isDenied`, so the record is in its final form at the call. The
|
|
1049
|
+
// filter is lazy per type and per (type, id), so it cannot observe a
|
|
1050
|
+
// pre-write state even if it were built earlier.
|
|
1051
|
+
//
|
|
1052
|
+
// `fields` is passed here and NOT in `updateHandler`: the two handlers
|
|
1053
|
+
// are asymmetric on purpose (`updateHandler` has no `fieldsMap` in
|
|
1054
|
+
// scope), and a single copy-pasted wiring would drop it from one of them.
|
|
1055
|
+
return { data: record.toJSON?.({ fields: modelFields, linkage: createLinkageFilter(request) }) };
|
|
966
1056
|
};
|
|
967
1057
|
|
|
968
|
-
const updateHandler: HandlerFn = async (
|
|
1058
|
+
const updateHandler: HandlerFn = async (request, { filter }) => {
|
|
1059
|
+
// Bound rather than destructured, for the reason given in
|
|
1060
|
+
// `createHandler` above (abofs/stonyx-orm#235). `PATCH /animals/1`
|
|
1061
|
+
// returned 200 naming angela seconds after `GET /animals/1` returned
|
|
1062
|
+
// `owner.data: null` for the same record -- one HTTP verb apart.
|
|
1063
|
+
const { body, params } = request;
|
|
969
1064
|
const found = await store.find(model, getId(params));
|
|
970
1065
|
if (!found || !isOrmRecord(found)) return 404;
|
|
971
1066
|
// Checked BEFORE any attribute is applied. 404 rather than 403 for the
|
|
@@ -1024,7 +1119,14 @@ export default class OrmRequest extends Request {
|
|
|
1024
1119
|
}
|
|
1025
1120
|
}
|
|
1026
1121
|
|
|
1027
|
-
|
|
1122
|
+
// No `fields` and no `baseUrl`, both unchanged: `updateHandler` has no
|
|
1123
|
+
// `fieldsMap` in scope, and adding `baseUrl` would put `links` on a
|
|
1124
|
+
// document that has never carried them -- an unrelated behaviour change.
|
|
1125
|
+
// #224 AC6's "emits `data: []` WITH links" is a statement about the READ
|
|
1126
|
+
// surfaces; on these two handlers a filtered relationship and a
|
|
1127
|
+
// genuinely-empty one are both a bare `{ data }`, which is what makes
|
|
1128
|
+
// them indistinguishable here too.
|
|
1129
|
+
return { data: record.toJSON?.({ linkage: createLinkageFilter(request) }) };
|
|
1028
1130
|
};
|
|
1029
1131
|
|
|
1030
1132
|
const deleteHandler: HandlerFn = async ({ params }, { filter }) => {
|
|
@@ -1331,14 +1433,21 @@ export default class OrmRequest extends Request {
|
|
|
1331
1433
|
const relatedData = record.__relationships[relationshipName];
|
|
1332
1434
|
const baseUrl = getBaseUrl(request);
|
|
1333
1435
|
|
|
1436
|
+
// LINKAGE ONLY. This filter decides which ids the emitted documents may
|
|
1437
|
+
// NAME in their own `relationships.*.data`; it does NOT decide whether
|
|
1438
|
+
// the related records themselves are served -- that is the parent-only
|
|
1439
|
+
// filtering this route has done since #190, and widening it to the
|
|
1440
|
+
// related record is abofs/stonyx-orm#196.
|
|
1441
|
+
const linkage = createLinkageFilter(request);
|
|
1442
|
+
|
|
1334
1443
|
let data: unknown;
|
|
1335
1444
|
if (info.isArray) {
|
|
1336
1445
|
// hasMany - return array
|
|
1337
1446
|
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
1338
|
-
data = related.map(r => r.toJSON?.({ baseUrl }));
|
|
1447
|
+
data = related.map(r => r.toJSON?.({ baseUrl, linkage }));
|
|
1339
1448
|
} else {
|
|
1340
1449
|
// belongsTo - return single or null
|
|
1341
|
-
data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl }) : null;
|
|
1450
|
+
data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl, linkage }) : null;
|
|
1342
1451
|
}
|
|
1343
1452
|
|
|
1344
1453
|
return {
|
|
@@ -1348,6 +1457,54 @@ export default class OrmRequest extends Request {
|
|
|
1348
1457
|
};
|
|
1349
1458
|
|
|
1350
1459
|
// Relationship linkage route: GET /:id/relationships/{relationship}
|
|
1460
|
+
//
|
|
1461
|
+
// NO `linkage` FILTER FROM abofs/stonyx-orm#235, AND THAT IS A SCOPE
|
|
1462
|
+
// BOUNDARY RATHER THAN AN OVERSIGHT -- abofs/stonyx-orm#232 OWNS THIS
|
|
1463
|
+
// ROUTE, and PR #247 is IN FLIGHT against it in this same sprint. If you
|
|
1464
|
+
// are reading this after #247 landed, the filtering below is #232's and
|
|
1465
|
+
// this note records why it was never #235's to add.
|
|
1466
|
+
//
|
|
1467
|
+
// The three sites #235 does own -- `buildResponse`'s `included`, and the
|
|
1468
|
+
// two write handlers, `POST /:models` and `PATCH /:models/:id` -- all
|
|
1469
|
+
// reach the filter through `record.toJSON()`, which is where the
|
|
1470
|
+
// `linkage` OPTION is applied.
|
|
1471
|
+
//
|
|
1472
|
+
// The related-resource branch above ALSO passes a `linkage` filter, and
|
|
1473
|
+
// it is NOT one of those three: it is abofs/stonyx-orm#234's code and
|
|
1474
|
+
// predates this change. `git diff 8dda5d6..HEAD -- src/orm-request.ts`
|
|
1475
|
+
// leaves that branch byte-unchanged.
|
|
1476
|
+
//
|
|
1477
|
+
// This branch builds its `{ type, id }` objects BY
|
|
1478
|
+
// HAND and never calls `toJSON` at all, so the `linkage` option cannot
|
|
1479
|
+
// reach it -- whatever this route filters, it has to filter itself, which
|
|
1480
|
+
// is precisely why doing so is a separate change with a separate owner.
|
|
1481
|
+
//
|
|
1482
|
+
// It is also a DIFFERENT QUESTION. Everywhere #235 touches, linkage is
|
|
1483
|
+
// metadata ABOUT a document. Here the linkage IS the primary data, so
|
|
1484
|
+
// dropping an entry is a MEMBERSHIP decision about what this route
|
|
1485
|
+
// serves -- the same class as abofs/stonyx-orm#233 and #196, not the
|
|
1486
|
+
// class #234/#235 close. That is why it is absent from #224 §2a's
|
|
1487
|
+
// seven-site inventory.
|
|
1488
|
+
//
|
|
1489
|
+
// MEASURED, so the next person does not re-derive it. Against this
|
|
1490
|
+
// branch's baseline of 1011/0, wiring `createLinkageFilter` into the
|
|
1491
|
+
// belongsTo branch below takes the suite to 1009/2, reddening
|
|
1492
|
+
// `[GUARD] #235 X2` and the
|
|
1493
|
+
// `GET /animals/:id/relationships/owner returns relationship linkage`
|
|
1494
|
+
// test -- the latter is #232's own reproduction, not a regression.
|
|
1495
|
+
//
|
|
1496
|
+
// THE BASELINE IS QUOTED WITH THE RESULT BECAUSE AN EARLIER REVISION OF
|
|
1497
|
+
// THIS COMMENT SAID 993/2 AND SHIPPED IT. This file lands in consumers'
|
|
1498
|
+
// `node_modules`, so a wrong number here is a wrong number in the
|
|
1499
|
+
// published package. 993+2 = 995 is the DEV baseline, carried over from
|
|
1500
|
+
// a branch on which `[GUARD] #235 X2` does not exist. A pass/fail pair
|
|
1501
|
+
// with no baseline beside it cannot be checked by reading, which is how
|
|
1502
|
+
// it survived three artifacts and a review; the qualitative claim was
|
|
1503
|
+
// right the whole time and only the count was wrong.
|
|
1504
|
+
//
|
|
1505
|
+
// `[GUARD] #235 X2` in test/integration/orm-test.ts pins the OWNERSHIP
|
|
1506
|
+
// BOUNDARY here rather than this route's current answer, so that it
|
|
1507
|
+
// survives #247 landing. Read its comment before changing it.
|
|
1351
1508
|
routes[`/:id/relationships/${dasherizedName}`] = async (request: OrmRequest$, { filter }: { [key: string]: unknown } = {}) => {
|
|
1352
1509
|
const record = await store.find(model, getId(request.params)) as OrmRecord | undefined;
|
|
1353
1510
|
if (!record) return 404;
|
|
@@ -1498,24 +1655,23 @@ export default class OrmRequest extends Request {
|
|
|
1498
1655
|
return 403; // Forbidden
|
|
1499
1656
|
}
|
|
1500
1657
|
|
|
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;
|
|
1658
|
+
// THE READING OF THE RETURN SHAPE LIVES IN ONE PLACE (#234).
|
|
1659
|
+
//
|
|
1660
|
+
// It used to be inline here, and it was the only copy, which was fine while
|
|
1661
|
+
// `auth()` was the only thing that had to ask. It is not any more: the
|
|
1662
|
+
// linkage path has to ask model X's predicate about model X's records while
|
|
1663
|
+
// servicing a request routed to model Y, and a second inline copy of these
|
|
1664
|
+
// six branches would be a second authorization vocabulary -- one that can
|
|
1665
|
+
// drift, and that reviewers would have to notice had drifted. The branch
|
|
1666
|
+
// order in `interpretAccess` is this block, moved, not rewritten.
|
|
1667
|
+
const verdict = interpretAccess(access, methodAccessMap[request.method]);
|
|
1668
|
+
|
|
1669
|
+
if (!verdict.granted) return 403;
|
|
1670
|
+
|
|
1671
|
+
// The function return shape is the per-record hook, and `state` is the
|
|
1672
|
+
// whole transport for it: @stonyx/rest-server memoises one state object per
|
|
1673
|
+
// request and hands the same one to `auth()` and to the handler.
|
|
1674
|
+
if (verdict.filter) state.filter = verdict.filter;
|
|
1519
1675
|
|
|
1520
1676
|
return undefined;
|
|
1521
1677
|
}
|