Quellcode durchsuchen

fix(core): Correctly implement SettingsStore validation & argument order (#3808)

Michael Bromley vor 4 Monaten
Ursprung
Commit
d8cdd6253e

+ 19 - 13
docs/docs/guides/developer-guide/settings-store/index.mdx

@@ -12,8 +12,8 @@ The Settings Store is a flexible system for storing configuration data with supp
 and validation. It allows plugins and the core system to store and retrieve arbitrary JSON data with
 and validation. It allows plugins and the core system to store and retrieve arbitrary JSON data with
 fine-grained control over access and isolation.
 fine-grained control over access and isolation.
 
 
-It provides a robust, secure, and flexible system for managing configuration data in your Vendure 
-application. Use it to store user preferences, plugin settings, feature flags, and any other 
+It provides a robust, secure, and flexible system for managing configuration data in your Vendure
+application. Use it to store user preferences, plugin settings, feature flags, and any other
 settings data your application needs.
 settings data your application needs.
 
 
 
 
@@ -221,7 +221,7 @@ mutation SetSettingsStoreValues($inputs: [SettingsStoreInput!]!) {
 ```
 ```
 
 
 :::note
 :::note
-By default, the Settings Store is not exposed in the Shop API. 
+By default, the Settings Store is not exposed in the Shop API.
 However, you can expose this functionality via a custom mutations & queries
 However, you can expose this functionality via a custom mutations & queries
 that internally use the `SettingsStoreService` (see next section).
 that internally use the `SettingsStoreService` (see next section).
 :::
 :::
@@ -310,12 +310,12 @@ For programmatic access within plugins or services, use the [SettingsStoreServic
             constructor(private settingsStoreService: SettingsStoreService) {}
             constructor(private settingsStoreService: SettingsStoreService) {}
 
 
             async getUserTheme(ctx: RequestContext): Promise<string> {
             async getUserTheme(ctx: RequestContext): Promise<string> {
-                const theme = await this.settingsStoreService.get<string>('dashboard.theme', ctx);
+                const theme = await this.settingsStoreService.get<string>(ctx, 'dashboard.theme');
                 return theme || 'light'; // Default fallback
                 return theme || 'light'; // Default fallback
             }
             }
 
 
             async setUserTheme(ctx: RequestContext, theme: string): Promise<boolean> {
             async setUserTheme(ctx: RequestContext, theme: string): Promise<boolean> {
-                const result = await this.settingsStoreService.set('dashboard.theme', theme, ctx);
+                const result = await this.settingsStoreService.set(ctx, 'dashboard.theme', theme);
                 return result.result;
                 return result.result;
             }
             }
         }
         }
@@ -339,11 +339,11 @@ For programmatic access within plugins or services, use the [SettingsStoreServic
             constructor(private settingsStoreService: SettingsStoreService) {}
             constructor(private settingsStoreService: SettingsStoreService) {}
 
 
             async getDashboardSettings(ctx: RequestContext): Promise<DashboardSettings> {
             async getDashboardSettings(ctx: RequestContext): Promise<DashboardSettings> {
-                const settings = await this.settingsStoreService.getMany([
+                const settings = await this.settingsStoreService.getMany(ctx, [
                     'dashboard.theme',
                     'dashboard.theme',
                     'dashboard.language',
                     'dashboard.language',
                     'dashboard.notifications'
                     'dashboard.notifications'
-                ], ctx);
+                ]);
 
 
                 return {
                 return {
                     theme: settings['dashboard.theme'] || 'light',
                     theme: settings['dashboard.theme'] || 'light',
@@ -364,7 +364,7 @@ For programmatic access within plugins or services, use the [SettingsStoreServic
                     updates['dashboard.notifications'] = settings.notifications;
                     updates['dashboard.notifications'] = settings.notifications;
                 }
                 }
 
 
-                const results = await this.settingsStoreService.setMany(updates, ctx);
+                const results = await this.settingsStoreService.setMany(ctx, updates);
 
 
                 return {
                 return {
                     success: results.every(r => r.result),
                     success: results.every(r => r.result),
@@ -382,15 +382,21 @@ For programmatic access within plugins or services, use the [SettingsStoreServic
 
 
 | Method                    | Description                                         |
 | Method                    | Description                                         |
 | ------------------------- | --------------------------------------------------- |
 | ------------------------- | --------------------------------------------------- |
-| `get<T>(key, ctx)`        | Get a single value with optional type parameter     |
-| `getMany(keys, ctx)`      | Get multiple values efficiently in a single query   |
-| `set<T>(key, value, ctx)` | Set a value with structured result feedback         |
-| `setMany(values, ctx)`    | Set multiple values with individual result feedback |
+| `get<T>(ctx, key)`        | Get a single value with optional type parameter     |
+| `getMany(ctx, keys)`      | Get multiple values efficiently in a single query   |
+| `set<T>(ctx, key, value)` | Set a value with structured result feedback         |
+| `setMany(ctx, values)`    | Set multiple values with individual result feedback |
 | `getFieldDefinition(key)` | Get the field configuration for a key               |
 | `getFieldDefinition(key)` | Get the field configuration for a key               |
 
 
+:::note
+Prior to v3.4.2, `ctx` was the _last_ argument to the above methods. However, since
+this is contrary to all other method usage which has `ctx` as the _first_ argument, it was
+changed while deprecating (but still supporting) the former signature.
+:::
+
 ## Orphaned Entries Cleanup
 ## Orphaned Entries Cleanup
 
 
-When field definitions are removed from your configuration, the corresponding 
+When field definitions are removed from your configuration, the corresponding
 database entries become "orphaned". The Settings Store includes an automatic cleanup system to handle this.
 database entries become "orphaned". The Settings Store includes an automatic cleanup system to handle this.
 
 
 ### Manual Cleanup
 ### Manual Cleanup

+ 53 - 12
packages/core/src/api/resolvers/admin/settings-store.resolver.ts

@@ -10,6 +10,11 @@ export class SettingsStoreInput {
     value: any;
     value: any;
 }
 }
 
 
+const ErrorMessage = {
+    permissions: 'Insufficient permissions to set settings store value',
+    readonly: 'Cannot modify readonly settings store field via API',
+};
+
 /**
 /**
  * @description
  * @description
  * Resolvers for settings store operations in the Admin API.
  * Resolvers for settings store operations in the Admin API.
@@ -20,7 +25,10 @@ export class SettingsStoreAdminResolver {
 
 
     @Query()
     @Query()
     async getSettingsStoreValue(@Ctx() ctx: RequestContext, @Args('key') key: string): Promise<any> {
     async getSettingsStoreValue(@Ctx() ctx: RequestContext, @Args('key') key: string): Promise<any> {
-        return this.settingsStoreService.get(key, ctx);
+        if (!this.settingsStoreService.hasPermission(ctx, key)) {
+            return undefined;
+        }
+        return this.settingsStoreService.get(ctx, key);
     }
     }
 
 
     @Query()
     @Query()
@@ -28,7 +36,13 @@ export class SettingsStoreAdminResolver {
         @Ctx() ctx: RequestContext,
         @Ctx() ctx: RequestContext,
         @Args('keys') keys: string[],
         @Args('keys') keys: string[],
     ): Promise<Record<string, any>> {
     ): Promise<Record<string, any>> {
-        return this.settingsStoreService.getMany(keys, ctx);
+        const permittedKeys = [];
+        for (const key of keys) {
+            if (this.settingsStoreService.hasPermission(ctx, key)) {
+                permittedKeys.push(key);
+            }
+        }
+        return this.settingsStoreService.getMany(ctx, permittedKeys);
     }
     }
 
 
     @Mutation()
     @Mutation()
@@ -36,7 +50,21 @@ export class SettingsStoreAdminResolver {
         @Ctx() ctx: RequestContext,
         @Ctx() ctx: RequestContext,
         @Args('input') input: SettingsStoreInput,
         @Args('input') input: SettingsStoreInput,
     ): Promise<SetSettingsStoreValueResult> {
     ): Promise<SetSettingsStoreValueResult> {
-        return this.settingsStoreService.set(input.key, input.value, ctx);
+        if (!this.settingsStoreService.hasPermission(ctx, input.key)) {
+            return {
+                key: input.key,
+                result: false,
+                error: ErrorMessage.permissions,
+            };
+        }
+        if (this.settingsStoreService.isReadonly(input.key)) {
+            return {
+                key: input.key,
+                result: false,
+                error: ErrorMessage.readonly,
+            };
+        }
+        return this.settingsStoreService.set(ctx, input.key, input.value);
     }
     }
 
 
     @Mutation()
     @Mutation()
@@ -44,14 +72,27 @@ export class SettingsStoreAdminResolver {
         @Ctx() ctx: RequestContext,
         @Ctx() ctx: RequestContext,
         @Args('inputs') inputs: SettingsStoreInput[],
         @Args('inputs') inputs: SettingsStoreInput[],
     ): Promise<SetSettingsStoreValueResult[]> {
     ): Promise<SetSettingsStoreValueResult[]> {
-        const values = inputs.reduce(
-            (acc, input) => {
-                acc[input.key] = input.value;
-                return acc;
-            },
-            {} as Record<string, any>,
-        );
-
-        return this.settingsStoreService.setMany(values, ctx);
+        const results: SetSettingsStoreValueResult[] = [];
+        for (const input of inputs) {
+            const hasPermission = this.settingsStoreService.hasPermission(ctx, input.key);
+            const isWritable = !this.settingsStoreService.isReadonly(input.key);
+            if (!hasPermission) {
+                results.push({
+                    key: input.key,
+                    result: false,
+                    error: ErrorMessage.permissions,
+                });
+            } else if (!isWritable) {
+                results.push({
+                    key: input.key,
+                    result: false,
+                    error: ErrorMessage.readonly,
+                });
+            } else {
+                const result = await this.settingsStoreService.set(ctx, input.key, input.value);
+                results.push(result);
+            }
+        }
+        return results;
     }
     }
 }
 }

+ 101 - 44
packages/core/src/service/helpers/settings-store/settings-store.service.ts

@@ -99,13 +99,17 @@ export class SettingsStoreService implements OnModuleInit {
      * @param ctx - Request context for scoping and permissions
      * @param ctx - Request context for scoping and permissions
      * @returns The stored value or undefined if not found or access denied
      * @returns The stored value or undefined if not found or access denied
      */
      */
-    async get<T = JsonCompatible<any>>(key: string, ctx: RequestContext): Promise<T | undefined> {
+    async get<T = JsonCompatible<any>>(ctx: RequestContext, key: string): Promise<T | undefined>;
+    /**
+     * @deprecated Use the `ctx` arg in the first position
+     */
+    async get<T = JsonCompatible<any>>(key: string, ctx: RequestContext): Promise<T | undefined>;
+    async get<T = JsonCompatible<any>>(
+        keyOrCtx: string | RequestContext,
+        ctxOrKey: RequestContext | string,
+    ): Promise<T | undefined> {
+        const { ctx, other: key } = this.determineCtx(keyOrCtx, ctxOrKey);
         const fieldConfig = this.getFieldConfig(key);
         const fieldConfig = this.getFieldConfig(key);
-
-        if (!this.hasPermission(ctx, fieldConfig)) {
-            return undefined;
-        }
-
         const scope = this.generateScope(key, undefined, ctx, fieldConfig);
         const scope = this.generateScope(key, undefined, ctx, fieldConfig);
 
 
         const entry = await this.connection.getRepository(ctx, SettingsStoreEntry).findOne({
         const entry = await this.connection.getRepository(ctx, SettingsStoreEntry).findOne({
@@ -124,7 +128,16 @@ export class SettingsStoreService implements OnModuleInit {
      * @param ctx - Request context for scoping and permissions
      * @param ctx - Request context for scoping and permissions
      * @returns Object mapping keys to their values
      * @returns Object mapping keys to their values
      */
      */
-    async getMany(keys: string[], ctx: RequestContext): Promise<Record<string, JsonCompatible<any>>> {
+    async getMany(ctx: RequestContext, keys: string[]): Promise<Record<string, JsonCompatible<any>>>;
+    /**
+     * @deprecated Use `ctx` as the first argument
+     */
+    async getMany(keys: string[], ctx: RequestContext): Promise<Record<string, JsonCompatible<any>>>;
+    async getMany(
+        keysOrCtx: string[] | RequestContext,
+        ctxOrKeys: RequestContext | string[],
+    ): Promise<Record<string, JsonCompatible<any>>> {
+        const { ctx, other: keys } = this.determineCtx(keysOrCtx, ctxOrKeys);
         const result: Record<string, any> = {};
         const result: Record<string, any> = {};
 
 
         // Build array of key/scopeKey pairs for authorized keys
         // Build array of key/scopeKey pairs for authorized keys
@@ -132,11 +145,8 @@ export class SettingsStoreService implements OnModuleInit {
 
 
         for (const key of keys) {
         for (const key of keys) {
             const fieldConfig = this.getFieldConfig(key);
             const fieldConfig = this.getFieldConfig(key);
-
-            if (this.hasPermission(ctx, fieldConfig)) {
-                const scope = this.generateScope(key, undefined, ctx, fieldConfig);
-                queries.push({ key, scope });
-            }
+            const scope = this.generateScope(key, undefined, ctx, fieldConfig);
+            queries.push({ key, scope });
         }
         }
 
 
         if (queries.length === 0) {
         if (queries.length === 0) {
@@ -181,30 +191,31 @@ export class SettingsStoreService implements OnModuleInit {
      * @param ctx - Request context for scoping and permissions
      * @param ctx - Request context for scoping and permissions
      * @returns SetSettingsStoreValueResult with operation status and error details
      * @returns SetSettingsStoreValueResult with operation status and error details
      */
      */
+    async set<T extends JsonCompatible<any> = JsonCompatible<any>>(
+        ctx: RequestContext,
+        key: string,
+        value: T,
+    ): Promise<SetSettingsStoreValueResult>;
+    /**
+     * @deprecated Use `ctx` as the first argument
+     */
     async set<T extends JsonCompatible<any> = JsonCompatible<any>>(
     async set<T extends JsonCompatible<any> = JsonCompatible<any>>(
         key: string,
         key: string,
         value: T,
         value: T,
         ctx: RequestContext,
         ctx: RequestContext,
+    ): Promise<SetSettingsStoreValueResult>;
+    async set<T extends JsonCompatible<any> = JsonCompatible<any>>(
+        keyOrCtx: string | RequestContext,
+        keyOrValue: string | T,
+        ctxOrValue: RequestContext | T,
     ): Promise<SetSettingsStoreValueResult> {
     ): Promise<SetSettingsStoreValueResult> {
+        // Sort out the overloaded signatures
+        const ctx = keyOrCtx instanceof RequestContext ? keyOrCtx : (ctxOrValue as RequestContext);
+        const key = keyOrCtx instanceof RequestContext ? (keyOrValue as string) : keyOrCtx;
+        const value = ctxOrValue instanceof RequestContext ? (keyOrValue as T) : ctxOrValue;
+
         try {
         try {
             const fieldConfig = this.getFieldConfig(key);
             const fieldConfig = this.getFieldConfig(key);
-
-            if (!this.hasPermission(ctx, fieldConfig)) {
-                return {
-                    key,
-                    result: false,
-                    error: 'Insufficient permissions to set settings store value',
-                };
-            }
-
-            if (fieldConfig.readonly) {
-                return {
-                    key,
-                    result: false,
-                    error: 'Cannot modify readonly settings store field via API',
-                };
-            }
-
             // Validate the value
             // Validate the value
             await this.validateValue(key, value, ctx);
             await this.validateValue(key, value, ctx);
 
 
@@ -245,19 +256,27 @@ export class SettingsStoreService implements OnModuleInit {
      * Set multiple values with structured result feedback for each operation.
      * Set multiple values with structured result feedback for each operation.
      * This method will not throw errors but will return
      * This method will not throw errors but will return
      * detailed results for each key-value pair.
      * detailed results for each key-value pair.
-     *
-     * @param values - Object mapping keys to their values
-     * @param ctx - Request context for scoping and permissions
-     * @returns Array of SetSettingsStoreValueResult with operation status for each key
+     */
+    async setMany(
+        ctx: RequestContext,
+        values: Record<string, JsonCompatible<any>>,
+    ): Promise<SetSettingsStoreValueResult[]>;
+    /**
+     * @deprecated Use `ctx` as the first argument
      */
      */
     async setMany(
     async setMany(
         values: Record<string, JsonCompatible<any>>,
         values: Record<string, JsonCompatible<any>>,
         ctx: RequestContext,
         ctx: RequestContext,
+    ): Promise<SetSettingsStoreValueResult[]>;
+    async setMany(
+        valuesOrCtx: Record<string, JsonCompatible<any>> | RequestContext,
+        ctxOrValues: RequestContext | Record<string, JsonCompatible<any>>,
     ): Promise<SetSettingsStoreValueResult[]> {
     ): Promise<SetSettingsStoreValueResult[]> {
+        const { ctx, other: values } = this.determineCtx(valuesOrCtx, ctxOrValues);
         const results: SetSettingsStoreValueResult[] = [];
         const results: SetSettingsStoreValueResult[] = [];
 
 
         for (const [key, value] of Object.entries(values)) {
         for (const [key, value] of Object.entries(values)) {
-            const result = await this.set(key, value, ctx);
+            const result = await this.set(ctx, key, value);
             results.push(result);
             results.push(result);
         }
         }
 
 
@@ -438,18 +457,56 @@ export class SettingsStoreService implements OnModuleInit {
     /**
     /**
      * @description
      * @description
      * Check if the current user has permission to access a field.
      * Check if the current user has permission to access a field.
+     * This is not called internally in the get and set methods, so should
+     * be used by any methods which are exposing these methods via the GraphQL
+     * APIs.
+     */
+    hasPermission(ctx: RequestContext, key: string): boolean {
+        try {
+            const fieldConfig = this.getFieldConfig(key);
+            // Admin API: check required permissions
+            const requiredPermissions = fieldConfig.requiresPermission;
+            if (requiredPermissions) {
+                const permissions = Array.isArray(requiredPermissions)
+                    ? requiredPermissions
+                    : [requiredPermissions];
+                return ctx.userHasPermissions(permissions as any);
+            }
+
+            // Default: require authentication
+            return ctx.userHasPermissions([Permission.Authenticated]);
+        } catch (error) {
+            return true;
+        }
+    }
+
+    /**
+     * @description
+     * Returns true if the settings field has the `readonly: true` configuration.
      */
      */
-    private hasPermission(ctx: RequestContext, fieldConfig: SettingsStoreFieldConfig): boolean {
-        // Admin API: check required permissions
-        const requiredPermissions = fieldConfig.requiresPermission;
-        if (requiredPermissions) {
-            const permissions = Array.isArray(requiredPermissions)
-                ? requiredPermissions
-                : [requiredPermissions];
-            return ctx.userHasPermissions(permissions as any);
+    isReadonly(key: string): boolean {
+        try {
+            const fieldConfig = this.getFieldConfig(key);
+            return fieldConfig.readonly === true;
+        } catch (error) {
+            return false;
         }
         }
+    }
 
 
-        // Default: require authentication
-        return ctx.userHasPermissions([Permission.Authenticated]);
+    /**
+     * This unfortunate workaround is here because in the first version of the SettingsStore we have the
+     * ctx arg last, which goes against all patterns in the rest of the code base. In v3.4.2 we overload
+     * the methods to allow the correct ordering, and deprecate the original order.
+     */
+    private determineCtx<K>(
+        a: K | RequestContext,
+        b: K | RequestContext,
+    ): {
+        other: K;
+        ctx: RequestContext;
+    } {
+        const ctx = a instanceof RequestContext ? a : (b as RequestContext);
+        const other = a instanceof RequestContext ? (b as K) : a;
+        return { other, ctx };
     }
     }
 }
 }