@stonyx/orm 0.3.2-alpha.71 → 0.3.2-alpha.72
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 +36 -6
- package/dist/access-verdict.d.ts +26 -0
- package/dist/access-verdict.js +32 -0
- package/dist/record.js +128 -36
- package/package.json +1 -1
- package/src/access-verdict.ts +34 -0
- package/src/record.ts +137 -37
package/README.md
CHANGED
|
@@ -1061,12 +1061,42 @@ Not this:
|
|
|
1061
1061
|
const linkage = (type, r) => Orm.instance.getAccess(type)?.(request)?.(r) ?? true;
|
|
1062
1062
|
```
|
|
1063
1063
|
|
|
1064
|
-
**`
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1064
|
+
**`createLinkageFilter` requires a live request, and there is no safe call
|
|
1065
|
+
without one.** `request` is the only authorization input the filter has — it is
|
|
1066
|
+
handed straight to your `access()` predicates, and a predicate that does not
|
|
1067
|
+
*read* it cannot fail closed when it is missing. Passing `undefined`, `null` or
|
|
1068
|
+
any non-object therefore denies **all** linkage and logs, once, at construction.
|
|
1069
|
+
Measured before that guard existed, `createLinkageFilter(undefined)` granted
|
|
1070
|
+
four of the five models in this repository's own fixture, silently.
|
|
1071
|
+
|
|
1072
|
+
**This is the catch for the request-less contexts named above.** In a queue
|
|
1073
|
+
consumer or a websocket handler there is no live request, so there is nothing to
|
|
1074
|
+
authorize against and nothing this package can resolve for you. Either carry the
|
|
1075
|
+
originating request through to the point of serialization, or publish no linkage
|
|
1076
|
+
at all — `record.toJSON({ linkage: () => false })` emits the document with every
|
|
1077
|
+
relationship empty. A stand-in is **not** a substitute: `{}` is an object
|
|
1078
|
+
and passes the guard, and any predicate that ignores its request will grant.
|
|
1079
|
+
|
|
1080
|
+
**`linkage` itself is validated, and an unusable value DENIES.** `undefined`
|
|
1081
|
+
means "no verdict supplied" and emits today's document. Anything else must be a
|
|
1082
|
+
**synchronous function that answers with a boolean**. Each of the following
|
|
1083
|
+
drops **all** linkage on that document and logs once:
|
|
1084
|
+
|
|
1085
|
+
- **A non-function** — `null`, `0`, `false`, `''`, `true`, a string, an object.
|
|
1086
|
+
`null` is the natural return of a resolver that could not resolve a session:
|
|
1087
|
+
it used to be read as "absent" and emit the full document silently.
|
|
1088
|
+
- **An `async` function, a generator function, or any predicate that returns a
|
|
1089
|
+
promise or thenable.** `toJSON` is the `JSON.stringify` hook and cannot await
|
|
1090
|
+
a verdict, and **an `async` resolver returns a promise, a promise is
|
|
1091
|
+
truthy**, so every related id was published, silently, exactly as if this fix
|
|
1092
|
+
were not here. If your
|
|
1093
|
+
authorization lookup is asynchronous, `await` it *before* you serialize and
|
|
1094
|
+
close over the result.
|
|
1095
|
+
- **Any answer that is not a boolean** — `{}`, `'no'`, `1`, `undefined`. A
|
|
1096
|
+
non-boolean is a resolver that did not answer, and a truthy one granted.
|
|
1097
|
+
- **A predicate that throws**, including a `class` passed by mistake. It is
|
|
1098
|
+
caught and denied; it used to escape the enclosing `JSON.stringify` and take
|
|
1099
|
+
the rest of that serialization down with it.
|
|
1070
1100
|
|
|
1071
1101
|
#### A predicate that ignores `context.model` makes cross-model resolution GRANT
|
|
1072
1102
|
|
package/dist/access-verdict.d.ts
CHANGED
|
@@ -55,5 +55,31 @@ export declare function interpretAccess(access: AccessMethod, operation: AccessO
|
|
|
55
55
|
* SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
|
|
56
56
|
* it -- a verdict cached across requests would answer a second caller with the
|
|
57
57
|
* first caller's authorization.
|
|
58
|
+
*
|
|
59
|
+
* A REQUEST IS REQUIRED, AND ITS ABSENCE IS CHECKED HERE RATHER THAN DELEGATED.
|
|
60
|
+
* This function is EXPORTED (src/index.ts), and the README's Consumer Contracts
|
|
61
|
+
* section points consumers at exactly the contexts that have no live request --
|
|
62
|
+
* a queue payload, a websocket frame, a custom route. Without one there is no
|
|
63
|
+
* caller to authorise against, and this file's header already says so: the
|
|
64
|
+
* shipped sample reads `request.path` and fail-closes when it is absent, so
|
|
65
|
+
* `getAccess('owner')(undefined, ...)` is `false`, while
|
|
66
|
+
* `getAccess('animal')(undefined, ...)` returns a per-record predicate and
|
|
67
|
+
* GRANTS. Measured on this repo's own fixture before this guard existed:
|
|
68
|
+
*
|
|
69
|
+
* createLinkageFilter(undefined | null | {} | 'x' | 0)
|
|
70
|
+
* -> owner=false animal=TRUE trait=TRUE category=TRUE phone-number=TRUE
|
|
71
|
+
*
|
|
72
|
+
* Four of five claimed models granted, with no log, because whether an absent
|
|
73
|
+
* request fails closed was left ENTIRELY to consumer predicates -- and a
|
|
74
|
+
* predicate that ignores its request cannot fail closed on one that is missing.
|
|
75
|
+
* A nullish or primitive `request` therefore denies every model outright and
|
|
76
|
+
* says so once, at construction, so the signal exists even for a caller that
|
|
77
|
+
* goes on to serialize nothing.
|
|
78
|
+
*
|
|
79
|
+
* WHAT THIS CANNOT CHECK: `{}` is an object and passes. There is no request
|
|
80
|
+
* contract this module owns -- `auth()` reads `.method`, the shipped sample
|
|
81
|
+
* reads `.path`, a consumer's reads whatever it likes -- so anything past
|
|
82
|
+
* "is it an object" would be this module inventing a shape for someone else's
|
|
83
|
+
* framework. The residual is documented in the README under Consumer Contracts.
|
|
58
84
|
*/
|
|
59
85
|
export declare function createLinkageFilter(request: unknown): LinkageFilter;
|
package/dist/access-verdict.js
CHANGED
|
@@ -186,8 +186,40 @@ function resolveVerdict(request, type) {
|
|
|
186
186
|
* SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
|
|
187
187
|
* it -- a verdict cached across requests would answer a second caller with the
|
|
188
188
|
* first caller's authorization.
|
|
189
|
+
*
|
|
190
|
+
* A REQUEST IS REQUIRED, AND ITS ABSENCE IS CHECKED HERE RATHER THAN DELEGATED.
|
|
191
|
+
* This function is EXPORTED (src/index.ts), and the README's Consumer Contracts
|
|
192
|
+
* section points consumers at exactly the contexts that have no live request --
|
|
193
|
+
* a queue payload, a websocket frame, a custom route. Without one there is no
|
|
194
|
+
* caller to authorise against, and this file's header already says so: the
|
|
195
|
+
* shipped sample reads `request.path` and fail-closes when it is absent, so
|
|
196
|
+
* `getAccess('owner')(undefined, ...)` is `false`, while
|
|
197
|
+
* `getAccess('animal')(undefined, ...)` returns a per-record predicate and
|
|
198
|
+
* GRANTS. Measured on this repo's own fixture before this guard existed:
|
|
199
|
+
*
|
|
200
|
+
* createLinkageFilter(undefined | null | {} | 'x' | 0)
|
|
201
|
+
* -> owner=false animal=TRUE trait=TRUE category=TRUE phone-number=TRUE
|
|
202
|
+
*
|
|
203
|
+
* Four of five claimed models granted, with no log, because whether an absent
|
|
204
|
+
* request fails closed was left ENTIRELY to consumer predicates -- and a
|
|
205
|
+
* predicate that ignores its request cannot fail closed on one that is missing.
|
|
206
|
+
* A nullish or primitive `request` therefore denies every model outright and
|
|
207
|
+
* says so once, at construction, so the signal exists even for a caller that
|
|
208
|
+
* goes on to serialize nothing.
|
|
209
|
+
*
|
|
210
|
+
* WHAT THIS CANNOT CHECK: `{}` is an object and passes. There is no request
|
|
211
|
+
* contract this module owns -- `auth()` reads `.method`, the shipped sample
|
|
212
|
+
* reads `.path`, a consumer's reads whatever it likes -- so anything past
|
|
213
|
+
* "is it an object" would be this module inventing a shape for someone else's
|
|
214
|
+
* framework. The residual is documented in the README under Consumer Contracts.
|
|
189
215
|
*/
|
|
190
216
|
export function createLinkageFilter(request) {
|
|
217
|
+
if (typeof request !== 'object' || request === null) {
|
|
218
|
+
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.`);
|
|
219
|
+
return function isLinkable(_type, _record) {
|
|
220
|
+
return false;
|
|
221
|
+
};
|
|
222
|
+
}
|
|
191
223
|
const byType = new Map();
|
|
192
224
|
return function isLinkable(type, record) {
|
|
193
225
|
let entry = byType.get(type);
|
package/dist/record.js
CHANGED
|
@@ -3,6 +3,24 @@ import log from 'stonyx/log';
|
|
|
3
3
|
import { getComputedProperties } from "./serializer.js";
|
|
4
4
|
import { camelCaseToKebabCase } from '@stonyx/utils/string';
|
|
5
5
|
import { getPluralName } from './plural-registry.js';
|
|
6
|
+
/**
|
|
7
|
+
* Name a non-boolean `linkage` return for the one log line that reports it.
|
|
8
|
+
*
|
|
9
|
+
* A thenable is called out BY NAME because it is the shape a consumer produces
|
|
10
|
+
* by accident -- an `async` resolver, or one that returns the promise of an
|
|
11
|
+
* authorization lookup -- and the one whose truthiness silently GRANTED every
|
|
12
|
+
* relationship before the ANSWER was checked (abofs/stonyx-orm#234).
|
|
13
|
+
*/
|
|
14
|
+
function describeNonVerdict(verdict) {
|
|
15
|
+
if (verdict === null)
|
|
16
|
+
return 'null';
|
|
17
|
+
if (Array.isArray(verdict))
|
|
18
|
+
return 'an array';
|
|
19
|
+
if ((typeof verdict === 'object' || typeof verdict === 'function')
|
|
20
|
+
&& typeof verdict.then === 'function')
|
|
21
|
+
return 'a Promise (or other thenable)';
|
|
22
|
+
return `a value of type ${typeof verdict}`;
|
|
23
|
+
}
|
|
6
24
|
export default class Record {
|
|
7
25
|
/** @private */
|
|
8
26
|
__data = {};
|
|
@@ -93,54 +111,128 @@ export default class Record {
|
|
|
93
111
|
}
|
|
94
112
|
// `linkage` is a PUBLIC option -- it is on `OrmRecord.toJSON`
|
|
95
113
|
// (src/types/orm-types.ts) and the README tells consumers to pass one -- so
|
|
96
|
-
// it arrives from outside this package
|
|
97
|
-
//
|
|
98
|
-
//
|
|
114
|
+
// it arrives from outside this package, may be ANY value, and whatever it
|
|
115
|
+
// is, it gets INVOKED here. That makes this the trust boundary, and it was
|
|
116
|
+
// the LAX side of one: the internal `createLinkageFilter` coerces and
|
|
117
|
+
// try/catches the consumer predicate it wraps, while this -- the site that
|
|
118
|
+
// consumes the PUBLIC option -- did neither.
|
|
119
|
+
//
|
|
120
|
+
// THREE QUESTIONS. Every wrong answer below was measured, on a two-
|
|
121
|
+
// relationship record, emitting the full pre-#234 document or throwing out
|
|
122
|
+
// of `JSON.stringify`.
|
|
123
|
+
//
|
|
124
|
+
// 1. IS IT SUPPLIED? ABSENT (`undefined`) means no verdict was supplied:
|
|
125
|
+
// emit today's document. Load-bearing and asserted (AC5/AC5b) --
|
|
126
|
+
// `toJSON` is also the `JSON.stringify` hook, so the implicit caller
|
|
127
|
+
// arrives as `toJSON('data')`, a STRING, which destructures to
|
|
128
|
+
// `undefined` here (abofs/stonyx-orm#230).
|
|
129
|
+
//
|
|
130
|
+
// 2. IS ITS SHAPE USABLE? `[object Function]` only, because
|
|
131
|
+
// `typeof x === 'function'` is NOT the question "can this answer a
|
|
132
|
+
// synchronous boolean".
|
|
99
133
|
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
134
|
+
// A NON-FUNCTION denies. Reading it as absent is what `!linkage ||`
|
|
135
|
+
// did, and a resolver returning `null` because it could not resolve a
|
|
136
|
+
// session is the natural shape of that value and the fail-closed
|
|
137
|
+
// INTENT -- measured, `toJSON({ linkage: null })` emitted the full
|
|
138
|
+
// pre-#234 linkage with no signal, byte-identical to unpatched dev.
|
|
105
139
|
//
|
|
106
|
-
//
|
|
140
|
+
// AN `AsyncFunction`, `GeneratorFunction` or `AsyncGeneratorFunction`
|
|
141
|
+
// denies for that SAME reason, one branch over -- and a `typeof`-only
|
|
142
|
+
// check left the whole defect standing there. `async (type, r) =>
|
|
143
|
+
// false` returns a PROMISE, a promise is TRUTHY, so every relationship
|
|
144
|
+
// was emitted in full with ZERO log, again byte-identical to unpatched
|
|
145
|
+
// dev. An awaited authorization lookup is at least as natural a
|
|
146
|
+
// resolver as a nullish one -- the README's own Consumer Contracts
|
|
147
|
+
// section points consumers at queue payloads and websocket frames,
|
|
148
|
+
// where lookups are routinely awaited -- and it landed on the GRANT
|
|
149
|
+
// side of the same branch the `null` reading closed.
|
|
107
150
|
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
151
|
+
// 3. IS ITS ANSWER A VERDICT? It must BE a boolean, not merely coerce to
|
|
152
|
+
// one. `Boolean(...)` -- the coercion `createLinkageFilter` applies to
|
|
153
|
+
// a consumer `access()` predicate, whose truthy contract predates this
|
|
154
|
+
// option and is deliberately NOT changed -- is not enough here, and
|
|
155
|
+
// was measured not to be: with `Boolean(...)` plus a try/catch in
|
|
156
|
+
// place, `async () => false`, `function* () {}`,
|
|
157
|
+
// `() => Promise.resolve(false)`, `() => ({})` and `() => 'no'` ALL
|
|
158
|
+
// still emitted the full pre-#234 linkage with no log, because
|
|
159
|
+
// truthiness is what they already had. A non-boolean is a resolver
|
|
160
|
+
// that did not answer, and the only safe reading of a non-answer is a
|
|
161
|
+
// denial.
|
|
162
|
+
//
|
|
163
|
+
// AND IT NEVER THROWS -- which is now true rather than only written down.
|
|
164
|
+
// A throw here escapes the enclosing `JSON.stringify` and takes
|
|
165
|
+
// `console.log` and `Orm.db.save()`'s neighbours with it, a far worse
|
|
166
|
+
// failure mode than a status. `class Klass {}`, `Klass.bind(null)` and any
|
|
167
|
+
// predicate that dereferences something undefined were all measured raising
|
|
168
|
+
// out of the `stringify`; all three are caught and denied.
|
|
118
169
|
//
|
|
119
170
|
// Logged once per DOCUMENT, not once per relationship key or per related
|
|
120
171
|
// record: an emptied relationship is deliberately indistinguishable from a
|
|
121
|
-
// genuinely empty one on the wire, so the log is the
|
|
122
|
-
// whose resolver
|
|
172
|
+
// genuinely empty one on the wire, so the log is the ONLY signal a consumer
|
|
173
|
+
// whose resolver quietly returned `null`, or a promise, will ever get.
|
|
123
174
|
const linkageSupplied = linkage !== undefined;
|
|
175
|
+
// Read the tag DEFENSIVELY. `Object.prototype.toString` consults
|
|
176
|
+
// `Symbol.toStringTag`, so a Proxy with a throwing `get` trap would throw
|
|
177
|
+
// out of the validation whose entire job is that nothing throws.
|
|
178
|
+
let linkageShape = 'a non-function';
|
|
179
|
+
if (typeof linkage === 'function') {
|
|
180
|
+
try {
|
|
181
|
+
linkageShape = Object.prototype.toString.call(linkage);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
linkageShape = '[object Unreadable]';
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const linkageUsable = linkageShape === '[object Function]';
|
|
188
|
+
let linkageReported = false;
|
|
189
|
+
const denyAllLinkage = (reason) => {
|
|
190
|
+
if (linkageReported)
|
|
191
|
+
return;
|
|
192
|
+
linkageReported = true;
|
|
193
|
+
log.error?.(`[@stonyx/orm] toJSON() received an unusable \`linkage\` option -- ${reason}, so ALL relationship linkage on this \`${modelName}\` document is denied.`);
|
|
194
|
+
};
|
|
195
|
+
if (linkageSupplied && !linkageUsable) {
|
|
196
|
+
denyAllLinkage(typeof linkage !== 'function'
|
|
197
|
+
? `it is of type ${linkage === null ? 'null' : typeof linkage} and it must be a function`
|
|
198
|
+
: `it is ${linkageShape} and it must be a SYNCHRONOUS function -- \`toJSON\` is the \`JSON.stringify\` hook and cannot await a verdict`);
|
|
199
|
+
}
|
|
124
200
|
const linkageVerdict = !linkageSupplied
|
|
125
201
|
? undefined
|
|
126
|
-
:
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
202
|
+
: linkageUsable ? linkage : () => false;
|
|
203
|
+
// Applied per related record, alongside the existing `__model` liveness
|
|
204
|
+
// check, and producing exactly the shapes that check already produces: a
|
|
205
|
+
// dropped hasMany member leaves `data: []`, a dropped belongsTo leaves
|
|
206
|
+
// `data: null`. Both already ship -- a genuinely-empty hasMany emits
|
|
207
|
+
// `data: []` with links, and a cleaned belongsTo emits `data: null` -- so a
|
|
208
|
+
// filtered relationship is BYTE-IDENTICAL to an empty one and there is no
|
|
209
|
+
// new wire shape and no oracle.
|
|
210
|
+
const isLinkable = (r) => {
|
|
211
|
+
if (!linkageVerdict)
|
|
212
|
+
return true;
|
|
213
|
+
try {
|
|
214
|
+
const verdict = linkageVerdict(r.__model.__name, r);
|
|
215
|
+
if (typeof verdict === 'boolean')
|
|
216
|
+
return verdict;
|
|
217
|
+
denyAllLinkage(`it answered with ${describeNonVerdict(verdict)} rather than a boolean`);
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
// Building the report is itself a throw site -- `throw Symbol('x')`
|
|
221
|
+
// makes `String(error)` throw, and a getter on `.message` can throw --
|
|
222
|
+
// and a throw from the reporter would escape the catch that exists so
|
|
223
|
+
// that nothing escapes.
|
|
224
|
+
let detail = 'a value that could not be described';
|
|
225
|
+
try {
|
|
226
|
+
detail = error instanceof Error ? error.message : String(error);
|
|
227
|
+
}
|
|
228
|
+
catch { /* keep the fallback -- the denial matters, the text does not */ }
|
|
229
|
+
denyAllLinkage(`it threw (${detail})`);
|
|
230
|
+
}
|
|
231
|
+
return false;
|
|
232
|
+
};
|
|
130
233
|
for (const [key, childRecord] of Object.entries(this.__relationships)) {
|
|
131
234
|
if (fields && !fields.has(key))
|
|
132
235
|
continue;
|
|
133
|
-
// The linkage decision is applied HERE, alongside the existing
|
|
134
|
-
// `__model` liveness check, and it produces exactly the shapes that
|
|
135
|
-
// check already produces: a dropped hasMany member leaves `data: []`,
|
|
136
|
-
// a dropped belongsTo leaves `data: null`. Both already ship -- a
|
|
137
|
-
// genuinely-empty hasMany emits `data: []` with links, and a cleaned
|
|
138
|
-
// belongsTo emits `data: null` -- so a filtered relationship is
|
|
139
|
-
// BYTE-IDENTICAL to an empty one and there is no new wire shape and no
|
|
140
|
-
// oracle. It never throws: a throw here escapes the enclosing
|
|
141
|
-
// `JSON.stringify` and takes `console.log` and `Orm.db.save()`'s
|
|
142
|
-
// neighbours with it, which is a far worse failure mode than a status.
|
|
143
|
-
const isLinkable = (r) => !linkageVerdict || linkageVerdict(r.__model.__name, r);
|
|
144
236
|
const relationshipData = Array.isArray(childRecord)
|
|
145
237
|
? childRecord.filter((r) => r?.__model).filter(isLinkable).map((r) => ({ type: r.__model.__name, id: r.id }))
|
|
146
238
|
: (childRecord && childRecord.__model && isLinkable(childRecord)) ? { type: childRecord.__model.__name, id: childRecord.id } : null;
|
package/package.json
CHANGED
package/src/access-verdict.ts
CHANGED
|
@@ -206,8 +206,42 @@ function resolveVerdict(request: unknown, type: string): AccessVerdict {
|
|
|
206
206
|
* SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
|
|
207
207
|
* it -- a verdict cached across requests would answer a second caller with the
|
|
208
208
|
* first caller's authorization.
|
|
209
|
+
*
|
|
210
|
+
* A REQUEST IS REQUIRED, AND ITS ABSENCE IS CHECKED HERE RATHER THAN DELEGATED.
|
|
211
|
+
* This function is EXPORTED (src/index.ts), and the README's Consumer Contracts
|
|
212
|
+
* section points consumers at exactly the contexts that have no live request --
|
|
213
|
+
* a queue payload, a websocket frame, a custom route. Without one there is no
|
|
214
|
+
* caller to authorise against, and this file's header already says so: the
|
|
215
|
+
* shipped sample reads `request.path` and fail-closes when it is absent, so
|
|
216
|
+
* `getAccess('owner')(undefined, ...)` is `false`, while
|
|
217
|
+
* `getAccess('animal')(undefined, ...)` returns a per-record predicate and
|
|
218
|
+
* GRANTS. Measured on this repo's own fixture before this guard existed:
|
|
219
|
+
*
|
|
220
|
+
* createLinkageFilter(undefined | null | {} | 'x' | 0)
|
|
221
|
+
* -> owner=false animal=TRUE trait=TRUE category=TRUE phone-number=TRUE
|
|
222
|
+
*
|
|
223
|
+
* Four of five claimed models granted, with no log, because whether an absent
|
|
224
|
+
* request fails closed was left ENTIRELY to consumer predicates -- and a
|
|
225
|
+
* predicate that ignores its request cannot fail closed on one that is missing.
|
|
226
|
+
* A nullish or primitive `request` therefore denies every model outright and
|
|
227
|
+
* says so once, at construction, so the signal exists even for a caller that
|
|
228
|
+
* goes on to serialize nothing.
|
|
229
|
+
*
|
|
230
|
+
* WHAT THIS CANNOT CHECK: `{}` is an object and passes. There is no request
|
|
231
|
+
* contract this module owns -- `auth()` reads `.method`, the shipped sample
|
|
232
|
+
* reads `.path`, a consumer's reads whatever it likes -- so anything past
|
|
233
|
+
* "is it an object" would be this module inventing a shape for someone else's
|
|
234
|
+
* framework. The residual is documented in the README under Consumer Contracts.
|
|
209
235
|
*/
|
|
210
236
|
export function createLinkageFilter(request: unknown): LinkageFilter {
|
|
237
|
+
if (typeof request !== 'object' || request === null) {
|
|
238
|
+
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.`);
|
|
239
|
+
|
|
240
|
+
return function isLinkable(_type: string, _record: unknown): boolean {
|
|
241
|
+
return false;
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
211
245
|
const byType = new Map<string, { verdict: AccessVerdict; decisions: Map<unknown, boolean> }>();
|
|
212
246
|
|
|
213
247
|
return function isLinkable(type: string, record: unknown): boolean {
|
package/src/record.ts
CHANGED
|
@@ -55,6 +55,25 @@ interface JSONAPIResult {
|
|
|
55
55
|
links?: { self: string };
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Name a non-boolean `linkage` return for the one log line that reports it.
|
|
61
|
+
*
|
|
62
|
+
* A thenable is called out BY NAME because it is the shape a consumer produces
|
|
63
|
+
* by accident -- an `async` resolver, or one that returns the promise of an
|
|
64
|
+
* authorization lookup -- and the one whose truthiness silently GRANTED every
|
|
65
|
+
* relationship before the ANSWER was checked (abofs/stonyx-orm#234).
|
|
66
|
+
*/
|
|
67
|
+
function describeNonVerdict(verdict: unknown): string {
|
|
68
|
+
if (verdict === null) return 'null';
|
|
69
|
+
if (Array.isArray(verdict)) return 'an array';
|
|
70
|
+
|
|
71
|
+
if ((typeof verdict === 'object' || typeof verdict === 'function')
|
|
72
|
+
&& typeof (verdict as { then?: unknown }).then === 'function') return 'a Promise (or other thenable)';
|
|
73
|
+
|
|
74
|
+
return `a value of type ${typeof verdict}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
58
77
|
export default class Record {
|
|
59
78
|
/** @private */
|
|
60
79
|
__data: { [key: string]: unknown } = {};
|
|
@@ -160,57 +179,138 @@ export default class Record {
|
|
|
160
179
|
|
|
161
180
|
// `linkage` is a PUBLIC option -- it is on `OrmRecord.toJSON`
|
|
162
181
|
// (src/types/orm-types.ts) and the README tells consumers to pass one -- so
|
|
163
|
-
// it arrives from outside this package
|
|
164
|
-
//
|
|
165
|
-
//
|
|
182
|
+
// it arrives from outside this package, may be ANY value, and whatever it
|
|
183
|
+
// is, it gets INVOKED here. That makes this the trust boundary, and it was
|
|
184
|
+
// the LAX side of one: the internal `createLinkageFilter` coerces and
|
|
185
|
+
// try/catches the consumer predicate it wraps, while this -- the site that
|
|
186
|
+
// consumes the PUBLIC option -- did neither.
|
|
187
|
+
//
|
|
188
|
+
// THREE QUESTIONS. Every wrong answer below was measured, on a two-
|
|
189
|
+
// relationship record, emitting the full pre-#234 document or throwing out
|
|
190
|
+
// of `JSON.stringify`.
|
|
191
|
+
//
|
|
192
|
+
// 1. IS IT SUPPLIED? ABSENT (`undefined`) means no verdict was supplied:
|
|
193
|
+
// emit today's document. Load-bearing and asserted (AC5/AC5b) --
|
|
194
|
+
// `toJSON` is also the `JSON.stringify` hook, so the implicit caller
|
|
195
|
+
// arrives as `toJSON('data')`, a STRING, which destructures to
|
|
196
|
+
// `undefined` here (abofs/stonyx-orm#230).
|
|
197
|
+
//
|
|
198
|
+
// 2. IS ITS SHAPE USABLE? `[object Function]` only, because
|
|
199
|
+
// `typeof x === 'function'` is NOT the question "can this answer a
|
|
200
|
+
// synchronous boolean".
|
|
166
201
|
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
202
|
+
// A NON-FUNCTION denies. Reading it as absent is what `!linkage ||`
|
|
203
|
+
// did, and a resolver returning `null` because it could not resolve a
|
|
204
|
+
// session is the natural shape of that value and the fail-closed
|
|
205
|
+
// INTENT -- measured, `toJSON({ linkage: null })` emitted the full
|
|
206
|
+
// pre-#234 linkage with no signal, byte-identical to unpatched dev.
|
|
172
207
|
//
|
|
173
|
-
//
|
|
208
|
+
// AN `AsyncFunction`, `GeneratorFunction` or `AsyncGeneratorFunction`
|
|
209
|
+
// denies for that SAME reason, one branch over -- and a `typeof`-only
|
|
210
|
+
// check left the whole defect standing there. `async (type, r) =>
|
|
211
|
+
// false` returns a PROMISE, a promise is TRUTHY, so every relationship
|
|
212
|
+
// was emitted in full with ZERO log, again byte-identical to unpatched
|
|
213
|
+
// dev. An awaited authorization lookup is at least as natural a
|
|
214
|
+
// resolver as a nullish one -- the README's own Consumer Contracts
|
|
215
|
+
// section points consumers at queue payloads and websocket frames,
|
|
216
|
+
// where lookups are routinely awaited -- and it landed on the GRANT
|
|
217
|
+
// side of the same branch the `null` reading closed.
|
|
174
218
|
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
219
|
+
// 3. IS ITS ANSWER A VERDICT? It must BE a boolean, not merely coerce to
|
|
220
|
+
// one. `Boolean(...)` -- the coercion `createLinkageFilter` applies to
|
|
221
|
+
// a consumer `access()` predicate, whose truthy contract predates this
|
|
222
|
+
// option and is deliberately NOT changed -- is not enough here, and
|
|
223
|
+
// was measured not to be: with `Boolean(...)` plus a try/catch in
|
|
224
|
+
// place, `async () => false`, `function* () {}`,
|
|
225
|
+
// `() => Promise.resolve(false)`, `() => ({})` and `() => 'no'` ALL
|
|
226
|
+
// still emitted the full pre-#234 linkage with no log, because
|
|
227
|
+
// truthiness is what they already had. A non-boolean is a resolver
|
|
228
|
+
// that did not answer, and the only safe reading of a non-answer is a
|
|
229
|
+
// denial.
|
|
230
|
+
//
|
|
231
|
+
// AND IT NEVER THROWS -- which is now true rather than only written down.
|
|
232
|
+
// A throw here escapes the enclosing `JSON.stringify` and takes
|
|
233
|
+
// `console.log` and `Orm.db.save()`'s neighbours with it, a far worse
|
|
234
|
+
// failure mode than a status. `class Klass {}`, `Klass.bind(null)` and any
|
|
235
|
+
// predicate that dereferences something undefined were all measured raising
|
|
236
|
+
// out of the `stringify`; all three are caught and denied.
|
|
185
237
|
//
|
|
186
238
|
// Logged once per DOCUMENT, not once per relationship key or per related
|
|
187
239
|
// record: an emptied relationship is deliberately indistinguishable from a
|
|
188
|
-
// genuinely empty one on the wire, so the log is the
|
|
189
|
-
// whose resolver
|
|
240
|
+
// genuinely empty one on the wire, so the log is the ONLY signal a consumer
|
|
241
|
+
// whose resolver quietly returned `null`, or a promise, will ever get.
|
|
190
242
|
const linkageSupplied = linkage !== undefined;
|
|
243
|
+
|
|
244
|
+
// Read the tag DEFENSIVELY. `Object.prototype.toString` consults
|
|
245
|
+
// `Symbol.toStringTag`, so a Proxy with a throwing `get` trap would throw
|
|
246
|
+
// out of the validation whose entire job is that nothing throws.
|
|
247
|
+
let linkageShape = 'a non-function';
|
|
248
|
+
|
|
249
|
+
if (typeof linkage === 'function') {
|
|
250
|
+
try {
|
|
251
|
+
linkageShape = Object.prototype.toString.call(linkage);
|
|
252
|
+
} catch {
|
|
253
|
+
linkageShape = '[object Unreadable]';
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const linkageUsable = linkageShape === '[object Function]';
|
|
258
|
+
|
|
259
|
+
let linkageReported = false;
|
|
260
|
+
|
|
261
|
+
const denyAllLinkage = (reason: string) => {
|
|
262
|
+
if (linkageReported) return;
|
|
263
|
+
linkageReported = true;
|
|
264
|
+
|
|
265
|
+
log.error?.(`[@stonyx/orm] toJSON() received an unusable \`linkage\` option -- ${reason}, so ALL relationship linkage on this \`${modelName}\` document is denied.`);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
if (linkageSupplied && !linkageUsable) {
|
|
269
|
+
denyAllLinkage(typeof linkage !== 'function'
|
|
270
|
+
? `it is of type ${linkage === null ? 'null' : typeof linkage} and it must be a function`
|
|
271
|
+
: `it is ${linkageShape} and it must be a SYNCHRONOUS function -- \`toJSON\` is the \`JSON.stringify\` hook and cannot await a verdict`);
|
|
272
|
+
}
|
|
273
|
+
|
|
191
274
|
const linkageVerdict: LinkageFilter | undefined = !linkageSupplied
|
|
192
275
|
? undefined
|
|
193
|
-
:
|
|
276
|
+
: linkageUsable ? linkage as LinkageFilter : () => false;
|
|
277
|
+
|
|
278
|
+
// Applied per related record, alongside the existing `__model` liveness
|
|
279
|
+
// check, and producing exactly the shapes that check already produces: a
|
|
280
|
+
// dropped hasMany member leaves `data: []`, a dropped belongsTo leaves
|
|
281
|
+
// `data: null`. Both already ship -- a genuinely-empty hasMany emits
|
|
282
|
+
// `data: []` with links, and a cleaned belongsTo emits `data: null` -- so a
|
|
283
|
+
// filtered relationship is BYTE-IDENTICAL to an empty one and there is no
|
|
284
|
+
// new wire shape and no oracle.
|
|
285
|
+
const isLinkable = (r: Record): boolean => {
|
|
286
|
+
if (!linkageVerdict) return true;
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
const verdict = linkageVerdict(r.__model.__name, r);
|
|
290
|
+
|
|
291
|
+
if (typeof verdict === 'boolean') return verdict;
|
|
292
|
+
|
|
293
|
+
denyAllLinkage(`it answered with ${describeNonVerdict(verdict)} rather than a boolean`);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
// Building the report is itself a throw site -- `throw Symbol('x')`
|
|
296
|
+
// makes `String(error)` throw, and a getter on `.message` can throw --
|
|
297
|
+
// and a throw from the reporter would escape the catch that exists so
|
|
298
|
+
// that nothing escapes.
|
|
299
|
+
let detail = 'a value that could not be described';
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
detail = error instanceof Error ? error.message : String(error);
|
|
303
|
+
} catch { /* keep the fallback -- the denial matters, the text does not */ }
|
|
304
|
+
|
|
305
|
+
denyAllLinkage(`it threw (${detail})`);
|
|
306
|
+
}
|
|
194
307
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
308
|
+
return false;
|
|
309
|
+
};
|
|
198
310
|
|
|
199
311
|
for (const [key, childRecord] of Object.entries(this.__relationships)) {
|
|
200
312
|
if (fields && !fields.has(key)) continue;
|
|
201
313
|
|
|
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
|
-
|
|
214
314
|
const relationshipData = Array.isArray(childRecord)
|
|
215
315
|
? childRecord.filter((r: Record) => r?.__model).filter(isLinkable).map((r: Record) => ({ type: r.__model.__name, id: r.id }))
|
|
216
316
|
: (childRecord && (childRecord as Record).__model && isLinkable(childRecord as Record)) ? { type: (childRecord as Record).__model.__name, id: (childRecord as Record).id } : null;
|