@simtlix/simfinity-core 3.5.2 → 3.5.3
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 +8 -3
- package/package.json +1 -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/relation-owners.js +5 -0
- package/src/runtime.js +293 -57
- 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 +144 -7
package/README.md
CHANGED
|
@@ -8,15 +8,20 @@ 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
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 it.
|
|
19
20
|
|
|
21
|
+
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.
|
|
22
|
+
|
|
23
|
+
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`).
|
|
24
|
+
|
|
20
25
|
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
26
|
|
|
22
27
|
Importing the runtime installs Simfinity's `__Field.extensions` introspection field once per GraphQL module instance. Multiple runtimes and driver packages can be imported together without cloning schemas or duplicating introspection types.
|
|
@@ -27,4 +32,4 @@ Importing the runtime installs Simfinity's `__Field.extensions` introspection fi
|
|
|
27
32
|
|
|
28
33
|
`createQueryPlan` and `resolveModelPath` turn Simfinity filter, sort, pagination, and aggregation inputs into a driver-neutral plan resolved against that metadata.
|
|
29
34
|
|
|
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.
|
|
35
|
+
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
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;
|
|
@@ -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();
|