diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 936db94d4e2..c7b088be9b6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -62,6 +62,7 @@ ## Mobile Platform Team /packages/app-metadata-controller @MetaMask/mobile-platform /packages/analytics-controller @MetaMask/mobile-platform @MetaMask/extension-platform +/packages/analytics-data-regulation-controller @MetaMask/mobile-platform @MetaMask/extension-platform ## Wallet Integrations Team /packages/chain-agnostic-permission @MetaMask/wallet-integrations @@ -115,6 +116,8 @@ /packages/accounts-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform /packages/analytics-controller/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform /packages/analytics-controller/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform +/packages/analytics-data-regulation-controller/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform +/packages/analytics-data-regulation-controller/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform /packages/accounts-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform /packages/address-book-controller/package.json @MetaMask/confirmations @MetaMask/core-platform /packages/address-book-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform diff --git a/README.md b/README.md index 74b53e892c4..af59a3ac73b 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/address-book-controller`](packages/address-book-controller) - [`@metamask/ai-controllers`](packages/ai-controllers) - [`@metamask/analytics-controller`](packages/analytics-controller) +- [`@metamask/analytics-data-regulation-controller`](packages/analytics-data-regulation-controller) - [`@metamask/announcement-controller`](packages/announcement-controller) - [`@metamask/app-metadata-controller`](packages/app-metadata-controller) - [`@metamask/approval-controller`](packages/approval-controller) @@ -103,6 +104,7 @@ linkStyle default opacity:0.5 address_book_controller(["@metamask/address-book-controller"]); ai_controllers(["@metamask/ai-controllers"]); analytics_controller(["@metamask/analytics-controller"]); + analytics_data_regulation_controller(["@metamask/analytics-data-regulation-controller"]); announcement_controller(["@metamask/announcement-controller"]); app_metadata_controller(["@metamask/app-metadata-controller"]); approval_controller(["@metamask/approval-controller"]); diff --git a/packages/analytics-data-regulation-controller/CHANGELOG.md b/packages/analytics-data-regulation-controller/CHANGELOG.md new file mode 100644 index 00000000000..b518709c7b8 --- /dev/null +++ b/packages/analytics-data-regulation-controller/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/analytics-data-regulation-controller/LICENSE b/packages/analytics-data-regulation-controller/LICENSE new file mode 100644 index 00000000000..c8a0ff6be3a --- /dev/null +++ b/packages/analytics-data-regulation-controller/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE diff --git a/packages/analytics-data-regulation-controller/README.md b/packages/analytics-data-regulation-controller/README.md new file mode 100644 index 00000000000..91a62f9f08e --- /dev/null +++ b/packages/analytics-data-regulation-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/analytics-data-regulation-controller` + +Controller for managing analytics privacy and GDPR/CCPA data deletion functionality + +## Installation + +`yarn add @metamask/analytics-data-regulation-controller` + +or + +`npm install @metamask/analytics-data-regulation-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/analytics-data-regulation-controller/jest.config.js b/packages/analytics-data-regulation-controller/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/analytics-data-regulation-controller/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/analytics-data-regulation-controller/package.json b/packages/analytics-data-regulation-controller/package.json new file mode 100644 index 00000000000..b1455758f8a --- /dev/null +++ b/packages/analytics-data-regulation-controller/package.json @@ -0,0 +1,76 @@ +{ + "name": "@metamask/analytics-data-regulation-controller", + "version": "0.0.0", + "description": "Controller for managing analytics privacy and GDPR/CCPA data deletion functionality", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/analytics-data-regulation-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "MIT", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/analytics-data-regulation-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/analytics-data-regulation-controller", + "publish:preview": "yarn npm publish --tag preview", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/base-controller": "^9.0.0", + "@metamask/controller-utils": "^11.18.0", + "@metamask/messenger": "^0.3.0", + "@metamask/utils": "^11.9.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^3.4.4", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^27.4.1", + "deepmerge": "^4.2.2", + "jest": "^27.5.1", + "nock": "^13.3.1", + "sinon": "^9.2.4", + "ts-jest": "^27.1.4", + "typedoc": "^0.24.8", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/analytics-data-regulation-controller/src/AnalyticsDataRegulationController-method-action-types.ts b/packages/analytics-data-regulation-controller/src/AnalyticsDataRegulationController-method-action-types.ts new file mode 100644 index 00000000000..4490946862b --- /dev/null +++ b/packages/analytics-data-regulation-controller/src/AnalyticsDataRegulationController-method-action-types.ts @@ -0,0 +1,51 @@ +/** + * This file is auto generated by `scripts/generate-method-action-types.ts`. + * Do not edit manually. + */ + +import type { AnalyticsDataRegulationController } from './AnalyticsDataRegulationController'; + +/** + * Creates a new delete regulation for the user. + * This is necessary to respect the GDPR and CCPA regulations. + * + * @returns Promise containing the status of the request with regulateId + * @throws Error if analytics ID is missing or if the service call fails + */ +export type AnalyticsDataRegulationControllerCreateDataDeletionTaskAction = { + type: `AnalyticsDataRegulationController:createDataDeletionTask`; + handler: AnalyticsDataRegulationController['createDataDeletionTask']; +}; + +/** + * Check the latest delete regulation status. + * + * @returns Promise containing the timestamp, delete status and collected data flag + */ +export type AnalyticsDataRegulationControllerCheckDataDeleteStatusAction = { + type: `AnalyticsDataRegulationController:checkDataDeleteStatus`; + handler: AnalyticsDataRegulationController['checkDataDeleteStatus']; +}; + +/** + * Update the data recording flag if needed. + * This method should be called after tracking events to ensure + * the data recording flag is properly updated for data deletion workflows. + * + * The flag can only be set to `true` (indicating data has been collected). + * It cannot be explicitly set to `false` - it is only reset to `false` when + * a new deletion task is created via `createDataDeletionTask`. + * + */ +export type AnalyticsDataRegulationControllerUpdateDataRecordingFlagAction = { + type: `AnalyticsDataRegulationController:updateDataRecordingFlag`; + handler: AnalyticsDataRegulationController['updateDataRecordingFlag']; +}; + +/** + * Union of all AnalyticsDataRegulationController action types. + */ +export type AnalyticsDataRegulationControllerMethodActions = + | AnalyticsDataRegulationControllerCreateDataDeletionTaskAction + | AnalyticsDataRegulationControllerCheckDataDeleteStatusAction + | AnalyticsDataRegulationControllerUpdateDataRecordingFlagAction; diff --git a/packages/analytics-data-regulation-controller/src/AnalyticsDataRegulationController.test.ts b/packages/analytics-data-regulation-controller/src/AnalyticsDataRegulationController.test.ts new file mode 100644 index 00000000000..88b44c3e599 --- /dev/null +++ b/packages/analytics-data-regulation-controller/src/AnalyticsDataRegulationController.test.ts @@ -0,0 +1,832 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { MockAnyNamespace } from '@metamask/messenger'; + +import { + AnalyticsDataRegulationController, + getDefaultAnalyticsDataRegulationControllerState, +} from '.'; +import type { + AnalyticsDataRegulationControllerMessenger, + AnalyticsDataRegulationControllerActions, + AnalyticsDataRegulationControllerEvents, + AnalyticsDataRegulationControllerState, +} from '.'; +import type { AnalyticsDataRegulationServiceActions } from './AnalyticsDataRegulationService'; +import { DATA_DELETE_RESPONSE_STATUSES, DATA_DELETE_STATUSES } from './types'; + +type SetupControllerOptions = { + state?: Partial; + analyticsId?: string; +}; + +type SetupControllerReturn = { + controller: AnalyticsDataRegulationController; + messenger: AnalyticsDataRegulationControllerMessenger; + rootMessenger: Messenger< + MockAnyNamespace, + | AnalyticsDataRegulationControllerActions + | AnalyticsDataRegulationServiceActions, + AnalyticsDataRegulationControllerEvents + >; +}; + +/** + * Sets up an AnalyticsDataRegulationController for testing. + * + * @param options - Controller options + * @param options.state - Optional partial controller state + * @param options.analyticsId - Optional analytics ID (defaults to 'test-analytics-id') + * @returns The controller, messenger, and root messenger + */ +function setupController( + options: SetupControllerOptions = {}, +): SetupControllerReturn { + const { state = {}, analyticsId = 'test-analytics-id' } = options; + + const rootMessenger = new Messenger< + MockAnyNamespace, + | AnalyticsDataRegulationControllerActions + | AnalyticsDataRegulationServiceActions, + AnalyticsDataRegulationControllerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + const analyticsDataRegulationControllerMessenger = new Messenger< + 'AnalyticsDataRegulationController', + | AnalyticsDataRegulationControllerActions + | AnalyticsDataRegulationServiceActions, + AnalyticsDataRegulationControllerEvents, + typeof rootMessenger + >({ + namespace: 'AnalyticsDataRegulationController', + parent: rootMessenger, + }); + + // Mock AnalyticsDataRegulationService actions (can be overridden in individual tests) + rootMessenger.registerActionHandler( + 'AnalyticsDataRegulationService:createDataDeletionTask', + jest.fn().mockResolvedValue({ + status: DATA_DELETE_RESPONSE_STATUSES.Success, + regulateId: 'test-regulate-id', + }), + ); + + rootMessenger.registerActionHandler( + 'AnalyticsDataRegulationService:checkDataDeleteStatus', + jest.fn().mockResolvedValue({ + status: DATA_DELETE_RESPONSE_STATUSES.Success, + dataDeleteStatus: DATA_DELETE_STATUSES.Finished, + }), + ); + + // Delegate service actions to controller messenger + rootMessenger.delegate({ + messenger: analyticsDataRegulationControllerMessenger, + actions: [ + 'AnalyticsDataRegulationService:createDataDeletionTask', + 'AnalyticsDataRegulationService:checkDataDeleteStatus', + ], + }); + + const controller = new AnalyticsDataRegulationController({ + messenger: analyticsDataRegulationControllerMessenger, + state, + analyticsId, + }); + + return { + controller, + messenger: analyticsDataRegulationControllerMessenger, + rootMessenger, + }; +} + +describe('AnalyticsDataRegulationController', () => { + describe('getDefaultAnalyticsDataRegulationControllerState', () => { + it('returns default state with hasCollectedDataSinceDeletionRequest false and undefined regulation fields', () => { + const defaults = getDefaultAnalyticsDataRegulationControllerState(); + + expect(defaults).toStrictEqual({ + hasCollectedDataSinceDeletionRequest: false, + }); + expect(defaults.deleteRegulationId).toBeUndefined(); + expect(defaults.deleteRegulationTimestamp).toBeUndefined(); + }); + + it('returns identical values on each call', () => { + const defaults1 = getDefaultAnalyticsDataRegulationControllerState(); + const defaults2 = getDefaultAnalyticsDataRegulationControllerState(); + + expect(defaults1).toStrictEqual(defaults2); + }); + }); + + describe('constructor', () => { + it('initializes with default state when no state provided', () => { + const { controller } = setupController(); + + expect(controller.state).toStrictEqual( + getDefaultAnalyticsDataRegulationControllerState(), + ); + }); + + it('initializes with provided state', () => { + const initialState = { + hasCollectedDataSinceDeletionRequest: true, + deleteRegulationId: 'existing-id', + deleteRegulationTimestamp: new Date('2026-01-15T12:00:00Z').getTime(), + }; + + const { controller } = setupController({ state: initialState }); + + expect(controller.state).toStrictEqual(initialState); + }); + + it('merges provided partial state with default values', () => { + const partialState = { + hasCollectedDataSinceDeletionRequest: true, + }; + + const { controller } = setupController({ state: partialState }); + + expect(controller.state.hasCollectedDataSinceDeletionRequest).toBe(true); + expect(controller.state.deleteRegulationId).toBeUndefined(); + expect(controller.state.deleteRegulationTimestamp).toBeUndefined(); + }); + }); + + describe('AnalyticsDataRegulationController:createDataDeletionTask', () => { + it('creates data deletion task and updates state with regulation ID and timestamp', async () => { + const { controller, rootMessenger } = setupController(); + + const fixedTimestamp = new Date('2026-01-15T12:00:00Z').getTime(); + jest.useFakeTimers(); + jest.setSystemTime(new Date(fixedTimestamp)); + + const response = await rootMessenger.call( + 'AnalyticsDataRegulationController:createDataDeletionTask', + ); + + expect(response.status).toBe(DATA_DELETE_RESPONSE_STATUSES.Success); + expect(response.regulateId).toBe('test-regulate-id'); + expect(controller.state.deleteRegulationId).toBe('test-regulate-id'); + expect(controller.state.deleteRegulationTimestamp).toBe(fixedTimestamp); + expect(controller.state.hasCollectedDataSinceDeletionRequest).toBe(false); + + jest.useRealTimers(); + }); + + it('stores deletion timestamp as number when task is created', async () => { + const rootMessenger = new Messenger< + MockAnyNamespace, + | AnalyticsDataRegulationControllerActions + | AnalyticsDataRegulationServiceActions, + AnalyticsDataRegulationControllerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + const analyticsDataRegulationControllerMessenger = new Messenger< + 'AnalyticsDataRegulationController', + | AnalyticsDataRegulationControllerActions + | AnalyticsDataRegulationServiceActions, + AnalyticsDataRegulationControllerEvents, + typeof rootMessenger + >({ + namespace: 'AnalyticsDataRegulationController', + parent: rootMessenger, + }); + + rootMessenger.registerActionHandler( + 'AnalyticsDataRegulationService:createDataDeletionTask', + jest.fn().mockResolvedValue({ + status: DATA_DELETE_RESPONSE_STATUSES.Success, + regulateId: 'test-regulate-id', + }), + ); + + rootMessenger.delegate({ + messenger: analyticsDataRegulationControllerMessenger, + actions: ['AnalyticsDataRegulationService:createDataDeletionTask'], + }); + + const controller = new AnalyticsDataRegulationController({ + messenger: analyticsDataRegulationControllerMessenger, + analyticsId: 'test-analytics-id', + }); + + const fixedTimestamp = new Date('2026-01-15T12:00:00Z').getTime(); + jest.useFakeTimers(); + jest.setSystemTime(new Date(fixedTimestamp)); + + await rootMessenger.call( + 'AnalyticsDataRegulationController:createDataDeletionTask', + ); + + expect(controller.state.deleteRegulationTimestamp).toBe(fixedTimestamp); + expect(typeof controller.state.deleteRegulationTimestamp).toBe('number'); + + jest.useRealTimers(); + }); + + it('emits dataDeletionTaskCreated event with response payload', async () => { + const { rootMessenger, messenger } = setupController(); + const eventListener = jest.fn(); + + messenger.subscribe( + 'AnalyticsDataRegulationController:dataDeletionTaskCreated', + eventListener, + ); + + const response = await rootMessenger.call( + 'AnalyticsDataRegulationController:createDataDeletionTask', + ); + + expect(response.status).toBe(DATA_DELETE_RESPONSE_STATUSES.Success); + expect(response.regulateId).toBe('test-regulate-id'); + + expect(eventListener).toHaveBeenCalledWith({ + status: DATA_DELETE_RESPONSE_STATUSES.Success, + regulateId: 'test-regulate-id', + }); + }); + + it('throws error when analyticsId is empty string', async () => { + const rootMessenger = new Messenger< + MockAnyNamespace, + | AnalyticsDataRegulationControllerActions + | AnalyticsDataRegulationServiceActions, + AnalyticsDataRegulationControllerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + const analyticsDataRegulationControllerMessenger = new Messenger< + 'AnalyticsDataRegulationController', + | AnalyticsDataRegulationControllerActions + | AnalyticsDataRegulationServiceActions, + AnalyticsDataRegulationControllerEvents, + typeof rootMessenger + >({ + namespace: 'AnalyticsDataRegulationController', + parent: rootMessenger, + }); + + rootMessenger.registerActionHandler( + 'AnalyticsDataRegulationService:createDataDeletionTask', + jest.fn().mockResolvedValue({ + status: DATA_DELETE_RESPONSE_STATUSES.Success, + regulateId: 'test-regulate-id', + }), + ); + + rootMessenger.delegate({ + messenger: analyticsDataRegulationControllerMessenger, + actions: ['AnalyticsDataRegulationService:createDataDeletionTask'], + }); + + // Controller is instantiated to register action handlers + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _controller = new AnalyticsDataRegulationController({ + messenger: analyticsDataRegulationControllerMessenger, + analyticsId: '', // Empty string to test the !analyticsId check + }); + + await expect( + rootMessenger.call( + 'AnalyticsDataRegulationController:createDataDeletionTask', + ), + ).rejects.toThrow( + 'Analytics ID not found. You need to provide a valid analytics ID when initializing the AnalyticsDataRegulationController.', + ); + }); + + it('throws error without updating state when service throws error for missing regulateId', async () => { + const rootMessenger = new Messenger< + MockAnyNamespace, + | AnalyticsDataRegulationControllerActions + | AnalyticsDataRegulationServiceActions, + AnalyticsDataRegulationControllerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + const analyticsDataRegulationControllerMessenger = new Messenger< + 'AnalyticsDataRegulationController', + | AnalyticsDataRegulationControllerActions + | AnalyticsDataRegulationServiceActions, + AnalyticsDataRegulationControllerEvents, + typeof rootMessenger + >({ + namespace: 'AnalyticsDataRegulationController', + parent: rootMessenger, + }); + + rootMessenger.registerActionHandler( + 'AnalyticsDataRegulationService:createDataDeletionTask', + jest + .fn() + .mockRejectedValue( + new Error( + 'Malformed response from Segment API: missing or invalid regulateId', + ), + ), + ); + + rootMessenger.delegate({ + messenger: analyticsDataRegulationControllerMessenger, + actions: ['AnalyticsDataRegulationService:createDataDeletionTask'], + }); + + // Controller is instantiated to register action handlers + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _controller = new AnalyticsDataRegulationController({ + messenger: analyticsDataRegulationControllerMessenger, + analyticsId: 'test-analytics-id', + }); + + await expect( + rootMessenger.call( + 'AnalyticsDataRegulationController:createDataDeletionTask', + ), + ).rejects.toThrow( + 'Malformed response from Segment API: missing or invalid regulateId', + ); + }); + + it('throws error without updating state when service throws error for empty regulateId', async () => { + const rootMessenger = new Messenger< + MockAnyNamespace, + | AnalyticsDataRegulationControllerActions + | AnalyticsDataRegulationServiceActions, + AnalyticsDataRegulationControllerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + const analyticsDataRegulationControllerMessenger = new Messenger< + 'AnalyticsDataRegulationController', + | AnalyticsDataRegulationControllerActions + | AnalyticsDataRegulationServiceActions, + AnalyticsDataRegulationControllerEvents, + typeof rootMessenger + >({ + namespace: 'AnalyticsDataRegulationController', + parent: rootMessenger, + }); + + rootMessenger.registerActionHandler( + 'AnalyticsDataRegulationService:createDataDeletionTask', + jest + .fn() + .mockRejectedValue( + new Error( + 'Malformed response from Segment API: missing or invalid regulateId', + ), + ), + ); + + rootMessenger.delegate({ + messenger: analyticsDataRegulationControllerMessenger, + actions: ['AnalyticsDataRegulationService:createDataDeletionTask'], + }); + + const controller = new AnalyticsDataRegulationController({ + messenger: analyticsDataRegulationControllerMessenger, + analyticsId: 'test-analytics-id', + }); + + await expect( + rootMessenger.call( + 'AnalyticsDataRegulationController:createDataDeletionTask', + ), + ).rejects.toThrow( + 'Malformed response from Segment API: missing or invalid regulateId', + ); + // State should not be updated when service throws error + expect(controller.state.deleteRegulationId).toBeUndefined(); + }); + + it('throws error and does not update state when service throws error for undefined regulateId', async () => { + const rootMessenger = new Messenger< + MockAnyNamespace, + | AnalyticsDataRegulationControllerActions + | AnalyticsDataRegulationServiceActions, + AnalyticsDataRegulationControllerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + const analyticsDataRegulationControllerMessenger = new Messenger< + 'AnalyticsDataRegulationController', + | AnalyticsDataRegulationControllerActions + | AnalyticsDataRegulationServiceActions, + AnalyticsDataRegulationControllerEvents, + typeof rootMessenger + >({ + namespace: 'AnalyticsDataRegulationController', + parent: rootMessenger, + }); + + rootMessenger.registerActionHandler( + 'AnalyticsDataRegulationService:createDataDeletionTask', + jest + .fn() + .mockRejectedValue( + new Error( + 'Malformed response from Segment API: missing or invalid regulateId', + ), + ), + ); + + rootMessenger.delegate({ + messenger: analyticsDataRegulationControllerMessenger, + actions: ['AnalyticsDataRegulationService:createDataDeletionTask'], + }); + + const controller = new AnalyticsDataRegulationController({ + messenger: analyticsDataRegulationControllerMessenger, + analyticsId: 'test-analytics-id', + }); + + await expect( + rootMessenger.call( + 'AnalyticsDataRegulationController:createDataDeletionTask', + ), + ).rejects.toThrow( + 'Malformed response from Segment API: missing or invalid regulateId', + ); + expect(controller.state.deleteRegulationId).toBeUndefined(); + }); + + it('throws error when service throws non-Error value', async () => { + const rootMessenger = new Messenger< + MockAnyNamespace, + | AnalyticsDataRegulationControllerActions + | AnalyticsDataRegulationServiceActions, + AnalyticsDataRegulationControllerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + const analyticsDataRegulationControllerMessenger = new Messenger< + 'AnalyticsDataRegulationController', + | AnalyticsDataRegulationControllerActions + | AnalyticsDataRegulationServiceActions, + AnalyticsDataRegulationControllerEvents, + typeof rootMessenger + >({ + namespace: 'AnalyticsDataRegulationController', + parent: rootMessenger, + }); + + rootMessenger.registerActionHandler( + 'AnalyticsDataRegulationService:createDataDeletionTask', + jest.fn().mockRejectedValue('String error'), + ); + + rootMessenger.delegate({ + messenger: analyticsDataRegulationControllerMessenger, + actions: ['AnalyticsDataRegulationService:createDataDeletionTask'], + }); + + // Controller is instantiated to register action handlers + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _controller = new AnalyticsDataRegulationController({ + messenger: analyticsDataRegulationControllerMessenger, + analyticsId: 'test-analytics-id', + }); + + await expect( + rootMessenger.call( + 'AnalyticsDataRegulationController:createDataDeletionTask', + ), + ).rejects.toBe('String error'); + }); + + it('throws error when service throws error', async () => { + const rootMessenger = new Messenger< + MockAnyNamespace, + | AnalyticsDataRegulationControllerActions + | AnalyticsDataRegulationServiceActions, + AnalyticsDataRegulationControllerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + const analyticsDataRegulationControllerMessenger = new Messenger< + 'AnalyticsDataRegulationController', + | AnalyticsDataRegulationControllerActions + | AnalyticsDataRegulationServiceActions, + AnalyticsDataRegulationControllerEvents, + typeof rootMessenger + >({ + namespace: 'AnalyticsDataRegulationController', + parent: rootMessenger, + }); + + rootMessenger.registerActionHandler( + 'AnalyticsDataRegulationService:createDataDeletionTask', + jest.fn().mockRejectedValue(new Error('Service error')), + ); + + rootMessenger.delegate({ + messenger: analyticsDataRegulationControllerMessenger, + actions: ['AnalyticsDataRegulationService:createDataDeletionTask'], + }); + + // Controller is instantiated to register action handlers + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _controller = new AnalyticsDataRegulationController({ + messenger: analyticsDataRegulationControllerMessenger, + analyticsId: 'test-analytics-id', + }); + + await expect( + rootMessenger.call( + 'AnalyticsDataRegulationController:createDataDeletionTask', + ), + ).rejects.toThrow('Service error'); + }); + + it('preserves initial state when service throws error', async () => { + const rootMessenger = new Messenger< + MockAnyNamespace, + | AnalyticsDataRegulationControllerActions + | AnalyticsDataRegulationServiceActions, + AnalyticsDataRegulationControllerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + const analyticsDataRegulationControllerMessenger = new Messenger< + 'AnalyticsDataRegulationController', + | AnalyticsDataRegulationControllerActions + | AnalyticsDataRegulationServiceActions, + AnalyticsDataRegulationControllerEvents, + typeof rootMessenger + >({ + namespace: 'AnalyticsDataRegulationController', + parent: rootMessenger, + }); + + rootMessenger.registerActionHandler( + 'AnalyticsDataRegulationService:createDataDeletionTask', + jest.fn().mockRejectedValue(new Error('Service error')), + ); + + rootMessenger.delegate({ + messenger: analyticsDataRegulationControllerMessenger, + actions: ['AnalyticsDataRegulationService:createDataDeletionTask'], + }); + + const controller = new AnalyticsDataRegulationController({ + messenger: analyticsDataRegulationControllerMessenger, + analyticsId: 'test-analytics-id', + }); + const initialState = controller.state; + + await expect( + rootMessenger.call( + 'AnalyticsDataRegulationController:createDataDeletionTask', + ), + ).rejects.toThrow('Service error'); + + expect(controller.state).toStrictEqual(initialState); + }); + }); + + describe('AnalyticsDataRegulationController:checkDataDeleteStatus', () => { + it('returns status with timestamp, deletion status, and data recorded flag when regulationId exists', async () => { + const testTimestamp = new Date('2026-01-15T12:00:00Z').getTime(); + const { rootMessenger } = setupController({ + state: { + deleteRegulationId: 'test-regulation-id', + deleteRegulationTimestamp: testTimestamp, + hasCollectedDataSinceDeletionRequest: true, + }, + }); + + const status = await rootMessenger.call( + 'AnalyticsDataRegulationController:checkDataDeleteStatus', + ); + + expect(status).toStrictEqual({ + deletionRequestTimestamp: testTimestamp, + dataDeletionRequestStatus: DATA_DELETE_STATUSES.Finished, + hasCollectedDataSinceDeletionRequest: true, + }); + }); + + it('returns status with unknown deletion status when regulationId is null', async () => { + const { rootMessenger } = setupController(); + + const status = await rootMessenger.call( + 'AnalyticsDataRegulationController:checkDataDeleteStatus', + ); + + expect(status).toStrictEqual({ + deletionRequestTimestamp: undefined, + dataDeletionRequestStatus: DATA_DELETE_STATUSES.Unknown, + hasCollectedDataSinceDeletionRequest: false, + }); + }); + + it('returns undefined timestamp when deleteRegulationTimestamp is not set', async () => { + const rootMessenger = new Messenger< + MockAnyNamespace, + | AnalyticsDataRegulationControllerActions + | AnalyticsDataRegulationServiceActions, + AnalyticsDataRegulationControllerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + const analyticsDataRegulationControllerMessenger = new Messenger< + 'AnalyticsDataRegulationController', + | AnalyticsDataRegulationControllerActions + | AnalyticsDataRegulationServiceActions, + AnalyticsDataRegulationControllerEvents, + typeof rootMessenger + >({ + namespace: 'AnalyticsDataRegulationController', + parent: rootMessenger, + }); + + rootMessenger.registerActionHandler( + 'AnalyticsDataRegulationService:checkDataDeleteStatus', + jest.fn().mockResolvedValue({ + status: DATA_DELETE_RESPONSE_STATUSES.Success, + dataDeleteStatus: DATA_DELETE_STATUSES.Finished, + }), + ); + + rootMessenger.delegate({ + messenger: analyticsDataRegulationControllerMessenger, + actions: ['AnalyticsDataRegulationService:checkDataDeleteStatus'], + }); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _controller = new AnalyticsDataRegulationController({ + messenger: analyticsDataRegulationControllerMessenger, + analyticsId: 'test-analytics-id', + state: { + deleteRegulationId: 'test-regulation-id', + hasCollectedDataSinceDeletionRequest: false, + }, + }); + + const status = await rootMessenger.call( + 'AnalyticsDataRegulationController:checkDataDeleteStatus', + ); + + expect(status.deletionRequestTimestamp).toBeUndefined(); + expect(status.dataDeletionRequestStatus).toBe( + DATA_DELETE_STATUSES.Finished, + ); + }); + + it('throws error when service throws Error', async () => { + const rootMessenger = new Messenger< + MockAnyNamespace, + | AnalyticsDataRegulationControllerActions + | AnalyticsDataRegulationServiceActions, + AnalyticsDataRegulationControllerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + const analyticsDataRegulationControllerMessenger = new Messenger< + 'AnalyticsDataRegulationController', + | AnalyticsDataRegulationControllerActions + | AnalyticsDataRegulationServiceActions, + AnalyticsDataRegulationControllerEvents, + typeof rootMessenger + >({ + namespace: 'AnalyticsDataRegulationController', + parent: rootMessenger, + }); + + rootMessenger.registerActionHandler( + 'AnalyticsDataRegulationService:checkDataDeleteStatus', + jest.fn().mockRejectedValue(new Error('Service error')), + ); + + rootMessenger.delegate({ + messenger: analyticsDataRegulationControllerMessenger, + actions: ['AnalyticsDataRegulationService:checkDataDeleteStatus'], + }); + + // Controller is instantiated to register action handlers + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _controller = new AnalyticsDataRegulationController({ + messenger: analyticsDataRegulationControllerMessenger, + analyticsId: 'test-analytics-id', + state: { + deleteRegulationId: 'test-regulation-id', + deleteRegulationTimestamp: new Date('2026-01-15T12:00:00Z').getTime(), + hasCollectedDataSinceDeletionRequest: false, + }, + }); + + await expect( + rootMessenger.call( + 'AnalyticsDataRegulationController:checkDataDeleteStatus', + ), + ).rejects.toThrow('Service error'); + }); + }); + + describe('AnalyticsDataRegulationController:updateDataRecordingFlag', () => { + it('sets hasCollectedDataSinceDeletionRequest to true', () => { + const { controller, rootMessenger } = setupController({ + state: { + hasCollectedDataSinceDeletionRequest: false, + }, + }); + + rootMessenger.call( + 'AnalyticsDataRegulationController:updateDataRecordingFlag', + ); + + expect(controller.state.hasCollectedDataSinceDeletionRequest).toBe(true); + }); + + it('preserves hasCollectedDataSinceDeletionRequest value when already true', () => { + const { controller, rootMessenger } = setupController({ + state: { + hasCollectedDataSinceDeletionRequest: true, + }, + }); + + rootMessenger.call( + 'AnalyticsDataRegulationController:updateDataRecordingFlag', + ); + + expect(controller.state.hasCollectedDataSinceDeletionRequest).toBe(true); + }); + + it('emits dataRecordingFlagUpdated event with true when hasCollectedDataSinceDeletionRequest changes from false to true', () => { + const { rootMessenger } = setupController({ + state: { + hasCollectedDataSinceDeletionRequest: false, + }, + }); + + const eventListener = jest.fn(); + rootMessenger.subscribe( + 'AnalyticsDataRegulationController:dataRecordingFlagUpdated', + eventListener, + ); + + rootMessenger.call( + 'AnalyticsDataRegulationController:updateDataRecordingFlag', + ); + + expect(eventListener).toHaveBeenCalledWith(true); + }); + + it('does not emit dataRecordingFlagUpdated event when hasCollectedDataSinceDeletionRequest is already true', () => { + const { rootMessenger } = setupController({ + state: { + hasCollectedDataSinceDeletionRequest: true, + }, + }); + + const eventListener = jest.fn(); + rootMessenger.subscribe( + 'AnalyticsDataRegulationController:dataRecordingFlagUpdated', + eventListener, + ); + + rootMessenger.call( + 'AnalyticsDataRegulationController:updateDataRecordingFlag', + ); + + expect(eventListener).not.toHaveBeenCalled(); + }); + }); + + describe('stateChange event', () => { + it('emits stateChange event with new state when hasCollectedDataSinceDeletionRequest is updated', () => { + const { rootMessenger, messenger } = setupController({ + state: { + hasCollectedDataSinceDeletionRequest: false, + }, + }); + + const eventListener = jest.fn(); + messenger.subscribe( + 'AnalyticsDataRegulationController:stateChange', + eventListener, + ); + + rootMessenger.call( + 'AnalyticsDataRegulationController:updateDataRecordingFlag', + ); + + expect(eventListener).toHaveBeenCalled(); + const [newState] = eventListener.mock.calls[0]; + expect(newState.hasCollectedDataSinceDeletionRequest).toBe(true); + }); + + it('does not emit stateChange event when hasCollectedDataSinceDeletionRequest is already true', () => { + const { rootMessenger, messenger } = setupController({ + state: { + hasCollectedDataSinceDeletionRequest: true, + }, + }); + + const eventListener = jest.fn(); + messenger.subscribe( + 'AnalyticsDataRegulationController:stateChange', + eventListener, + ); + + rootMessenger.call( + 'AnalyticsDataRegulationController:updateDataRecordingFlag', + ); + + expect(eventListener).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/analytics-data-regulation-controller/src/AnalyticsDataRegulationController.ts b/packages/analytics-data-regulation-controller/src/AnalyticsDataRegulationController.ts new file mode 100644 index 00000000000..9a52143c36f --- /dev/null +++ b/packages/analytics-data-regulation-controller/src/AnalyticsDataRegulationController.ts @@ -0,0 +1,334 @@ +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; + +import type { AnalyticsDataRegulationControllerMethodActions } from './AnalyticsDataRegulationController-method-action-types'; +import type { AnalyticsDataRegulationServiceActions } from './AnalyticsDataRegulationService'; +import { projectLogger as log } from './logger'; +import { DATA_DELETE_RESPONSE_STATUSES, DATA_DELETE_STATUSES } from './types'; +import type { DeleteRegulationResponse, DeleteRegulationStatus } from './types'; + +// === GENERAL === + +/** + * The name of the {@link AnalyticsDataRegulationController}, used to namespace the + * controller's actions and events and to namespace the controller's state data + * when composed with other controllers. + */ +export const controllerName = 'AnalyticsDataRegulationController'; + +// === STATE === + +/** + * Describes the shape of the state object for {@link AnalyticsDataRegulationController}. + */ +export type AnalyticsDataRegulationControllerState = { + /** + * Indicates if data has been recorded since the last deletion request. + */ + hasCollectedDataSinceDeletionRequest: boolean; + + /** + * Segment's data deletion regulation ID. + * The ID returned by the Segment delete API which allows checking the status of the deletion request. + */ + deleteRegulationId?: string; + + /** + * Segment's data deletion regulation creation timestamp. + * The timestamp (in milliseconds since epoch) when the deletion request was created. + */ + deleteRegulationTimestamp?: number; +}; + +/** + * Returns default values for AnalyticsDataRegulationController state. + * + * @returns Default state + */ +export function getDefaultAnalyticsDataRegulationControllerState(): AnalyticsDataRegulationControllerState { + return { + hasCollectedDataSinceDeletionRequest: false, + }; +} + +/** + * The metadata for each property in {@link AnalyticsDataRegulationControllerState}. + */ +const analyticsDataRegulationControllerMetadata = { + hasCollectedDataSinceDeletionRequest: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, + deleteRegulationId: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, + deleteRegulationTimestamp: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, +} satisfies StateMetadata; + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = [ + 'createDataDeletionTask', + 'checkDataDeleteStatus', + 'updateDataRecordingFlag', +] as const; + +/** + * Returns the state of the {@link AnalyticsDataRegulationController}. + */ +export type AnalyticsDataRegulationControllerGetStateAction = + ControllerGetStateAction< + typeof controllerName, + AnalyticsDataRegulationControllerState + >; + +/** + * Actions that {@link AnalyticsDataRegulationControllerMessenger} exposes to other consumers. + */ +export type AnalyticsDataRegulationControllerActions = + | AnalyticsDataRegulationControllerGetStateAction + | AnalyticsDataRegulationControllerMethodActions; + +/** + * Actions from other messengers that {@link AnalyticsDataRegulationControllerMessenger} calls. + */ +type AllowedActions = AnalyticsDataRegulationServiceActions; + +/** + * Event emitted when a data deletion task is created. + */ +export type DataDeletionTaskCreatedEvent = { + type: `${typeof controllerName}:dataDeletionTaskCreated`; + payload: [DeleteRegulationResponse]; +}; + +/** + * Event emitted when the data recording flag is updated. + */ +export type DataRecordingFlagUpdatedEvent = { + type: `${typeof controllerName}:dataRecordingFlagUpdated`; + payload: [boolean]; +}; + +/** + * Event emitted when the state of the {@link AnalyticsDataRegulationController} changes. + */ +export type AnalyticsDataRegulationControllerStateChangeEvent = + ControllerStateChangeEvent< + typeof controllerName, + AnalyticsDataRegulationControllerState + >; + +/** + * Events that {@link AnalyticsDataRegulationControllerMessenger} exposes to other consumers. + */ +export type AnalyticsDataRegulationControllerEvents = + | AnalyticsDataRegulationControllerStateChangeEvent + | DataDeletionTaskCreatedEvent + | DataRecordingFlagUpdatedEvent; + +/** + * Events from other messengers that {@link AnalyticsDataRegulationControllerMessenger} subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger restricted to actions and events accessed by + * {@link AnalyticsDataRegulationController}. + */ +export type AnalyticsDataRegulationControllerMessenger = Messenger< + typeof controllerName, + AnalyticsDataRegulationControllerActions | AllowedActions, + AnalyticsDataRegulationControllerEvents | AllowedEvents +>; + +// === CONTROLLER DEFINITION === + +/** + * The options that AnalyticsDataRegulationController takes. + */ +export type AnalyticsDataRegulationControllerOptions = { + /** + * Initial controller state. + */ + state?: Partial; + /** + * Messenger used to communicate with BaseController and other controllers. + */ + messenger: AnalyticsDataRegulationControllerMessenger; + /** + * Analytics ID used for data deletion requests. + */ + analyticsId: string; +}; + +/** + * The AnalyticsDataRegulationController manages analytics privacy and GDPR/CCPA data deletion functionality. + * It communicates with Segment's Regulations API via a proxy to create and monitor data deletion requests. + * + * This controller follows the MetaMask controller pattern and integrates with the + * messenger system to allow other controllers and components to manage data deletion tasks. + */ +export class AnalyticsDataRegulationController extends BaseController< + typeof controllerName, + AnalyticsDataRegulationControllerState, + AnalyticsDataRegulationControllerMessenger +> { + /** + * Analytics ID used for data deletion requests. + */ + readonly #analyticsId: string; + + /** + * Constructs an AnalyticsDataRegulationController instance. + * + * @param options - Controller options + * @param options.state - Initial controller state. Use `getDefaultAnalyticsDataRegulationControllerState()` for defaults. + * @param options.messenger - Messenger used to communicate with BaseController + * @param options.analyticsId - Analytics ID used for data deletion requests + */ + constructor({ + state = {}, + messenger, + analyticsId, + }: AnalyticsDataRegulationControllerOptions) { + const initialState: AnalyticsDataRegulationControllerState = { + ...getDefaultAnalyticsDataRegulationControllerState(), + ...state, + }; + + super({ + name: controllerName, + metadata: analyticsDataRegulationControllerMetadata, + state: initialState, + messenger, + }); + + this.#analyticsId = analyticsId; + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + + log('AnalyticsDataRegulationController initialized', { + hasCollectedDataSinceDeletionRequest: + this.state.hasCollectedDataSinceDeletionRequest, + hasDeleteRegulationId: Boolean(this.state.deleteRegulationId), + deleteRegulationTimestamp: this.state.deleteRegulationTimestamp, + }); + } + + /** + * Creates a new delete regulation for the user. + * This is necessary to respect the GDPR and CCPA regulations. + * + * @returns Promise containing the status of the request with regulateId + * @throws Error if analytics ID is missing or if the service call fails + */ + async createDataDeletionTask(): Promise<{ + status: typeof DATA_DELETE_RESPONSE_STATUSES.Success; + regulateId: string; + }> { + if (!this.#analyticsId || this.#analyticsId.trim() === '') { + const error = new Error( + 'Analytics ID not found. You need to provide a valid analytics ID when initializing the AnalyticsDataRegulationController.', + ); + log('Analytics Deletion Task Error', error); + throw error; + } + + const response = await this.messenger.call( + 'AnalyticsDataRegulationService:createDataDeletionTask', + this.#analyticsId, + ); + + const deletionTimestamp = Date.now(); + // Service validates and throws on all errors, so if we reach here, the response + // is guaranteed to be a success response with regulateId present + this.update((state) => { + state.deleteRegulationId = response.regulateId; + state.deleteRegulationTimestamp = deletionTimestamp; + state.hasCollectedDataSinceDeletionRequest = false; + }); + + this.messenger.publish( + `${controllerName}:dataDeletionTaskCreated`, + response, + ); + + return response; + } + + /** + * Check the latest delete regulation status. + * + * @returns Promise containing the timestamp, delete status and collected data flag + */ + async checkDataDeleteStatus(): Promise { + // Capture all state values before async call to ensure consistency + // in case createDataDeletionTask() completes concurrently + const { deleteRegulationId } = this.state; + const { deleteRegulationTimestamp } = this.state; + const { hasCollectedDataSinceDeletionRequest } = this.state; + + const status: DeleteRegulationStatus = { + deletionRequestTimestamp: deleteRegulationTimestamp, + dataDeletionRequestStatus: DATA_DELETE_STATUSES.Unknown, + hasCollectedDataSinceDeletionRequest, + }; + + if (!deleteRegulationId) { + return status; + } + + // Service validates and throws on all errors, so if we reach here, the response + // is guaranteed to be a success response with dataDeleteStatus present + const dataDeletionTaskStatus = await this.messenger.call( + 'AnalyticsDataRegulationService:checkDataDeleteStatus', + deleteRegulationId, + ); + + status.dataDeletionRequestStatus = dataDeletionTaskStatus.dataDeleteStatus; + + return status; + } + + /** + * Update the data recording flag if needed. + * This method should be called after tracking events to ensure + * the data recording flag is properly updated for data deletion workflows. + * + * The flag can only be set to `true` (indicating data has been collected). + * It cannot be explicitly set to `false` - it is only reset to `false` when + * a new deletion task is created via `createDataDeletionTask`. + * + */ + updateDataRecordingFlag(): void { + if (!this.state.hasCollectedDataSinceDeletionRequest) { + this.update((state) => { + state.hasCollectedDataSinceDeletionRequest = true; + }); + + this.messenger.publish( + `${controllerName}:dataRecordingFlagUpdated`, + true, + ); + } + } +} diff --git a/packages/analytics-data-regulation-controller/src/AnalyticsDataRegulationService-method-action-types.ts b/packages/analytics-data-regulation-controller/src/AnalyticsDataRegulationService-method-action-types.ts new file mode 100644 index 00000000000..cae15ca9d71 --- /dev/null +++ b/packages/analytics-data-regulation-controller/src/AnalyticsDataRegulationService-method-action-types.ts @@ -0,0 +1,37 @@ +/** + * This file is auto generated by `scripts/generate-method-action-types.ts`. + * Do not edit manually. + */ + +import type { AnalyticsDataRegulationService } from './AnalyticsDataRegulationService'; + +/** + * Creates a DELETE_ONLY regulation for the given analyticsId. + * + * @param analyticsId - The analytics ID of the user for whom to create the deletion task. + * @returns Promise resolving to a successful deletion regulation response. + * @throws Error if the request fails or the response is invalid. + */ +export type AnalyticsDataRegulationServiceCreateDataDeletionTaskAction = { + type: `AnalyticsDataRegulationService:createDataDeletionTask`; + handler: AnalyticsDataRegulationService['createDataDeletionTask']; +}; + +/** + * Checks the status of a regulation by ID. + * + * @param regulationId - The regulation ID to check. + * @returns Promise resolving to a successful regulation status response. + * @throws Error if the request fails or the response is invalid. + */ +export type AnalyticsDataRegulationServiceCheckDataDeleteStatusAction = { + type: `AnalyticsDataRegulationService:checkDataDeleteStatus`; + handler: AnalyticsDataRegulationService['checkDataDeleteStatus']; +}; + +/** + * Union of all AnalyticsDataRegulationService action types. + */ +export type AnalyticsDataRegulationServiceMethodActions = + | AnalyticsDataRegulationServiceCreateDataDeletionTaskAction + | AnalyticsDataRegulationServiceCheckDataDeleteStatusAction; diff --git a/packages/analytics-data-regulation-controller/src/AnalyticsDataRegulationService.test.ts b/packages/analytics-data-regulation-controller/src/AnalyticsDataRegulationService.test.ts new file mode 100644 index 00000000000..f0e6b23a94e --- /dev/null +++ b/packages/analytics-data-regulation-controller/src/AnalyticsDataRegulationService.test.ts @@ -0,0 +1,539 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import nock, { cleanAll, disableNetConnect, enableNetConnect } from 'nock'; +import { useFakeTimers } from 'sinon'; +import type { SinonFakeTimers } from 'sinon'; + +import type { AnalyticsDataRegulationServiceMessenger } from './AnalyticsDataRegulationService'; +import { AnalyticsDataRegulationService } from './AnalyticsDataRegulationService'; +import { DATA_DELETE_RESPONSE_STATUSES, DATA_DELETE_STATUSES } from './types'; + +describe('AnalyticsDataRegulationService', () => { + let clock: SinonFakeTimers; + const segmentSourceId = 'test-source-id'; + const segmentRegulationsEndpoint = 'https://proxy.example.com/v1beta'; + + beforeEach(() => { + clock = useFakeTimers(); + cleanAll(); + disableNetConnect(); + }); + + afterEach(() => { + clock.restore(); + cleanAll(); + enableNetConnect(); + }); + + describe('AnalyticsDataRegulationService:createDataDeletionTask', () => { + it('returns regulateId when deletion task is created', async () => { + const analyticsId = 'test-analytics-id'; + const regulateId = 'test-regulate-id'; + + nock(segmentRegulationsEndpoint) + .post(`/regulations/sources/${segmentSourceId}`) + .reply(200, { + data: { + data: { + regulateId, + }, + }, + }); + + const { rootMessenger } = getService(); + + const response = await rootMessenger.call( + 'AnalyticsDataRegulationService:createDataDeletionTask', + analyticsId, + ); + + expect(response).toStrictEqual({ + status: DATA_DELETE_RESPONSE_STATUSES.Success, + regulateId, + }); + }); + + it('throws error when segmentSourceId is empty string', async () => { + const analyticsId = 'test-analytics-id'; + + const { rootMessenger } = getService({ + options: { + segmentSourceId: '', + segmentRegulationsEndpoint, + }, + }); + + await expect( + rootMessenger.call( + 'AnalyticsDataRegulationService:createDataDeletionTask', + analyticsId, + ), + ).rejects.toThrow('Segment API source ID or endpoint not found'); + }); + + it('throws error when segmentRegulationsEndpoint is empty string', async () => { + const analyticsId = 'test-analytics-id'; + + const { rootMessenger } = getService({ + options: { + segmentSourceId, + segmentRegulationsEndpoint: '', + }, + }); + + await expect( + rootMessenger.call( + 'AnalyticsDataRegulationService:createDataDeletionTask', + analyticsId, + ), + ).rejects.toThrow('Segment API source ID or endpoint not found'); + }); + + it('throws error when API returns 500 status', async () => { + const analyticsId = 'test-analytics-id'; + + nock(segmentRegulationsEndpoint) + .post(`/regulations/sources/${segmentSourceId}`) + .reply(500); + + const { rootMessenger } = getService({ + options: { + policyOptions: { + maxRetries: 0, // Disable retries for faster test execution + }, + }, + }); + + await expect( + rootMessenger.call( + 'AnalyticsDataRegulationService:createDataDeletionTask', + analyticsId, + ), + ).rejects.toThrow('Creating data deletion task failed'); + }); + + it('throws error when API response is missing regulateId', async () => { + const analyticsId = 'test-analytics-id'; + + nock(segmentRegulationsEndpoint) + .post(`/regulations/sources/${segmentSourceId}`) + .reply(200, { + data: { + // Missing data.regulateId + }, + }); + + const { rootMessenger } = getService(); + + await expect( + rootMessenger.call( + 'AnalyticsDataRegulationService:createDataDeletionTask', + analyticsId, + ), + ).rejects.toThrow( + 'Malformed response from Segment API: missing or invalid regulateId', + ); + }); + + it('sends request body with DELETE_ONLY regulation type and analyticsId in subjectIds', async () => { + const analyticsId = 'test-analytics-id'; + const regulateId = 'test-regulate-id'; + + const scope = nock(segmentRegulationsEndpoint) + .post(`/regulations/sources/${segmentSourceId}`, (body: unknown) => { + const parsedBody = typeof body === 'string' ? JSON.parse(body) : body; + return ( + parsedBody.regulationType === 'DELETE_ONLY' && + parsedBody.subjectType === 'USER_ID' && + Array.isArray(parsedBody.subjectIds) && + parsedBody.subjectIds.length === 1 && + parsedBody.subjectIds[0] === analyticsId + ); + }) + .reply(200, { + data: { + data: { + regulateId, + }, + }, + }); + + const { rootMessenger } = getService(); + + await rootMessenger.call( + 'AnalyticsDataRegulationService:createDataDeletionTask', + analyticsId, + ); + + expect(scope.isDone()).toBe(true); + }); + + it('sends POST request with application/vnd.segment.v1+json Content-Type header', async () => { + const analyticsId = 'test-analytics-id'; + const regulateId = 'test-regulate-id'; + + const scope = nock(segmentRegulationsEndpoint, { + reqheaders: { + 'Content-Type': 'application/vnd.segment.v1+json', + }, + }) + .post(`/regulations/sources/${segmentSourceId}`) + .reply(200, { + data: { + data: { + regulateId, + }, + }, + }); + + const { rootMessenger } = getService(); + + await rootMessenger.call( + 'AnalyticsDataRegulationService:createDataDeletionTask', + analyticsId, + ); + + expect(scope.isDone()).toBe(true); + }); + }); + + describe('AnalyticsDataRegulationService:checkDataDeleteStatus', () => { + it('returns dataDeleteStatus when regulation status is retrieved', async () => { + const regulationId = 'test-regulation-id'; + const status = DATA_DELETE_STATUSES.Finished; + + nock(segmentRegulationsEndpoint) + .get(`/regulations/${regulationId}`) + .reply(200, { + data: { + data: { + regulation: { + overallStatus: status, + }, + }, + }, + }); + + const { rootMessenger } = getService(); + + const response = await rootMessenger.call( + 'AnalyticsDataRegulationService:checkDataDeleteStatus', + regulationId, + ); + + expect(response).toStrictEqual({ + status: DATA_DELETE_RESPONSE_STATUSES.Success, + dataDeleteStatus: status, + }); + }); + + it('throws error when regulationId is empty string', async () => { + const { rootMessenger } = getService(); + + await expect( + rootMessenger.call( + 'AnalyticsDataRegulationService:checkDataDeleteStatus', + '', + ), + ).rejects.toThrow('Regulation ID or endpoint not configured'); + }); + + it('throws error when segmentRegulationsEndpoint is empty string', async () => { + const regulationId = 'test-regulation-id'; + + const { rootMessenger } = getService({ + options: { + segmentSourceId, + segmentRegulationsEndpoint: '', + }, + }); + + await expect( + rootMessenger.call( + 'AnalyticsDataRegulationService:checkDataDeleteStatus', + regulationId, + ), + ).rejects.toThrow('Regulation ID or endpoint not configured'); + }); + + it('throws error when API returns 500 status', async () => { + const regulationId = 'test-regulation-id'; + + nock(segmentRegulationsEndpoint) + .get(`/regulations/${regulationId}`) + .reply(500); + + const { rootMessenger } = getService({ + options: { + policyOptions: { + maxRetries: 0, // Disable retries for faster test execution + }, + }, + }); + + await expect( + rootMessenger.call( + 'AnalyticsDataRegulationService:checkDataDeleteStatus', + regulationId, + ), + ).rejects.toThrow('Checking data deletion status failed'); + }); + + it('returns unknown status when API response is missing overallStatus', async () => { + const regulationId = 'test-regulation-id'; + + nock(segmentRegulationsEndpoint) + .get(`/regulations/${regulationId}`) + .reply(200, { + data: { + data: { + regulation: { + // Missing overallStatus + }, + }, + }, + }); + + const { rootMessenger } = getService(); + + const response = await rootMessenger.call( + 'AnalyticsDataRegulationService:checkDataDeleteStatus', + regulationId, + ); + + expect(response).toStrictEqual({ + status: DATA_DELETE_RESPONSE_STATUSES.Success, + dataDeleteStatus: DATA_DELETE_STATUSES.Unknown, + }); + }); + + it('sends GET request with application/vnd.segment.v1+json Content-Type header', async () => { + const regulationId = 'test-regulation-id'; + const status = DATA_DELETE_STATUSES.Running; + + const scope = nock(segmentRegulationsEndpoint, { + reqheaders: { + 'Content-Type': 'application/vnd.segment.v1+json', + }, + }) + .get(`/regulations/${regulationId}`) + .reply(200, { + data: { + data: { + regulation: { + overallStatus: status, + }, + }, + }, + }); + + const { rootMessenger } = getService(); + + await rootMessenger.call( + 'AnalyticsDataRegulationService:checkDataDeleteStatus', + regulationId, + ); + + expect(scope.isDone()).toBe(true); + }); + }); + + describe('onRetry', () => { + it('calls retry listener when request is retried', async () => { + nock(segmentRegulationsEndpoint) + .post(`/regulations/sources/${segmentSourceId}`) + .times(2) + .reply(500); + + const { service, rootMessenger } = getService({ + options: { + policyOptions: { + maxRetries: 1, + }, + }, + }); + + const onRetryListener = jest.fn(); + service.onRetry(() => { + clock.nextAsync().catch(console.error); + onRetryListener(); + }); + + await expect( + rootMessenger.call( + 'AnalyticsDataRegulationService:createDataDeletionTask', + 'test-analytics-id', + ), + ).rejects.toThrow('Creating data deletion task failed'); + + expect(onRetryListener).toHaveBeenCalled(); + }); + }); + + describe('onBreak', () => { + it('calls break listener when circuit breaker opens after multiple failures', async () => { + nock(segmentRegulationsEndpoint) + .post(`/regulations/sources/${segmentSourceId}`) + .times(12) + .reply(500); + + const { service, rootMessenger } = getService(); + service.onRetry(() => { + clock.nextAsync().catch(console.error); + }); + + const onBreakListener = jest.fn(); + service.onBreak(onBreakListener); + + // Make 3 failed requests to trigger circuit breaker + for (let i = 0; i < 3; i++) { + await expect( + rootMessenger.call( + 'AnalyticsDataRegulationService:createDataDeletionTask', + 'test-analytics-id', + ), + ).rejects.toThrow('Creating data deletion task failed'); + } + + // 4th request should trigger circuit breaker - service throws error + await expect( + rootMessenger.call( + 'AnalyticsDataRegulationService:createDataDeletionTask', + 'test-analytics-id', + ), + ).rejects.toThrow( + 'Execution prevented because the circuit breaker is open', + ); + + expect(onBreakListener).toHaveBeenCalled(); + }); + }); + + describe('onDegraded', () => { + it('calls onDegraded listener when request takes longer than 5 seconds', async () => { + nock(segmentRegulationsEndpoint) + .post(`/regulations/sources/${segmentSourceId}`) + .reply(200, () => { + clock.tick(6000); + return { + data: { + data: { + regulateId: 'test-regulate-id', + }, + }, + }; + }); + + const { service, rootMessenger } = getService(); + const onDegradedListener = jest.fn(); + service.onDegraded(onDegradedListener); + + await rootMessenger.call( + 'AnalyticsDataRegulationService:createDataDeletionTask', + 'test-analytics-id', + ); + + expect(onDegradedListener).toHaveBeenCalled(); + }); + + it('calls onDegraded listener when maximum number of retries is exceeded', async () => { + nock(segmentRegulationsEndpoint) + .post(`/regulations/sources/${segmentSourceId}`) + .times(4) + .reply(500); + + const { service, rootMessenger } = getService(); + service.onRetry(() => { + clock.nextAsync().catch(console.error); + }); + const onDegradedListener = jest.fn(); + service.onDegraded(onDegradedListener); + + await expect( + rootMessenger.call( + 'AnalyticsDataRegulationService:createDataDeletionTask', + 'test-analytics-id', + ), + ).rejects.toThrow('Creating data deletion task failed'); + + expect(onDegradedListener).toHaveBeenCalled(); + }); + }); +}); + +/** + * The type of the messenger populated with all external actions and events + * required by the service under test. + */ +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +/** + * Constructs the messenger populated with all external actions and events + * required by the service under test. + * + * @returns The root messenger. + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +/** + * Constructs the messenger for the service under test. + * + * @param rootMessenger - The root messenger, with all external actions and + * events required by the service's messenger. + * @returns The service-specific messenger. + */ +function getMessenger( + rootMessenger: RootMessenger, +): AnalyticsDataRegulationServiceMessenger { + return new Messenger({ + namespace: 'AnalyticsDataRegulationService', + parent: rootMessenger, + }); +} + +/** + * Constructs the service under test. + * + * @param args - The arguments to this function. + * @param args.options - The options that the service constructor takes. All are + * optional and will be filled in with defaults as needed (including + * `messenger`). + * @returns The new service, root messenger, and service messenger. + */ +function getService({ + options = {}, +}: { + options?: Partial< + ConstructorParameters[0] + >; +} = {}): { + service: AnalyticsDataRegulationService; + rootMessenger: RootMessenger; + messenger: AnalyticsDataRegulationServiceMessenger; +} { + const rootMessenger = getRootMessenger(); + const messenger = getMessenger(rootMessenger); + const defaultSegmentSourceId = 'test-source-id'; + const defaultSegmentRegulationsEndpoint = 'https://proxy.example.com/v1beta'; + + const service = new AnalyticsDataRegulationService({ + fetch, + messenger, + segmentSourceId: options.segmentSourceId ?? defaultSegmentSourceId, + segmentRegulationsEndpoint: + options.segmentRegulationsEndpoint ?? defaultSegmentRegulationsEndpoint, + ...options, + }); + + return { service, rootMessenger, messenger }; +} diff --git a/packages/analytics-data-regulation-controller/src/AnalyticsDataRegulationService.ts b/packages/analytics-data-regulation-controller/src/AnalyticsDataRegulationService.ts new file mode 100644 index 00000000000..1a916888c16 --- /dev/null +++ b/packages/analytics-data-regulation-controller/src/AnalyticsDataRegulationService.ts @@ -0,0 +1,389 @@ +import type { + CreateServicePolicyOptions, + ServicePolicy, +} from '@metamask/controller-utils'; +import { createServicePolicy, HttpError } from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import type { IDisposable } from 'cockatiel'; + +import type { AnalyticsDataRegulationServiceMethodActions } from './AnalyticsDataRegulationService-method-action-types'; +import { DATA_DELETE_RESPONSE_STATUSES, DATA_DELETE_STATUSES } from './types'; +import type { DataDeleteStatus } from './types'; + +/** + * Segment API regulation type for DELETE_ONLY operations. + */ +const SEGMENT_REGULATION_TYPE_DELETE_ONLY = 'DELETE_ONLY'; + +/** + * Segment API subject type for user ID operations. + */ +const SEGMENT_SUBJECT_TYPE_USER_ID = 'USER_ID'; + +/** + * Segment API Content-Type header value. + */ +const SEGMENT_CONTENT_TYPE = 'application/vnd.segment.v1+json'; + +// === GENERAL === + +/** + * The name of the {@link AnalyticsDataRegulationService}, used to namespace the + * service's actions and events. + */ +export const serviceName = 'AnalyticsDataRegulationService'; + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = [ + 'createDataDeletionTask', + 'checkDataDeleteStatus', +] as const; + +/** + * Actions that {@link AnalyticsDataRegulationService} exposes to other consumers. + */ +export type AnalyticsDataRegulationServiceActions = + AnalyticsDataRegulationServiceMethodActions; + +/** + * Actions from other messengers that {@link AnalyticsDataRegulationServiceMessenger} calls. + */ +type AllowedActions = never; + +/** + * Events that {@link AnalyticsDataRegulationService} exposes to other consumers. + */ +export type AnalyticsDataRegulationServiceEvents = never; + +/** + * Events from other messengers that {@link AnalyticsDataRegulationService} subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger which is restricted to actions and events accessed by + * {@link AnalyticsDataRegulationService}. + */ +export type AnalyticsDataRegulationServiceMessenger = Messenger< + typeof serviceName, + AnalyticsDataRegulationServiceActions | AllowedActions, + AnalyticsDataRegulationServiceEvents | AllowedEvents +>; + +// === SERVICE DEFINITION === + +/** + * Response structure from Segment API for creating a regulation. + */ +type CreateRegulationResponse = { + data: { + data: { + regulateId: string; + }; + }; +}; + +/** + * Response structure from Segment API for getting regulation status. + */ +type GetRegulationStatusResponse = { + data: { + data: { + regulation: { + overallStatus: string; + }; + }; + }; +}; + +/** + * Options for constructing {@link AnalyticsDataRegulationService}. + */ +export type AnalyticsDataRegulationServiceOptions = { + /** + * The messenger suited for this service. + */ + messenger: AnalyticsDataRegulationServiceMessenger; + + /** + * A function that can be used to make an HTTP request. + */ + fetch: typeof fetch; + + /** + * Segment API source ID (required for creating regulations). + */ + segmentSourceId: string; + + /** + * Base URL for the proxy endpoint that communicates with Segment's Regulations API. + * This is a proxy endpoint (not Segment API directly) that forwards requests to Segment's + * Regulations API and adds authentication tokens. The endpoint URL varies by environment + * (e.g., development, staging, production) and should be configured accordingly. + * Example: 'https://proxy.example.com/v1beta' + */ + segmentRegulationsEndpoint: string; + + /** + * Options to pass to `createServicePolicy`, which is used to wrap each request. + */ + policyOptions?: CreateServicePolicyOptions; +}; + +/** + * Type guard to check if a value is a valid DataDeleteStatus. + * + * @param status - The value to check. + * @returns True if the value is a valid DataDeleteStatus. + */ +function isDataDeleteStatus(status: unknown): status is DataDeleteStatus { + const dataDeleteStatuses: string[] = Object.values(DATA_DELETE_STATUSES); + return dataDeleteStatuses.includes(status as string); +} + +/** + * This service object is responsible for making requests to the Segment Regulations API + * via a proxy endpoint for GDPR/CCPA data deletion functionality. + * + * @example + * + * ```ts + * import { Messenger } from '@metamask/messenger'; + * import type { + * AnalyticsDataRegulationServiceActions, + * AnalyticsDataRegulationServiceEvents, + * } from '@metamask/analytics-data-regulation-controller'; + * + * const rootMessenger = new Messenger< + * 'Root', + * AnalyticsDataRegulationServiceActions, + * AnalyticsDataRegulationServiceEvents + * >({ namespace: 'Root' }); + * const serviceMessenger = new Messenger< + * 'AnalyticsDataRegulationService', + * AnalyticsDataRegulationServiceActions, + * AnalyticsDataRegulationServiceEvents, + * typeof rootMessenger, + * >({ + * namespace: 'AnalyticsDataRegulationService', + * parent: rootMessenger, + * }); + * // Instantiate the service to register its actions on the messenger + * new AnalyticsDataRegulationService({ + * messenger: serviceMessenger, + * fetch, + * segmentSourceId: 'abc123', + * segmentRegulationsEndpoint: 'https://proxy.example.com/v1beta', + * }); + * + * // Later... + * // Create a data deletion task + * const response = await rootMessenger.call( + * 'AnalyticsDataRegulationService:createDataDeletionTask', + * 'user-analytics-id', + * ); + * ``` + */ +export class AnalyticsDataRegulationService { + /** + * The name of the service. + */ + readonly name: typeof serviceName; + + /** + * The messenger suited for this service. + */ + readonly #messenger: AnalyticsDataRegulationServiceMessenger; + + /** + * A function that can be used to make an HTTP request. + */ + readonly #fetch: typeof fetch; + + /** + * Segment API source ID. + */ + readonly #segmentSourceId: string; + + /** + * Base URL for the proxy endpoint that communicates with Segment's Regulations API. + * This endpoint varies by environment and forwards requests to Segment API with authentication. + */ + readonly #segmentRegulationsEndpoint: string; + + /** + * The policy that wraps the request. + * + * @see {@link createServicePolicy} + */ + readonly #policy: ServicePolicy; + + /** + * Constructs a new AnalyticsDataRegulationService object. + * + * @param options - The constructor options. + */ + constructor(options: AnalyticsDataRegulationServiceOptions) { + this.name = serviceName; + this.#messenger = options.messenger; + this.#fetch = options.fetch; + this.#segmentSourceId = options.segmentSourceId; + this.#segmentRegulationsEndpoint = options.segmentRegulationsEndpoint; + this.#policy = createServicePolicy(options.policyOptions ?? {}); + + this.#messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Registers a handler that will be called after a request returns a non-500 + * response, causing a retry. Primarily useful in tests where timers are being + * mocked. + * + * @param listener - The handler to be called. + * @returns An object that can be used to unregister the handler. + * @see {@link createServicePolicy} + */ + onRetry(listener: Parameters[0]): IDisposable { + return this.#policy.onRetry(listener); + } + + /** + * Registers a handler that will be called after a set number of retry rounds + * prove that requests to the API endpoint consistently return a 5xx response. + * + * @param listener - The handler to be called. + * @returns An object that can be used to unregister the handler. + * @see {@link createServicePolicy} + */ + onBreak(listener: Parameters[0]): IDisposable { + return this.#policy.onBreak(listener); + } + + /** + * Registers a handler that will be called under one of two circumstances: + * + * 1. After a set number of retries prove that requests to the API + * consistently result in failures. + * 2. After a successful request is made to the API, but the response takes + * longer than a set duration to return. + * + * @param listener - The handler to be called. + * @returns An object that can be used to unregister the handler. + */ + onDegraded( + listener: Parameters[0], + ): IDisposable { + return this.#policy.onDegraded(listener); + } + + /** + * Creates a DELETE_ONLY regulation for the given analyticsId. + * + * @param analyticsId - The analytics ID of the user for whom to create the deletion task. + * @returns Promise resolving to a successful deletion regulation response. + * @throws Error if the request fails or the response is invalid. + */ + async createDataDeletionTask(analyticsId: string): Promise<{ + status: typeof DATA_DELETE_RESPONSE_STATUSES.Success; + regulateId: string; + }> { + if (!this.#segmentSourceId || !this.#segmentRegulationsEndpoint) { + throw new Error('Segment API source ID or endpoint not found'); + } + + const url = `${this.#segmentRegulationsEndpoint}/regulations/sources/${this.#segmentSourceId}`; + const body = JSON.stringify({ + regulationType: SEGMENT_REGULATION_TYPE_DELETE_ONLY, + subjectType: SEGMENT_SUBJECT_TYPE_USER_ID, + subjectIds: [analyticsId], + }); + + const response = await this.#policy.execute(async () => { + const localResponse = await this.#fetch(url, { + method: 'POST', + headers: { + 'Content-Type': SEGMENT_CONTENT_TYPE, + }, + body, + }); + + if (!localResponse.ok) { + throw new HttpError( + localResponse.status, + `Creating data deletion task failed with status '${localResponse.status}'`, + ); + } + + return localResponse; + }); + + const jsonResponse = (await response.json()) as CreateRegulationResponse; + + if ( + !jsonResponse?.data?.data?.regulateId || + typeof jsonResponse.data.data.regulateId !== 'string' || + jsonResponse.data.data.regulateId.trim() === '' + ) { + throw new Error( + 'Malformed response from Segment API: missing or invalid regulateId', + ); + } + + return { + status: DATA_DELETE_RESPONSE_STATUSES.Success, + regulateId: jsonResponse.data.data.regulateId, + }; + } + + /** + * Checks the status of a regulation by ID. + * + * @param regulationId - The regulation ID to check. + * @returns Promise resolving to a successful regulation status response. + * @throws Error if the request fails or the response is invalid. + */ + async checkDataDeleteStatus(regulationId: string): Promise<{ + status: typeof DATA_DELETE_RESPONSE_STATUSES.Success; + dataDeleteStatus: DataDeleteStatus; + }> { + if (!regulationId || !this.#segmentRegulationsEndpoint) { + throw new Error('Regulation ID or endpoint not configured'); + } + + const url = `${this.#segmentRegulationsEndpoint}/regulations/${regulationId}`; + + const response = await this.#policy.execute(async () => { + const localResponse = await this.#fetch(url, { + method: 'GET', + headers: { + 'Content-Type': SEGMENT_CONTENT_TYPE, + }, + }); + + if (!localResponse.ok) { + throw new HttpError( + localResponse.status, + `Checking data deletion status failed with status '${localResponse.status}'`, + ); + } + + return localResponse; + }); + + const jsonResponse = (await response.json()) as GetRegulationStatusResponse; + + const rawStatus = jsonResponse?.data?.data?.regulation?.overallStatus; + const dataDeleteStatus = isDataDeleteStatus(rawStatus) + ? rawStatus + : DATA_DELETE_STATUSES.Unknown; + + return { + status: DATA_DELETE_RESPONSE_STATUSES.Success, + dataDeleteStatus, + }; + } +} diff --git a/packages/analytics-data-regulation-controller/src/index.ts b/packages/analytics-data-regulation-controller/src/index.ts new file mode 100644 index 00000000000..31ea1eb281b --- /dev/null +++ b/packages/analytics-data-regulation-controller/src/index.ts @@ -0,0 +1,36 @@ +export { + AnalyticsDataRegulationController, + getDefaultAnalyticsDataRegulationControllerState, +} from './AnalyticsDataRegulationController'; +export type { AnalyticsDataRegulationControllerOptions } from './AnalyticsDataRegulationController'; + +export { AnalyticsDataRegulationService } from './AnalyticsDataRegulationService'; +export type { + AnalyticsDataRegulationServiceActions, + AnalyticsDataRegulationServiceEvents, + AnalyticsDataRegulationServiceMessenger, + AnalyticsDataRegulationServiceOptions, +} from './AnalyticsDataRegulationService'; + +export { + DATA_DELETE_STATUSES, + DATA_DELETE_RESPONSE_STATUSES, + type DataDeleteStatus, + type DataDeleteResponseStatus, +} from './types'; +export type { DeleteRegulationResponse, DeleteRegulationStatus } from './types'; + +export type { AnalyticsDataRegulationControllerState } from './AnalyticsDataRegulationController'; + +export { analyticsDataRegulationControllerSelectors } from './selectors'; + +export type { AnalyticsDataRegulationControllerMessenger } from './AnalyticsDataRegulationController'; + +export type { + AnalyticsDataRegulationControllerActions, + AnalyticsDataRegulationControllerEvents, + AnalyticsDataRegulationControllerGetStateAction, + AnalyticsDataRegulationControllerStateChangeEvent, + DataDeletionTaskCreatedEvent, + DataRecordingFlagUpdatedEvent, +} from './AnalyticsDataRegulationController'; diff --git a/packages/analytics-data-regulation-controller/src/logger.ts b/packages/analytics-data-regulation-controller/src/logger.ts new file mode 100644 index 00000000000..cdb7872ccb1 --- /dev/null +++ b/packages/analytics-data-regulation-controller/src/logger.ts @@ -0,0 +1,7 @@ +/* istanbul ignore file */ + +import { createProjectLogger } from '@metamask/utils'; + +export const projectLogger = createProjectLogger( + 'analytics-data-regulation-controller', +); diff --git a/packages/analytics-data-regulation-controller/src/selectors.test.ts b/packages/analytics-data-regulation-controller/src/selectors.test.ts new file mode 100644 index 00000000000..644130b8513 --- /dev/null +++ b/packages/analytics-data-regulation-controller/src/selectors.test.ts @@ -0,0 +1,90 @@ +import { + analyticsDataRegulationControllerSelectors, + getDefaultAnalyticsDataRegulationControllerState, +} from '.'; +import type { AnalyticsDataRegulationControllerState } from './AnalyticsDataRegulationController'; + +describe('analyticsDataRegulationControllerSelectors', () => { + describe('selectHasCollectedDataSinceDeletionRequest', () => { + it('returns true when hasCollectedDataSinceDeletionRequest is true in state', () => { + const state: AnalyticsDataRegulationControllerState = { + ...getDefaultAnalyticsDataRegulationControllerState(), + hasCollectedDataSinceDeletionRequest: true, + }; + + expect( + analyticsDataRegulationControllerSelectors.selectHasCollectedDataSinceDeletionRequest( + state, + ), + ).toBe(true); + }); + + it('returns false when hasCollectedDataSinceDeletionRequest is false in state', () => { + const state: AnalyticsDataRegulationControllerState = { + ...getDefaultAnalyticsDataRegulationControllerState(), + hasCollectedDataSinceDeletionRequest: false, + }; + + expect( + analyticsDataRegulationControllerSelectors.selectHasCollectedDataSinceDeletionRequest( + state, + ), + ).toBe(false); + }); + }); + + describe('selectDeleteRegulationId', () => { + it('returns deleteRegulationId string when set in state', () => { + const state: AnalyticsDataRegulationControllerState = { + ...getDefaultAnalyticsDataRegulationControllerState(), + deleteRegulationId: 'test-regulation-id', + }; + + expect( + analyticsDataRegulationControllerSelectors.selectDeleteRegulationId( + state, + ), + ).toBe('test-regulation-id'); + }); + + it('returns undefined when deleteRegulationId is not set in state', () => { + const state: AnalyticsDataRegulationControllerState = { + ...getDefaultAnalyticsDataRegulationControllerState(), + }; + + expect( + analyticsDataRegulationControllerSelectors.selectDeleteRegulationId( + state, + ), + ).toBeUndefined(); + }); + }); + + describe('selectDeleteRegulationTimestamp', () => { + it('returns deleteRegulationTimestamp number when set in state', () => { + const testTimestamp = new Date('2026-01-15T12:00:00Z').getTime(); + const state: AnalyticsDataRegulationControllerState = { + ...getDefaultAnalyticsDataRegulationControllerState(), + deleteRegulationTimestamp: testTimestamp, + }; + + expect( + analyticsDataRegulationControllerSelectors.selectDeleteRegulationTimestamp( + state, + ), + ).toBe(testTimestamp); + }); + + it('returns undefined when deleteRegulationTimestamp is not set in state', () => { + const state: AnalyticsDataRegulationControllerState = { + ...getDefaultAnalyticsDataRegulationControllerState(), + }; + + expect( + analyticsDataRegulationControllerSelectors.selectDeleteRegulationTimestamp( + state, + ), + ).toBeUndefined(); + }); + }); +}); diff --git a/packages/analytics-data-regulation-controller/src/selectors.ts b/packages/analytics-data-regulation-controller/src/selectors.ts new file mode 100644 index 00000000000..1b98c59dd2d --- /dev/null +++ b/packages/analytics-data-regulation-controller/src/selectors.ts @@ -0,0 +1,41 @@ +import type { AnalyticsDataRegulationControllerState } from './AnalyticsDataRegulationController'; + +/** + * Selects the data recorded flag from the controller state. + * + * @param state - The controller state + * @returns Whether data has been recorded since the last deletion request + */ +const selectHasCollectedDataSinceDeletionRequest = ( + state: AnalyticsDataRegulationControllerState, +): boolean => state.hasCollectedDataSinceDeletionRequest; + +/** + * Selects the delete regulation ID from the controller state. + * + * @param state - The controller state + * @returns The regulation ID, or undefined if not set + */ +const selectDeleteRegulationId = ( + state: AnalyticsDataRegulationControllerState, +): string | undefined => state.deleteRegulationId; + +/** + * Selects the delete regulation creation timestamp from the controller state. + * + * @param state - The controller state + * @returns The deletion timestamp (in milliseconds since epoch), or undefined if not set + */ +const selectDeleteRegulationTimestamp = ( + state: AnalyticsDataRegulationControllerState, +): number | undefined => state.deleteRegulationTimestamp; + +/** + * Selectors for the AnalyticsDataRegulationController state. + * These can be used with Redux or directly with controller state. + */ +export const analyticsDataRegulationControllerSelectors = { + selectHasCollectedDataSinceDeletionRequest, + selectDeleteRegulationId, + selectDeleteRegulationTimestamp, +}; diff --git a/packages/analytics-data-regulation-controller/src/types.ts b/packages/analytics-data-regulation-controller/src/types.ts new file mode 100644 index 00000000000..b7d6aa76f6d --- /dev/null +++ b/packages/analytics-data-regulation-controller/src/types.ts @@ -0,0 +1,52 @@ +/** + * Status values for data deletion requests from Segment API. + * Values match Segment API response values exactly. + */ +export const DATA_DELETE_STATUSES = { + Failed: 'FAILED', + Finished: 'FINISHED', + Initialized: 'INITIALIZED', + Invalid: 'INVALID', + NotSupported: 'NOT_SUPPORTED', + PartialSuccess: 'PARTIAL_SUCCESS', + Running: 'RUNNING', + Unknown: 'UNKNOWN', +} as const; + +/** + * Type union for data deletion status values. + */ +export type DataDeleteStatus = + (typeof DATA_DELETE_STATUSES)[keyof typeof DATA_DELETE_STATUSES]; + +/** + * Response status for deletion regulation operations. + */ +export const DATA_DELETE_RESPONSE_STATUSES = { + Success: 'ok', + Failure: 'error', +} as const; + +/** + * Type union for data deletion response status values. + */ +export type DataDeleteResponseStatus = + (typeof DATA_DELETE_RESPONSE_STATUSES)[keyof typeof DATA_DELETE_RESPONSE_STATUSES]; + +/** + * Response from creating a data deletion task. + * The service throws errors on failure, so this type only represents the Success case. + */ +export type DeleteRegulationResponse = { + status: typeof DATA_DELETE_RESPONSE_STATUSES.Success; + regulateId: string; // Using exact API field name from Segment API response +}; + +/** + * Status information for a data deletion request. + */ +export type DeleteRegulationStatus = { + deletionRequestTimestamp?: number; + hasCollectedDataSinceDeletionRequest: boolean; + dataDeletionRequestStatus: DataDeleteStatus; +}; diff --git a/packages/analytics-data-regulation-controller/tsconfig.build.json b/packages/analytics-data-regulation-controller/tsconfig.build.json new file mode 100644 index 00000000000..5a5c9e2326a --- /dev/null +++ b/packages/analytics-data-regulation-controller/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/analytics-data-regulation-controller/tsconfig.json b/packages/analytics-data-regulation-controller/tsconfig.json new file mode 100644 index 00000000000..dfd15011442 --- /dev/null +++ b/packages/analytics-data-regulation-controller/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../base-controller" }, + { "path": "../controller-utils" }, + { "path": "../messenger" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/analytics-data-regulation-controller/typedoc.json b/packages/analytics-data-regulation-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/analytics-data-regulation-controller/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/teams.json b/teams.json index aa75b048c99..1c234bf104d 100644 --- a/teams.json +++ b/teams.json @@ -66,6 +66,7 @@ "metamask/permission-controller": "team-wallet-integrations,team-core-platform", "metamask/permission-log-controller": "team-wallet-integrations,team-core-platform", "metamask/analytics-controller": "team-extension-platform,team-mobile-platform", + "metamask/analytics-data-regulation-controller": "team-extension-platform,team-mobile-platform", "metamask/remote-feature-flag-controller": "team-extension-platform,team-mobile-platform", "metamask/storage-service": "team-extension-platform,team-mobile-platform" } diff --git a/tsconfig.build.json b/tsconfig.build.json index 3949c41a8b7..a4209721862 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -22,6 +22,9 @@ { "path": "./packages/analytics-controller/tsconfig.build.json" }, + { + "path": "./packages/analytics-data-regulation-controller/tsconfig.build.json" + }, { "path": "./packages/announcement-controller/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index 77e194f6213..b9327eb9908 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,9 @@ { "path": "./packages/analytics-controller" }, + { + "path": "./packages/analytics-data-regulation-controller" + }, { "path": "./packages/announcement-controller" }, diff --git a/yarn.lock b/yarn.lock index 583fba09ecf..9298ef4f212 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2573,6 +2573,28 @@ __metadata: languageName: unknown linkType: soft +"@metamask/analytics-data-regulation-controller@workspace:packages/analytics-data-regulation-controller": + version: 0.0.0-use.local + resolution: "@metamask/analytics-data-regulation-controller@workspace:packages/analytics-data-regulation-controller" + dependencies: + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/controller-utils": "npm:^11.18.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/utils": "npm:^11.9.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^27.4.1" + deepmerge: "npm:^4.2.2" + jest: "npm:^27.5.1" + nock: "npm:^13.3.1" + sinon: "npm:^9.2.4" + ts-jest: "npm:^27.1.4" + typedoc: "npm:^0.24.8" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/announcement-controller@workspace:packages/announcement-controller": version: 0.0.0-use.local resolution: "@metamask/announcement-controller@workspace:packages/announcement-controller"