@simtlix/simfinity-core 3.5.1 → 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 +161 -7
- package/src/auth/index.js +168 -71
- package/src/auth/rules.js +136 -66
- 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 +151 -9
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
|
|
@@ -26,6 +28,48 @@ export const resolvePath = (obj, pathOrFn) => {
|
|
|
26
28
|
return undefined;
|
|
27
29
|
};
|
|
28
30
|
|
|
31
|
+
// Built-in rules also have a check that returns true, false or a factory for the denial error.
|
|
32
|
+
// anyRule uses it so a denial that a later rule overrides never builds and throws an error.
|
|
33
|
+
// Checks are keyed by the rule function itself: a property would be copied by Object.assign or
|
|
34
|
+
// read through a Proxy, and anyRule would then skip the checks of a wrapping function.
|
|
35
|
+
const checks = new WeakMap();
|
|
36
|
+
|
|
37
|
+
const ruleFromCheck = (check) => {
|
|
38
|
+
const rule = (parent, args, ctx, info) => {
|
|
39
|
+
const result = check(parent, args, ctx, info);
|
|
40
|
+
if (typeof result === 'function') throw result();
|
|
41
|
+
return result;
|
|
42
|
+
};
|
|
43
|
+
checks.set(rule, check);
|
|
44
|
+
return rule;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const unauthenticated = () => new UnauthenticatedError('You must be logged in to access this resource');
|
|
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
|
+
|
|
29
73
|
/**
|
|
30
74
|
* Rule that requires the user to be authenticated
|
|
31
75
|
* Checks for user existence at the specified path in context
|
|
@@ -37,16 +81,13 @@ export const resolvePath = (obj, pathOrFn) => {
|
|
|
37
81
|
* requireAuth('session.currentUser') // checks ctx.session.currentUser
|
|
38
82
|
*/
|
|
39
83
|
export const requireAuth = (userPath = 'user') => {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
return true;
|
|
49
|
-
};
|
|
84
|
+
validatePath('userPath', userPath);
|
|
85
|
+
return ruleFromCheck((_parent, _args, ctx) => {
|
|
86
|
+
if (!ctx) return unauthenticated;
|
|
87
|
+
const user = readPath(ctx, userPath);
|
|
88
|
+
if (user === pending) return pendingValue;
|
|
89
|
+
return user ? true : unauthenticated;
|
|
90
|
+
});
|
|
50
91
|
};
|
|
51
92
|
|
|
52
93
|
/**
|
|
@@ -62,29 +103,44 @@ export const requireAuth = (userPath = 'user') => {
|
|
|
62
103
|
* requireRole('ADMIN', { userPath: 'auth.user', rolePath: 'roles.primary' })
|
|
63
104
|
*/
|
|
64
105
|
export const requireRole = (role, options = {}) => {
|
|
65
|
-
|
|
106
|
+
// Copy the list, so changing the caller's array later cannot bypass this validation.
|
|
107
|
+
const roles = Object.freeze(Array.isArray(role) ? Array.from(role) : [role]);
|
|
66
108
|
if (roles.length === 0 || Array.from(roles).some(value => typeof value !== 'string' || value.length === 0)) {
|
|
67
109
|
throw new TypeError('Required roles must be a nonempty string or array of nonempty strings');
|
|
68
110
|
}
|
|
69
111
|
const { userPath = 'user', rolePath = 'role' } = options;
|
|
112
|
+
validatePath('userPath', userPath);
|
|
113
|
+
validatePath('rolePath', rolePath);
|
|
114
|
+
const forbidden = () => new ForbiddenError(`Requires role: ${roles.join(' or ')}`);
|
|
70
115
|
|
|
71
|
-
return (_parent, _args, ctx) => {
|
|
72
|
-
if (!ctx)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (!user) {
|
|
77
|
-
throw new UnauthenticatedError('You must be logged in to access this resource');
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const userRole = resolvePath(user, rolePath);
|
|
116
|
+
return ruleFromCheck((_parent, _args, ctx) => {
|
|
117
|
+
if (!ctx) return unauthenticated;
|
|
118
|
+
const user = readPath(ctx, userPath);
|
|
119
|
+
if (user === pending) return pendingValue;
|
|
120
|
+
if (!user) return unauthenticated;
|
|
81
121
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
122
|
+
const userRole = readPath(user, rolePath);
|
|
123
|
+
if (userRole === pending) return pendingValue;
|
|
124
|
+
return roles.includes(userRole) ? true : forbidden;
|
|
125
|
+
});
|
|
126
|
+
};
|
|
85
127
|
|
|
86
|
-
|
|
87
|
-
|
|
128
|
+
/**
|
|
129
|
+
* Validate the claims and find the required permissions they hold in one pass, without copying
|
|
130
|
+
* them. Returns null for malformed claims (including holes) and true for a standalone '*' entry.
|
|
131
|
+
* Nothing is cached: a context can outlive a request, and claims can change in place.
|
|
132
|
+
*/
|
|
133
|
+
const findHeldPermissions = (claims, requiredPermissions) => {
|
|
134
|
+
if (!Array.isArray(claims)) return null;
|
|
135
|
+
let wildcard = false;
|
|
136
|
+
const held = new Set();
|
|
137
|
+
for (let index = 0; index < claims.length; index++) {
|
|
138
|
+
const claim = claims[index];
|
|
139
|
+
if (typeof claim !== 'string' || claim.length === 0) return null;
|
|
140
|
+
if (claim === '*') wildcard = true;
|
|
141
|
+
else if (requiredPermissions.includes(claim)) held.add(claim);
|
|
142
|
+
}
|
|
143
|
+
return wildcard || held;
|
|
88
144
|
};
|
|
89
145
|
|
|
90
146
|
/**
|
|
@@ -100,42 +156,38 @@ export const requireRole = (role, options = {}) => {
|
|
|
100
156
|
* requirePermission('posts:read', { userPath: 'auth.user', permissionsPath: 'grants' })
|
|
101
157
|
*/
|
|
102
158
|
export const requirePermission = (permission, options = {}) => {
|
|
103
|
-
|
|
159
|
+
// Copy the list, so changing the caller's array later cannot bypass this validation.
|
|
160
|
+
const requiredPermissions = Object.freeze(Array.isArray(permission) ? Array.from(permission) : [permission]);
|
|
104
161
|
if (requiredPermissions.length === 0
|
|
105
162
|
|| Array.from(requiredPermissions).some(perm => typeof perm !== 'string' || perm.length === 0)) {
|
|
106
163
|
throw new TypeError('Required permissions must be a nonempty string or array of nonempty strings');
|
|
107
164
|
}
|
|
108
165
|
const { userPath = 'user', permissionsPath = 'permissions' } = options;
|
|
166
|
+
validatePath('userPath', userPath);
|
|
167
|
+
validatePath('permissionsPath', permissionsPath);
|
|
109
168
|
|
|
110
|
-
return (_parent, _args, ctx) => {
|
|
111
|
-
if (!ctx)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (!user) {
|
|
116
|
-
throw new UnauthenticatedError('You must be logged in to access this resource');
|
|
117
|
-
}
|
|
169
|
+
return ruleFromCheck((_parent, _args, ctx) => {
|
|
170
|
+
if (!ctx) return unauthenticated;
|
|
171
|
+
const user = readPath(ctx, userPath);
|
|
172
|
+
if (user === pending) return pendingValue;
|
|
173
|
+
if (!user) return unauthenticated;
|
|
118
174
|
|
|
119
|
-
const
|
|
120
|
-
if (
|
|
121
|
-
|
|
122
|
-
|
|
175
|
+
const claims = readPath(user, permissionsPath);
|
|
176
|
+
if (claims === pending) return pendingValue;
|
|
177
|
+
const held = findHeldPermissions(claims, requiredPermissions);
|
|
178
|
+
if (held === null) {
|
|
179
|
+
return () => new ForbiddenError('User permissions must be an array of nonempty strings');
|
|
123
180
|
}
|
|
124
181
|
|
|
125
|
-
//
|
|
126
|
-
if (
|
|
182
|
+
// A wildcard entry grants every permission
|
|
183
|
+
if (held === true) {
|
|
127
184
|
return true;
|
|
128
185
|
}
|
|
129
186
|
|
|
130
187
|
// All required permissions must be present
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
return true;
|
|
138
|
-
};
|
|
188
|
+
const missing = requiredPermissions.find(perm => !held.has(perm));
|
|
189
|
+
return missing === undefined ? true : () => new ForbiddenError(`Missing permission: ${missing}`);
|
|
190
|
+
});
|
|
139
191
|
};
|
|
140
192
|
|
|
141
193
|
/**
|
|
@@ -144,6 +196,10 @@ export const requirePermission = (permission, options = {}) => {
|
|
|
144
196
|
* @returns {Function} Composed rule function
|
|
145
197
|
*/
|
|
146
198
|
export const composeRules = (...rules) => {
|
|
199
|
+
// With no rules the composition would grant everything; use allow() for an intentional grant.
|
|
200
|
+
if (rules.length === 0) {
|
|
201
|
+
throw new TypeError('composeRules requires at least one rule');
|
|
202
|
+
}
|
|
147
203
|
if (rules.some(rule => typeof rule !== 'function')) {
|
|
148
204
|
throw new TypeError('composeRules requires rule functions');
|
|
149
205
|
}
|
|
@@ -166,25 +222,38 @@ export const composeRules = (...rules) => {
|
|
|
166
222
|
* @returns {Function} Combined rule function
|
|
167
223
|
*/
|
|
168
224
|
export const anyRule = (...rules) => {
|
|
225
|
+
if (rules.length === 0) {
|
|
226
|
+
throw new TypeError('anyRule requires at least one rule');
|
|
227
|
+
}
|
|
169
228
|
if (rules.some(rule => typeof rule !== 'function')) {
|
|
170
229
|
throw new TypeError('anyRule requires rule functions');
|
|
171
230
|
}
|
|
172
231
|
return async (parent, args, ctx, info) => {
|
|
173
232
|
let lastError = null;
|
|
233
|
+
// A built-in rule's denial is kept as a factory and only built if it is the error thrown.
|
|
234
|
+
let lastErrorIsFactory = false;
|
|
174
235
|
|
|
175
236
|
for (const rule of rules) {
|
|
176
237
|
try {
|
|
177
|
-
const
|
|
178
|
-
|
|
238
|
+
const check = checks.get(rule);
|
|
239
|
+
const result = check ? check(parent, args, ctx, info) : await rule(parent, args, ctx, info);
|
|
240
|
+
if (check && typeof result === 'function') {
|
|
241
|
+
lastError = result;
|
|
242
|
+
lastErrorIsFactory = true;
|
|
243
|
+
} else if (result === true || result === undefined) {
|
|
179
244
|
return true; // At least one rule passed
|
|
180
245
|
}
|
|
181
246
|
} catch (error) {
|
|
182
247
|
lastError = error;
|
|
248
|
+
lastErrorIsFactory = false;
|
|
183
249
|
// Continue to next rule
|
|
184
250
|
}
|
|
185
251
|
}
|
|
186
252
|
|
|
187
253
|
// No rule passed - throw the last error or return false
|
|
254
|
+
if (lastErrorIsFactory) {
|
|
255
|
+
throw lastError();
|
|
256
|
+
}
|
|
188
257
|
if (lastError) {
|
|
189
258
|
throw lastError;
|
|
190
259
|
}
|
|
@@ -213,28 +282,30 @@ const normalizeOwnerId = (value) => {
|
|
|
213
282
|
*/
|
|
214
283
|
export const isOwner = (ownerField = 'userId', userIdField = 'id', options = {}) => {
|
|
215
284
|
const { userPath = 'user' } = options;
|
|
285
|
+
validatePath('ownerField', ownerField);
|
|
286
|
+
validatePath('userIdField', userIdField);
|
|
287
|
+
validatePath('userPath', userPath);
|
|
216
288
|
|
|
217
|
-
return (parent, _args, ctx) => {
|
|
218
|
-
if (!ctx)
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if (!user) {
|
|
223
|
-
throw new UnauthenticatedError('You must be logged in to access this resource');
|
|
224
|
-
}
|
|
289
|
+
return ruleFromCheck((parent, _args, ctx) => {
|
|
290
|
+
if (!ctx) return unauthenticated;
|
|
291
|
+
const user = readPath(ctx, userPath);
|
|
292
|
+
if (user === pending) return pendingValue;
|
|
293
|
+
if (!user) return unauthenticated;
|
|
225
294
|
|
|
226
295
|
// Get ownerId from parent (using path or function)
|
|
227
|
-
const
|
|
228
|
-
|
|
296
|
+
const owner = readPath(parent, ownerField);
|
|
229
297
|
// Get userId from user object (using path or function)
|
|
230
|
-
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);
|
|
231
302
|
|
|
232
303
|
if (ownerId === undefined || userId === undefined) {
|
|
233
304
|
return false;
|
|
234
305
|
}
|
|
235
306
|
|
|
236
307
|
return ownerId === userId;
|
|
237
|
-
};
|
|
308
|
+
});
|
|
238
309
|
};
|
|
239
310
|
|
|
240
311
|
/**
|
|
@@ -276,9 +347,8 @@ export const allow = () => {
|
|
|
276
347
|
* @returns {Function} Rule function that always throws ForbiddenError
|
|
277
348
|
*/
|
|
278
349
|
export const deny = (message = 'Access denied') => {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
};
|
|
350
|
+
const forbidden = () => new ForbiddenError(message);
|
|
351
|
+
return ruleFromCheck(() => forbidden);
|
|
282
352
|
};
|
|
283
353
|
|
|
284
354
|
// Export all rules as an object for convenience
|
|
@@ -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();
|