diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index 94f88ea6a8d..b9eea3814c9 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `authenticationControllerSelectors` for best-effort reads of cached session identity from `AuthenticationController` state ([#9604](https://github.com/MetaMask/core/pull/9604)) + - `selectSrpSessionData` — raw `srpSessionData` map + - `selectSessionData` — primary (first) SRP session entry + - `selectCanonicalProfileId` — cached `canonicalProfileId`, treating `''` as missing + - Intended for sync consumers (e.g. Braze) that should not wait on `getSessionProfile()`. Same staleness as a valid cached session; use `refreshCanonicalProfileId()` when freshness is required. + ## [28.3.0] ### Added diff --git a/packages/profile-sync-controller/src/controllers/authentication/index.ts b/packages/profile-sync-controller/src/controllers/authentication/index.ts index 9ac35a37056..c9b052f2729 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/index.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/index.ts @@ -4,6 +4,7 @@ export { AuthenticationController as Controller }; export default AuthenticationController; export * from './AuthenticationController.js'; export * as Mocks from './mocks/index.js'; +export { authenticationControllerSelectors } from './selectors.js'; export type { AuthenticationControllerPerformSignInAction, diff --git a/packages/profile-sync-controller/src/controllers/authentication/selectors.test.ts b/packages/profile-sync-controller/src/controllers/authentication/selectors.test.ts new file mode 100644 index 00000000000..cb3d118354e --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/authentication/selectors.test.ts @@ -0,0 +1,147 @@ +import type { LoginResponse } from '../../sdk/index.js'; +import type { AuthenticationControllerState } from './AuthenticationController.js'; +import { authenticationControllerSelectors } from './selectors.js'; + +const createLoginResponse = ( + overrides: Partial = {}, +): LoginResponse => ({ + token: { + accessToken: 'access-token', + expiresIn: 3600, + obtainedAt: Date.now(), + }, + profile: { + identifierId: 'identifier-id', + profileId: 'profile-id', + canonicalProfileId: 'canonical-profile-id', + metaMetricsId: 'metametrics-id', + ...overrides, + }, +}); + +describe('authenticationControllerSelectors', () => { + describe('selectSrpSessionData', () => { + it('returns undefined when srpSessionData is missing', () => { + const state: AuthenticationControllerState = { + isSignedIn: false, + }; + + expect( + authenticationControllerSelectors.selectSrpSessionData(state), + ).toBeUndefined(); + }); + + it('returns the srpSessionData map when present', () => { + const srpSessionData = { + 'entropy-1': createLoginResponse(), + }; + const state: AuthenticationControllerState = { + isSignedIn: true, + srpSessionData, + }; + + expect( + authenticationControllerSelectors.selectSrpSessionData(state), + ).toBe(srpSessionData); + }); + }); + + describe('selectSessionData', () => { + it('returns undefined when srpSessionData is missing', () => { + const state: AuthenticationControllerState = { + isSignedIn: false, + }; + + expect( + authenticationControllerSelectors.selectSessionData(state), + ).toBeUndefined(); + }); + + it('returns undefined when srpSessionData is empty', () => { + const state: AuthenticationControllerState = { + isSignedIn: true, + srpSessionData: {}, + }; + + expect( + authenticationControllerSelectors.selectSessionData(state), + ).toBeUndefined(); + }); + + it('returns the first session entry', () => { + const primary = createLoginResponse({ profileId: 'primary-profile' }); + const secondary = createLoginResponse({ profileId: 'secondary-profile' }); + const state: AuthenticationControllerState = { + isSignedIn: true, + srpSessionData: { + 'entropy-1': primary, + 'entropy-2': secondary, + }, + }; + + expect(authenticationControllerSelectors.selectSessionData(state)).toBe( + primary, + ); + }); + }); + + describe('selectCanonicalProfileId', () => { + it('returns undefined when srpSessionData is missing', () => { + const state: AuthenticationControllerState = { + isSignedIn: false, + }; + + expect( + authenticationControllerSelectors.selectCanonicalProfileId(state), + ).toBeUndefined(); + }); + + it('returns undefined when canonicalProfileId is an empty string', () => { + const state: AuthenticationControllerState = { + isSignedIn: true, + srpSessionData: { + 'entropy-1': createLoginResponse({ canonicalProfileId: '' }), + }, + }; + + expect( + authenticationControllerSelectors.selectCanonicalProfileId(state), + ).toBeUndefined(); + }); + + it('returns the canonical profile ID on the happy path', () => { + const state: AuthenticationControllerState = { + isSignedIn: true, + srpSessionData: { + 'entropy-1': createLoginResponse({ + canonicalProfileId: 'canonical-profile-id', + }), + }, + }; + + expect( + authenticationControllerSelectors.selectCanonicalProfileId(state), + ).toBe('canonical-profile-id'); + }); + + it('returns the shared canonical when multiple SRP sessions are present after propagation', () => { + const state: AuthenticationControllerState = { + isSignedIn: true, + srpSessionData: { + 'entropy-1': createLoginResponse({ + profileId: 'original-1', + canonicalProfileId: 'shared-canonical', + }), + 'entropy-2': createLoginResponse({ + profileId: 'original-2', + canonicalProfileId: 'shared-canonical', + }), + }, + }; + + expect( + authenticationControllerSelectors.selectCanonicalProfileId(state), + ).toBe('shared-canonical'); + }); + }); +}); diff --git a/packages/profile-sync-controller/src/controllers/authentication/selectors.ts b/packages/profile-sync-controller/src/controllers/authentication/selectors.ts new file mode 100644 index 00000000000..189cd1337f1 --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/authentication/selectors.ts @@ -0,0 +1,73 @@ +import type { LoginResponse } from '../../sdk/index.js'; +import type { AuthenticationControllerState } from './AuthenticationController.js'; + +/** + * Selects the raw SRP session map from AuthenticationController state. + * + * @param state - The AuthenticationController state. + * @returns The `srpSessionData` map, or `undefined` when not signed in / cleared. + */ +const selectSrpSessionData = ( + state: AuthenticationControllerState, +): AuthenticationControllerState['srpSessionData'] => state.srpSessionData; + +/** + * Selects the primary (first) SRP session entry from cached `srpSessionData`. + * + * Uses the first `Object.entries` value — the same "primary / first written" + * convention as extension `selectSessionData`. During `performSignIn`, sessions + * are written sequentially with the primary SRP first. + * + * @param state - The AuthenticationController state. + * @returns The primary session login response, or `undefined` if unavailable. + */ +const selectSessionData = ( + state: AuthenticationControllerState, +): LoginResponse | undefined => { + const srpSessionData = selectSrpSessionData(state); + if (!srpSessionData) { + return undefined; + } + const firstEntry = Object.entries(srpSessionData)[0]; + return firstEntry?.[1]; +}; + +/** + * Selects the best-effort cached canonical profile ID. + * + * Reads from the primary SRP session in `srpSessionData` without triggering + * login or unlock checks. Suitable for non-security consumers. + * + * Empty string is treated as missing (`undefined`) because the controller sets + * `canonicalProfileId` to `''` when invalidating a session + * (`#invalidateSrpSession`). + * + * Staleness: same as a valid cached `getSessionProfile()` result. Cross-device + * pairing can change the canonical without updating this cache until the next + * login or pair. Use `refreshCanonicalProfileId()` when freshness is required. + * + * @param state - The AuthenticationController state. + * @returns The cached canonical profile ID, or `undefined` if unavailable. + */ +const selectCanonicalProfileId = ( + state: AuthenticationControllerState, +): string | undefined => { + const canonicalProfileId = + selectSessionData(state)?.profile?.canonicalProfileId; + // `''` is used by `#invalidateSrpSession` to mark the session as invalid. + if (!canonicalProfileId) { + return undefined; + } + return canonicalProfileId; +}; + +/** + * Selectors for AuthenticationController state. + * These take controller state (not client `RootState`) and can be composed + * with Redux `createSelector` in clients. + */ +export const authenticationControllerSelectors = { + selectSrpSessionData, + selectSessionData, + selectCanonicalProfileId, +};