@stonyx/orm 0.3.2-alpha.71 → 0.3.2-alpha.73
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 +155 -52
- package/dist/access-verdict.d.ts +26 -0
- package/dist/access-verdict.js +63 -1
- package/dist/hooks.d.ts +15 -1
- package/dist/orm-request.d.ts +48 -0
- package/dist/orm-request.js +91 -0
- package/dist/record.js +128 -36
- package/dist/types/orm-types.d.ts +95 -0
- package/package.json +1 -1
- package/src/access-verdict.ts +65 -1
- package/src/hooks.ts +15 -1
- package/src/orm-request.ts +91 -0
- package/src/record.ts +137 -37
- package/src/types/orm-types.ts +96 -0
package/src/orm-request.ts
CHANGED
|
@@ -69,6 +69,15 @@
|
|
|
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
|
+
*
|
|
72
81
|
* `record` IS NOT IN THIS CONTEXT, deliberately. `auth()` runs after route
|
|
73
82
|
* matching but BEFORE any handler executes (`@stonyx/rest-server`
|
|
74
83
|
* `src/request.ts:58-60`), so nothing has been fetched yet -- supplying a
|
|
@@ -158,6 +167,11 @@
|
|
|
158
167
|
* `/archived` SUB-PATH rule is still a string match, and abofs/stonyx-orm#228 is
|
|
159
168
|
* a sixth spelling that gets past it.
|
|
160
169
|
*
|
|
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
|
+
*
|
|
161
175
|
* An intermediate revision of the sample read `request.baseUrl` -- the mount
|
|
162
176
|
* Express ACTUALLY MATCHED. That closed all five variants (no query string,
|
|
163
177
|
* not mount-relative, unaffected by absolute-form, already carrying the
|
|
@@ -173,12 +187,26 @@
|
|
|
173
187
|
* does not decode, so `GET /owners/%61rchived` steps past it. See the
|
|
174
188
|
* normalisation paragraph below and abofs/stonyx-orm#228.
|
|
175
189
|
*
|
|
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
|
+
*
|
|
176
198
|
* ONE READ OF ARGUMENT ONE SURVIVES, AND IT MUST: `request.path`. It is
|
|
177
199
|
* mount-relative and query-free, and it is for rules that distinguish SUB-PATHS
|
|
178
200
|
* beneath the mount. The context names which model and which verb, NOT which
|
|
179
201
|
* route, so the sample's `/archived` deny cannot be expressed from the context
|
|
180
202
|
* alone and a context-ONLY rewrite would silently turn that deny into an allow.
|
|
181
203
|
*
|
|
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
|
+
*
|
|
182
210
|
* NORMALISE THE WAY THE ROUTER DOES, AND CASE-FOLDING ALONE IS NOT THAT. The
|
|
183
211
|
* sample lower-cases before comparing, because a matcher stricter than the
|
|
184
212
|
* case-insensitive router can be stepped around. That closes the case gap only.
|
|
@@ -188,6 +216,20 @@
|
|
|
188
216
|
* sample and is tracked as abofs/stonyx-orm#228; the `.toLowerCase()` is not a
|
|
189
217
|
* complete normalisation recipe. Compare record ids at their real case.
|
|
190
218
|
*
|
|
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
|
+
*
|
|
191
233
|
* `?? ''` is not a defence. It converts an absent request target into an empty
|
|
192
234
|
* string, which matches no collection, which falls through to the permission
|
|
193
235
|
* array -- a total grant. An input you cannot identify must DENY, and that
|
|
@@ -196,6 +238,12 @@
|
|
|
196
238
|
* argument one. The sample returns `false` for an absent `model` AND for an
|
|
197
239
|
* absent or non-string `request.path`, rather than falling through either way.
|
|
198
240
|
*
|
|
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
|
+
*
|
|
199
247
|
* THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
|
|
200
248
|
* the operation and the record. Prefer the array shape (`['read']`) or `false`
|
|
201
249
|
* until #202 lands; the function shape is what requires any matching at all.
|
|
@@ -1429,10 +1477,53 @@ export default class OrmRequest extends Request {
|
|
|
1429
1477
|
// src/types/orm-types.ts. Nothing is fetched at this point and adding a
|
|
1430
1478
|
// lookup here would put a store read in the middle of an authorization
|
|
1431
1479
|
// path. The function return shape below IS the per-record hook.
|
|
1480
|
+
//
|
|
1481
|
+
// -------------------------------------------------------------------------
|
|
1482
|
+
// #236 -- `recordId`, the DECODED route-parameter id, for the same reason.
|
|
1483
|
+
//
|
|
1484
|
+
// WHICH RECORD is the third structural fact the framework already holds and
|
|
1485
|
+
// the consumer was left to re-derive, and re-deriving it failed OPEN. The
|
|
1486
|
+
// documented sample compared `request.path` -- the RAW, undecoded pathname
|
|
1487
|
+
// -- against a literal `/archived`, while the router DECODES `:id`. So
|
|
1488
|
+
// `GET /owners/%61rchived` walked past the deny and was dispatched as the
|
|
1489
|
+
// record `archived`: 200 with the record in full, and DELETE answered 204
|
|
1490
|
+
// with the record destroyed, unauthenticated. Four spellings measured, all
|
|
1491
|
+
// four through; 255 non-canonical spellings of that 8-character id decode
|
|
1492
|
+
// to the same key, so this was never a deny-list of one.
|
|
1493
|
+
//
|
|
1494
|
+
// TWO CONSUMER-SIDE NORMALISATIONS WERE MEASURED WRONG IN OPPOSITE
|
|
1495
|
+
// DIRECTIONS, which is the argument for doing it once, here.
|
|
1496
|
+
// `.toLowerCase()` case-folds a route-parameter VALUE on the axis that
|
|
1497
|
+
// governs literal SEGMENTS: with a distinct owner seeded at `ARCHIVED`,
|
|
1498
|
+
// `GET /owners/ARCHIVED` was a false DENY on the wrong record and
|
|
1499
|
+
// `GET /owners/%41RCHIVED` a false ALLOW on that same one.
|
|
1500
|
+
// `decodeURIComponent(request.path)` decodes THEN splits while the router
|
|
1501
|
+
// splits THEN decodes, so it over-denied `/owners/archived%2fx` -- 403 for
|
|
1502
|
+
// a genuinely distinct record. Failing closed there was luck, not design.
|
|
1503
|
+
//
|
|
1504
|
+
// `getId(request.params)` AND NOT `request.params.id`, for exactly the
|
|
1505
|
+
// reason `operation` is a `methodAccessMap` lookup: it is the SAME single
|
|
1506
|
+
// coercion the store lookup one layer down performs, so the predicate and
|
|
1507
|
+
// the dispatch cannot disagree about which record a request addresses.
|
|
1508
|
+
// The raw string would reintroduce that divergence on hex-shaped ids --
|
|
1509
|
+
// `GET /animals/0x2391` looks up record `9105`.
|
|
1510
|
+
//
|
|
1511
|
+
// NOTHING HERE PARSES THE REQUEST TARGET EITHER. `request.params` is what
|
|
1512
|
+
// the router matched, so a mount prefix, an absolute-form target, a query
|
|
1513
|
+
// string or a case-varied mount cannot move this value -- the same
|
|
1514
|
+
// guarantee `model` carries, by the same means.
|
|
1515
|
+
//
|
|
1516
|
+
// `null` and not `undefined` on a collection route, so the KEY IS ALWAYS
|
|
1517
|
+
// PRESENT -- the rule `operation`'s own docblock already establishes. A
|
|
1518
|
+
// context reaching a predicate WITHOUT the key therefore did not come from
|
|
1519
|
+
// here; it was hand-assembled by a caller resolving the predicate through
|
|
1520
|
+
// `Orm.instance.getAccess()`, and that absence stays deniable only because
|
|
1521
|
+
// `auth()` never produces it.
|
|
1432
1522
|
// -------------------------------------------------------------------------
|
|
1433
1523
|
const context: AccessContext = {
|
|
1434
1524
|
model: this.model,
|
|
1435
1525
|
operation: methodAccessMap[request.method],
|
|
1526
|
+
recordId: request.params && 'id' in request.params ? getId(request.params) : null,
|
|
1436
1527
|
};
|
|
1437
1528
|
|
|
1438
1529
|
let access: AccessMethod;
|
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;
|
package/src/types/orm-types.ts
CHANGED
|
@@ -260,6 +260,102 @@ export interface AccessContext {
|
|
|
260
260
|
* from one that classified the request and found nothing.
|
|
261
261
|
*/
|
|
262
262
|
operation: AccessOperation | undefined;
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The record this route was addressed to, as the store key -- or `null` on a
|
|
266
|
+
* collection route, which is addressed to no record (abofs/stonyx-orm#236).
|
|
267
|
+
*
|
|
268
|
+
* IT IS ALREADY DECODED, AND THAT IS THE WHOLE POINT. Express decodes route
|
|
269
|
+
* PARAMETERS while leaving `request.path` raw, so a consumer comparing
|
|
270
|
+
* `request.path` against a literal compares an undecoded string against a
|
|
271
|
+
* decoded dispatch. `GET /owners/%61rchived` reached such a comparison as
|
|
272
|
+
* `/%61rchived`, walked past a `/archived` deny, and was dispatched as the
|
|
273
|
+
* record `archived` -- 200 with the record in full, and `DELETE` destroyed
|
|
274
|
+
* it, unauthenticated. 255 non-canonical spellings of an 8-character id
|
|
275
|
+
* decode to the same key, so a deny-list of spellings is the wrong shape.
|
|
276
|
+
*
|
|
277
|
+
* SO DO NOT NORMALISE THIS, AND DO NOT NORMALISE ANYTHING ELSE INSTEAD:
|
|
278
|
+
*
|
|
279
|
+
* - Do NOT decode it. Express decodes exactly ONCE, which is what a route
|
|
280
|
+
* parameter means. `GET /owners/%2561rchived` is the legitimate id
|
|
281
|
+
* `%61rchived`, not a second-order spelling of `archived`; a predicate that
|
|
282
|
+
* decoded until stable would deny a record it was never asked about.
|
|
283
|
+
* - Do NOT case-fold it. A record id is a VALUE, not a literal route segment,
|
|
284
|
+
* and express's `case sensitive routing` governs literal segments only.
|
|
285
|
+
* With a distinct owner seeded at `ARCHIVED`, `.toLowerCase()` was measured
|
|
286
|
+
* wrong in BOTH directions at once: `GET /owners/ARCHIVED` 403 (a false
|
|
287
|
+
* deny, on the wrong record) and `GET /owners/%41RCHIVED` 200 (a false
|
|
288
|
+
* allow, on that same record).
|
|
289
|
+
* - Do NOT derive it from `request.path` or the request target. Decoding the
|
|
290
|
+
* whole path decodes THEN splits, while the router splits THEN decodes, so
|
|
291
|
+
* `/owners/archived%2fx` -- a genuinely distinct record whose id is
|
|
292
|
+
* `archived/x` -- was measured over-denied 403.
|
|
293
|
+
*
|
|
294
|
+
* IT IS `getId(request.params)`, BYTE FOR BYTE -- the same single coercion
|
|
295
|
+
* the store lookup uses, exactly as `operation` is the same `methodAccessMap`
|
|
296
|
+
* lookup the permission-array branch uses. The predicate and the dispatch
|
|
297
|
+
* therefore cannot disagree about which record a request addresses. Handing
|
|
298
|
+
* over the raw `request.params.id` instead would reintroduce that divergence
|
|
299
|
+
* on hex-shaped ids: `GET /animals/0x2391` looks up record `9105`.
|
|
300
|
+
*
|
|
301
|
+
* It inherits abofs/stonyx-orm#209 along with that coercion -- on a model
|
|
302
|
+
* declaring `id = attr('string')`, `'9107'` arrives here as the number
|
|
303
|
+
* `9107`. That is consistency WITH THE LOOKUP, which is the property this key
|
|
304
|
+
* exists to buy; it is not a defect to repair here.
|
|
305
|
+
*
|
|
306
|
+
* `null`, not `undefined`, on a collection route -- and the KEY IS ALWAYS
|
|
307
|
+
* PRESENT, the same rule `operation` states above. `auth()` always sets it,
|
|
308
|
+
* so a context arriving WITHOUT the key did not come from `auth()`: it was
|
|
309
|
+
* hand-assembled by a caller resolving the predicate through
|
|
310
|
+
* `Orm.instance.getAccess()`. That absence stays a distinguishable, deniable
|
|
311
|
+
* signal only because the framework never produces it.
|
|
312
|
+
*
|
|
313
|
+
* IT DISAGREES WITH THE HOOK VOCABULARY, AND NOT ONLY ON THE ABSENCE
|
|
314
|
+
* SPELLING. `HookContext.recordId` (`src/hooks.ts`) is an identically-named
|
|
315
|
+
* key on an identically-shaped context object, which is the exact
|
|
316
|
+
* configuration that makes `operation` fail-open shaped -- a hook sees
|
|
317
|
+
* `'get'` where `access()` sees `'read'`. An earlier revision of THIS
|
|
318
|
+
* docblock asserted the opposite ("here they AGREE... they differ in ONE way
|
|
319
|
+
* and it is the absence spelling"). That was measured false, in the fail-open
|
|
320
|
+
* direction, and it is corrected here rather than deleted.
|
|
321
|
+
*
|
|
322
|
+
* MEASURED over the live dispatch, before-hooks registered for all five
|
|
323
|
+
* operations on one model:
|
|
324
|
+
*
|
|
325
|
+
* before:list key ABSENT ('recordId' in context === false)
|
|
326
|
+
* before:get key ABSENT params={"id":"visible1"}
|
|
327
|
+
* before:create key ABSENT
|
|
328
|
+
* before:update key ABSENT params={"id":"visible2"}
|
|
329
|
+
* before:delete recordId="visible3"
|
|
330
|
+
* after:delete recordId="visible3"
|
|
331
|
+
*
|
|
332
|
+
* `_withHooks` assigns `context.recordId` at exactly TWO sites in
|
|
333
|
+
* `src/orm-request.ts`, and BOTH sit inside an `operation === 'delete'`
|
|
334
|
+
* branch. So the two keys differ in COVERAGE, on four of five operations: on
|
|
335
|
+
* a hook context the key is absent for get, list, create and update, while
|
|
336
|
+
* this key is present on every route `auth()` classifies. The absence
|
|
337
|
+
* spelling is the smaller half of the difference, not the whole of it.
|
|
338
|
+
*
|
|
339
|
+
* AND THAT INVERTS THE ARGUMENT ABOVE WHEN IT IS READ ACROSS THE TWO. Here,
|
|
340
|
+
* a missing `recordId` means "did not come from `auth()`" and is deniable.
|
|
341
|
+
* On a hook context it means "this is a get / list / create / update" -- an
|
|
342
|
+
* ordinary request. A consumer who writes the hook-side half of the same
|
|
343
|
+
* rule --
|
|
344
|
+
*
|
|
345
|
+
* beforeHook('update', 'owner', ctx => ctx.recordId === 'archived' ? 403 : undefined)
|
|
346
|
+
*
|
|
347
|
+
* -- gets a deny that NEVER FIRES: measured, `PATCH /owners/visible2` -> 200,
|
|
348
|
+
* with `ctx.recordId === undefined` while the addressed record sits in
|
|
349
|
+
* `ctx.params`. The hook side is abofs/stonyx-orm#242 and is deliberately not
|
|
350
|
+
* repaired here. A predicate must not read `undefined` here as "collection",
|
|
351
|
+
* and nothing in this contract makes it safe to read the two keys as one key.
|
|
352
|
+
*
|
|
353
|
+
* IT NAMES WHICH RECORD, NOT WHICH SURFACE. `GET /owners/gina`,
|
|
354
|
+
* `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` all
|
|
355
|
+
* carry `recordId: 'gina'`; the related-resource gap is abofs/stonyx-orm#196
|
|
356
|
+
* and is untouched by this key.
|
|
357
|
+
*/
|
|
358
|
+
recordId: string | number | null;
|
|
263
359
|
}
|
|
264
360
|
|
|
265
361
|
/**
|