@simtlix/simfinity-core 3.5.2 → 3.5.5
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 +11 -4
- package/package.json +2 -1
- package/src/auth/expressions.js +77 -16
- package/src/auth/index.js +44 -8
- package/src/auth/rules.js +54 -10
- package/src/auth/thenable.js +17 -0
- package/src/index.js +1 -0
- package/src/mutation-limits.js +24 -0
- package/src/query-plan.js +14 -6
- package/src/relation-owners.js +5 -0
- package/src/relation-storage.js +58 -0
- package/src/runtime.js +523 -127
- package/src/scalars/factory.js +30 -10
- package/src/scalars/pattern.js +16 -0
- package/src/scalars.js +3 -2
- package/src/validators.js +4 -4
- package/types/index.d.ts +179 -7
package/README.md
CHANGED
|
@@ -8,14 +8,21 @@ For relational storage, `@simtlix/simfinity-sql` builds on core and delegates ph
|
|
|
8
8
|
|
|
9
9
|
## Runtime
|
|
10
10
|
|
|
11
|
-
`createRuntime(adapter)` creates
|
|
11
|
+
`createRuntime(adapter)` creates a Simfinity runtime. Each instance owns its registrations, generated input types, middleware, scopes, hooks, custom mutations, and state machines; the GraphQL type objects it builds resolvers for are bound to it, and query and mutation limits are process-wide. It exposes:
|
|
12
12
|
|
|
13
13
|
- `connect`, `addNoEndpointType`, and `createSchema` for registration and schema creation;
|
|
14
14
|
- `getModel`, `getType`, `getInputType`, and `getRegistrations` for runtime inspection;
|
|
15
15
|
- `use`, `registerMutation`, and `saveObject` for middleware and write orchestration;
|
|
16
|
-
- `preventCreatingCollection` to pass validation-only storage setup to an adapter
|
|
16
|
+
- `preventCreatingCollection` to pass validation-only storage setup to an adapter;
|
|
17
|
+
- `configureQueryLimits` and `configureMutationLimits` for the process-wide page-size and nested-operation limits.
|
|
17
18
|
|
|
18
|
-
The adapter supplies model creation, identifiers, transactions, record reads and writes, queries, counts, aggregations, and inverse collection reads. `DatabaseAdapter`, `Runtime`, controller, middleware, registration, and state-machine interfaces are exported in the TypeScript declarations. A runtime binds one adapter; create another runtime to use another database or configuration. An adapter can also define `getByIds(model, ids, { context })`, which returns the records it finds in `getById` shape and any order; the runtime then batches generated single-reference reads per request and type, matching records by `castId`, splitting batches at `maxPageSize`, and reading an ID with `getById` when a batch read fails or when `castId` would normalize it.
|
|
19
|
+
The adapter supplies model creation, identifiers, transactions, record reads and writes, queries, counts, aggregations, and inverse collection reads. `DatabaseAdapter`, `Runtime`, controller, middleware, registration, and state-machine interfaces are exported in the TypeScript declarations. A runtime binds one adapter; create another runtime to use another database or configuration. An adapter can also define `getByIds(model, ids, { context })`, which returns the records it finds in `getById` shape and any order; the runtime then batches generated single-reference reads per request and type, matching records by `castId`, splitting batches at `maxPageSize`, and reading an ID with `getById` when a batch read fails or when `castId` would normalize or reject it. `castId` should throw a `NOT_VALID_ID` (400) `SimfinityError` for a malformed value and must not create a new identifier. When `getById` rejects a stored reference value that way, including one that `castId` rejected first, the generated reference field fails with an `InternalServerError`, since the client did not send the value. An adapter that hydrates records can define `readEmbeddedValue(value)`: every nullable, singular embedded object field without a resolver then passes its value through it, so an explicitly stored `null` reads as `null`. The field is read as graphql's default resolver reads it, so a method on the parent is called and a promise it returns is awaited before the hook receives the value. The MongoDB adapter defines it.
|
|
20
|
+
|
|
21
|
+
Entity types, those registered with `connect()` and the targets of non-embedded relations, resolve `id` to `_id`, falling back to `id`, whatever the `createSchema()` allowlists include; types used only as embedded values keep their declared `id`. Embedded copies of an entity type resolve the same way, so they return their declared `id` unless the adapter stores an `_id` for them, as MongoDB does for embedded list items. The resolver is set on the type object, so it applies in every runtime that shares the type. `createSchema()` marks these registrations with `storedIdentity: true` before calling `adapter.prepare()`.
|
|
22
|
+
|
|
23
|
+
When a runtime first creates a schema, it binds each GraphQL object type whose non-embedded relation fields receive its generated resolvers. It also reserves each reachable object type that still has a non-embedded relation field without a resolver, such as an unregistered custom mutation result. Registering a bound or reserved type in another runtime, or building another runtime's schema that reaches it through fields, interfaces, union members or custom mutation results, throws `TYPE_BOUND_TO_OTHER_RUNTIME` (409). `createSchema()` also rejects a type with a relation field copied, by `toConfig()` or a field spread, after another runtime generated that field's resolver. Create type objects once per runtime, for example with a factory function. Types without non-embedded relation fields, and types whose relation fields all have application resolvers, can be shared, and another runtime can use a `toConfig()` copy made before the first runtime built its schema. A shared custom mutation result type with an unresolved relation field is rejected.
|
|
24
|
+
|
|
25
|
+
Registration and `createSchema()` validate `extensions.scope` (`INVALID_SCOPE`) and reject list or embedded relations to unregistered types (`UNREGISTERED_RELATION_TARGET`); `use()` rejects non-functions (`INVALID_MIDDLEWARE`). Before `adapter.prepare()`, `createSchema()` rejects a writable non-embedded list relation without a non-empty child `connectionField` whose resolver it would generate (`INVALID_MODEL`, 400). A collection without `connectionField` that has an application resolver, or is `readOnly`, only logs a `Configuration issue` warning, unless it is both, and nested writes through it fail with `INVALID_MODEL` (500) before anything is written; adapters that describe the whole model, such as PostgreSQL and transactional MongoDB, still reject it in `prepare()`. Fields named like generated query arguments (`sort`, `pagination`, `AND`, `OR`, and `aggregation` on aggregate endpoints) also log a warning once per runtime.
|
|
19
26
|
|
|
20
27
|
Generated and custom mutations run through `adapter.withTransaction`. Retried attempts receive fresh clones of GraphQL input objects, lists, and dates while enum values keep their identity. An adapter can define `stateValue` when its persisted enum representation differs from GraphQL enum names.
|
|
21
28
|
|
|
@@ -27,4 +34,4 @@ Importing the runtime installs Simfinity's `__Field.extensions` introspection fi
|
|
|
27
34
|
|
|
28
35
|
`createQueryPlan` and `resolveModelPath` turn Simfinity filter, sort, pagination, and aggregation inputs into a driver-neutral plan resolved against that metadata.
|
|
29
36
|
|
|
30
|
-
The package also exports the `auth`, `validators`, `scalars`, and `plugins` helper namespaces alongside `createValidatedScalar`, `SimfinityError`, `InternalServerError`, `buildErrorFormatter`, `QLOperator`, `QLSort`, and `QLValue`. Database packages reuse these exports so helpers, errors, and globally named GraphQL types retain one identity.
|
|
37
|
+
The package also exports the `auth`, `validators`, `scalars`, and `plugins` helper namespaces alongside `createValidatedScalar`, `SimfinityError`, `InternalServerError`, `buildErrorFormatter`, `configureQueryLimits`, `configureMutationLimits`, `QLOperator`, `QLSort`, and `QLValue`. `buildErrorFormatter` returns a `GraphQLError` whose `originalError` is the classified Simfinity error. Database packages reuse these exports so helpers, errors, and globally named GraphQL types retain one identity.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simtlix/simfinity-core",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.5",
|
|
4
4
|
"description": "Driver-free GraphQL runtime and model metadata for Simfinity",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"./auth/expressions": "./src/auth/expressions.js",
|
|
16
16
|
"./auth/rules": "./src/auth/rules.js",
|
|
17
17
|
"./internal/object-id": "./src/auth/object-id.js",
|
|
18
|
+
"./internal/relation-storage": "./src/relation-storage.js",
|
|
18
19
|
"./plugins": "./src/plugins.js",
|
|
19
20
|
"./scalars": "./src/scalars.js",
|
|
20
21
|
"./validators": "./src/validators.js"
|
package/src/auth/expressions.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { normalizeObjectId } from './object-id.js';
|
|
8
|
+
import { isThenable, discardThenable } from './thenable.js';
|
|
8
9
|
|
|
9
10
|
const invalidResult = Symbol('invalid policy result');
|
|
10
11
|
|
|
@@ -21,7 +22,8 @@ const isValidValue = (value) => {
|
|
|
21
22
|
if (isReference(value)) {
|
|
22
23
|
return Object.keys(value).length === 1 && Object.hasOwn(value, 'ref') && isValidRef(value.ref);
|
|
23
24
|
}
|
|
24
|
-
|
|
25
|
+
// NaN equals nothing, so a literal NaN is rejected, as in literal `in` lists.
|
|
26
|
+
return value !== undefined && typeof value !== 'function' && typeof value !== 'symbol' && !Number.isNaN(value);
|
|
25
27
|
};
|
|
26
28
|
|
|
27
29
|
const isDenseArray = value => Array.isArray(value)
|
|
@@ -105,38 +107,93 @@ const resolveValue = (value, context) => {
|
|
|
105
107
|
return isReference(value) ? resolveRef(value.ref, context) : value;
|
|
106
108
|
};
|
|
107
109
|
|
|
108
|
-
|
|
110
|
+
/**
|
|
111
|
+
* The time value of a genuine Date from any realm (NaN when invalid), or undefined for other values.
|
|
112
|
+
* The tag skips other objects cheaply; getTime then rejects objects that only spoof the tag.
|
|
113
|
+
*/
|
|
114
|
+
const dateTime = (value) => {
|
|
115
|
+
if (value === null || typeof value !== 'object') return undefined;
|
|
116
|
+
try {
|
|
117
|
+
return Object.prototype.toString.call(value) === '[object Date]' ? Date.prototype.getTime.call(value) : undefined;
|
|
118
|
+
} catch {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// Registered ObjectIds compare by hexadecimal value; other values except Dates compare strictly.
|
|
109
124
|
const comparable = value => normalizeObjectId(value) ?? value;
|
|
110
125
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Classify the left value of a comparison once: a promise, null, NaN, a Date by its time, or another
|
|
128
|
+
* value in its comparable form. A membership list then repeats only the checks on its items.
|
|
129
|
+
*/
|
|
130
|
+
const classifyOperand = (value) => {
|
|
131
|
+
if (isThenable(value)) return { kind: 'promise', value: undefined };
|
|
132
|
+
if (value === null) return { kind: 'null', value };
|
|
133
|
+
if (Number.isNaN(value)) return { kind: 'nan', value };
|
|
134
|
+
const time = dateTime(value);
|
|
135
|
+
if (time !== undefined) return { kind: 'date', value: time };
|
|
136
|
+
return { kind: 'value', value: comparable(value) };
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Compare a classified value with a resolved value: true, false, or invalidResult when they cannot
|
|
141
|
+
* be compared reliably. A promise is not its eventual value, so it is never compared; a rejected
|
|
142
|
+
* native one is handled, so it cannot crash the process. null stays comparable with anything else,
|
|
143
|
+
* as an explicit check for a missing value. NaN equals nothing, and Dates compare by time, only with
|
|
144
|
+
* valid Dates. Otherwise strict equality never matches values of different types, such as a string
|
|
145
|
+
* and a number. Such comparisons are invalid, so `not` cannot turn them into a grant.
|
|
146
|
+
*/
|
|
147
|
+
const compareOperand = (left, right) => {
|
|
148
|
+
if (isThenable(right)) {
|
|
149
|
+
discardThenable(right);
|
|
150
|
+
return invalidResult;
|
|
151
|
+
}
|
|
152
|
+
if (left.kind === 'promise') return invalidResult;
|
|
153
|
+
if (left.kind === 'null' || right === null) return left.kind === 'null' && right === null;
|
|
154
|
+
if (left.kind === 'nan' || Number.isNaN(right)) return invalidResult;
|
|
155
|
+
const rightTime = dateTime(right);
|
|
156
|
+
if (left.kind === 'date' || rightTime !== undefined) {
|
|
157
|
+
if (left.kind !== 'date' || rightTime === undefined || Number.isNaN(left.value) || Number.isNaN(rightTime)) {
|
|
158
|
+
return invalidResult;
|
|
159
|
+
}
|
|
160
|
+
return left.value === rightTime;
|
|
161
|
+
}
|
|
162
|
+
const other = comparable(right);
|
|
163
|
+
return typeof left.value !== typeof other ? invalidResult : left.value === other;
|
|
164
|
+
};
|
|
115
165
|
|
|
116
166
|
const evaluateMembership = (target, list) => {
|
|
117
167
|
// A list or plain object is not a member value.
|
|
118
168
|
if (!Array.isArray(list) || isPlainValue(target)) return invalidResult;
|
|
169
|
+
const operand = classifyOperand(target);
|
|
119
170
|
// Never call the list's own includes: Mongoose arrays cast and compare loosely.
|
|
120
171
|
let mismatched = false;
|
|
121
172
|
for (let index = 0; index < list.length; index++) {
|
|
122
|
-
const
|
|
123
|
-
if (
|
|
124
|
-
if (
|
|
173
|
+
const result = compareOperand(operand, list[index]);
|
|
174
|
+
if (result === true) return true;
|
|
175
|
+
if (result === invalidResult) mismatched = true;
|
|
125
176
|
}
|
|
126
|
-
// Without a match, an item
|
|
177
|
+
// Without a match, an item that could not be compared means the list was not checked reliably.
|
|
127
178
|
return mismatched ? invalidResult : false;
|
|
128
179
|
};
|
|
129
180
|
|
|
130
181
|
const evaluateComparison = (operator, operands, context) => {
|
|
131
182
|
const left = resolveValue(operands[0], context);
|
|
132
183
|
const right = resolveValue(operands[1], context);
|
|
133
|
-
|
|
184
|
+
// A promise is not its eventual value, so it cannot be compared, even with an empty list. A rejected
|
|
185
|
+
// native promise a ref yields, such as a lazy relation that failed to load, is handled so it cannot
|
|
186
|
+
// crash the process.
|
|
187
|
+
const leftThenable = isThenable(left);
|
|
188
|
+
const rightThenable = isThenable(right);
|
|
189
|
+
if (leftThenable) discardThenable(left);
|
|
190
|
+
if (rightThenable) discardThenable(right);
|
|
191
|
+
if (left === undefined || right === undefined || typeof left === 'function' || typeof right === 'function'
|
|
192
|
+
|| leftThenable || rightThenable) {
|
|
134
193
|
return invalidResult;
|
|
135
194
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const other = comparable(right);
|
|
139
|
-
return isMismatch(target, other) ? invalidResult : target === other;
|
|
195
|
+
if (operator === 'in') return evaluateMembership(left, right);
|
|
196
|
+
return compareOperand(classifyOperand(left), right);
|
|
140
197
|
};
|
|
141
198
|
|
|
142
199
|
const evaluateAllOf = (expressions, context) => {
|
|
@@ -209,6 +266,9 @@ const compileOperand = (operator, value, index) => {
|
|
|
209
266
|
if (isReference(value)) return new CompiledOperand(true, value.ref);
|
|
210
267
|
// A literal membership list is copied, so only its validated items are compared.
|
|
211
268
|
if (operator === 'in' && index === 1) return new CompiledOperand(false, Object.freeze(copyArray(value)));
|
|
269
|
+
// A literal Date compares by time, so a private copy keeps setTime on the configured one out.
|
|
270
|
+
const time = dateTime(value);
|
|
271
|
+
if (time !== undefined) return new CompiledOperand(false, new Date(time));
|
|
212
272
|
// Other literals keep their identity, which equality compares.
|
|
213
273
|
return new CompiledOperand(false, value);
|
|
214
274
|
};
|
|
@@ -219,7 +279,8 @@ const snapshotOperands = (operator, operand) => Object.freeze(
|
|
|
219
279
|
|
|
220
280
|
/**
|
|
221
281
|
* Copy the validated structure, so evaluation needs no revalidation and later changes to the
|
|
222
|
-
* configured object cannot make it malformed.
|
|
282
|
+
* configured object cannot make it malformed. Date literals are copied; other literal operands keep
|
|
283
|
+
* their identity.
|
|
223
284
|
*/
|
|
224
285
|
const snapshotExpression = (expression) => {
|
|
225
286
|
if (typeof expression === 'boolean') return expression;
|
package/src/auth/index.js
CHANGED
|
@@ -31,11 +31,23 @@
|
|
|
31
31
|
* const yoga = createYoga({ schema, plugins: [authPlugin] });
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
GraphQLError,
|
|
36
|
+
GraphQLEnumType,
|
|
37
|
+
GraphQLInputObjectType,
|
|
38
|
+
GraphQLInterfaceType,
|
|
39
|
+
GraphQLObjectType,
|
|
40
|
+
GraphQLScalarType,
|
|
41
|
+
GraphQLUnionType,
|
|
42
|
+
defaultFieldResolver,
|
|
43
|
+
__Field,
|
|
44
|
+
} from 'graphql';
|
|
35
45
|
import SimfinityError from '../errors/simfinity.error.js';
|
|
36
46
|
import { UnauthenticatedError, ForbiddenError, createAuthError } from './errors.js';
|
|
37
47
|
import { isPolicyExpression, createRuleFromExpression, evaluateExpression } from './expressions.js';
|
|
38
48
|
import { collectQueryPaths, walkQueryPath } from '../query-plan.js';
|
|
49
|
+
import { relationFieldOwners } from '../relation-owners.js';
|
|
50
|
+
import { isThenable } from './thenable.js';
|
|
39
51
|
import {
|
|
40
52
|
resolvePath,
|
|
41
53
|
requireAuth,
|
|
@@ -145,8 +157,20 @@ const isExemptMetadataType = (permissions, type) => isSimfinityMetadataType(type
|
|
|
145
157
|
// these schemas; in a schema no instance processed, every wrapper keeps enforcing its rules.
|
|
146
158
|
const authorizedSchemas = new WeakSet();
|
|
147
159
|
|
|
148
|
-
const
|
|
149
|
-
|
|
160
|
+
const namedTypeClasses = [
|
|
161
|
+
GraphQLObjectType, GraphQLInterfaceType, GraphQLUnionType,
|
|
162
|
+
GraphQLScalarType, GraphQLEnumType, GraphQLInputObjectType,
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A type created by another copy of graphql (a second installed version, or its ESM and CommonJS
|
|
167
|
+
* builds loaded together) fails `instanceof`, so it cannot be classified and its fields could not be
|
|
168
|
+
* protected. graphql's own isObjectType only reports such copies outside production.
|
|
169
|
+
*/
|
|
170
|
+
const assertSameGraphQLModule = (typeName, type) => {
|
|
171
|
+
if (namedTypeClasses.some(NamedType => type instanceof NamedType)) return;
|
|
172
|
+
throw new TypeError(`Cannot authorize type ${typeName}: it was created by a different copy of the graphql module than the auth plugin uses. Install a single graphql version and load it through one entry point.`);
|
|
173
|
+
};
|
|
150
174
|
|
|
151
175
|
const validateConfiguration = (permissions, defaultPolicy) => {
|
|
152
176
|
if (defaultPolicy !== 'ALLOW' && defaultPolicy !== 'DENY') {
|
|
@@ -348,6 +372,9 @@ export const createFieldMiddleware = (permissions, options = {}) => createAuthMi
|
|
|
348
372
|
* and rebuilds the schema), this plugin mutates resolvers directly on the existing schema,
|
|
349
373
|
* avoiding schema reconstruction and the duplicate-type errors it can cause.
|
|
350
374
|
*
|
|
375
|
+
* `onSchemaChange` throws `TypeError` before wrapping any field when a schema type was created by
|
|
376
|
+
* another copy of the graphql module, instead of leaving that type's fields unprotected.
|
|
377
|
+
*
|
|
351
378
|
* @param {PermissionSchema} permissions - The permission schema object
|
|
352
379
|
* @param {AuthMiddlewareOptions} [options={}] - Plugin options
|
|
353
380
|
* @returns {Object} An Envelop plugin with an `onSchemaChange` hook
|
|
@@ -441,7 +468,7 @@ export const createAuthPlugin = (permissions, options = {}) => {
|
|
|
441
468
|
return denied();
|
|
442
469
|
};
|
|
443
470
|
|
|
444
|
-
|
|
471
|
+
const resolver = (parent, args, ctx, info) => {
|
|
445
472
|
// In a schema that only other auth plugin instances processed, their rules apply, not these.
|
|
446
473
|
if (info?.schema && authorizedSchemas.has(info.schema) && !processedSchemas.has(info.schema)) {
|
|
447
474
|
return originalResolve(parent, args, ctx, info);
|
|
@@ -454,6 +481,11 @@ export const createAuthPlugin = (permissions, options = {}) => {
|
|
|
454
481
|
}
|
|
455
482
|
return runRules(0, parent, args, ctx, info);
|
|
456
483
|
};
|
|
484
|
+
// The wrapper still reads through the runtime that generated a relation resolver, so a field
|
|
485
|
+
// copied with it stays bound to that runtime.
|
|
486
|
+
const owner = relationFieldOwners.get(originalResolve);
|
|
487
|
+
if (owner !== undefined) relationFieldOwners.set(resolver, owner);
|
|
488
|
+
return resolver;
|
|
457
489
|
};
|
|
458
490
|
|
|
459
491
|
const wrapSchemaResolvers = (schema) => {
|
|
@@ -461,11 +493,15 @@ export const createAuthPlugin = (permissions, options = {}) => {
|
|
|
461
493
|
validateConfiguration(permissions, defaultPolicy);
|
|
462
494
|
warnAboutQueryRoot(schema);
|
|
463
495
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
for (const [typeName, type] of Object.entries(
|
|
467
|
-
if (
|
|
496
|
+
// Check every type before wrapping any field, so an unsupported schema is left unchanged.
|
|
497
|
+
const objectTypes = [];
|
|
498
|
+
for (const [typeName, type] of Object.entries(schema.getTypeMap())) {
|
|
499
|
+
if (typeName.startsWith('__')) continue;
|
|
500
|
+
assertSameGraphQLModule(typeName, type);
|
|
501
|
+
if (type instanceof GraphQLObjectType && !isExemptMetadataType(permissions, type)) objectTypes.push([typeName, type]);
|
|
502
|
+
}
|
|
468
503
|
|
|
504
|
+
for (const [typeName, type] of objectTypes) {
|
|
469
505
|
const fields = type.getFields();
|
|
470
506
|
|
|
471
507
|
for (const [fieldName, field] of Object.entries(fields)) {
|
package/src/auth/rules.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { UnauthenticatedError, ForbiddenError } from './errors.js';
|
|
2
2
|
import { normalizeObjectId } from './object-id.js';
|
|
3
|
+
import { isThenable, discardThenable } from './thenable.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Resolves a value from an object using a dotted path string or function.
|
|
7
|
+
* A function's result is returned as is, including a promise; the built-in rules deny such results.
|
|
6
8
|
* @param {Object} obj - The object to resolve from
|
|
7
9
|
* @param {string|Function} pathOrFn - Dotted path (e.g., 'user.profile.id') or function to extract value
|
|
8
10
|
* @returns {*} The resolved value or undefined if not found
|
|
@@ -44,6 +46,30 @@ const ruleFromCheck = (check) => {
|
|
|
44
46
|
|
|
45
47
|
const unauthenticated = () => new UnauthenticatedError('You must be logged in to access this resource');
|
|
46
48
|
|
|
49
|
+
// Built-in checks are synchronous, so they cannot wait for a path that yields a promise or another
|
|
50
|
+
// thenable. Such a value is never truthy evidence of a user or claim: it denies with this error.
|
|
51
|
+
const pendingValue = () => new TypeError(
|
|
52
|
+
'Authorization paths must return values synchronously; resolve asynchronous data in the GraphQL context or use an async rule',
|
|
53
|
+
);
|
|
54
|
+
const pending = Symbol('pending authorization value');
|
|
55
|
+
|
|
56
|
+
const isAsyncFunction = value => ['[object AsyncFunction]', '[object AsyncGeneratorFunction]']
|
|
57
|
+
.includes(Object.prototype.toString.call(value));
|
|
58
|
+
|
|
59
|
+
const validatePath = (name, pathOrFn) => {
|
|
60
|
+
if (isAsyncFunction(pathOrFn)) {
|
|
61
|
+
throw new TypeError(`${name} must be a dotted path or a synchronous extractor function`);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Resolve a path for a built-in check; a thenable result is discarded and reported as pending. */
|
|
66
|
+
const readPath = (obj, pathOrFn) => {
|
|
67
|
+
const value = resolvePath(obj, pathOrFn);
|
|
68
|
+
if (!isThenable(value)) return value;
|
|
69
|
+
discardThenable(value);
|
|
70
|
+
return pending;
|
|
71
|
+
};
|
|
72
|
+
|
|
47
73
|
/**
|
|
48
74
|
* Rule that requires the user to be authenticated
|
|
49
75
|
* Checks for user existence at the specified path in context
|
|
@@ -55,9 +81,12 @@ const unauthenticated = () => new UnauthenticatedError('You must be logged in to
|
|
|
55
81
|
* requireAuth('session.currentUser') // checks ctx.session.currentUser
|
|
56
82
|
*/
|
|
57
83
|
export const requireAuth = (userPath = 'user') => {
|
|
84
|
+
validatePath('userPath', userPath);
|
|
58
85
|
return ruleFromCheck((_parent, _args, ctx) => {
|
|
59
|
-
if (!ctx
|
|
60
|
-
|
|
86
|
+
if (!ctx) return unauthenticated;
|
|
87
|
+
const user = readPath(ctx, userPath);
|
|
88
|
+
if (user === pending) return pendingValue;
|
|
89
|
+
return user ? true : unauthenticated;
|
|
61
90
|
});
|
|
62
91
|
};
|
|
63
92
|
|
|
@@ -80,14 +109,18 @@ export const requireRole = (role, options = {}) => {
|
|
|
80
109
|
throw new TypeError('Required roles must be a nonempty string or array of nonempty strings');
|
|
81
110
|
}
|
|
82
111
|
const { userPath = 'user', rolePath = 'role' } = options;
|
|
112
|
+
validatePath('userPath', userPath);
|
|
113
|
+
validatePath('rolePath', rolePath);
|
|
83
114
|
const forbidden = () => new ForbiddenError(`Requires role: ${roles.join(' or ')}`);
|
|
84
115
|
|
|
85
116
|
return ruleFromCheck((_parent, _args, ctx) => {
|
|
86
117
|
if (!ctx) return unauthenticated;
|
|
87
|
-
const user =
|
|
118
|
+
const user = readPath(ctx, userPath);
|
|
119
|
+
if (user === pending) return pendingValue;
|
|
88
120
|
if (!user) return unauthenticated;
|
|
89
121
|
|
|
90
|
-
const userRole =
|
|
122
|
+
const userRole = readPath(user, rolePath);
|
|
123
|
+
if (userRole === pending) return pendingValue;
|
|
91
124
|
return roles.includes(userRole) ? true : forbidden;
|
|
92
125
|
});
|
|
93
126
|
};
|
|
@@ -130,13 +163,18 @@ export const requirePermission = (permission, options = {}) => {
|
|
|
130
163
|
throw new TypeError('Required permissions must be a nonempty string or array of nonempty strings');
|
|
131
164
|
}
|
|
132
165
|
const { userPath = 'user', permissionsPath = 'permissions' } = options;
|
|
166
|
+
validatePath('userPath', userPath);
|
|
167
|
+
validatePath('permissionsPath', permissionsPath);
|
|
133
168
|
|
|
134
169
|
return ruleFromCheck((_parent, _args, ctx) => {
|
|
135
170
|
if (!ctx) return unauthenticated;
|
|
136
|
-
const user =
|
|
171
|
+
const user = readPath(ctx, userPath);
|
|
172
|
+
if (user === pending) return pendingValue;
|
|
137
173
|
if (!user) return unauthenticated;
|
|
138
174
|
|
|
139
|
-
const
|
|
175
|
+
const claims = readPath(user, permissionsPath);
|
|
176
|
+
if (claims === pending) return pendingValue;
|
|
177
|
+
const held = findHeldPermissions(claims, requiredPermissions);
|
|
140
178
|
if (held === null) {
|
|
141
179
|
return () => new ForbiddenError('User permissions must be an array of nonempty strings');
|
|
142
180
|
}
|
|
@@ -244,17 +282,23 @@ const normalizeOwnerId = (value) => {
|
|
|
244
282
|
*/
|
|
245
283
|
export const isOwner = (ownerField = 'userId', userIdField = 'id', options = {}) => {
|
|
246
284
|
const { userPath = 'user' } = options;
|
|
285
|
+
validatePath('ownerField', ownerField);
|
|
286
|
+
validatePath('userIdField', userIdField);
|
|
287
|
+
validatePath('userPath', userPath);
|
|
247
288
|
|
|
248
289
|
return ruleFromCheck((parent, _args, ctx) => {
|
|
249
290
|
if (!ctx) return unauthenticated;
|
|
250
|
-
const user =
|
|
291
|
+
const user = readPath(ctx, userPath);
|
|
292
|
+
if (user === pending) return pendingValue;
|
|
251
293
|
if (!user) return unauthenticated;
|
|
252
294
|
|
|
253
295
|
// Get ownerId from parent (using path or function)
|
|
254
|
-
const
|
|
255
|
-
|
|
296
|
+
const owner = readPath(parent, ownerField);
|
|
256
297
|
// Get userId from user object (using path or function)
|
|
257
|
-
const
|
|
298
|
+
const id = readPath(user, userIdField);
|
|
299
|
+
if (owner === pending || id === pending) return pendingValue;
|
|
300
|
+
const ownerId = normalizeOwnerId(owner);
|
|
301
|
+
const userId = normalizeOwnerId(id);
|
|
258
302
|
|
|
259
303
|
if (ownerId === undefined || userId === undefined) {
|
|
260
304
|
return false;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Internal: true for promises and other values that `await` would wait for. */
|
|
2
|
+
export const isThenable = value => value !== null && (typeof value === 'object' || typeof value === 'function')
|
|
3
|
+
&& typeof value.then === 'function';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Internal: handle a native promise from any realm whose result is never used, so its rejection
|
|
7
|
+
* cannot crash the process. The built-in then() is called directly: it throws for anything that is
|
|
8
|
+
* not a native promise without running its code, and skips an overriding then(). Other thenables are
|
|
9
|
+
* left untouched, because calling their then() can start work, such as a lazy query builder.
|
|
10
|
+
*/
|
|
11
|
+
export const discardThenable = (value) => {
|
|
12
|
+
try {
|
|
13
|
+
Promise.prototype.then.call(value, undefined, () => {});
|
|
14
|
+
} catch {
|
|
15
|
+
// Not a native promise: there is no rejection to handle without running its own then().
|
|
16
|
+
}
|
|
17
|
+
};
|
package/src/index.js
CHANGED
|
@@ -11,6 +11,7 @@ export { default as QLOperator } from './const/QLOperator.js';
|
|
|
11
11
|
export { default as QLSort } from './const/QLSort.js';
|
|
12
12
|
export { default as QLValue } from './const/QLValue.js';
|
|
13
13
|
export { configureQueryLimits, paginationStages } from './query-limits.js';
|
|
14
|
+
export { configureMutationLimits } from './mutation-limits.js';
|
|
14
15
|
export { default as auth } from './auth/index.js';
|
|
15
16
|
export { default as plugins } from './plugins.js';
|
|
16
17
|
export { default as scalars } from './scalars.js';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import SimfinityError from './errors/simfinity.error.js';
|
|
2
|
+
|
|
3
|
+
// Process-wide, like the query limits. null leaves nested collection operations unlimited.
|
|
4
|
+
let maxNestedOperations = null;
|
|
5
|
+
|
|
6
|
+
export const configureMutationLimits = (options = {}) => {
|
|
7
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)
|
|
8
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(options))) {
|
|
9
|
+
throw new SimfinityError('Mutation limit options must be a plain object', 'INVALID_MUTATION_LIMITS', 400);
|
|
10
|
+
}
|
|
11
|
+
// A misspelled option must not silently reset the limit to unlimited.
|
|
12
|
+
const unknown = Reflect.ownKeys(options).find((key) => key !== 'maxNestedOperations');
|
|
13
|
+
if (unknown !== undefined) {
|
|
14
|
+
throw new SimfinityError(`Unknown mutation limit option ${String(unknown)}; use maxNestedOperations`,
|
|
15
|
+
'INVALID_MUTATION_LIMITS', 400);
|
|
16
|
+
}
|
|
17
|
+
const { maxNestedOperations: value = null } = options;
|
|
18
|
+
if (value !== null && (!Number.isSafeInteger(value) || value < 0)) {
|
|
19
|
+
throw new SimfinityError('maxNestedOperations must be null or a non-negative safe integer', 'INVALID_MUTATION_LIMITS', 400);
|
|
20
|
+
}
|
|
21
|
+
maxNestedOperations = value;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const getMaxNestedOperations = () => maxNestedOperations;
|
package/src/query-plan.js
CHANGED
|
@@ -4,12 +4,17 @@ import SimfinityError from './errors/simfinity.error.js';
|
|
|
4
4
|
const fail = (message, code = 'INVALID_FILTER_VALUE') => { throw new SimfinityError(message, code, 400); };
|
|
5
5
|
const operators = new Set(['EQ', 'NE', 'LT', 'LTE', 'GT', 'GTE', 'BTW', 'IN', 'NIN', 'LIKE']);
|
|
6
6
|
const reserved = new Set(['AND', 'OR', 'sort', 'pagination', 'aggregation']);
|
|
7
|
+
// `aggregation` is a generated argument only on aggregate endpoints; on find and count a field of
|
|
8
|
+
// that name is filtered like any other.
|
|
9
|
+
const listReserved = new Set(['AND', 'OR', 'sort', 'pagination']);
|
|
10
|
+
const controlArgs = (operation) => (operation === 'aggregate' ? reserved : listReserved);
|
|
7
11
|
const listOf = (value) => (Array.isArray(value) ? value : []);
|
|
8
12
|
|
|
9
13
|
/**
|
|
10
14
|
* Internal: the model paths named by generated query arguments, as `{ segments, group }` entries.
|
|
11
15
|
* Covers filter keys and relation terms, AND/OR conditions, list sort terms, and aggregation
|
|
12
|
-
* groupId and fact paths; aggregate sort terms name result keys.
|
|
16
|
+
* groupId and fact paths; aggregate sort terms name result keys. Outside aggregate mode a key
|
|
17
|
+
* named `aggregation` is a field filter like any other. `group` is the AND/OR group
|
|
13
18
|
* object whose `conditions` hold the path, or null for the other forms. Malformed entries are
|
|
14
19
|
* left to the backends.
|
|
15
20
|
*/
|
|
@@ -27,8 +32,9 @@ export const collectQueryPathEntries = (args, operation = 'find') => {
|
|
|
27
32
|
}
|
|
28
33
|
for (const item of [...listOf(group.AND), ...listOf(group.OR)]) visit(item);
|
|
29
34
|
};
|
|
35
|
+
const controls = controlArgs(operation);
|
|
30
36
|
for (const [name, value] of Object.entries(args)) {
|
|
31
|
-
if (
|
|
37
|
+
if (controls.has(name) || value == null) continue;
|
|
32
38
|
const terms = listOf(value.terms).filter((term) => typeof term?.path === 'string' && term.path);
|
|
33
39
|
if (terms.length) for (const term of terms) add(`${name}.${term.path}`);
|
|
34
40
|
else add(name);
|
|
@@ -100,9 +106,9 @@ export const createQueryPlan = (models, entityName, input = {}, { mode = 'find'
|
|
|
100
106
|
if (op === 'BTW' && value.length !== 2) fail('BTW requires two values');
|
|
101
107
|
return { kind: 'predicate', path: Array.isArray(path) ? path : path.split('.'), operator: op, value: value === undefined ? null : value };
|
|
102
108
|
};
|
|
103
|
-
const validateGroup = (value) => {
|
|
109
|
+
const validateGroup = (value, keys = ['conditions', 'AND', 'OR']) => {
|
|
104
110
|
if (!value || typeof value !== 'object' || Array.isArray(value)) fail('Expected a filter group object');
|
|
105
|
-
for (const key of
|
|
111
|
+
for (const key of keys) if (value[key] != null && !Array.isArray(value[key])) fail(`${key} requires an array`);
|
|
106
112
|
};
|
|
107
113
|
const group = (value, depth = 0) => {
|
|
108
114
|
validateGroup(value);
|
|
@@ -117,10 +123,12 @@ export const createQueryPlan = (models, entityName, input = {}, { mode = 'find'
|
|
|
117
123
|
terms.push(combine('or', (value.OR || []).map((item) => group(item, depth + 1))));
|
|
118
124
|
return combine('and', terms);
|
|
119
125
|
};
|
|
120
|
-
|
|
126
|
+
// Top-level arguments are field filters plus AND/OR; `conditions` only lists conditions inside groups.
|
|
127
|
+
validateGroup(input, ['AND', 'OR']);
|
|
128
|
+
const controls = controlArgs(mode);
|
|
121
129
|
const terms = [];
|
|
122
130
|
for (const [name, value] of Object.entries(input)) {
|
|
123
|
-
if (
|
|
131
|
+
if (controls.has(name) || value == null) continue;
|
|
124
132
|
const field = resolveModelPath(models, entityName, name).at(-1);
|
|
125
133
|
if (typeof value !== 'object' || Array.isArray(value)) fail('Expected a filter object');
|
|
126
134
|
if (field.kind !== 'scalar' && !field.inferred && (!Array.isArray(value.terms) || !value.terms.length)) fail('Object filters require non-empty terms', 'MISSING_FILTER_PATH');
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Internal: generated relation resolvers, the extensions object given to each field that holds one,
|
|
2
|
+
// and Simfinity's own wrappers of those resolvers, mapped to the runtime that generated them.
|
|
3
|
+
// toConfig() copies and field spreads keep the resolver or the extensions object, so a field copied
|
|
4
|
+
// from a bound type still shows whose adapter, registrations and middleware it reads through.
|
|
5
|
+
export const relationFieldOwners = new WeakMap();
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { GraphQLList, GraphQLNonNull } from 'graphql';
|
|
2
|
+
|
|
3
|
+
// Internal helpers that decide where a relation stores its link and which types keep a stored
|
|
4
|
+
// identity. They are shared by the core runtime and the MongoDB adapter through the
|
|
5
|
+
// `./internal/relation-storage` subpath, which is not public API.
|
|
6
|
+
|
|
7
|
+
const unwrapNonNull = (type) => (type instanceof GraphQLNonNull ? type.ofType : type);
|
|
8
|
+
|
|
9
|
+
export const getListShape = (type) => {
|
|
10
|
+
const outerNonNull = type instanceof GraphQLNonNull;
|
|
11
|
+
const listType = unwrapNonNull(type);
|
|
12
|
+
if (!(listType instanceof GraphQLList)) return null;
|
|
13
|
+
const itemNonNull = listType.ofType instanceof GraphQLNonNull;
|
|
14
|
+
return {
|
|
15
|
+
outerNonNull,
|
|
16
|
+
itemNonNull,
|
|
17
|
+
itemType: unwrapNonNull(listType.ofType),
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const getFieldStorageName = (fieldName, field) => {
|
|
22
|
+
const relation = field.extensions?.relation;
|
|
23
|
+
return relation && !relation.embedded && !getListShape(field.type)
|
|
24
|
+
? relation.connectionField || fieldName
|
|
25
|
+
: fieldName;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Entity types, which keep their own stored identity. The generated id resolver lives on the type
|
|
29
|
+
// object, which runtimes may share, so the identity rule is per type object, not per runtime
|
|
30
|
+
// registration: a store path for `id` follows the resolver any runtime installed on the type.
|
|
31
|
+
const storedIdentityTypes = new WeakSet();
|
|
32
|
+
|
|
33
|
+
export const markStoredIdentity = (gqltype) => {
|
|
34
|
+
storedIdentityTypes.add(gqltype);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const hasStoredIdentity = (gqltype) => storedIdentityTypes.has(gqltype);
|
|
38
|
+
|
|
39
|
+
export const normalizeConnectionField = (childType, declaredFieldName) => {
|
|
40
|
+
if (!declaredFieldName) {
|
|
41
|
+
return { declaredFieldName, graphqlFieldName: null, storageFieldName: null };
|
|
42
|
+
}
|
|
43
|
+
const childFields = childType.getFields();
|
|
44
|
+
let graphqlFieldName = childFields[declaredFieldName] ? declaredFieldName : null;
|
|
45
|
+
if (!graphqlFieldName) {
|
|
46
|
+
const aliasedField = Object.entries(childFields).find(([fieldName, field]) => (
|
|
47
|
+
getFieldStorageName(fieldName, field) === declaredFieldName
|
|
48
|
+
));
|
|
49
|
+
graphqlFieldName = aliasedField?.[0] || null;
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
declaredFieldName,
|
|
53
|
+
graphqlFieldName,
|
|
54
|
+
storageFieldName: graphqlFieldName
|
|
55
|
+
? getFieldStorageName(graphqlFieldName, childFields[graphqlFieldName])
|
|
56
|
+
: declaredFieldName,
|
|
57
|
+
};
|
|
58
|
+
};
|