diff --git a/README.md b/README.md index f8e26f3f067..d75568ec121 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,7 @@ linkStyle default opacity:0.5 ai_controllers --> base_controller; ai_controllers --> messenger; analytics_controller --> base_controller; + analytics_controller --> config_registry_controller; analytics_controller --> geolocation_controller; analytics_controller --> messenger; analytics_controller --> utils; diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 004539ecf20..f4f7d82530a 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -2452,16 +2452,6 @@ "count": 1 } }, - "packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.test.ts": { - "typescript/no-unsafe-assignment": { - "count": 6 - } - }, - "packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.ts": { - "typescript/no-unsafe-assignment": { - "count": 1 - } - }, "packages/connectivity-controller/jest.config.cjs": { "import/unambiguous": { "count": 1 diff --git a/packages/analytics-controller/CHANGELOG.md b/packages/analytics-controller/CHANGELOG.md index 914a4f71389..881cb31bd28 100644 --- a/packages/analytics-controller/CHANGELOG.md +++ b/packages/analytics-controller/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** `AnalyticsControllerMessenger` requires `ConfigRegistryController:getState` in its allowed actions and `ConfigRegistryController:stateChanged` in its allowed events ([#10448](https://github.com/MetaMask/core/pull/10448)) +- Update `AnalyticsController.init` to listen for ConfigRegistryController state changes and update in-memory event-purpose classification when the version differs ([#10448](https://github.com/MetaMask/core/pull/10448)) +- Add `@metamask/config-registry-controller` `^4.0.0` as a dependency ([#10448](https://github.com/MetaMask/core/pull/10448)) - Bump `lodash-es` from `^4.17.21` to `^4.18.1` ([#10447](https://github.com/MetaMask/core/pull/10447)) ## [3.2.0] diff --git a/packages/analytics-controller/README.md b/packages/analytics-controller/README.md index a765fbed7d1..b35e94b1baa 100644 --- a/packages/analytics-controller/README.md +++ b/packages/analytics-controller/README.md @@ -34,7 +34,7 @@ The AnalyticsController provides a unified interface for tracking analytics even 3. **Subscribe to state changes**: Persist changes to isolated storage 4. **Persist to isolated storage**: Keep analytics settings separate from main state (protects against state corruption) -`eventsConfig.events` maps event names to one or both `AnalyticsPurpose` values (`product` and `marketing`). Unlisted names default to product-only. Phase 1 uses the config already persisted in state. Loading it from config registry will be added later. +`eventsConfig.events` maps event names to one or both `AnalyticsPurpose` values (`product` and `marketing`). Unlisted names default to product-only. The config is loaded from `ConfigRegistryController` state during `init` and refreshed automatically when `ConfigRegistryController` state changes. Each `track` and `view` payload is emitted once when at least one eligible purpose is opted in. A dual-purpose event is still emitted once when both consents are enabled. Its allowed purposes are stamped using Segment's consent context: diff --git a/packages/analytics-controller/package.json b/packages/analytics-controller/package.json index 1742f993a20..03ee85e3daa 100644 --- a/packages/analytics-controller/package.json +++ b/packages/analytics-controller/package.json @@ -51,6 +51,7 @@ }, "dependencies": { "@metamask/base-controller": "^10.0.0", + "@metamask/config-registry-controller": "^4.0.0", "@metamask/geolocation-controller": "^2.0.0", "@metamask/messenger": "^3.0.0", "@metamask/utils": "^12.0.0", diff --git a/packages/analytics-controller/src/AnalyticsController.test.ts b/packages/analytics-controller/src/AnalyticsController.test.ts index f035c672c14..1da79cd5fe6 100644 --- a/packages/analytics-controller/src/AnalyticsController.test.ts +++ b/packages/analytics-controller/src/AnalyticsController.test.ts @@ -1,4 +1,8 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; +import type { + ConfigRegistryControllerGetStateAction, + ConfigRegistryControllerStateChangedEvent, +} from '@metamask/config-registry-controller'; import type { GeolocationControllerGetGeolocationDataAction, GeolocationData, @@ -34,7 +38,8 @@ import type { */ type AnalyticsControllerTestActions = | AnalyticsControllerActions - | GeolocationControllerGetGeolocationDataAction; + | GeolocationControllerGetGeolocationDataAction + | ConfigRegistryControllerGetStateAction; type SetupControllerOptions = { state: AnalyticsControllerState; @@ -70,6 +75,41 @@ type SetupControllerReturn = { messenger: AnalyticsControllerMessenger; }; +/** + * Registers a no-op `ConfigRegistryController:getState` action handler on a + * root messenger and delegates it to a child messenger. + * + * @param rootMessenger - The root messenger to register the handler on. + * @param childMessenger - The child messenger to delegate the action to. + */ +function registerConfigRegistryMock( + rootMessenger: Messenger< + MockAnyNamespace, + AnalyticsControllerTestActions, + AnalyticsControllerEvents + >, + childMessenger: Messenger< + 'AnalyticsController', + AnalyticsControllerTestActions, + AnalyticsControllerEvents, + typeof rootMessenger + >, +): void { + rootMessenger.registerActionHandler( + 'ConfigRegistryController:getState', + () => ({ + configs: { networks: {}, eventsConfig: null }, + version: null, + lastFetched: null, + etag: null, + }), + ); + rootMessenger.delegate({ + actions: ['ConfigRegistryController:getState'], + messenger: childMessenger, + }); +} + /** * Builds complete geolocation data from a partial fixture. * @@ -148,6 +188,8 @@ async function setupController( parent: rootMessenger, }); + registerConfigRegistryMock(rootMessenger, analyticsControllerMessenger); + if (!omitGeolocationAction) { rootMessenger.registerActionHandler( 'GeolocationController:getGeolocationData', @@ -632,6 +674,8 @@ describe('AnalyticsController', () => { parent: rootMessenger, }); + registerConfigRegistryMock(rootMessenger, messenger); + // Create controller without isAnonymousEventsFeatureEnabled to test default value const controller = new AnalyticsController({ messenger, @@ -686,6 +730,8 @@ describe('AnalyticsController', () => { parent: rootMessenger, }); + registerConfigRegistryMock(rootMessenger, messenger); + expect(() => { // eslint-disable-next-line no-new new AnalyticsController({ @@ -718,6 +764,8 @@ describe('AnalyticsController', () => { parent: rootMessenger, }); + registerConfigRegistryMock(rootMessenger, messenger); + expect(() => { // eslint-disable-next-line no-new new AnalyticsController({ @@ -751,6 +799,8 @@ describe('AnalyticsController', () => { parent: rootMessenger, }); + registerConfigRegistryMock(rootMessenger, messenger); + expect(() => { // eslint-disable-next-line no-new new AnalyticsController({ @@ -828,6 +878,8 @@ describe('AnalyticsController', () => { parent: rootMessenger, }); + registerConfigRegistryMock(rootMessenger, messenger); + const controller = new AnalyticsController({ messenger, platformAdapter: mockAdapter, @@ -867,6 +919,8 @@ describe('AnalyticsController', () => { parent: rootMessenger, }); + registerConfigRegistryMock(rootMessenger, messenger); + const controller = new AnalyticsController({ messenger, platformAdapter: mockAdapter, @@ -908,6 +962,8 @@ describe('AnalyticsController', () => { parent: rootMessenger, }); + registerConfigRegistryMock(rootMessenger, messenger); + const controller = new AnalyticsController({ messenger, platformAdapter: mockAdapter, @@ -1012,6 +1068,8 @@ describe('AnalyticsController', () => { parent: rootMessenger, }); + registerConfigRegistryMock(rootMessenger, messenger); + const controller = new AnalyticsController({ messenger, platformAdapter: mockAdapter, @@ -1030,6 +1088,286 @@ describe('AnalyticsController', () => { }); }); + describe('fetchEventsConfig (via init)', () => { + const analyticsId = '77777777-7777-4777-a777-777777777777'; + + function buildConfigRegistryState( + eventsConfig: Record | null = null, + ): ReturnType { + return { + configs: { + networks: {}, + eventsConfig, + }, + version: null, + lastFetched: null, + etag: null, + eventsConfigEtag: null, + } as unknown as ReturnType< + ConfigRegistryControllerGetStateAction['handler'] + >; + } + + it('loads events config from ConfigRegistryController state during init', async () => { + const remoteEventsConfig = { + schemaVersion: '1.0.0', + version: '4a1153d78e32ac8f9975c5fe40e3526a19497525', + timestamp: 1761829548000, + events: { + SomeEvent: [AnalyticsPurpose.Product], + MarketingEvent: [AnalyticsPurpose.Marketing], + }, + }; + + const rootMessenger = new Messenger< + MockAnyNamespace, + AnalyticsControllerTestActions, + AnalyticsControllerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + const messenger = new Messenger< + 'AnalyticsController', + AnalyticsControllerTestActions, + AnalyticsControllerEvents, + typeof rootMessenger + >({ namespace: 'AnalyticsController', parent: rootMessenger }); + + rootMessenger.registerActionHandler( + 'ConfigRegistryController:getState', + () => buildConfigRegistryState(remoteEventsConfig), + ); + rootMessenger.delegate({ + actions: ['ConfigRegistryController:getState'], + messenger, + }); + + const controller = new AnalyticsController({ + messenger, + platformAdapter: createMockAdapter(), + state: { ...getDefaultAnalyticsControllerState(), analyticsId }, + }); + + await controller.init(); + + expect(controller.state.eventsConfig).toStrictEqual(remoteEventsConfig); + }); + + it('skips update when ConfigRegistryController has no eventsConfig', async () => { + const rootMessenger = new Messenger< + MockAnyNamespace, + AnalyticsControllerTestActions, + AnalyticsControllerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + const messenger = new Messenger< + 'AnalyticsController', + AnalyticsControllerTestActions, + AnalyticsControllerEvents, + typeof rootMessenger + >({ namespace: 'AnalyticsController', parent: rootMessenger }); + + rootMessenger.registerActionHandler( + 'ConfigRegistryController:getState', + () => buildConfigRegistryState(null), + ); + rootMessenger.delegate({ + actions: ['ConfigRegistryController:getState'], + messenger, + }); + + const controller = new AnalyticsController({ + messenger, + platformAdapter: createMockAdapter(), + state: { ...getDefaultAnalyticsControllerState(), analyticsId }, + }); + + await controller.init(); + + expect(controller.state.eventsConfig).toBeUndefined(); + }); + + it('skips update when remote version matches current version', async () => { + const existingEventsConfig = { + schemaVersion: '1.0.0', + version: '4a1153d78e32ac8f9975c5fe40e3526a19497525', + timestamp: 1761829548000, + events: { OldEvent: [AnalyticsPurpose.Product] }, + }; + + const remoteEventsConfig = { + ...existingEventsConfig, + events: { NewEvent: [AnalyticsPurpose.Product] }, + }; + + const rootMessenger = new Messenger< + MockAnyNamespace, + AnalyticsControllerTestActions, + AnalyticsControllerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + const messenger = new Messenger< + 'AnalyticsController', + AnalyticsControllerTestActions, + AnalyticsControllerEvents, + typeof rootMessenger + >({ namespace: 'AnalyticsController', parent: rootMessenger }); + + rootMessenger.registerActionHandler( + 'ConfigRegistryController:getState', + () => buildConfigRegistryState(remoteEventsConfig), + ); + rootMessenger.delegate({ + actions: ['ConfigRegistryController:getState'], + messenger, + }); + + const controller = new AnalyticsController({ + messenger, + platformAdapter: createMockAdapter(), + state: { + ...getDefaultAnalyticsControllerState(), + analyticsId, + eventsConfig: existingEventsConfig, + }, + }); + + await controller.init(); + + // Events config was not updated because version matches + expect(controller.state.eventsConfig?.events).toStrictEqual( + existingEventsConfig.events, + ); + }); + + it('refreshes events config when ConfigRegistryController state changes', async () => { + type TestEvents = + | AnalyticsControllerEvents + | ConfigRegistryControllerStateChangedEvent; + + const initialEventsConfig = { + schemaVersion: '1.0.0', + version: '4a1153d78e32ac8f9975c5fe40e3526a19497525', + timestamp: 1761829548000, + events: { OldEvent: [AnalyticsPurpose.Product] }, + }; + + const updatedEventsConfig = { + schemaVersion: '1.0.0', + version: 'b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1', + timestamp: 1761829549000, + events: { NewEvent: [AnalyticsPurpose.Marketing] }, + }; + + const rootMessenger = new Messenger< + MockAnyNamespace, + AnalyticsControllerTestActions, + TestEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + const messenger = new Messenger< + 'AnalyticsController', + AnalyticsControllerTestActions, + TestEvents, + typeof rootMessenger + >({ namespace: 'AnalyticsController', parent: rootMessenger }); + + let currentEventsConfig: Record | null = + initialEventsConfig; + rootMessenger.registerActionHandler( + 'ConfigRegistryController:getState', + () => buildConfigRegistryState(currentEventsConfig), + ); + rootMessenger.delegate({ + actions: ['ConfigRegistryController:getState'], + events: ['ConfigRegistryController:stateChanged'], + messenger, + }); + + const controller = new AnalyticsController({ + messenger, + platformAdapter: createMockAdapter(), + state: { ...getDefaultAnalyticsControllerState(), analyticsId }, + }); + + await controller.init(); + expect(controller.state.eventsConfig).toStrictEqual(initialEventsConfig); + + currentEventsConfig = updatedEventsConfig; + rootMessenger.publish( + 'ConfigRegistryController:stateChanged', + buildConfigRegistryState(updatedEventsConfig), + [], + ); + + // Allow the async fetch callback to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(controller.state.eventsConfig).toStrictEqual(updatedEventsConfig); + }); + + it('skips refresh when ConfigRegistryController state changes but eventsConfig version is unchanged', async () => { + type TestEvents = + | AnalyticsControllerEvents + | ConfigRegistryControllerStateChangedEvent; + + const eventsConfig = { + schemaVersion: '1.0.0', + version: '4a1153d78e32ac8f9975c5fe40e3526a19497525', + timestamp: 1761829548000, + events: { SomeEvent: [AnalyticsPurpose.Product] }, + }; + + const rootMessenger = new Messenger< + MockAnyNamespace, + AnalyticsControllerTestActions, + TestEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + const messenger = new Messenger< + 'AnalyticsController', + AnalyticsControllerTestActions, + TestEvents, + typeof rootMessenger + >({ namespace: 'AnalyticsController', parent: rootMessenger }); + + const getStateMock = jest.fn(() => + buildConfigRegistryState(eventsConfig), + ); + rootMessenger.registerActionHandler( + 'ConfigRegistryController:getState', + getStateMock, + ); + rootMessenger.delegate({ + actions: ['ConfigRegistryController:getState'], + events: ['ConfigRegistryController:stateChanged'], + messenger, + }); + + const controller = new AnalyticsController({ + messenger, + platformAdapter: createMockAdapter(), + state: { ...getDefaultAnalyticsControllerState(), analyticsId }, + }); + + await controller.init(); + const eventsConfigAfterInit = controller.state.eventsConfig; + + // Publish a state change with the same eventsConfig version + rootMessenger.publish( + 'ConfigRegistryController:stateChanged', + buildConfigRegistryState(eventsConfig), + [], + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + // eventsConfig state should not have changed since version is unchanged + expect(controller.state.eventsConfig).toStrictEqual( + eventsConfigAfterInit, + ); + }); + }); + describe('trackEvent', () => { it('calls platform adapter to track event when enabled', async () => { const mockAdapter = createMockAdapter(); diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index 1bd8bd3f19f..22efba4000d 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -4,6 +4,10 @@ import type { StateMetadata, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; +import type { + ConfigRegistryControllerGetStateAction, + ConfigRegistryControllerStateChangedEvent, +} from '@metamask/config-registry-controller'; import type { GeolocationControllerGetGeolocationDataAction, GeolocationData, @@ -119,10 +123,8 @@ export type AnalyticsControllerState = { /** * Cached event-purpose configuration. Optional for backward compatibility. - * - * Phase 1 does not load this from a remote source. Until a later phase wires - * that up, classification uses the persisted config. Unlisted names are - * product-only. + * Loaded from ConfigRegistryController state during init. Unlisted events + * default to product-only. */ eventsConfig?: AnalyticsEventsConfig; @@ -388,7 +390,9 @@ export type AnalyticsControllerActions = /** * Actions from other messengers that {@link AnalyticsControllerMessenger} calls. */ -type AllowedActions = GeolocationControllerGetGeolocationDataAction; +type AllowedActions = + | GeolocationControllerGetGeolocationDataAction + | ConfigRegistryControllerGetStateAction; /** * Event emitted when the state of the {@link AnalyticsController} changes. @@ -406,7 +410,7 @@ export type AnalyticsControllerEvents = AnalyticsControllerStateChangeEvent; /** * Events from other messengers that {@link AnalyticsControllerMessenger} subscribes to. */ -type AllowedEvents = never; +type AllowedEvents = ConfigRegistryControllerStateChangedEvent; /** * The messenger restricted to actions and events accessed by @@ -738,9 +742,9 @@ export class AnalyticsController extends BaseController< /** * In-memory event-purpose lookup from persisted state. */ - readonly #eventPurposes: Map; + #eventPurposes: Map; - readonly #eventsConfigVersion: string | undefined; + #eventsConfigVersion: string | undefined; /** * The in-flight (or settled) initialization promise. Set on the first @@ -891,6 +895,16 @@ export class AnalyticsController extends BaseController< await this.#fetchEventsConfig(); + this.messenger.subscribe( + 'ConfigRegistryController:stateChanged', + () => { + this.#fetchEventsConfig().catch( + /* istanbul ignore next */ () => undefined, + ); + }, + (state) => state.configs.eventsConfig?.version, + ); + // Resolve geolocation only when the user is already opted in to product or // marketing analytics. For undecided or opted-out users it is deferred to // {@link optIn} / {@link optInToMarketing}. Awaited so that an already-opted-in @@ -1029,13 +1043,38 @@ export class AnalyticsController extends BaseController< } /** - * Load event-purpose configuration. - * - * Phase 1 stub. Persisted configuration remains authoritative until a remote - * source is wired up. + * Load event-purpose configuration from ConfigRegistryController state. + * Updates in-memory purposes map and persisted state when the version differs. */ async #fetchEventsConfig(): Promise { - // Intentionally empty until an events-config source is wired up. + const eventsConfigState = this.messenger.call( + 'ConfigRegistryController:getState', + ); + + const remoteEventsConfig = eventsConfigState.configs.eventsConfig; + if (!remoteEventsConfig) { + return; + } + + const candidate: AnalyticsEventsConfig = { + schemaVersion: remoteEventsConfig.schemaVersion, + version: remoteEventsConfig.version, + timestamp: remoteEventsConfig.timestamp, + events: remoteEventsConfig.events as Record, + }; + + if ( + !isAnalyticsEventsConfig(candidate) || + candidate.version === this.#eventsConfigVersion + ) { + return; + } + + this.#eventPurposes = new Map(Object.entries(candidate.events)); + this.#eventsConfigVersion = candidate.version; + this.update((state) => { + state.eventsConfig = candidate; + }); } #purposesFromName(name: string): AnalyticsPurpose[] { diff --git a/packages/analytics-controller/tsconfig.build.json b/packages/analytics-controller/tsconfig.build.json index 0af8ba701a3..9729a829f41 100644 --- a/packages/analytics-controller/tsconfig.build.json +++ b/packages/analytics-controller/tsconfig.build.json @@ -16,6 +16,9 @@ }, { "path": "../utils/tsconfig.build.json" + }, + { + "path": "../config-registry-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"] diff --git a/packages/analytics-controller/tsconfig.json b/packages/analytics-controller/tsconfig.json index c83b485b7b9..1926d93446a 100644 --- a/packages/analytics-controller/tsconfig.json +++ b/packages/analytics-controller/tsconfig.json @@ -12,6 +12,9 @@ }, { "path": "../utils" + }, + { + "path": "../config-registry-controller" } ], "include": ["../../types", "./src"] diff --git a/packages/analytics-controller/tsconfig.lint.json b/packages/analytics-controller/tsconfig.lint.json index 6dc1a4cdc07..2b8a490580f 100644 --- a/packages/analytics-controller/tsconfig.lint.json +++ b/packages/analytics-controller/tsconfig.lint.json @@ -16,6 +16,9 @@ }, { "path": "../utils/tsconfig.lint.json" + }, + { + "path": "../config-registry-controller/tsconfig.lint.json" } ] } diff --git a/packages/config-registry-controller/CHANGELOG.md b/packages/config-registry-controller/CHANGELOG.md index 9ca5d48991b..153e4931290 100644 --- a/packages/config-registry-controller/CHANGELOG.md +++ b/packages/config-registry-controller/CHANGELOG.md @@ -7,8 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `ConfigRegistryApiService.fetchEventsConfig`, which hits `/v1/config/events-config` ([#10448](https://github.com/MetaMask/core/pull/10448)) + ### Changed +- **BREAKING:** `ConfigRegistryControllerMessenger` requires `ConfigRegistryApiService:fetchEventsConfig` in its allowed actions ([#10448](https://github.com/MetaMask/core/pull/10448)) +- `ConfigRegistryController` now caches the result from `/v1/config/events-config` in `configs.eventsConfig` in its polling loop ([#10448](https://github.com/MetaMask/core/pull/10448)) - Bump `@metamask/utils` from `^11.12.0` to `^12.0.0` ([#10192](https://github.com/MetaMask/core/pull/10192)) - Bump `@metamask/keyring-controller` from `^28.0.0` to `^28.1.0` ([#10418](https://github.com/MetaMask/core/pull/10418)) diff --git a/packages/config-registry-controller/src/ConfigRegistryController.test.ts b/packages/config-registry-controller/src/ConfigRegistryController.test.ts index d737bd04c4a..d8a1e216d2e 100644 --- a/packages/config-registry-controller/src/ConfigRegistryController.test.ts +++ b/packages/config-registry-controller/src/ConfigRegistryController.test.ts @@ -53,6 +53,7 @@ function getConfigRegistryControllerMessenger(): { actions: [ 'RemoteFeatureFlagController:getState', 'ConfigRegistryApiService:fetchConfig', + 'ConfigRegistryApiService:fetchEventsConfig', 'KeyringController:getState', ], events: [ @@ -128,6 +129,11 @@ async function withController( mockApiServiceHandler, ); + rootMessenger.registerActionHandler( + 'ConfigRegistryApiService:fetchEventsConfig', + jest.fn().mockResolvedValue({ modified: false }), + ); + const mockRemoteFeatureFlagGetState = jest.fn().mockReturnValue({ remoteFeatureFlags: { configRegistryApiEnabled: true, @@ -175,10 +181,11 @@ describe('ConfigRegistryController', () => { it('sets default state', async () => { await withController(({ controller }) => { expect(controller.state).toStrictEqual({ - configs: { networks: {} }, + configs: { networks: {}, eventsConfig: null }, version: null, lastFetched: null, etag: null, + eventsConfigEtag: null, }); }); }); @@ -224,6 +231,7 @@ describe('ConfigRegistryController', () => { ({ controller }) => { expect(controller.state.configs).toStrictEqual({ networks: MOCK_FALLBACK_CONFIG, + eventsConfig: null, }); }, ); @@ -290,6 +298,7 @@ describe('ConfigRegistryController', () => { ); expect(controller.state.configs).toStrictEqual({ networks: MOCK_FALLBACK_CONFIG, + eventsConfig: null, }); }, ); @@ -353,6 +362,7 @@ describe('ConfigRegistryController', () => { ); expect(controller.state.configs).toStrictEqual({ networks: MOCK_FALLBACK_CONFIG, + eventsConfig: null, }); }, ); @@ -734,6 +744,7 @@ describe('ConfigRegistryController', () => { ); expect(controller.state.configs).toStrictEqual({ networks: MOCK_FALLBACK_CONFIG, + eventsConfig: null, }); }, ); @@ -771,6 +782,7 @@ describe('ConfigRegistryController', () => { ); expect(controller.state.configs).toStrictEqual({ networks: MOCK_FALLBACK_CONFIG, + eventsConfig: null, }); }, ); @@ -792,6 +804,195 @@ describe('ConfigRegistryController', () => { expect(mockApiServiceHandler).toHaveBeenCalledTimes(1); }); }); + + it('fetches and stores events config when polling', async () => { + const mockEventsConfig = { + schemaVersion: '1.0.0', + version: '4a1153d78e32ac8f9975c5fe40e3526a19497525', + timestamp: 1761829548000, + events: { TestEvent: ['product'] }, + }; + + await withController( + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { configRegistryApiEnabled: true }, + cacheTimestamp: Date.now(), + }); + + rootMessenger.unregisterActionHandler( + 'ConfigRegistryApiService:fetchEventsConfig', + ); + rootMessenger.registerActionHandler( + 'ConfigRegistryApiService:fetchEventsConfig', + jest.fn().mockResolvedValue({ + modified: true, + data: { data: mockEventsConfig }, + etag: '"events-etag-1"', + }), + ); + + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + + expect(controller.state.configs.eventsConfig).toStrictEqual( + mockEventsConfig, + ); + expect(controller.state.eventsConfigEtag).toBe('"events-etag-1"'); + }, + ); + }); + + it('does not update eventsConfig when events config fetch returns unmodified', async () => { + const initialEventsConfig = { + schemaVersion: '1.0.0', + version: '4a1153d78e32ac8f9975c5fe40e3526a19497525', + timestamp: 1761829548000, + events: { TestEvent: ['product'] }, + }; + + await withController( + { + options: { + state: { + configs: { networks: {}, eventsConfig: initialEventsConfig }, + eventsConfigEtag: '"events-etag-1"', + }, + }, + }, + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { configRegistryApiEnabled: true }, + cacheTimestamp: Date.now(), + }); + + rootMessenger.unregisterActionHandler( + 'ConfigRegistryApiService:fetchEventsConfig', + ); + rootMessenger.registerActionHandler( + 'ConfigRegistryApiService:fetchEventsConfig', + jest.fn().mockResolvedValue({ + modified: false, + etag: '"events-etag-1"', + }), + ); + + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + + expect(controller.state.configs.eventsConfig).toStrictEqual( + initialEventsConfig, + ); + }, + ); + }); + + it('updates eventsConfig when events config fetch returns modified without etag', async () => { + await withController( + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { configRegistryApiEnabled: true }, + cacheTimestamp: Date.now(), + }); + + rootMessenger.unregisterActionHandler( + 'ConfigRegistryApiService:fetchEventsConfig', + ); + const eventsConfig = { + schemaVersion: '1.0.0', + version: '4a1153d78e32ac8f9975c5fe40e3526a19497525', + timestamp: 1761829548000, + events: { TestEvent: ['product'] }, + }; + rootMessenger.registerActionHandler( + 'ConfigRegistryApiService:fetchEventsConfig', + jest.fn().mockResolvedValue({ + modified: true, + data: { data: eventsConfig }, + }), + ); + + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + + expect(controller.state.configs.eventsConfig).toStrictEqual( + eventsConfig, + ); + expect(controller.state.eventsConfigEtag).toBeNull(); + }, + ); + }); + + it('captures exception when events config fetch throws an Error', async () => { + await withController( + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { configRegistryApiEnabled: true }, + cacheTimestamp: Date.now(), + }); + + rootMessenger.unregisterActionHandler( + 'ConfigRegistryApiService:fetchEventsConfig', + ); + rootMessenger.registerActionHandler( + 'ConfigRegistryApiService:fetchEventsConfig', + jest + .fn() + .mockRejectedValue(new Error('Events config fetch failed')), + ); + + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + + expect(rootMessenger.captureException).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Events config fetch failed' }), + ); + expect(controller.state.configs.eventsConfig).toBeNull(); + }, + ); + }); + + it('captures exception when events config fetch throws a non-Error value', async () => { + await withController( + async ({ rootMessenger, mockRemoteFeatureFlagGetState }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { configRegistryApiEnabled: true }, + cacheTimestamp: Date.now(), + }); + + rootMessenger.unregisterActionHandler( + 'ConfigRegistryApiService:fetchEventsConfig', + ); + rootMessenger.registerActionHandler( + 'ConfigRegistryApiService:fetchEventsConfig', + jest.fn().mockRejectedValue('string error'), + ); + + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + + expect(rootMessenger.captureException).toHaveBeenCalledWith( + expect.objectContaining({ message: 'string error' }), + ); + }, + ); + }); }); describe('state persistence', () => { diff --git a/packages/config-registry-controller/src/ConfigRegistryController.ts b/packages/config-registry-controller/src/ConfigRegistryController.ts index 3348a65a833..fe35f36c07f 100644 --- a/packages/config-registry-controller/src/ConfigRegistryController.ts +++ b/packages/config-registry-controller/src/ConfigRegistryController.ts @@ -14,8 +14,14 @@ import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote import type { RemoteFeatureFlagControllerStateChangeEvent } from '@metamask/remote-feature-flag-controller'; import { CaipChainId, Duration, inMilliseconds, Json } from '@metamask/utils'; -import type { ConfigRegistryApiServiceFetchConfigAction } from './config-registry-api-service/config-registry-api-service-method-action-types.js'; -import type { RegistryNetworkConfig } from './config-registry-api-service/types.js'; +import type { + ConfigRegistryApiServiceFetchConfigAction, + ConfigRegistryApiServiceFetchEventsConfigAction, +} from './config-registry-api-service/config-registry-api-service-method-action-types.js'; +import type { + RegistryEventsConfig, + RegistryNetworkConfig, +} from './config-registry-api-service/types.js'; import type { ConfigRegistryControllerMethodActions } from './ConfigRegistryController-method-action-types.js'; const controllerName = 'ConfigRegistryController'; @@ -32,12 +38,12 @@ const FEATURE_FLAG_KEY = 'configRegistryApiEnabled'; */ export type ConfigRegistryControllerState = { /** - * Network configurations organized by chain ID. - * Stores the full API response including isFeatured, isTestnet, etc. - * Use selectors (e.g. selectFeaturedNetworks) to filter when needed. + * Configurations fetched from the config registry API. + * Stores network configs and events config. */ configs: { networks: Record; + eventsConfig?: RegistryEventsConfig | null; }; /** * Semantic version string of the configuration data from the API. @@ -60,6 +66,10 @@ export type ConfigRegistryControllerState = { * indicating the schema/version of the configuration data itself. */ etag: string | null; + /** + * HTTP entity tag (ETag) for the events-config endpoint cache validation. + */ + eventsConfigEtag?: string | null; }; const stateMetadata = { @@ -87,6 +97,12 @@ const stateMetadata = { includeInDebugSnapshot: false, usedInUi: false, }, + eventsConfigEtag: { + persist: true, + includeInStateLogs: false, + includeInDebugSnapshot: false, + usedInUi: false, + }, } satisfies StateMetadata; /** @@ -131,7 +147,8 @@ export type ConfigRegistryControllerActions = type AllowedActions = | KeyringControllerGetStateAction | RemoteFeatureFlagControllerGetStateAction - | ConfigRegistryApiServiceFetchConfigAction; + | ConfigRegistryApiServiceFetchConfigAction + | ConfigRegistryApiServiceFetchEventsConfigAction; /** * Events that {@link ConfigRegistryControllerMessenger} exposes to other consumers. @@ -173,7 +190,8 @@ export class ConfigRegistryController extends StaticIntervalPollingController { + await Promise.all([this.#fetchNetworksConfig(), this.#fetchEventsConfig()]); + } + + async #fetchNetworksConfig(): Promise { try { const result = await this.messenger.call( 'ConfigRegistryApiService:fetchConfig', @@ -262,6 +286,36 @@ export class ConfigRegistryController extends StaticIntervalPollingController { + try { + const result = await this.messenger.call( + 'ConfigRegistryApiService:fetchEventsConfig', + { + etag: this.state.eventsConfigEtag ?? undefined, + }, + ); + + if (!result.modified) { + if (result.etag !== undefined) { + this.update((state) => { + state.eventsConfigEtag = result.etag as string; + }); + } + return; + } + + this.update((state) => { + state.configs.eventsConfig = result.data.data; + state.eventsConfigEtag = result.etag ?? null; + }); + } catch (error) { + const errorInstance = + error instanceof Error ? error : new Error(String(error)); + + this.messenger.captureException?.(errorInstance); + } + } + /** * Stop all polling. */ diff --git a/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service-method-action-types.ts b/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service-method-action-types.ts index 5f2f15c4025..d4286003b1f 100644 --- a/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service-method-action-types.ts +++ b/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service-method-action-types.ts @@ -10,8 +10,14 @@ export type ConfigRegistryApiServiceFetchConfigAction = { handler: ConfigRegistryApiService['fetchConfig']; }; +export type ConfigRegistryApiServiceFetchEventsConfigAction = { + type: `ConfigRegistryApiService:fetchEventsConfig`; + handler: ConfigRegistryApiService['fetchEventsConfig']; +}; + /** * Union of all ConfigRegistryApiService action types. */ export type ConfigRegistryApiServiceMethodActions = - ConfigRegistryApiServiceFetchConfigAction; + | ConfigRegistryApiServiceFetchConfigAction + | ConfigRegistryApiServiceFetchEventsConfigAction; diff --git a/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.test.ts b/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.test.ts index eb44697c0ff..b21dc36d724 100644 --- a/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.test.ts +++ b/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.test.ts @@ -9,7 +9,10 @@ import type { ConfigRegistryApiServiceMessenger, ConfigRegistryApiServiceOptions, } from './config-registry-api-service.js'; -import type { RegistryConfigApiResponse } from './types.js'; +import type { + RegistryConfigApiResponse, + RegistryEventsConfigApiResponse, +} from './types.js'; function createMockServiceMessenger(): ConfigRegistryApiServiceMessenger { return { @@ -27,6 +30,7 @@ function createService( } const CONFIG_PATH = '/v1/config/networks'; +const EVENTS_CONFIG_PATH = '/v1/config/events-config'; const UAT_ORIGIN = 'https://client-config.uat-api.cx.metamask.io'; const DEV_ORIGIN = 'https://client-config.dev-api.cx.metamask.io'; const PRD_ORIGIN = 'https://client-config.api.cx.metamask.io'; @@ -39,6 +43,18 @@ const MOCK_API_RESPONSE: RegistryConfigApiResponse = { }, }; +const MOCK_EVENTS_CONFIG_RESPONSE: RegistryEventsConfigApiResponse = { + data: { + schemaVersion: '1.0.0', + version: '4a1153d78e32ac8f9975c5fe40e3526a19497525', + timestamp: 1761829548000, + events: { + TestEvent: ['product'], + MarketingEvent: ['marketing'], + }, + }, +}; + describe('ConfigRegistryApiService', () => { describe('fetchConfig', () => { describe('URL by env', () => { @@ -208,9 +224,7 @@ describe('ConfigRegistryApiService', () => { const service = createService(); - await expect(service.fetchConfig()).rejects.toMatchObject( - expect.objectContaining({ message: expect.any(String) }), - ); + await expect(service.fetchConfig()).rejects.toThrow(Error); expect(scope.isDone()).toBe(true); }); @@ -219,9 +233,7 @@ describe('ConfigRegistryApiService', () => { const service = createService(); - await expect(service.fetchConfig()).rejects.toMatchObject( - expect.objectContaining({ message: expect.any(String) }), - ); + await expect(service.fetchConfig()).rejects.toThrow(Error); expect(scope.isDone()).toBe(true); }); @@ -232,9 +244,7 @@ describe('ConfigRegistryApiService', () => { const service = createService(); - await expect(service.fetchConfig()).rejects.toMatchObject( - expect.objectContaining({ message: expect.any(String) }), - ); + await expect(service.fetchConfig()).rejects.toThrow(Error); expect(scope.isDone()).toBe(true); }); @@ -247,9 +257,7 @@ describe('ConfigRegistryApiService', () => { const service = createService(); - await expect(service.fetchConfig()).rejects.toMatchObject( - expect.objectContaining({ message: expect.any(String) }), - ); + await expect(service.fetchConfig()).rejects.toThrow(Error); expect(scope.isDone()).toBe(true); }); @@ -344,15 +352,11 @@ describe('ConfigRegistryApiService', () => { }); for (let i = 0; i < maximumConsecutiveFailures; i++) { - await expect(service.fetchConfig()).rejects.toMatchObject( - expect.objectContaining({ message: expect.any(String) }), - ); + await expect(service.fetchConfig()).rejects.toThrow(Error); } const finalPromise = service.fetchConfig(); - await expect(finalPromise).rejects.toMatchObject( - expect.objectContaining({ message: expect.any(String) }), - ); + await expect(finalPromise).rejects.toThrow(Error); expect(onBreakHandler).toHaveBeenCalled(); }); }); @@ -408,3 +412,113 @@ describe('ConfigRegistryApiService', () => { }); }); }); + +describe('ConfigRegistryApiService - fetchEventsConfig', () => { + describe('URL by env', () => { + it('uses UAT URL when env is UAT', async () => { + const scope = nock(UAT_ORIGIN) + .get(EVENTS_CONFIG_PATH) + .reply(200, MOCK_EVENTS_CONFIG_RESPONSE); + + const service = createService({ env: ConfigRegistryApiEnv.UAT }); + await service.fetchEventsConfig(); + expect(scope.isDone()).toBe(true); + }); + + it('uses DEV URL when env is DEV', async () => { + const scope = nock(DEV_ORIGIN) + .get(EVENTS_CONFIG_PATH) + .reply(200, MOCK_EVENTS_CONFIG_RESPONSE); + + const service = createService({ env: ConfigRegistryApiEnv.DEV }); + await service.fetchEventsConfig(); + expect(scope.isDone()).toBe(true); + }); + + it('uses PRD URL when env is PRD', async () => { + const scope = nock(PRD_ORIGIN) + .get(EVENTS_CONFIG_PATH) + .reply(200, MOCK_EVENTS_CONFIG_RESPONSE); + + const service = createService({ env: ConfigRegistryApiEnv.PRD }); + await service.fetchEventsConfig(); + expect(scope.isDone()).toBe(true); + }); + }); + + it('fetches events config from API successfully', async () => { + const scope = nock(UAT_ORIGIN) + .get(EVENTS_CONFIG_PATH) + .reply(200, MOCK_EVENTS_CONFIG_RESPONSE, { + ETag: '"events-etag-123"', + }); + + const service = createService(); + const result = await service.fetchEventsConfig(); + + expect(result).toMatchObject({ + modified: true, + etag: '"events-etag-123"', + data: MOCK_EVENTS_CONFIG_RESPONSE, + }); + expect(scope.isDone()).toBe(true); + }); + + it('handles 304 Not Modified response', async () => { + const etag = '"events-etag-123"'; + nock(UAT_ORIGIN) + .get(EVENTS_CONFIG_PATH) + .matchHeader('If-None-Match', etag) + .reply(304); + + const service = createService(); + const result = await service.fetchEventsConfig({ etag }); + + expect(result.modified).toBe(false); + expect(result.data).toBeUndefined(); + }); + + it('returns cached data when 304 is received and service has prior successful response', async () => { + const etag = '"events-etag-123"'; + nock(UAT_ORIGIN) + .get(EVENTS_CONFIG_PATH) + .reply(200, MOCK_EVENTS_CONFIG_RESPONSE, { ETag: etag }); + + const service = createService(); + await service.fetchEventsConfig(); + + nock(UAT_ORIGIN) + .get(EVENTS_CONFIG_PATH) + .matchHeader('If-None-Match', etag) + .reply(304); + + const result = await service.fetchEventsConfig({ etag }); + + expect(result.modified).toBe(false); + expect(result.data).toStrictEqual(MOCK_EVENTS_CONFIG_RESPONSE); + }); + + it('throws error on invalid response structure', async () => { + nock(UAT_ORIGIN).get(EVENTS_CONFIG_PATH).reply(200, { invalid: 'data' }); + + const service = createService(); + + await expect(service.fetchEventsConfig()).rejects.toThrow(Error); + }); + + it('throws error on HTTP error status', async () => { + nock(UAT_ORIGIN) + .get(EVENTS_CONFIG_PATH) + .reply(500, 'Internal Server Error'); + + const service = createService({ + policyOptions: { maxRetries: 0 }, + }); + + await expect(service.fetchEventsConfig()).rejects.toMatchObject( + expect.objectContaining({ + message: 'Failed to fetch events config: 500 Internal Server Error', + }), + ); + }); +}); diff --git a/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.ts b/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.ts index e6fce4d49a9..6e9b433d0c3 100644 --- a/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.ts +++ b/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.ts @@ -10,11 +10,17 @@ import type { ConfigRegistryApiServiceMethodActions } from './config-registry-ap import type { FetchConfigOptions, FetchConfigResult, + FetchEventsConfigResult, RegistryConfigApiResponse, + RegistryEventsConfigApiResponse, +} from './types.js'; +import { + validateRegistryConfigApiResponse, + validateRegistryEventsConfigApiResponse, } from './types.js'; -import { validateRegistryConfigApiResponse } from './types.js'; -const ENDPOINT_PATH = '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/config/networks'; +const NETWORKS_ENDPOINT_PATH = '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/config/networks'; +const EVENTS_CONFIG_ENDPOINT_PATH = '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/config/events-config'; export enum ConfigRegistryApiEnv { DEV = 'dev', @@ -30,7 +36,7 @@ export const serviceName = 'ConfigRegistryApiService'; // === MESSENGER === -const MESSENGER_EXPOSED_METHODS = ['fetchConfig'] as const; +const MESSENGER_EXPOSED_METHODS = ['fetchConfig', 'fetchEventsConfig'] as const; /** * Actions that {@link ConfigRegistryApiService} exposes to other consumers. @@ -69,11 +75,15 @@ export type ConfigRegistryApiServiceMessenger = Messenger< * Returns the base URL for the config registry API for the given environment. * * @param env - The environment to get the URL for. - * @returns The base URL for the environment. + * @param endpointPath - The endpoint path to append. + * @returns The full URL for the endpoint. */ -function getConfigRegistryUrl(env: ConfigRegistryApiEnv): string { +function getConfigRegistryUrl( + env: ConfigRegistryApiEnv, + endpointPath: string, +): string { const envPrefix = env === ConfigRegistryApiEnv.PRD ? '' : `${env}-`; - return `https://client-config.${envPrefix}api.cx.metamask.io/v1${ENDPOINT_PATH}`; + return `https://client-config.${envPrefix}api.cx.metamask.io/v1${endpointPath}`; } export type ConfigRegistryApiServiceOptions = { @@ -98,13 +108,18 @@ export class ConfigRegistryApiService { readonly #policy: ServicePolicy; - readonly #url: string; + readonly #networksUrl: string; + + readonly #eventsConfigUrl: string; readonly #fetch: typeof fetch; - /** Cached response from the last successful fetch. Used when server returns 304. */ + /** Cached response from the last successful networks fetch. Used when server returns 304. */ #cachedResponse: RegistryConfigApiResponse | null = null; + /** Cached response from the last successful events-config fetch. Used when server returns 304. */ + #cachedEventsConfigResponse: RegistryEventsConfigApiResponse | null = null; + /** * Construct a Config Registry API Service. * @@ -122,7 +137,11 @@ export class ConfigRegistryApiService { }: ConfigRegistryApiServiceOptions) { this.name = serviceName; this.#messenger = messenger; - this.#url = getConfigRegistryUrl(env); + this.#networksUrl = getConfigRegistryUrl(env, NETWORKS_ENDPOINT_PATH); + this.#eventsConfigUrl = getConfigRegistryUrl( + env, + EVENTS_CONFIG_ENDPOINT_PATH, + ); this.#fetch = customFetch; this.#policy = createServicePolicy(policyOptions); @@ -199,7 +218,7 @@ export class ConfigRegistryApiService { } const response = await this.#policy.execute(async () => { - const res = await this.#fetch(this.#url, { + const res = await this.#fetch(this.#networksUrl, { headers, }); @@ -227,7 +246,7 @@ export class ConfigRegistryApiService { } const etag = response.headers.get('ETag') ?? undefined; - const jsonData = await response.json(); + const jsonData: unknown = await response.json(); validateRegistryConfigApiResponse(jsonData); @@ -239,4 +258,59 @@ export class ConfigRegistryApiService { modified: true, }; } + + async fetchEventsConfig( + options: FetchConfigOptions = {}, + ): Promise { + const headers: HeadersInit = { + 'Cache-Control': 'no-cache', + }; + + if (options.etag) { + headers['If-None-Match'] = options.etag; + } + + const response = await this.#policy.execute(async () => { + const res = await this.#fetch(this.#eventsConfigUrl, { + headers, + }); + + if (res.status === 304) { + return res; + } + + if (!res.ok) { + throw new HttpError( + res.status, + `Failed to fetch events config: ${res.status} ${res.statusText}`, + ); + } + + return res; + }); + + if (response.status === 304) { + const etag = response.headers.get('ETag') ?? undefined; + return { + modified: false, + etag, + ...(this.#cachedEventsConfigResponse !== null && { + data: this.#cachedEventsConfigResponse, + }), + }; + } + + const etag = response.headers.get('ETag') ?? undefined; + const jsonData: unknown = await response.json(); + + validateRegistryEventsConfigApiResponse(jsonData); + + this.#cachedEventsConfigResponse = jsonData; + + return { + data: jsonData, + etag, + modified: true, + }; + } } diff --git a/packages/config-registry-controller/src/config-registry-api-service/types.ts b/packages/config-registry-controller/src/config-registry-api-service/types.ts index 18343ffba07..9dbe11dac91 100644 --- a/packages/config-registry-controller/src/config-registry-api-service/types.ts +++ b/packages/config-registry-controller/src/config-registry-api-service/types.ts @@ -5,6 +5,7 @@ import { boolean, number, optional, + record, string, type, } from '@metamask/superstruct'; @@ -96,6 +97,31 @@ export function validateRegistryConfigApiResponse( assert(data, RegistryConfigApiResponseSchema); } +/** + * Schema for the events-config API response. + * Maps event names to their allowed analytics purposes. + */ +export const RegistryEventsConfigApiResponseSchema = type({ + data: type({ + schemaVersion: string(), + version: string(), + timestamp: number(), + events: record(string(), array(string())), + }), +}); + +export type RegistryEventsConfigApiResponse = Infer< + typeof RegistryEventsConfigApiResponseSchema +>; + +export type RegistryEventsConfig = RegistryEventsConfigApiResponse['data']; + +export function validateRegistryEventsConfigApiResponse( + data: unknown, +): asserts data is RegistryEventsConfigApiResponse { + assert(data, RegistryEventsConfigApiResponseSchema); +} + export type FetchConfigOptions = { etag?: string; }; @@ -115,3 +141,15 @@ export type FetchConfigResult = data: RegistryConfigApiResponse; etag?: string; }; + +export type FetchEventsConfigResult = + | { + modified: false; + etag?: string; + data?: RegistryEventsConfigApiResponse; + } + | { + modified: true; + data: RegistryEventsConfigApiResponse; + etag?: string; + }; diff --git a/packages/config-registry-controller/src/index.ts b/packages/config-registry-controller/src/index.ts index 4e9cd1096fa..a9cc17c74ad 100644 --- a/packages/config-registry-controller/src/index.ts +++ b/packages/config-registry-controller/src/index.ts @@ -24,8 +24,11 @@ export { export type { FetchConfigOptions, FetchConfigResult, + FetchEventsConfigResult, RegistryNetworkConfig, RegistryConfigApiResponse, + RegistryEventsConfig, + RegistryEventsConfigApiResponse, } from './config-registry-api-service/types.js'; export type { ConfigRegistryApiServiceOptions, @@ -35,6 +38,7 @@ export type { } from './config-registry-api-service/config-registry-api-service.js'; export type { ConfigRegistryApiServiceFetchConfigAction, + ConfigRegistryApiServiceFetchEventsConfigAction, ConfigRegistryApiServiceMethodActions, } from './config-registry-api-service/config-registry-api-service-method-action-types.js'; export type { NetworkFilterOptions } from './config-registry-api-service/filters.js'; diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index f88f0347c73..6790856732d 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Grant `SubscriptionController` access to `SeedlessOnboardingController:getIsUserAuthenticated` ([#10622](https://github.com/MetaMask/core/pull/10622)) - Clients must delegate this action to the Wallet messenger. +- **BREAKING:** Wire up `ConfigRegistryApiService:fetchEventsConfig` action handler in `configRegistryController` initialization ([#10448](https://github.com/MetaMask/core/pull/10448)) + - The messenger passed to Wallet must support this action. ## [15.1.0] diff --git a/packages/wallet/src/initialization/instances/config-registry-controller/config-registry-controller.test.ts b/packages/wallet/src/initialization/instances/config-registry-controller/config-registry-controller.test.ts index 0be134937f5..ce1696669d8 100644 --- a/packages/wallet/src/initialization/instances/config-registry-controller/config-registry-controller.test.ts +++ b/packages/wallet/src/initialization/instances/config-registry-controller/config-registry-controller.test.ts @@ -56,6 +56,12 @@ function registerDependencies( 'ConfigRegistryApiService:fetchConfig', async () => ({ modified: false }), ); + registerActionHandler( + rootMessenger, + 'ConfigRegistryApiService', + 'ConfigRegistryApiService:fetchEventsConfig', + async () => ({ modified: false }), + ); } describe('configRegistryController', () => { @@ -91,10 +97,11 @@ describe('configRegistryController', () => { }); expect(instance.state).toStrictEqual({ - configs: { networks: {} }, + configs: { networks: {}, eventsConfig: null }, version: null, lastFetched: null, etag: null, + eventsConfigEtag: null, }); }); @@ -127,10 +134,11 @@ describe('configRegistryController', () => { expect( rootMessenger.call('ConfigRegistryController:getState'), ).toStrictEqual({ - configs: { networks: {} }, + configs: { networks: {}, eventsConfig: null }, version: null, lastFetched: null, etag: null, + eventsConfigEtag: null, }); }); }); diff --git a/packages/wallet/src/initialization/instances/config-registry-controller/config-registry-controller.ts b/packages/wallet/src/initialization/instances/config-registry-controller/config-registry-controller.ts index c7d05303b71..7d8f55f5cde 100644 --- a/packages/wallet/src/initialization/instances/config-registry-controller/config-registry-controller.ts +++ b/packages/wallet/src/initialization/instances/config-registry-controller/config-registry-controller.ts @@ -30,6 +30,7 @@ export const configRegistryController: InitializationConfiguration< 'KeyringController:getState', 'RemoteFeatureFlagController:getState', 'ConfigRegistryApiService:fetchConfig', + 'ConfigRegistryApiService:fetchEventsConfig', ], events: [ 'KeyringController:unlock', diff --git a/yarn.lock b/yarn.lock index 305945c8126..67b8ab094c1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5458,6 +5458,7 @@ __metadata: dependencies: "@metamask/auto-changelog": "npm:^6.2.1" "@metamask/base-controller": "npm:^10.0.0" + "@metamask/config-registry-controller": "npm:^4.0.0" "@metamask/geolocation-controller": "npm:^2.0.0" "@metamask/messenger": "npm:^3.0.0" "@metamask/utils": "npm:^12.0.0"