@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/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
- return (_parent, _args, ctx) => {
41
- if (!ctx) {
42
- throw new UnauthenticatedError('You must be logged in to access this resource');
43
- }
44
- const user = resolvePath(ctx, userPath);
45
- if (!user) {
46
- throw new UnauthenticatedError('You must be logged in to access this resource');
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
- const roles = Array.isArray(role) ? role : [role];
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
- throw new UnauthenticatedError('You must be logged in to access this resource');
74
- }
75
- const user = resolvePath(ctx, userPath);
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
- if (!roles.includes(userRole)) {
83
- throw new ForbiddenError(`Requires role: ${roles.join(' or ')}`);
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
- return true;
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
- const requiredPermissions = Array.isArray(permission) ? permission : [permission];
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
- throw new UnauthenticatedError('You must be logged in to access this resource');
113
- }
114
- const user = resolvePath(ctx, userPath);
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 userPermissions = resolvePath(user, permissionsPath);
120
- if (!Array.isArray(userPermissions)
121
- || Array.from(userPermissions).some(perm => typeof perm !== 'string' || perm.length === 0)) {
122
- throw new ForbiddenError('User permissions must be an array of nonempty strings');
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
- // Check if user has wildcard permission
126
- if (userPermissions.includes('*')) {
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
- for (const perm of requiredPermissions) {
132
- if (!userPermissions.includes(perm)) {
133
- throw new ForbiddenError(`Missing permission: ${perm}`);
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 result = await rule(parent, args, ctx, info);
178
- if (result === true || result === undefined) {
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
- throw new UnauthenticatedError('You must be logged in to access this resource');
220
- }
221
- const user = resolvePath(ctx, userPath);
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 ownerId = normalizeOwnerId(resolvePath(parent, ownerField));
228
-
296
+ const owner = readPath(parent, ownerField);
229
297
  // Get userId from user object (using path or function)
230
- const userId = normalizeOwnerId(resolvePath(user, userIdField));
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
- return () => {
280
- throw new ForbiddenError(message);
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();