@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 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 an isolated Simfinity runtime. Each instance owns its registrations, generated input types, middleware, scopes, hooks, custom mutations, and state machines. It exposes:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-core",
3
- "version": "3.5.1",
3
+ "version": "3.5.3",
4
4
  "description": "Driver-free GraphQL runtime and model metadata for Simfinity",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -4,6 +4,9 @@
4
4
  * distinct from false, so negation cannot turn an invalid comparison into a grant.
5
5
  */
6
6
 
7
+ import { normalizeObjectId } from './object-id.js';
8
+ import { isThenable, discardThenable } from './thenable.js';
9
+
7
10
  const invalidResult = Symbol('invalid policy result');
8
11
 
9
12
  const isValidRef = (refPath) => {
@@ -19,12 +22,34 @@ const isValidValue = (value) => {
19
22
  if (isReference(value)) {
20
23
  return Object.keys(value).length === 1 && Object.hasOwn(value, 'ref') && isValidRef(value.ref);
21
24
  }
22
- return value !== undefined && typeof value !== 'function' && typeof value !== 'symbol';
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);
23
27
  };
24
28
 
25
29
  const isDenseArray = value => Array.isArray(value)
26
30
  && Array.from({ length: value.length }, (_, index) => Object.hasOwn(value, index)).every(Boolean);
27
31
 
32
+ const isPlainValue = value => value !== null && typeof value === 'object'
33
+ && (Array.isArray(value) || [Object.prototype, null].includes(Object.getPrototypeOf(value)));
34
+
35
+ // Literals are compared as they are, so a `{ ref }` nested inside one would never be resolved.
36
+ const containsReference = (value, seen = new Set()) => {
37
+ if (!isPlainValue(value) || seen.has(value)) return false;
38
+ if (isReference(value)) return true;
39
+ seen.add(value);
40
+ return Object.values(value).some(item => containsReference(item, seen));
41
+ };
42
+
43
+ const isMembershipItem = value => value === null || ['string', 'boolean', 'bigint'].includes(typeof value)
44
+ || (typeof value === 'number' && Number.isFinite(value)) || normalizeObjectId(value) !== undefined;
45
+
46
+ const isValidComparison = (operator, operand) => {
47
+ if (!isDenseArray(operand) || operand.length !== 2 || !operand.every(isValidValue)) return false;
48
+ if (operand.some(value => !isReference(value) && containsReference(value))) return false;
49
+ if (operator !== 'in' || isReference(operand[1])) return true;
50
+ return isDenseArray(operand[1]) && operand[1].every(isMembershipItem);
51
+ };
52
+
28
53
  const validateExpression = (expression, ancestors = new Set()) => {
29
54
  if (typeof expression === 'boolean') return true;
30
55
  if (expression === null || typeof expression !== 'object' || Array.isArray(expression)) return false;
@@ -38,8 +63,7 @@ const validateExpression = (expression, ancestors = new Set()) => {
38
63
  switch (operator) {
39
64
  case 'eq':
40
65
  case 'in':
41
- return isDenseArray(operand) && operand.length === 2 && operand.every(isValidValue)
42
- && (operator !== 'in' || Array.isArray(operand[1]) || isReference(operand[1]));
66
+ return isValidComparison(operator, operand);
43
67
  case 'allOf':
44
68
  case 'anyOf':
45
69
  return isDenseArray(operand) && operand.every(expr => validateExpression(expr, ancestors));
@@ -65,19 +89,111 @@ const resolveRef = (refPath, context) => {
65
89
  return value;
66
90
  };
67
91
 
92
+ /**
93
+ * An operand of a created rule. The literal or reference classification is made once, when the
94
+ * rule is created, so mutating a literal object afterwards cannot turn it into a reference.
95
+ */
96
+ class CompiledOperand {
97
+ constructor(reference, value) {
98
+ this.reference = reference;
99
+ this.value = value;
100
+ Object.freeze(this);
101
+ }
102
+ }
103
+
68
104
  /** Resolve a literal or reference without coercing its value. */
69
105
  const resolveValue = (value, context) => {
106
+ if (value instanceof CompiledOperand) return value.reference ? resolveRef(value.value, context) : value.value;
70
107
  return isReference(value) ? resolveRef(value.ref, context) : value;
71
108
  };
72
109
 
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.
124
+ const comparable = value => normalizeObjectId(value) ?? value;
125
+
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
+ };
165
+
166
+ const evaluateMembership = (target, list) => {
167
+ // A list or plain object is not a member value.
168
+ if (!Array.isArray(list) || isPlainValue(target)) return invalidResult;
169
+ const operand = classifyOperand(target);
170
+ // Never call the list's own includes: Mongoose arrays cast and compare loosely.
171
+ let mismatched = false;
172
+ for (let index = 0; index < list.length; index++) {
173
+ const result = compareOperand(operand, list[index]);
174
+ if (result === true) return true;
175
+ if (result === invalidResult) mismatched = true;
176
+ }
177
+ // Without a match, an item that could not be compared means the list was not checked reliably.
178
+ return mismatched ? invalidResult : false;
179
+ };
180
+
73
181
  const evaluateComparison = (operator, operands, context) => {
74
182
  const left = resolveValue(operands[0], context);
75
183
  const right = resolveValue(operands[1], context);
76
- if (left === undefined || right === undefined || typeof left === 'function' || typeof right === 'function') {
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) {
77
193
  return invalidResult;
78
194
  }
79
- if (operator === 'eq') return left === right;
80
- return Array.isArray(right) ? right.includes(left) : invalidResult;
195
+ if (operator === 'in') return evaluateMembership(left, right);
196
+ return compareOperand(classifyOperand(left), right);
81
197
  };
82
198
 
83
199
  const evaluateAllOf = (expressions, context) => {
@@ -140,12 +256,50 @@ export const evaluateExpression = (expression, context) => {
140
256
  /** Check the complete AST, including boolean literals and every nested branch. */
141
257
  export const isPolicyExpression = value => validateExpression(value);
142
258
 
259
+ // Copy by index with built-in methods only, so an Array subclass cannot alter the copy.
260
+ const copyArray = (array, transform = value => value) => Array.from(
261
+ { length: array.length },
262
+ (_, index) => transform(array[index], index),
263
+ );
264
+
265
+ const compileOperand = (operator, value, index) => {
266
+ if (isReference(value)) return new CompiledOperand(true, value.ref);
267
+ // A literal membership list is copied, so only its validated items are compared.
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));
272
+ // Other literals keep their identity, which equality compares.
273
+ return new CompiledOperand(false, value);
274
+ };
275
+
276
+ const snapshotOperands = (operator, operand) => Object.freeze(
277
+ copyArray(operand, (value, index) => compileOperand(operator, value, index)),
278
+ );
279
+
280
+ /**
281
+ * Copy the validated structure, so evaluation needs no revalidation and later changes to the
282
+ * configured object cannot make it malformed. Date literals are copied; other literal operands keep
283
+ * their identity.
284
+ */
285
+ const snapshotExpression = (expression) => {
286
+ if (typeof expression === 'boolean') return expression;
287
+ const snapshot = {};
288
+ for (const [operator, operand] of Object.entries(expression)) {
289
+ if (operator === 'not') snapshot[operator] = snapshotExpression(operand);
290
+ else if (operator === 'eq' || operator === 'in') snapshot[operator] = snapshotOperands(operator, operand);
291
+ else snapshot[operator] = Object.freeze(copyArray(operand, snapshotExpression));
292
+ }
293
+ return Object.freeze(snapshot);
294
+ };
295
+
143
296
  /** Create a rule, rejecting malformed policies at configuration time. */
144
297
  export const createRuleFromExpression = (expression) => {
145
298
  if (!isPolicyExpression(expression)) {
146
299
  throw new TypeError('Invalid authorization policy expression');
147
300
  }
148
- return (parent, args, ctx) => evaluateExpression(expression, { parent, args, ctx });
301
+ const snapshot = snapshotExpression(expression);
302
+ return (parent, args, ctx) => evaluateValidatedExpression(snapshot, { parent, args, ctx }) === true;
149
303
  };
150
304
 
151
305
  const expressions = {
package/src/auth/index.js CHANGED
@@ -9,13 +9,12 @@
9
9
  * - Default allow/deny policies
10
10
  *
11
11
  * @example
12
- * import { auth } from '@simtlix/simfinity-js';
12
+ * import { createAuthPlugin, requireAuth, requireRole } from '@simtlix/simfinity-core/auth';
13
13
  * import { createYoga } from 'graphql-yoga';
14
14
  *
15
- * const { createAuthPlugin, requireAuth, requireRole } = auth;
16
- *
15
+ * // Simfinity names its roots `RootQueryType` and `Mutation`.
17
16
  * const permissions = {
18
- * Query: {
17
+ * RootQueryType: {
19
18
  * series: requireAuth(),
20
19
  * seasons: requireAuth(),
21
20
  * },
@@ -32,11 +31,23 @@
32
31
  * const yoga = createYoga({ schema, plugins: [authPlugin] });
33
32
  */
34
33
 
35
- import { GraphQLError, GraphQLObjectType, defaultFieldResolver } from 'graphql';
34
+ import {
35
+ GraphQLError,
36
+ GraphQLEnumType,
37
+ GraphQLInputObjectType,
38
+ GraphQLInterfaceType,
39
+ GraphQLObjectType,
40
+ GraphQLScalarType,
41
+ GraphQLUnionType,
42
+ defaultFieldResolver,
43
+ __Field,
44
+ } from 'graphql';
36
45
  import SimfinityError from '../errors/simfinity.error.js';
37
46
  import { UnauthenticatedError, ForbiddenError, createAuthError } from './errors.js';
38
47
  import { isPolicyExpression, createRuleFromExpression, evaluateExpression } from './expressions.js';
39
48
  import { collectQueryPaths, walkQueryPath } from '../query-plan.js';
49
+ import { relationFieldOwners } from '../relation-owners.js';
50
+ import { isThenable } from './thenable.js';
40
51
  import {
41
52
  resolvePath,
42
53
  requireAuth,
@@ -128,6 +139,39 @@ const normalizeRule = (rule) => {
128
139
  const isPermissionMap = value => value !== null && typeof value === 'object'
129
140
  && [Object.prototype, null].includes(Object.getPrototypeOf(value));
130
141
 
142
+ /**
143
+ * Simfinity adds an `extensions` field to introspection's `__Field`. Its object types are shared by
144
+ * every schema in the process and describe the schema itself, so authorization skips them like
145
+ * `__` types unless the permission map names them. They are matched by identity.
146
+ */
147
+ const isSimfinityMetadataType = (type) => {
148
+ const extensionsType = __Field.getFields().extensions?.type;
149
+ if (!(extensionsType instanceof GraphQLObjectType)) return false;
150
+ return type === extensionsType || type === extensionsType.getFields().relation?.type;
151
+ };
152
+
153
+ const isExemptMetadataType = (permissions, type) => isSimfinityMetadataType(type)
154
+ && !Object.hasOwn(permissions, type.name);
155
+
156
+ // Schemas processed by any auth plugin instance. A wrapper defers to the other instances only in
157
+ // these schemas; in a schema no instance processed, every wrapper keeps enforcing its rules.
158
+ const authorizedSchemas = new WeakSet();
159
+
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
+ };
174
+
131
175
  const validateConfiguration = (permissions, defaultPolicy) => {
132
176
  if (defaultPolicy !== 'ALLOW' && defaultPolicy !== 'DENY') {
133
177
  throw new TypeError('defaultPolicy must be ALLOW or DENY');
@@ -262,6 +306,7 @@ export const createAuthMiddleware = (permissions, options = {}) => {
262
306
  * Returns a middleware object keyed by type name, each containing field resolvers
263
307
  */
264
308
  return async (resolve, parent, args, ctx, info) => {
309
+ if (isExemptMetadataType(permissions, info.parentType)) return resolve(parent, args, ctx, info);
265
310
  const typeName = info.parentType.name;
266
311
  const fieldName = info.fieldName;
267
312
 
@@ -305,35 +350,20 @@ export const createAuthMiddleware = (permissions, options = {}) => {
305
350
  };
306
351
 
307
352
  /**
308
- * Creates a field-level middleware object from a permission schema.
309
- * This can be used with graphql-middleware's applyMiddleware.
353
+ * Creates graphql-middleware authorization for every object field of a schema.
354
+ *
355
+ * It returns the same function as {@link createAuthMiddleware}. graphql-middleware applies a
356
+ * function middleware to every field, so `'*'` rules and the default policy also apply to fields
357
+ * the permission map does not name. A map of only the named fields would leave the others open.
310
358
  *
311
359
  * @deprecated Use {@link createAuthPlugin} instead. `applyMiddleware` from graphql-middleware
312
360
  * can cause duplicate-type errors when the schema contains custom introspection extensions.
313
361
  *
314
362
  * @param {PermissionSchema} permissions - The permission schema
315
363
  * @param {AuthMiddlewareOptions} [options={}] - Middleware options
316
- * @returns {Object} Field middleware object compatible with graphql-middleware
364
+ * @returns {Function} A graphql-middleware compatible middleware function
317
365
  */
318
- export const createFieldMiddleware = (permissions, options = {}) => {
319
- const middleware = createAuthMiddleware(permissions, options);
320
- const fieldMiddleware = {};
321
-
322
- for (const typeName of Object.keys(permissions)) {
323
- fieldMiddleware[typeName] = {};
324
-
325
- const typePerms = permissions[typeName];
326
- for (const fieldName of Object.keys(typePerms)) {
327
- if (fieldName === '*') {
328
- // Wildcard rules are handled by the middleware internally
329
- continue;
330
- }
331
- fieldMiddleware[typeName][fieldName] = middleware;
332
- }
333
- }
334
-
335
- return fieldMiddleware;
336
- };
366
+ export const createFieldMiddleware = (permissions, options = {}) => createAuthMiddleware(permissions, options);
337
367
 
338
368
  /**
339
369
  * Creates an Envelop-compatible authorization plugin that wraps schema resolvers in-place.
@@ -342,26 +372,33 @@ export const createFieldMiddleware = (permissions, options = {}) => {
342
372
  * and rebuilds the schema), this plugin mutates resolvers directly on the existing schema,
343
373
  * avoiding schema reconstruction and the duplicate-type errors it can cause.
344
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
+ *
345
378
  * @param {PermissionSchema} permissions - The permission schema object
346
379
  * @param {AuthMiddlewareOptions} [options={}] - Plugin options
347
380
  * @returns {Object} An Envelop plugin with an `onSchemaChange` hook
348
381
  *
349
382
  * @example
350
- * import { auth } from '@simtlix/simfinity-js';
383
+ * import { createAuthPlugin, requireAuth, requireRole } from '@simtlix/simfinity-core/auth';
351
384
  * import { createYoga } from 'graphql-yoga';
352
385
  *
386
+ * // Keys are the schema's type names: Simfinity names its query root `RootQueryType`.
353
387
  * const permissions = {
354
- * Query: {
355
- * users: requireAuth(),
356
- * adminDashboard: requireRole('ADMIN')
388
+ * RootQueryType: {
389
+ * series: requireAuth(),
390
+ * serie: requireAuth(),
357
391
  * },
358
- * User: {
392
+ * Mutation: {
393
+ * deleteserie: requireRole('ADMIN'),
394
+ * },
395
+ * serie: {
359
396
  * '*': requireAuth(),
360
- * email: requireRole('ADMIN')
361
- * }
397
+ * budget: requireRole('ADMIN'),
398
+ * },
362
399
  * };
363
400
  *
364
- * const authPlugin = auth.createAuthPlugin(permissions, { defaultPolicy: 'DENY' });
401
+ * const authPlugin = createAuthPlugin(permissions, { defaultPolicy: 'DENY' });
365
402
  * const yoga = createYoga({ schema, plugins: [authPlugin] });
366
403
  */
367
404
  export const createAuthPlugin = (permissions, options = {}) => {
@@ -374,57 +411,117 @@ export const createAuthPlugin = (permissions, options = {}) => {
374
411
 
375
412
  const log = debug ? console.log.bind(console, '[auth]') : () => {};
376
413
  const processedSchemas = new WeakSet();
414
+ // Schemas built from the same types share their field objects, so each field is wrapped once.
415
+ const wrappers = new WeakMap();
416
+ let warnedAboutQueryRoot = false;
417
+
418
+ const warnAboutQueryRoot = (schema) => {
419
+ if (warnedAboutQueryRoot || !Object.hasOwn(permissions, 'Query') || schema.getType('Query')) return;
420
+ const queryRoot = schema.getQueryType()?.name;
421
+ if (!queryRoot) return;
422
+ warnedAboutQueryRoot = true;
423
+ console.warn(`[auth] Permissions for type "Query" match no type in the schema. Its query root is named "${queryRoot}"; did you mean "${queryRoot}"?`);
424
+ };
425
+
426
+ const createFieldResolver = (schema, typeName, fieldName, rules, query, originalResolve) => {
427
+ const key = `${typeName}.${fieldName}`;
428
+ const denied = () => Promise.reject(new ForbiddenError(`Access denied to ${key}`));
429
+
430
+ // Errors keep reaching GraphQL as rejections, as they did when the whole check was async.
431
+ const resolveField = (parent, args, ctx, info) => {
432
+ try {
433
+ return originalResolve(parent, args, ctx, info);
434
+ } catch (error) {
435
+ return Promise.reject(error);
436
+ }
437
+ };
438
+
439
+ const grant = (parent, args, ctx, info) => {
440
+ if (!query) return resolveField(parent, args, ctx, info);
441
+ return authorizeQueryPaths(permissions, defaultPolicy, info?.schema ?? schema, query, args, ctx, info)
442
+ .then(() => resolveField(parent, args, ctx, info));
443
+ };
444
+
445
+ // Rules run in order and synchronously until one returns a promise.
446
+ const runRules = (index, parent, args, ctx, info) => {
447
+ for (let current = index; current < rules.length; current++) {
448
+ if (debug) log(`Executing rule for ${key}`);
449
+ let result;
450
+ try {
451
+ result = rules[current](parent, args, ctx, info);
452
+ } catch (error) {
453
+ return Promise.reject(error);
454
+ }
455
+ if (isThenable(result)) {
456
+ return Promise.resolve(result).then(value => (
457
+ value === true || value === undefined ? runRules(current + 1, parent, args, ctx, info) : deny()
458
+ ));
459
+ }
460
+ if (result !== true && result !== undefined) return deny();
461
+ }
462
+ if (debug) log(`Access granted to ${key}`);
463
+ return grant(parent, args, ctx, info);
464
+ };
465
+
466
+ const deny = () => {
467
+ if (debug) log(`Rule denied access to ${key}`);
468
+ return denied();
469
+ };
470
+
471
+ const resolver = (parent, args, ctx, info) => {
472
+ // In a schema that only other auth plugin instances processed, their rules apply, not these.
473
+ if (info?.schema && authorizedSchemas.has(info.schema) && !processedSchemas.has(info.schema)) {
474
+ return originalResolve(parent, args, ctx, info);
475
+ }
476
+ if (debug) log(`Checking ${key}`);
477
+
478
+ if (rules === null) {
479
+ if (debug) log(`No rules for ${key}, applying default policy: ${defaultPolicy}`);
480
+ return defaultPolicy === 'DENY' ? denied() : grant(parent, args, ctx, info);
481
+ }
482
+ return runRules(0, parent, args, ctx, info);
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;
489
+ };
377
490
 
378
491
  const wrapSchemaResolvers = (schema) => {
379
492
  if (processedSchemas.has(schema)) return;
380
493
  validateConfiguration(permissions, defaultPolicy);
494
+ warnAboutQueryRoot(schema);
495
+
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
+ }
381
503
 
382
- const typeMap = schema.getTypeMap();
383
-
384
- for (const [typeName, type] of Object.entries(typeMap)) {
385
- if (!(type instanceof GraphQLObjectType) || typeName.startsWith('__')) continue;
386
-
504
+ for (const [typeName, type] of objectTypes) {
387
505
  const fields = type.getFields();
388
506
 
389
507
  for (const [fieldName, field] of Object.entries(fields)) {
508
+ // Skip fields this plugin already wraps; a resolver replaced since then is wrapped again.
509
+ const wrapper = wrappers.get(field);
510
+ if (wrapper !== undefined && wrapper === field.resolve) continue;
511
+
390
512
  const rules = getFieldRules(permissions, typeName, fieldName);
391
- const originalResolve = field.resolve || defaultFieldResolver;
392
513
  const query = field.extensions?.simfinityQuery;
514
+ // An allowed field with no rule and no client query paths has nothing to check.
515
+ if (rules === null && defaultPolicy === 'ALLOW' && !query && !debug) continue;
393
516
 
394
- field.resolve = async (parent, args, ctx, info) => {
395
- log(`Checking ${typeName}.${fieldName}`);
396
-
397
- if (rules === null) {
398
- log(`No rules for ${typeName}.${fieldName}, applying default policy: ${defaultPolicy}`);
399
-
400
- if (defaultPolicy === 'DENY') {
401
- throw new ForbiddenError(`Access denied to ${typeName}.${fieldName}`);
402
- }
403
-
404
- await authorizeQueryPaths(permissions, defaultPolicy, schema, query, args, ctx, info);
405
- return originalResolve(parent, args, ctx, info);
406
- }
407
-
408
- for (const rule of rules) {
409
- log(`Executing rule for ${typeName}.${fieldName}`);
410
-
411
- const allowed = await executeRule(rule, parent, args, ctx, info);
412
-
413
- if (!allowed) {
414
- log(`Rule denied access to ${typeName}.${fieldName}`);
415
- throw new ForbiddenError(`Access denied to ${typeName}.${fieldName}`);
416
- }
417
- }
418
-
419
- await authorizeQueryPaths(permissions, defaultPolicy, schema, query, args, ctx, info);
420
- log(`Access granted to ${typeName}.${fieldName}`);
421
-
422
- return originalResolve(parent, args, ctx, info);
423
- };
517
+ const resolver = createFieldResolver(schema, typeName, fieldName, rules, query, field.resolve || defaultFieldResolver);
518
+ field.resolve = resolver;
519
+ wrappers.set(field, resolver);
424
520
  }
425
521
  }
426
522
 
427
523
  processedSchemas.add(schema);
524
+ authorizedSchemas.add(schema);
428
525
  };
429
526
 
430
527
  return {