@stonyx/orm 0.3.2-beta.156 → 0.3.2-beta.158

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/src/record.ts CHANGED
@@ -1,12 +1,29 @@
1
1
  import { store } from '@stonyx/orm';
2
+ import log from 'stonyx/log';
2
3
  import { getComputedProperties } from "./serializer.js";
3
4
  import { camelCaseToKebabCase } from '@stonyx/utils/string';
4
5
  import { getPluralName } from './plural-registry.js';
5
6
  import type Serializer from './serializer.js';
7
+ import type { LinkageFilter } from './types/orm-types.js';
6
8
 
7
9
  interface ToJSONOptions {
8
10
  fields?: Set<string>;
9
11
  baseUrl?: string;
12
+ /**
13
+ * An ALREADY-RESOLVED linkage decision, supplied by a caller that holds the
14
+ * request (abofs/stonyx-orm#234). Returning `false` for a related record
15
+ * drops that record's `{ type, id }` from `relationships.*.data`.
16
+ *
17
+ * This method APPLIES a verdict; it never RESOLVES one -- see
18
+ * `src/access-verdict.ts` for the two measured reasons it cannot. ABSENT is
19
+ * the default and the default is TODAY'S DOCUMENT, unchanged, because
20
+ * `toJSON` is also the `JSON.stringify` hook and an implicit caller has no
21
+ * syntactic place to pass this (abofs/stonyx-orm#230).
22
+ *
23
+ * ABSENT and UNUSABLE are read differently, and the difference is a security
24
+ * decision -- see the three-way reading at the call site below.
25
+ */
26
+ linkage?: LinkageFilter;
10
27
  }
11
28
 
12
29
  interface SerializeOptions {
@@ -38,6 +55,25 @@ interface JSONAPIResult {
38
55
  links?: { self: string };
39
56
  }
40
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
+
41
77
  export default class Record {
42
78
  /** @private */
43
79
  __data: { [key: string]: unknown } = {};
@@ -116,7 +152,13 @@ export default class Record {
116
152
  toJSON(options: ToJSONOptions = {}): JSONAPIResult {
117
153
  if (!this.__serialized) throw new Error('Record must be serialized before being converted to JSON');
118
154
 
119
- const { fields, baseUrl } = options;
155
+ // DESTRUCTURED FROM A VALUE THAT IS NOT ALWAYS AN OBJECT. `toJSON` is the
156
+ // ECMAScript serialization hook, so `JSON.stringify({ data: record })`
157
+ // arrives here as `toJSON('data')` -- a STRING in the options slot.
158
+ // Destructuring a string yields `undefined` for every key, which is exactly
159
+ // the no-argument default, so the implicit path keeps working and keeps
160
+ // emitting today's document (abofs/stonyx-orm#230).
161
+ const { fields, baseUrl, linkage } = options;
120
162
  const { __data: data } = this;
121
163
  const modelName = this.__model.__name;
122
164
  const pluralizedModelName = getPluralName(modelName);
@@ -135,12 +177,143 @@ export default class Record {
135
177
  attributes[key] = (getter as () => unknown).call(this);
136
178
  }
137
179
 
180
+ // `linkage` is a PUBLIC option -- it is on `OrmRecord.toJSON`
181
+ // (src/types/orm-types.ts) and the README tells consumers to pass one -- so
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".
201
+ //
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.
207
+ //
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.
218
+ //
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.
237
+ //
238
+ // Logged once per DOCUMENT, not once per relationship key or per related
239
+ // record: an emptied relationship is deliberately indistinguishable from a
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.
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
+
274
+ const linkageVerdict: LinkageFilter | undefined = !linkageSupplied
275
+ ? undefined
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
+ }
307
+
308
+ return false;
309
+ };
310
+
138
311
  for (const [key, childRecord] of Object.entries(this.__relationships)) {
139
312
  if (fields && !fields.has(key)) continue;
140
313
 
141
314
  const relationshipData = Array.isArray(childRecord)
142
- ? childRecord.filter((r: Record) => r?.__model).map((r: Record) => ({ type: r.__model.__name, id: r.id }))
143
- : (childRecord && (childRecord as Record).__model) ? { type: (childRecord as Record).__model.__name, id: (childRecord as Record).id } : null;
315
+ ? childRecord.filter((r: Record) => r?.__model).filter(isLinkable).map((r: Record) => ({ type: r.__model.__name, id: r.id }))
316
+ : (childRecord && (childRecord as Record).__model && isLinkable(childRecord as Record)) ? { type: (childRecord as Record).__model.__name, id: (childRecord as Record).id } : null;
144
317
 
145
318
  // Dasherize the key for URL paths (e.g., accessLinks -> access-links)
146
319
  const dasherizedKey = camelCaseToKebabCase(key);
@@ -89,7 +89,15 @@ export interface OrmRecord {
89
89
  __model?: { __name: string };
90
90
  __data: Record<string, unknown> & { id?: string | number; __pendingSqlId?: boolean };
91
91
  __relationships: Record<string, unknown>;
92
- toJSON?(options?: { fields?: Set<string>; baseUrl?: string }): Record<string, unknown>;
92
+ /**
93
+ * `linkage` is an ALREADY-RESOLVED decision supplied by a caller that holds
94
+ * the request (abofs/stonyx-orm#234): return `false` for a related record and
95
+ * its `{ type, id }` is dropped from `relationships.*.data`. Omitting it is
96
+ * the default, and the default is the pre-#234 document unchanged -- this
97
+ * method is also the `JSON.stringify` hook, so an implicit caller has no
98
+ * syntactic place to pass it (abofs/stonyx-orm#230).
99
+ */
100
+ toJSON?(options?: { fields?: Set<string>; baseUrl?: string; linkage?: LinkageFilter }): Record<string, unknown>;
93
101
  [key: string]: unknown;
94
102
  }
95
103
 
@@ -252,6 +260,102 @@ export interface AccessContext {
252
260
  * from one that classified the request and found nothing.
253
261
  */
254
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;
255
359
  }
256
360
 
257
361
  /**
@@ -274,3 +378,22 @@ export interface AccessContext {
274
378
  * context gets `TS2554: Expected 2 arguments, but got 1`.
275
379
  */
276
380
  export type AccessFunction = (request: unknown, context: AccessContext) => AccessMethod;
381
+
382
+ /**
383
+ * A resolved, request-scoped linkage decision: may `record` of model `type` be
384
+ * NAMED, by id, inside another model's document (abofs/stonyx-orm#234)?
385
+ *
386
+ * Arity is `(type, record)` and not `(type, id)` because the per-record filter
387
+ * a consumer returns is handed the RECORD -- this repo's own fixture reads
388
+ * `record.owner?.id`, not just `record.id`. The `(type, id)` pair is the CACHE
389
+ * key inside `createLinkageFilter`, not the input.
390
+ *
391
+ * DECLARED HERE, with the rest of the access vocabulary, and imported by every
392
+ * site that names it. It had three structurally-identical hand-written copies
393
+ * (`access-verdict.ts`, `record.ts`, `OrmRecord.toJSON` below) bridged to each
394
+ * other by nothing, so a drift in nullability or a widening of `type` would
395
+ * have landed on one and not the others -- which is the same "second,
396
+ * unreviewed vocabulary" failure `src/access-verdict.ts` exists to prevent, one
397
+ * level up in the type system.
398
+ */
399
+ export type LinkageFilter = (type: string, record: unknown) => boolean;