@stonyx/orm 0.3.2-beta.160 → 0.3.2-beta.161
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 +9 -1373
- package/dist/hooks.d.ts +1 -15
- package/dist/index.d.ts +0 -3
- package/dist/index.js +0 -8
- package/dist/main.d.ts +0 -116
- package/dist/main.js +0 -119
- package/dist/manage-record.js +9 -234
- package/dist/orm-request.d.ts +3 -274
- package/dist/orm-request.js +62 -1255
- package/dist/record.d.ts +0 -16
- package/dist/record.js +3 -149
- package/dist/setup-rest-server.js +5 -51
- package/dist/standalone-db.js +5 -17
- package/dist/types/orm-types.d.ts +0 -249
- package/dist/utils.d.ts +0 -44
- package/dist/utils.js +0 -47
- package/package.json +4 -4
- package/src/hooks.ts +1 -15
- package/src/index.ts +0 -10
- package/src/main.ts +0 -123
- package/src/manage-record.ts +9 -253
- package/src/orm-request.ts +65 -1276
- package/src/record.ts +3 -176
- package/src/setup-rest-server.ts +6 -59
- package/src/standalone-db.ts +6 -17
- package/src/types/orm-types.ts +1 -256
- package/src/types/stonyx-rest-server.d.ts +1 -14
- package/src/utils.ts +0 -50
- package/dist/access-verdict.d.ts +0 -85
- package/dist/access-verdict.js +0 -284
- package/src/access-verdict.ts +0 -312
package/src/record.ts
CHANGED
|
@@ -1,29 +1,12 @@
|
|
|
1
1
|
import { store } from '@stonyx/orm';
|
|
2
|
-
import log from 'stonyx/log';
|
|
3
2
|
import { getComputedProperties } from "./serializer.js";
|
|
4
3
|
import { camelCaseToKebabCase } from '@stonyx/utils/string';
|
|
5
4
|
import { getPluralName } from './plural-registry.js';
|
|
6
5
|
import type Serializer from './serializer.js';
|
|
7
|
-
import type { LinkageFilter } from './types/orm-types.js';
|
|
8
6
|
|
|
9
7
|
interface ToJSONOptions {
|
|
10
8
|
fields?: Set<string>;
|
|
11
9
|
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;
|
|
27
10
|
}
|
|
28
11
|
|
|
29
12
|
interface SerializeOptions {
|
|
@@ -55,25 +38,6 @@ interface JSONAPIResult {
|
|
|
55
38
|
links?: { self: string };
|
|
56
39
|
}
|
|
57
40
|
|
|
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
|
-
|
|
77
41
|
export default class Record {
|
|
78
42
|
/** @private */
|
|
79
43
|
__data: { [key: string]: unknown } = {};
|
|
@@ -152,13 +116,7 @@ export default class Record {
|
|
|
152
116
|
toJSON(options: ToJSONOptions = {}): JSONAPIResult {
|
|
153
117
|
if (!this.__serialized) throw new Error('Record must be serialized before being converted to JSON');
|
|
154
118
|
|
|
155
|
-
|
|
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;
|
|
119
|
+
const { fields, baseUrl } = options;
|
|
162
120
|
const { __data: data } = this;
|
|
163
121
|
const modelName = this.__model.__name;
|
|
164
122
|
const pluralizedModelName = getPluralName(modelName);
|
|
@@ -177,143 +135,12 @@ export default class Record {
|
|
|
177
135
|
attributes[key] = (getter as () => unknown).call(this);
|
|
178
136
|
}
|
|
179
137
|
|
|
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
|
-
|
|
311
138
|
for (const [key, childRecord] of Object.entries(this.__relationships)) {
|
|
312
139
|
if (fields && !fields.has(key)) continue;
|
|
313
140
|
|
|
314
141
|
const relationshipData = Array.isArray(childRecord)
|
|
315
|
-
? childRecord.filter((r: Record) => r?.__model).
|
|
316
|
-
: (childRecord && (childRecord as Record).__model
|
|
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;
|
|
317
144
|
|
|
318
145
|
// Dasherize the key for URL paths (e.g., accessLinks -> access-links)
|
|
319
146
|
const dasherizedKey = camelCaseToKebabCase(key);
|
package/src/setup-rest-server.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { waitForModule } from 'stonyx';
|
|
2
|
-
import
|
|
2
|
+
import { store } from '@stonyx/orm';
|
|
3
3
|
import OrmRequest from './orm-request.js';
|
|
4
4
|
import MetaRequest from './meta-request.js';
|
|
5
5
|
import RestServer from '@stonyx/rest-server';
|
|
@@ -7,20 +7,14 @@ import { forEachFileImport } from '@stonyx/utils/file';
|
|
|
7
7
|
import { dbKey } from './db.js';
|
|
8
8
|
import { getPluralName } from './plural-registry.js';
|
|
9
9
|
import log from 'stonyx/log';
|
|
10
|
-
import type { AccessFunction } from './types/orm-types.js';
|
|
11
10
|
|
|
12
11
|
interface AccessInstance {
|
|
13
12
|
models: string[] | '*';
|
|
14
|
-
|
|
15
|
-
* The consumer predicate. Called as `access(request, { model, operation })`
|
|
16
|
-
* -- the second argument is additive (abofs/stonyx-orm#202), so a predicate
|
|
17
|
-
* declared with a single parameter is still valid and still works.
|
|
18
|
-
*/
|
|
19
|
-
access: AccessFunction;
|
|
13
|
+
access: (request: unknown) => unknown;
|
|
20
14
|
}
|
|
21
15
|
|
|
22
16
|
export default async function(route: string, accessPath: string, metaRoute: boolean): Promise<void> {
|
|
23
|
-
const
|
|
17
|
+
const accessFiles: Record<string, (request: unknown) => unknown> = {};
|
|
24
18
|
|
|
25
19
|
try {
|
|
26
20
|
await forEachFileImport(accessPath, (accessClass: unknown) => {
|
|
@@ -37,9 +31,9 @@ export default async function(route: string, accessPath: string, metaRoute: bool
|
|
|
37
31
|
for (const model of models === '*' ? availableModels : models) {
|
|
38
32
|
if (model === dbKey) continue;
|
|
39
33
|
if (!store.data.has(model)) throw new Error(`Unable to define access for Invalid Model "${model}". Model does not exist`);
|
|
40
|
-
if (
|
|
34
|
+
if (accessFiles![model]) throw new Error(`Access for model "${model}" has already been defined by another access class.`);
|
|
41
35
|
|
|
42
|
-
|
|
36
|
+
accessFiles![model] = accessInstance.access;
|
|
43
37
|
}
|
|
44
38
|
});
|
|
45
39
|
} catch (error) {
|
|
@@ -47,60 +41,13 @@ export default async function(route: string, accessPath: string, metaRoute: bool
|
|
|
47
41
|
log.warn?.('You must define a valid access configuration file in order to access ORM generated REST endpoints.');
|
|
48
42
|
}
|
|
49
43
|
|
|
50
|
-
// -------------------------------------------------------------------------
|
|
51
|
-
// #202 -- the registry has to survive this function.
|
|
52
|
-
//
|
|
53
|
-
// `accessFunctions` used to be a function-local that was discarded at the return
|
|
54
|
-
// below, so the only thing that ever saw it was the mount loop. Each mounted
|
|
55
|
-
// OrmRequest then held its OWN model's predicate and nothing held the map, so
|
|
56
|
-
// at request time there was no route from a model NAME to that model's
|
|
57
|
-
// predicate -- which is what abofs/stonyx-orm#196 and #207 need in order to
|
|
58
|
-
// ask model X's predicate about a request routed to model Y.
|
|
59
|
-
//
|
|
60
|
-
// Published BEFORE `await waitForModule('rest-server')`, deliberately: that
|
|
61
|
-
// await is the ONLY yield point in this function, and the rest-server module
|
|
62
|
-
// may already be listening by the time it reports ready, so an assignment
|
|
63
|
-
// after it would leave a window in which a route is live and the registry is
|
|
64
|
-
// not.
|
|
65
|
-
//
|
|
66
|
-
// It is NOT before the mount loop for that reason, and the comment here used
|
|
67
|
-
// to say it was. `RestServer.mountRoute` is fully synchronous -- construct,
|
|
68
|
-
// registerCalls(), api.use() -- and nothing between the loop and this
|
|
69
|
-
// function's closing brace yields, so the event loop cannot deliver a request
|
|
70
|
-
// in there and the window that clause described cannot open. Measured:
|
|
71
|
-
// moving this assignment to the last statement of the function leaves the
|
|
72
|
-
// suite at 951 pass / 0 fail. Being ahead of the mount loop is free and
|
|
73
|
-
// harmless; it is not what makes the ordering correct.
|
|
74
|
-
//
|
|
75
|
-
// Assigned unconditionally, including when the try above failed and the map
|
|
76
|
-
// is empty or partial: the mount loop below is driven by this exact object,
|
|
77
|
-
// so at the moment of assignment whatever is reachable through
|
|
78
|
-
// `Orm.instance` is the same set of predicates that is about to enforce.
|
|
79
|
-
// A guard such as `if (Object.keys(accessFunctions).length)` would let the
|
|
80
|
-
// registry go silently missing on a total load failure, and a later consumer
|
|
81
|
-
// would read `undefined` from `getAccess` and have to distinguish "no access
|
|
82
|
-
// class" from "the registry was never published" -- which it cannot. That is
|
|
83
|
-
// the reasoning, and it is REASONING, not something this suite tests: the
|
|
84
|
-
// guarded variant is also 951 pass / 0 fail, AC8 included, because every boot
|
|
85
|
-
// in this suite loads a non-empty access map so the guard never fires. AC8
|
|
86
|
-
// demonstrably cannot catch it. Catching it needs a boot with
|
|
87
|
-
// `orm.paths.access` pointed at an empty directory, which this suite has no
|
|
88
|
-
// harness for.
|
|
89
|
-
//
|
|
90
|
-
// One further limit on "by construction": the mount loop passes `access` BY
|
|
91
|
-
// VALUE into each OrmRequest, so the enforcing set is a snapshot taken here,
|
|
92
|
-
// while `getAccess` reads the map live. The two are the same set at boot and
|
|
93
|
-
// stay the same set only for as long as nobody writes to the public field.
|
|
94
|
-
// The equality is a boot-time fact, not an invariant.
|
|
95
|
-
Orm.instance.accessFunctions = accessFunctions;
|
|
96
|
-
|
|
97
44
|
await waitForModule('rest-server');
|
|
98
45
|
|
|
99
46
|
// Remove "/" prefix and name mount point accordingly
|
|
100
47
|
const name = route === '/' ? 'index' : (route[0] === '/' ? route.slice(1) : route);
|
|
101
48
|
|
|
102
49
|
// Configure endpoints for models and views with access configuration
|
|
103
|
-
for (const [model, access] of Object.entries(
|
|
50
|
+
for (const [model, access] of Object.entries(accessFiles!)) {
|
|
104
51
|
const pluralizedModel = getPluralName(model);
|
|
105
52
|
const modelName = name === 'index' ? pluralizedModel : `${name}/${pluralizedModel}`;
|
|
106
53
|
RestServer.instance.mountRoute(OrmRequest, { name: modelName, options: { model, access } });
|
package/src/standalone-db.ts
CHANGED
|
@@ -8,10 +8,6 @@
|
|
|
8
8
|
|
|
9
9
|
import fs from 'fs/promises';
|
|
10
10
|
import path from 'path';
|
|
11
|
-
// `./utils.js` pulls in `@stonyx/utils/string` and nothing else -- no ORM
|
|
12
|
-
// bootstrap, no `@stonyx/orm` index, no side effects -- so the "no framework
|
|
13
|
-
// dependencies" property above still holds.
|
|
14
|
-
import { maxNumericId } from './utils.js';
|
|
15
11
|
|
|
16
12
|
interface StandaloneDBOptions {
|
|
17
13
|
dbPath?: string;
|
|
@@ -135,19 +131,12 @@ export default class StandaloneDB {
|
|
|
135
131
|
const records = await this.readCollection(collection);
|
|
136
132
|
|
|
137
133
|
if (!data.id) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
// THE TWO ARE NOT THE SAME FUNCTION beyond this line, deliberately.
|
|
145
|
-
// `StandaloneDB` has no model, id-type or transform concept, so `maxId + 1`
|
|
146
|
-
// IS its store key; `assignRecordId` has to map the candidate through the
|
|
147
|
-
// model's declared id transform first, and then walk past occupied keys.
|
|
148
|
-
// Transplanting this method's remaining logic into the ORM reproduces
|
|
149
|
-
// #203's landing-key defect exactly — which is what AC4 pins.
|
|
150
|
-
data.id = maxNumericId(records) + 1;
|
|
134
|
+
const maxId = records.reduce((max, r) => {
|
|
135
|
+
const rid = typeof r.id === 'number' ? r.id : 0;
|
|
136
|
+
return rid > max ? rid : max;
|
|
137
|
+
}, 0);
|
|
138
|
+
|
|
139
|
+
data.id = maxId + 1;
|
|
151
140
|
}
|
|
152
141
|
|
|
153
142
|
// Check for duplicate id
|
package/src/types/orm-types.ts
CHANGED
|
@@ -89,15 +89,7 @@ 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
|
-
|
|
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>;
|
|
92
|
+
toJSON?(options?: { fields?: Set<string>; baseUrl?: string }): Record<string, unknown>;
|
|
101
93
|
[key: string]: unknown;
|
|
102
94
|
}
|
|
103
95
|
|
|
@@ -176,250 +168,3 @@ export interface SnapshotEntry {
|
|
|
176
168
|
source?: string;
|
|
177
169
|
viewQuery?: string;
|
|
178
170
|
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* The shapes a consumer `access()` predicate may return.
|
|
182
|
-
*
|
|
183
|
-
* - `false` (or any falsy value) -- deny, 403.
|
|
184
|
-
* - `true` -- allow, with no per-record filter.
|
|
185
|
-
* - a permission string or array of them, drawn from the same four verbs as
|
|
186
|
-
* {@link AccessContext.operation}. A BARE STRING IS ONE PERMISSION, not a
|
|
187
|
-
* grant of all four.
|
|
188
|
-
* - a `(record) => boolean` predicate -- allow, and filter every record the
|
|
189
|
-
* request touches through it.
|
|
190
|
-
*
|
|
191
|
-
* Anything else fails CLOSED. See `src/orm-request.ts` `auth()`.
|
|
192
|
-
*/
|
|
193
|
-
export type AccessMethod = string | boolean | string[] | ((record: unknown) => boolean);
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* The closed vocabulary `AccessContext.operation` is drawn from
|
|
197
|
-
* (abofs/stonyx-orm#202).
|
|
198
|
-
*
|
|
199
|
-
* A literal union rather than `string`, so the guarantee the prose makes is the
|
|
200
|
-
* one the compiler enforces: a consumer who writes `operation === 'GET'` or
|
|
201
|
-
* `operation === 'get'` -- the hook vocabulary, see below -- gets a compile
|
|
202
|
-
* error instead of a comparison that never matches. A predicate that stops
|
|
203
|
-
* matching falls through to the permission array, so the misreading is
|
|
204
|
-
* fail-open shaped.
|
|
205
|
-
*
|
|
206
|
-
* In-repo precedent: `PersistErrorDetail.operation` in `src/main.ts`.
|
|
207
|
-
*/
|
|
208
|
-
export type AccessOperation = 'read' | 'create' | 'update' | 'delete';
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* The structural facts about the request being authorised, handed to a consumer
|
|
212
|
-
* `access()` predicate as its SECOND argument (abofs/stonyx-orm#202).
|
|
213
|
-
*
|
|
214
|
-
* These are the facts the framework already holds at authorisation time. Before
|
|
215
|
-
* #202 a consumer had to reconstruct both of them by string-matching a URL, and
|
|
216
|
-
* five independent fail-open variants of that reconstruction were found in one
|
|
217
|
-
* three-line documented example -- each one wrong in the direction that GRANTS
|
|
218
|
-
* access. Read these instead; there is nothing to parse and no variant to miss.
|
|
219
|
-
*
|
|
220
|
-
* `record` is deliberately NOT a member. `auth()` runs after route matching but
|
|
221
|
-
* before any handler executes (`@stonyx/rest-server` `src/request.ts:58-60`),
|
|
222
|
-
* so nothing has been fetched yet -- carrying a record here would force a
|
|
223
|
-
* pre-fetch on every request. It is also unnecessary: the `(record) => boolean`
|
|
224
|
-
* return shape of {@link AccessMethod} already IS the per-record hook, applied
|
|
225
|
-
* by the handlers. Auth-time and record-time are separate decision points.
|
|
226
|
-
*/
|
|
227
|
-
export interface AccessContext {
|
|
228
|
-
/**
|
|
229
|
-
* The model this route was mounted for, e.g. `'owner'` or `'phone-number'`.
|
|
230
|
-
*
|
|
231
|
-
* Model names are kebab-case, as declared under `config.orm.paths.model` and
|
|
232
|
-
* keyed in the store -- NOT the pluralised, mount-prefixed route name. It is
|
|
233
|
-
* read from the `OrmRequest` instance and is never derived from the request
|
|
234
|
-
* target, so a mount prefix, a case-varied path, a query string or an
|
|
235
|
-
* absolute-form request-target cannot change it.
|
|
236
|
-
*/
|
|
237
|
-
model: string;
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* The operation being authorised. Exactly one of the four {@link
|
|
241
|
-
* AccessOperation} verbs, or `undefined`. These are exactly the values of
|
|
242
|
-
* `methodAccessMap` in `src/orm-request.ts`, which is also what the
|
|
243
|
-
* permission-array return shape is matched against -- so the two forms cannot
|
|
244
|
-
* disagree.
|
|
245
|
-
*
|
|
246
|
-
* NOT the hook vocabulary. `HookContext.operation` (`src/hooks.ts`) carries
|
|
247
|
-
* `'list' | 'get' | 'create' | 'update' | 'delete'` on an identically-named
|
|
248
|
-
* key of an identically-shaped context object, and the access vocabulary
|
|
249
|
-
* collapses `list` and `get` into `'read'`. For one `GET /animals/1` a hook
|
|
250
|
-
* sees `'get'` and `access()` sees `'read'`. "No second vocabulary" is a
|
|
251
|
-
* statement about the ACCESS path only.
|
|
252
|
-
*
|
|
253
|
-
* `undefined` when the dispatched method has no entry in that map. Express
|
|
254
|
-
* delivers `HEAD` to the `GET` handler, so this is reachable. It is left
|
|
255
|
-
* undefined rather than defaulted on purpose: a fabricated `'read'` would
|
|
256
|
-
* turn an unclassified request into an authorised one.
|
|
257
|
-
*
|
|
258
|
-
* The KEY is required even though the value may be undefined: `auth()` always
|
|
259
|
-
* sets it, and a context that simply omitted it would be indistinguishable
|
|
260
|
-
* from one that classified the request and found nothing.
|
|
261
|
-
*/
|
|
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 OF THE MODEL BEING ASKED ABOUT, NOT WHICH SURFACE,
|
|
354
|
-
* AND THE ANSWER DEPENDS ON WHICH MODEL IS BEING ASKED ABOUT.
|
|
355
|
-
*
|
|
356
|
-
* For the ask about the ROUTE'S OWN model, all three of `GET /owners/gina`,
|
|
357
|
-
* `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` carry
|
|
358
|
-
* `recordId: 'gina'` -- `auth()` reads it off `request.params`.
|
|
359
|
-
*
|
|
360
|
-
* FOR THE ASK ABOUT A RELATED MODEL, IT IS `null`, AND THAT IS A LIMIT ON
|
|
361
|
-
* WHAT A PREDICATE CAN EXPRESS (abofs/stonyx-orm#232). The two relationship
|
|
362
|
-
* route families resolve the RELATED model's own predicate -- `animal` on
|
|
363
|
-
* `GET /owners/gina/pets`, `owner` on `GET /animals/4/owner` -- and that ask
|
|
364
|
-
* carries `recordId: null` while `request.params` names a record of a
|
|
365
|
-
* DIFFERENT model. So a predicate answering about a related model gets the
|
|
366
|
-
* model name, the operation and the request, and CANNOT branch on which
|
|
367
|
-
* related record it is being asked about.
|
|
368
|
-
*
|
|
369
|
-
* The rule, so it is not re-derived wrong: `recordId` may name a record only
|
|
370
|
-
* where the route addresses exactly one record OF THE MODEL BEING ASKED
|
|
371
|
-
* ABOUT. A `hasMany` related-resource route returns many records of one type
|
|
372
|
-
* and the verdict is resolved ONCE PER TYPE, before any record is examined --
|
|
373
|
-
* seeding it from a record would let the first one decide for all of them.
|
|
374
|
-
*
|
|
375
|
-
* What still works, and what does not, is pinned as behaviour by `#232 AC10`
|
|
376
|
-
* in test/integration/orm-test.ts and stated for consumers in README.md:
|
|
377
|
-
* model-level denies work, request-level denies work, and the per-record
|
|
378
|
-
* FILTER shape works because `access()` may return a function and that
|
|
379
|
-
* function receives the whole record. Branching on identity BEFORE returning
|
|
380
|
-
* does not.
|
|
381
|
-
*
|
|
382
|
-
* `?include=` is a separate surface and is abofs/stonyx-orm#233 / #235.
|
|
383
|
-
*/
|
|
384
|
-
recordId: string | number | null;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/**
|
|
388
|
-
* A consumer `access()` predicate.
|
|
389
|
-
*
|
|
390
|
-
* The second argument is ADDITIVE: JavaScript ignores extra arguments, so every
|
|
391
|
-
* pre-#202 single-argument predicate keeps working untouched. Changing the
|
|
392
|
-
* FIRST argument instead would have been the breaking form, and a predicate
|
|
393
|
-
* that can no longer identify its collection falls through to a full CRUD
|
|
394
|
-
* grant -- so the "safer" breaking change would have converted every unmigrated
|
|
395
|
-
* predicate into a fail-open.
|
|
396
|
-
*
|
|
397
|
-
* `context` is nonetheless REQUIRED in the type, and that costs back-compat
|
|
398
|
-
* nothing. TypeScript already lets a fewer-parameter implementation satisfy a
|
|
399
|
-
* more-parameter signature, so an arity-1 predicate assigns to this type
|
|
400
|
-
* cleanly -- measured under `--strict`. What the `?` bought was the opposite of
|
|
401
|
-
* safety: it silently permitted `getAccess('animal')?.(request)` at the CALL
|
|
402
|
-
* site, i.e. exactly the omission {@link AccessContext} exists to prevent, and
|
|
403
|
-
* that call gets the model-wrong answer. Required, a caller that drops the
|
|
404
|
-
* context gets `TS2554: Expected 2 arguments, but got 1`.
|
|
405
|
-
*/
|
|
406
|
-
export type AccessFunction = (request: unknown, context: AccessContext) => AccessMethod;
|
|
407
|
-
|
|
408
|
-
/**
|
|
409
|
-
* A resolved, request-scoped linkage decision: may `record` of model `type` be
|
|
410
|
-
* NAMED, by id, inside another model's document (abofs/stonyx-orm#234)?
|
|
411
|
-
*
|
|
412
|
-
* Arity is `(type, record)` and not `(type, id)` because the per-record filter
|
|
413
|
-
* a consumer returns is handed the RECORD -- this repo's own fixture reads
|
|
414
|
-
* `record.owner?.id`, not just `record.id`. The `(type, id)` pair is the CACHE
|
|
415
|
-
* key inside `createLinkageFilter`, not the input.
|
|
416
|
-
*
|
|
417
|
-
* DECLARED HERE, with the rest of the access vocabulary, and imported by every
|
|
418
|
-
* site that names it. It had three structurally-identical hand-written copies
|
|
419
|
-
* (`access-verdict.ts`, `record.ts`, `OrmRecord.toJSON` below) bridged to each
|
|
420
|
-
* other by nothing, so a drift in nullability or a widening of `type` would
|
|
421
|
-
* have landed on one and not the others -- which is the same "second,
|
|
422
|
-
* unreviewed vocabulary" failure `src/access-verdict.ts` exists to prevent, one
|
|
423
|
-
* level up in the type system.
|
|
424
|
-
*/
|
|
425
|
-
export type LinkageFilter = (type: string, record: unknown) => boolean;
|
|
@@ -5,20 +5,7 @@ declare module '@stonyx/rest-server' {
|
|
|
5
5
|
|
|
6
6
|
interface RouteOptions {
|
|
7
7
|
name: string;
|
|
8
|
-
|
|
9
|
-
* `access` is the two-argument post-#202 shape. This is the THIRD place the
|
|
10
|
-
* contract is declared (`AccessInstance.access` in
|
|
11
|
-
* `src/setup-rest-server.ts` and `OrmRequest.access` in
|
|
12
|
-
* `src/orm-request.ts` are the other two) and it is the one `mountRoute` is
|
|
13
|
-
* actually called through, at `src/setup-rest-server.ts`. It kept the
|
|
14
|
-
* pre-#202 single-argument signature after the other two migrated; the
|
|
15
|
-
* union with `Record<string, unknown>` meant nothing broke, which is
|
|
16
|
-
* exactly why it would have drifted silently.
|
|
17
|
-
*
|
|
18
|
-
* Spelled structurally rather than as `AccessFunction`: an ambient
|
|
19
|
-
* `declare module` block cannot carry an `import type`.
|
|
20
|
-
*/
|
|
21
|
-
options?: { model: string; access: (request: unknown, context: { model: string; operation: string | undefined }) => unknown } | Record<string, unknown>;
|
|
8
|
+
options?: { model: string; access: (request: unknown) => unknown } | Record<string, unknown>;
|
|
22
9
|
}
|
|
23
10
|
|
|
24
11
|
export default class RestServer {
|