diff --git a/.changeset/notifications-connect-customer-profiles-push.md b/.changeset/notifications-connect-customer-profiles-push.md new file mode 100644 index 00000000000..0c12aad4cad --- /dev/null +++ b/.changeset/notifications-connect-customer-profiles-push.md @@ -0,0 +1,13 @@ +--- +'@aws-amplify/core': minor +'@aws-amplify/notifications': minor +'aws-amplify': minor +--- + +feat(notifications): add Amazon Connect Customer Profiles push notifications provider + +Push Notifications can now be delivered through Amazon Connect Customer Profiles via the new `aws-amplify/push-notifications/customer-profiles` sub-path export. The provider ships `identifyUser`, `initializePushNotifications`, `registerDevice`, and `removeDevice` alongside the transport-agnostic badge, permission, launch-notification, and notification/token listener APIs, with SigV4-signed device registration and client-side user-profile validation. `Amplify.configure` accepts the corresponding `amazon_connect` notifications configuration from `amplify_outputs.json`. + +Device registration follows the signed-in principal: `initializePushNotifications` registers the device when a push token is received and re-registers it on sign-in so an existing registration is re-homed to the authenticated principal. Because de-registration is authorized against the calling principal, applications should await `removeDevice()` before `signOut()` to stop delivery to a device. + +The default `aws-amplify/push-notifications` entry point emits a one-time `ConsoleLogger` notice at runtime directing customers to the Customer Profiles sub-path, since that entry point is backed by Amazon Pinpoint and AWS ends support for Amazon Pinpoint on October 30, 2026. Both changes are backwards compatible: existing exports keep their names, types, and signatures. diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index 086eb3508f8..0efb24e2e84 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -156,6 +156,12 @@ "import": "./dist/esm/push-notifications/pinpoint/index.mjs", "require": "./dist/cjs/push-notifications/pinpoint/index.js" }, + "./push-notifications/customer-profiles": { + "react-native": "./dist/cjs/push-notifications/customer-profiles/index.js", + "types": "./dist/esm/push-notifications/customer-profiles/index.d.ts", + "import": "./dist/esm/push-notifications/customer-profiles/index.mjs", + "require": "./dist/cjs/push-notifications/customer-profiles/index.js" + }, "./adapter-core": { "types": "./dist/esm/adapter-core/index.d.ts", "import": "./dist/esm/adapter-core/index.mjs", @@ -239,6 +245,9 @@ "push-notifications/pinpoint": [ "./dist/esm/push-notifications/pinpoint/index.d.ts" ], + "push-notifications/customer-profiles": [ + "./dist/esm/push-notifications/customer-profiles/index.d.ts" + ], "adapter-core": [ "./dist/esm/adapter-core/index.d.ts" ], diff --git a/packages/aws-amplify/push-notifications/customer-profiles/package.json b/packages/aws-amplify/push-notifications/customer-profiles/package.json new file mode 100644 index 00000000000..6c70ea18ba0 --- /dev/null +++ b/packages/aws-amplify/push-notifications/customer-profiles/package.json @@ -0,0 +1,8 @@ +{ + "name": "aws-amplify/push-notifications/customer-profiles", + "main": "../../dist/cjs/push-notifications/customer-profiles/index.js", + "react-native": "../../dist/cjs/push-notifications/customer-profiles/index.js", + "browser": "../../dist/esm/push-notifications/customer-profiles/index.mjs", + "module": "../../dist/esm/push-notifications/customer-profiles/index.mjs", + "typings": "../../dist/esm/push-notifications/customer-profiles/index.d.ts" +} diff --git a/packages/aws-amplify/src/push-notifications/customer-profiles/index.ts b/packages/aws-amplify/src/push-notifications/customer-profiles/index.ts new file mode 100644 index 00000000000..7bc50859735 --- /dev/null +++ b/packages/aws-amplify/src/push-notifications/customer-profiles/index.ts @@ -0,0 +1,8 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/* +This file maps exports from `aws-amplify/push-notifications/customer-profiles`. +It provides access to the Amazon Connect Customer Profiles provider of the PushNotification sub-category. +*/ +export * from '@aws-amplify/notifications/push-notifications/customer-profiles'; diff --git a/packages/core/__tests__/parseAmplifyOutputs.test.ts b/packages/core/__tests__/parseAmplifyOutputs.test.ts index 3e0636e0a47..2ea1d9e54dc 100644 --- a/packages/core/__tests__/parseAmplifyOutputs.test.ts +++ b/packages/core/__tests__/parseAmplifyOutputs.test.ts @@ -560,6 +560,61 @@ describe('parseAmplifyOutputs tests', () => { }, }); }); + + it('should configure Pinpoint and Customer Profiles push notifications together', () => { + const amplifyOutputs: AmplifyOutputs = { + version: '1', + notifications: { + aws_region: 'us-west-2', + amazon_pinpoint_app_id: 'appid123', + channels: ['APNS', 'FCM'], + amazon_connect: { + endpoint: 'https://example.com/prod', + aws_region: 'us-east-1', + }, + }, + }; + + const result = parseAmplifyOutputs(amplifyOutputs); + expect(result).toEqual({ + Notifications: { + PushNotification: { + Pinpoint: { + appId: 'appid123', + region: 'us-west-2', + }, + CustomerProfiles: { + endpoint: 'https://example.com/prod', + region: 'us-east-1', + }, + }, + }, + }); + }); + + it('should configure Customer Profiles push notifications without Pinpoint channels', () => { + const amplifyOutputs: AmplifyOutputs = { + version: '1', + notifications: { + amazon_connect: { + endpoint: 'https://example.com/prod', + aws_region: 'us-east-1', + }, + }, + }; + + const result = parseAmplifyOutputs(amplifyOutputs); + expect(result).toEqual({ + Notifications: { + PushNotification: { + CustomerProfiles: { + endpoint: 'https://example.com/prod', + region: 'us-east-1', + }, + }, + }, + }); + }); }); }); }); diff --git a/packages/core/src/Platform/types.ts b/packages/core/src/Platform/types.ts index fd6057c9704..53d2566c786 100644 --- a/packages/core/src/Platform/types.ts +++ b/packages/core/src/Platform/types.ts @@ -128,6 +128,8 @@ export enum PubSubAction { export enum PushNotificationAction { InitializePushNotifications = '1', IdentifyUser = '2', + RegisterDevice = '3', + RemoveDevice = '4', } export enum StorageAction { UploadData = '1', diff --git a/packages/core/src/parseAmplifyOutputs.ts b/packages/core/src/parseAmplifyOutputs.ts index 342db054891..0d34ea1ab81 100644 --- a/packages/core/src/parseAmplifyOutputs.ts +++ b/packages/core/src/parseAmplifyOutputs.ts @@ -19,6 +19,7 @@ import { PreferredChallenge, } from './singleton/Auth/types'; import { NotificationsConfig } from './singleton/Notifications/types'; +import { PushNotificationConfig } from './singleton/Notifications/PushNotification/types'; import { AmplifyOutputsAnalyticsProperties, AmplifyOutputsAuthProperties, @@ -272,21 +273,23 @@ function parseNotifications( return undefined; } - const { aws_region, channels, amazon_pinpoint_app_id } = + const { aws_region, channels, amazon_pinpoint_app_id, amazon_connect } = amplifyOutputsNotificationsProperties; - const hasInAppMessaging = channels.includes('IN_APP_MESSAGING'); + const supportedChannels = channels ?? []; + const hasInAppMessaging = supportedChannels.includes('IN_APP_MESSAGING'); const hasPushNotification = - channels.includes('APNS') || channels.includes('FCM'); + supportedChannels.includes('APNS') || supportedChannels.includes('FCM'); + const hasCustomerProfilesPush = !!amazon_connect; - if (!(hasInAppMessaging || hasPushNotification)) { + if (!(hasInAppMessaging || hasPushNotification || hasCustomerProfilesPush)) { return undefined; } // At this point, we know the Amplify outputs contains at least one supported channel const notificationsConfig: NotificationsConfig = {} as NotificationsConfig; - if (hasInAppMessaging) { + if (hasInAppMessaging && amazon_pinpoint_app_id && aws_region) { notificationsConfig.InAppMessaging = { Pinpoint: { appId: amazon_pinpoint_app_id, @@ -295,15 +298,30 @@ function parseNotifications( }; } - if (hasPushNotification) { - notificationsConfig.PushNotification = { - Pinpoint: { - appId: amazon_pinpoint_app_id, - region: aws_region, - }, + // Push device registration can be backed by Amazon Pinpoint and/or Amazon + // Connect Customer Profiles. Each provider is emitted independently when its + // configuration is present, mirroring how analytics is parsed. + const pushNotificationConfig: Partial = {}; + + if (hasPushNotification && amazon_pinpoint_app_id && aws_region) { + pushNotificationConfig.Pinpoint = { + appId: amazon_pinpoint_app_id, + region: aws_region, }; } + if (amazon_connect) { + pushNotificationConfig.CustomerProfiles = { + endpoint: amazon_connect.endpoint, + region: amazon_connect.aws_region, + }; + } + + if (Object.keys(pushNotificationConfig).length > 0) { + notificationsConfig.PushNotification = + pushNotificationConfig as PushNotificationConfig; + } + return notificationsConfig; } diff --git a/packages/core/src/singleton/AmplifyOutputs/types.ts b/packages/core/src/singleton/AmplifyOutputs/types.ts index 20738f7cf50..d1ecfcbd015 100644 --- a/packages/core/src/singleton/AmplifyOutputs/types.ts +++ b/packages/core/src/singleton/AmplifyOutputs/types.ts @@ -119,9 +119,13 @@ export interface AmplifyOutputsCustomProperties { } export interface AmplifyOutputsNotificationsProperties { - aws_region: string; - amazon_pinpoint_app_id: string; - channels: string[]; + aws_region?: string; + amazon_pinpoint_app_id?: string; + channels?: string[]; + amazon_connect?: { + aws_region: string; + endpoint: string; + }; } /** @deprecated This type is deprecated and will be removed in future versions. */ diff --git a/packages/core/src/singleton/Notifications/PushNotification/types.ts b/packages/core/src/singleton/Notifications/PushNotification/types.ts index 3e599118a73..dbf14f282a7 100644 --- a/packages/core/src/singleton/Notifications/PushNotification/types.ts +++ b/packages/core/src/singleton/Notifications/PushNotification/types.ts @@ -2,5 +2,21 @@ // SPDX-License-Identifier: Apache-2.0 import { PinpointProviderConfig } from '../../../providers/pinpoint/types'; +import { AtLeastOne } from '../../types'; -export type PushNotificationConfig = PinpointProviderConfig; +/** + * Configuration for the Amazon Connect Customer Profiles Push Notification + * provider. The provider backs device registration (`identifyUser` / + * `initializePushNotifications`) with a REST endpoint (typically fronted by a + * Lambda) that writes device tokens to Amazon Connect Customer Profiles. + */ +export interface ConnectCustomerProfilesPushProviderConfig { + CustomerProfiles: { + endpoint: string; + region: string; + }; +} + +export type PushNotificationConfig = AtLeastOne< + PinpointProviderConfig & ConnectCustomerProfilesPushProviderConfig +>; diff --git a/packages/notifications/__tests__/pushNotifications/index.test.ts b/packages/notifications/__tests__/pushNotifications/index.test.ts new file mode 100644 index 00000000000..7ab151db4c7 --- /dev/null +++ b/packages/notifications/__tests__/pushNotifications/index.test.ts @@ -0,0 +1,59 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ConsoleLogger } from '@aws-amplify/core'; + +import * as defaultExports from '../../src/pushNotifications'; +import * as customerProfilesExports from '../../src/pushNotifications/providers/customer-profiles'; + +const DEPRECATED_RUNTIME_APIS = [ + 'getBadgeCount', + 'setBadgeCount', + 'getPermissionStatus', + 'requestPermissions', + 'getLaunchNotification', + 'onNotificationReceivedInForeground', + 'onNotificationReceivedInBackground', + 'onNotificationOpened', + 'onTokenReceived', + 'identifyUser', + 'initializePushNotifications', +] as const; + +describe('push-notifications default (Pinpoint) entry point', () => { + const loggerWarnSpy = jest.spyOn(ConsoleLogger.prototype, 'warn'); + + beforeEach(() => { + loggerWarnSpy.mockClear(); + }); + + it.each(DEPRECATED_RUNTIME_APIS)( + 'emits a deprecation warning when %s is invoked', + async apiName => { + const api = defaultExports[apiName] as (...args: any[]) => unknown; + + // Every default API is unsupported on the web platform, so invoking it + // throws synchronously or rejects. The warning must still be emitted + // beforehand in both cases, and the underlying error must reach the + // caller untouched. + try { + await api({ handler: () => undefined }); + throw new Error(`expected ${apiName} to reject or throw`); + } catch (error) { + expect((error as Error).name).toBe('PlatformNotSupported'); + } + + expect(loggerWarnSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'aws-amplify/push-notifications/customer-profiles', + ), + ); + }, + ); + + it('exposes an equivalent for every deprecated API on the customer-profiles sub-path', () => { + DEPRECATED_RUNTIME_APIS.forEach(apiName => { + expect(typeof customerProfilesExports[apiName]).toBe('function'); + }); + }); +}); diff --git a/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/apis/identifyUser.native.test.ts b/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/apis/identifyUser.native.test.ts new file mode 100644 index 00000000000..ff2dfc8f2b3 --- /dev/null +++ b/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/apis/identifyUser.native.test.ts @@ -0,0 +1,57 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { identifyUser } from '../../../../../src/pushNotifications/providers/customer-profiles/apis/identifyUser.native'; +import { identifyUserInternal } from '../../../../../src/pushNotifications/providers/customer-profiles/utils/identifyUserInternal'; +import { IdentifyUserInput } from '../../../../../src/pushNotifications/providers/customer-profiles/types'; + +jest.mock( + '../../../../../src/pushNotifications/providers/customer-profiles/utils/identifyUserInternal', +); + +describe('identifyUser (customer-profiles, native)', () => { + const mockIdentifyUserInternal = identifyUserInternal as jest.Mock; + + beforeEach(() => { + mockIdentifyUserInternal.mockResolvedValue(undefined); + }); + + afterEach(() => { + mockIdentifyUserInternal.mockReset(); + }); + + it('performs a profile-only identify and registers NO device', async () => { + const input: IdentifyUserInput = { + userProfile: { + email: 'email', + name: 'name', + customAttributes: { hobby: 'biking' }, + }, + }; + await identifyUser(input); + + expect(mockIdentifyUserInternal).toHaveBeenCalledTimes(1); + expect(mockIdentifyUserInternal).toHaveBeenCalledWith({ + userProfile: input.userProfile, + }); + const call = mockIdentifyUserInternal.mock.calls[0][0]; + // native identify no longer registers a device or sends a userId + expect(call).not.toHaveProperty('userId'); + expect(call).not.toHaveProperty('deviceToken'); + expect(call).not.toHaveProperty('channelType'); + expect(call).not.toHaveProperty('options'); + }); + + it('forwards a minimal input (empty userProfile)', async () => { + await identifyUser({ userProfile: {} }); + + expect(mockIdentifyUserInternal).toHaveBeenCalledWith({ userProfile: {} }); + }); + + it('rejects if the identify request rejects', async () => { + mockIdentifyUserInternal.mockRejectedValue(new Error('service error')); + await expect(identifyUser({ userProfile: {} })).rejects.toThrow( + 'service error', + ); + }); +}); diff --git a/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/apis/identifyUser.test.ts b/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/apis/identifyUser.test.ts new file mode 100644 index 00000000000..d4bdd3227a7 --- /dev/null +++ b/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/apis/identifyUser.test.ts @@ -0,0 +1,57 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { identifyUser } from '../../../../../src/pushNotifications/providers/customer-profiles/apis/identifyUser'; +import { identifyUserInternal } from '../../../../../src/pushNotifications/providers/customer-profiles/utils/identifyUserInternal'; +import { IdentifyUserInput } from '../../../../../src/pushNotifications/providers/customer-profiles/types'; + +jest.mock( + '../../../../../src/pushNotifications/providers/customer-profiles/utils/identifyUserInternal', +); + +describe('identifyUser (customer-profiles, web)', () => { + const mockIdentifyUserInternal = identifyUserInternal as jest.Mock; + + beforeEach(() => { + mockIdentifyUserInternal.mockResolvedValue(undefined); + }); + + afterEach(() => { + mockIdentifyUserInternal.mockReset(); + }); + + it('performs a profile-only identify with the userProfile and no userId', async () => { + const input: IdentifyUserInput = { + userProfile: { + email: 'user@example.com', + name: 'Jane Doe', + phone: '555-555-5555', + location: { city: 'Seattle', country: 'US' }, + }, + }; + await identifyUser(input); + + expect(mockIdentifyUserInternal).toHaveBeenCalledTimes(1); + expect(mockIdentifyUserInternal).toHaveBeenCalledWith({ + userProfile: input.userProfile, + }); + const call = mockIdentifyUserInternal.mock.calls[0][0]; + expect(call).not.toHaveProperty('userId'); + expect(call).not.toHaveProperty('deviceToken'); + expect(call).not.toHaveProperty('channelType'); + expect(call).not.toHaveProperty('options'); + }); + + it('forwards a minimal input (empty userProfile)', async () => { + await identifyUser({ userProfile: {} }); + + expect(mockIdentifyUserInternal).toHaveBeenCalledWith({ userProfile: {} }); + }); + + it('rejects when the underlying identify request rejects', async () => { + mockIdentifyUserInternal.mockRejectedValue(new Error('service error')); + await expect(identifyUser({ userProfile: {} })).rejects.toThrow( + 'service error', + ); + }); +}); diff --git a/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/apis/initializePushNotifications.native.test.ts b/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/apis/initializePushNotifications.native.test.ts new file mode 100644 index 00000000000..87d2820264a --- /dev/null +++ b/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/apis/initializePushNotifications.native.test.ts @@ -0,0 +1,418 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Hub } from '@aws-amplify/core'; + +import { + notifyEventListeners, + notifyEventListenersAndAwaitHandlers, +} from '../../../../../src/eventListeners'; +import { + getToken, + initialize, + isInitialized, + setToken, +} from '../../../../../src/pushNotifications/utils'; +import { + rejectInflightDeviceRegistration, + resolveInflightDeviceRegistration, +} from '../../../../../src/pushNotifications/providers/customer-profiles/utils'; +import { registerDevice } from '../../../../../src/pushNotifications/providers/customer-profiles/apis/registerDevice'; +import { removeDevice } from '../../../../../src/pushNotifications/providers/customer-profiles/apis/removeDevice'; +import { + completionHandlerId, + pushModuleConstants, + pushToken, + simplePushMessage, +} from '../../../../testUtils/data'; + +jest.mock('@aws-amplify/core', () => ({ + ConsoleLogger: jest.fn(() => ({ + info: jest.fn(), + error: jest.fn(), + })), + Hub: { listen: jest.fn() }, +})); +jest.mock('@aws-amplify/react-native', () => ({ + getOperatingSystem: jest.fn(), + loadAmplifyPushNotification: jest.fn(() => ({ + addMessageEventListener: mockAddMessageEventListener, + addTokenEventListener: mockAddTokenEventListener, + completeNotification: mockCompleteNotification, + getConstants: mockGetConstants, + registerHeadlessTask: mockRegisterHeadlessTask, + })), +})); +jest.mock('../../../../../src/eventListeners'); +jest.mock( + '../../../../../src/pushNotifications/providers/customer-profiles/utils', +); +jest.mock( + '../../../../../src/pushNotifications/providers/customer-profiles/apis/registerDevice', +); +jest.mock( + '../../../../../src/pushNotifications/providers/customer-profiles/apis/removeDevice', +); +jest.mock('../../../../../src/pushNotifications/utils'); + +// module level mocks +const mockAddMessageEventListener = jest.fn(); +const mockAddTokenEventListener = jest.fn(); +const mockCompleteNotification = jest.fn(); +const mockGetConstants = jest.fn(); +const mockRegisterHeadlessTask = jest.fn(); + +describe('initializePushNotifications (customer-profiles, native)', () => { + let initializePushNotifications: () => void; + const { NativeEvent } = pushModuleConstants; + // create mocks + const mockEventListenerRemover = { remove: jest.fn() }; + // assert mocks + const mockRegisterDevice = registerDevice as jest.Mock; + const mockRemoveDevice = removeDevice as jest.Mock; + const mockHubListen = Hub.listen as jest.Mock; + const mockGetToken = getToken as jest.Mock; + const mockInitialize = initialize as jest.Mock; + const mockIsInitialized = isInitialized as jest.Mock; + const mockRejectInflightDeviceRegistration = + rejectInflightDeviceRegistration as jest.Mock; + const mockResolveInflightDeviceRegistration = + resolveInflightDeviceRegistration as jest.Mock; + const mockSetToken = setToken as jest.Mock; + const mockNotifyEventListeners = notifyEventListeners as jest.Mock; + const mockNotifyEventListenersAndAwaitHandlers = + notifyEventListenersAndAwaitHandlers as jest.Mock; + // helpers + const expectListenerForEvent = (event: string) => ({ + toBeAdded: () => { + expect(mockAddMessageEventListener).toHaveBeenCalledWith( + event, + expect.any(Function), + ); + }, + notToBeAdded: () => { + expect(mockAddMessageEventListener).not.toHaveBeenCalledWith( + event, + expect.any(Function), + ); + }, + }); + + const listenForEvent = (event: string) => { + mockAddMessageEventListener.mockImplementation((heardEvent, handler) => { + if (heardEvent === event) { + handler(simplePushMessage); + } + }); + }; + + beforeAll(() => { + ({ + initializePushNotifications, + } = require('../../../../../src/pushNotifications/providers/customer-profiles/apis/initializePushNotifications.native')); + mockAddMessageEventListener.mockReturnValue(mockEventListenerRemover); + mockRegisterDevice.mockResolvedValue(undefined); + mockRemoveDevice.mockResolvedValue(undefined); + }); + + beforeEach(() => { + mockGetConstants.mockReturnValue(pushModuleConstants); + mockIsInitialized.mockReturnValue(false); + }); + + afterEach(() => { + mockGetToken.mockReset(); + mockIsInitialized.mockReset(); + mockGetConstants.mockReset(); + mockRegisterHeadlessTask.mockReset(); + mockAddMessageEventListener.mockReset(); + mockAddTokenEventListener.mockReset(); + mockRegisterDevice.mockReset(); + mockRemoveDevice.mockReset(); + mockHubListen.mockReset(); + mockInitialize.mockClear(); + mockSetToken.mockClear(); + mockCompleteNotification.mockClear(); + mockEventListenerRemover.remove.mockClear(); + mockNotifyEventListeners.mockClear(); + mockNotifyEventListenersAndAwaitHandlers.mockClear(); + mockRejectInflightDeviceRegistration.mockClear(); + mockResolveInflightDeviceRegistration.mockClear(); + // restore default resolved values cleared by mockReset above + mockRegisterDevice.mockResolvedValue(undefined); + mockRemoveDevice.mockResolvedValue(undefined); + mockAddMessageEventListener.mockReturnValue(mockEventListenerRemover); + }); + + it('only enables once', () => { + mockIsInitialized.mockReturnValue(true); + initializePushNotifications(); + expect(mockInitialize).not.toHaveBeenCalled(); + }); + + describe('background notification', () => { + it('registers a headless task if able', () => { + initializePushNotifications(); + expect(mockRegisterHeadlessTask).toHaveBeenCalledWith( + expect.any(Function), + ); + expectListenerForEvent( + NativeEvent.BACKGROUND_MESSAGE_RECEIVED, + ).notToBeAdded(); + }); + + it('calls background notification handlers when headless task is run', () => { + mockRegisterHeadlessTask.mockImplementation(task => { + task(simplePushMessage); + }); + initializePushNotifications(); + expect(mockNotifyEventListenersAndAwaitHandlers).toHaveBeenCalledWith( + 'backgroundMessageReceived', + simplePushMessage, + ); + }); + + it('registers and calls background notification listener if unable to register headless task', () => { + listenForEvent(NativeEvent.BACKGROUND_MESSAGE_RECEIVED); + mockGetConstants.mockReturnValue({ NativeEvent }); + initializePushNotifications(); + expectListenerForEvent( + NativeEvent.BACKGROUND_MESSAGE_RECEIVED, + ).toBeAdded(); + expect(mockRegisterHeadlessTask).not.toHaveBeenCalled(); + expect(mockNotifyEventListenersAndAwaitHandlers).toHaveBeenCalledWith( + 'backgroundMessageReceived', + simplePushMessage, + ); + }); + + it('completes the notification if completionHandlerId is provided', done => { + mockAddMessageEventListener.mockImplementation((heardEvent, handler) => { + if (heardEvent === NativeEvent.BACKGROUND_MESSAGE_RECEIVED) { + handler(simplePushMessage, completionHandlerId); + } + }); + mockCompleteNotification.mockImplementation(() => { + expect(mockCompleteNotification).toHaveBeenCalled(); + done(); + }); + mockGetConstants.mockReturnValue({ NativeEvent }); + initializePushNotifications(); + expectListenerForEvent( + NativeEvent.BACKGROUND_MESSAGE_RECEIVED, + ).toBeAdded(); + expect(mockRegisterHeadlessTask).not.toHaveBeenCalled(); + expect(mockNotifyEventListenersAndAwaitHandlers).toHaveBeenCalledWith( + 'backgroundMessageReceived', + simplePushMessage, + ); + }); + }); + + describe('launch notification', () => { + it('registers and calls launch notification listener if able', () => { + listenForEvent(NativeEvent.LAUNCH_NOTIFICATION_OPENED); + initializePushNotifications(); + + expectListenerForEvent( + NativeEvent.LAUNCH_NOTIFICATION_OPENED, + ).toBeAdded(); + expect(mockNotifyEventListeners).toHaveBeenCalledWith( + 'launchNotificationOpened', + simplePushMessage, + ); + }); + + it('does not register launch notification listener if unable', () => { + listenForEvent(NativeEvent.LAUNCH_NOTIFICATION_OPENED); + mockGetConstants.mockReturnValue({ + NativeEvent: { + ...NativeEvent, + LAUNCH_NOTIFICATION_OPENED: undefined, + }, + }); + initializePushNotifications(); + + expectListenerForEvent( + NativeEvent.LAUNCH_NOTIFICATION_OPENED, + ).notToBeAdded(); + expect(mockNotifyEventListeners).not.toHaveBeenCalled(); + }); + }); + + it('registers and calls foreground message listener', () => { + listenForEvent(NativeEvent.FOREGROUND_MESSAGE_RECEIVED); + initializePushNotifications(); + + expectListenerForEvent(NativeEvent.FOREGROUND_MESSAGE_RECEIVED).toBeAdded(); + expect(mockNotifyEventListeners).toHaveBeenCalledWith( + 'foregroundMessageReceived', + simplePushMessage, + ); + }); + + it('registers and calls notification opened listener', () => { + listenForEvent(NativeEvent.NOTIFICATION_OPENED); + initializePushNotifications(); + + expectListenerForEvent(NativeEvent.NOTIFICATION_OPENED).toBeAdded(); + expect(mockNotifyEventListeners).toHaveBeenCalledWith( + 'notificationOpened', + simplePushMessage, + ); + }); + + describe('token received', () => { + it('registers the device with Customer Profiles and resolves the inflight registration', done => { + expect.assertions(6); + mockGetToken.mockReturnValue(undefined); + mockAddTokenEventListener.mockImplementation( + async (heardEvent, handler) => { + if (heardEvent === NativeEvent.TOKEN_RECEIVED) { + await handler(pushToken); + expect(mockAddTokenEventListener).toHaveBeenCalledWith( + NativeEvent.TOKEN_RECEIVED, + expect.any(Function), + ); + expect(mockSetToken).toHaveBeenCalledWith(pushToken); + expect(mockNotifyEventListeners).toHaveBeenCalledWith( + 'tokenReceived', + pushToken, + ); + expect(mockRegisterDevice).toHaveBeenCalledWith({ + token: pushToken, + }); + expect(mockResolveInflightDeviceRegistration).toHaveBeenCalled(); + expect(mockRejectInflightDeviceRegistration).not.toHaveBeenCalled(); + done(); + } + }, + ); + initializePushNotifications(); + }); + + it('should not invoke token received listener with the same token twice', () => { + mockGetToken + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(pushToken); + mockAddTokenEventListener.mockImplementation((heardEvent, handler) => { + if (heardEvent === NativeEvent.TOKEN_RECEIVED) { + handler(pushToken); + handler(pushToken); + } + }); + initializePushNotifications(); + + expect(mockNotifyEventListeners).toHaveBeenCalledTimes(1); + }); + + it('token received should be invoked with different tokens', () => { + mockGetToken + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(pushToken); + mockAddTokenEventListener.mockImplementation((heardEvent, handler) => { + if (heardEvent === NativeEvent.TOKEN_RECEIVED) { + handler(pushToken); + handler('bar-foo'); + } + }); + initializePushNotifications(); + + expect(mockNotifyEventListeners).toHaveBeenCalledTimes(2); + }); + + it('throws if device registration fails', done => { + expect.assertions(3); + mockRegisterDevice.mockImplementation(() => { + throw new Error(); + }); + mockAddTokenEventListener.mockImplementation( + async (heardEvent, handler) => { + if (heardEvent === NativeEvent.TOKEN_RECEIVED) { + await expect(handler(pushToken)).rejects.toThrow(); + expect( + mockResolveInflightDeviceRegistration, + ).not.toHaveBeenCalled(); + expect(mockRejectInflightDeviceRegistration).toHaveBeenCalled(); + done(); + } + }, + ); + initializePushNotifications(); + }); + }); + + describe('sign-in re-registration', () => { + const getAuthHandler = () => { + expect(mockHubListen).toHaveBeenCalledWith('auth', expect.any(Function)); + + return mockHubListen.mock.calls.find(call => call[0] === 'auth')![1]; + }; + + it('re-registers the current token when an auth signedIn event is received', async () => { + mockGetToken.mockReturnValue(pushToken); + initializePushNotifications(); + + getAuthHandler()({ payload: { event: 'signedIn' } }); + await Promise.resolve(); + + expect(mockRegisterDevice).toHaveBeenCalledTimes(1); + expect(mockRegisterDevice).toHaveBeenCalledWith({ token: pushToken }); + }); + + it('does NOT re-register when no token has been received yet', () => { + mockGetToken.mockReturnValue(undefined); + initializePushNotifications(); + + getAuthHandler()({ payload: { event: 'signedIn' } }); + + expect(mockRegisterDevice).not.toHaveBeenCalled(); + }); + + it('swallows errors from the sign-in re-registration', async () => { + mockGetToken.mockReturnValue(pushToken); + mockRegisterDevice.mockRejectedValue(new Error('register failed')); + initializePushNotifications(); + + const authHandler = getAuthHandler(); + expect(() => + authHandler({ payload: { event: 'signedIn' } }), + ).not.toThrow(); + await Promise.resolve(); + + expect(mockRegisterDevice).toHaveBeenCalledTimes(1); + }); + + it('ignores auth events other than signedIn', () => { + mockGetToken.mockReturnValue(pushToken); + initializePushNotifications(); + + const authHandler = getAuthHandler(); + authHandler({ payload: { event: 'tokenRefresh' } }); + authHandler({ payload: { event: 'signInWithRedirect' } }); + + expect(mockRegisterDevice).not.toHaveBeenCalled(); + }); + }); + + describe('sign-out', () => { + // De-registration cannot be performed after sign-out: `signOut` clears the + // credentials before the `signedOut` event fires, so the listener would + // sign as a brand-new guest identity and the principal-gated backend + // removal would silently no-op. Applications call `removeDevice()` BEFORE + // `signOut()` instead. + it('does NOT remove the device when an auth signedOut event is received', async () => { + mockGetToken.mockReturnValue(pushToken); + initializePushNotifications(); + + const authHandler = mockHubListen.mock.calls.find( + call => call[0] === 'auth', + )![1]; + authHandler({ payload: { event: 'signedOut' } }); + await Promise.resolve(); + + expect(mockRemoveDevice).not.toHaveBeenCalled(); + expect(mockRegisterDevice).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/apis/registerDevice.native.test.ts b/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/apis/registerDevice.native.test.ts new file mode 100644 index 00000000000..751bbd43240 --- /dev/null +++ b/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/apis/registerDevice.native.test.ts @@ -0,0 +1,106 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getClientInfo } from '@aws-amplify/core/internals/utils'; + +import { assertIsInitialized } from '../../../../../src/pushNotifications/errors/errorHelpers'; +import { + buildDeviceRegistration, + registerDevice, +} from '../../../../../src/pushNotifications/providers/customer-profiles/apis/registerDevice.native'; +import { PushNotificationError } from '../../../../../src/pushNotifications/errors'; +import { getToken } from '../../../../../src/pushNotifications/utils'; +import { + getChannelType, + getDeviceId, + registerDeviceInternal, +} from '../../../../../src/pushNotifications/providers/customer-profiles/utils'; +import { channelType, pushToken } from '../../../../testUtils/data'; + +jest.mock('@aws-amplify/core/internals/utils'); +jest.mock('@aws-amplify/react-native', () => ({ + getOperatingSystem: jest.fn(), + loadAsyncStorage: jest.fn(), +})); +jest.mock('../../../../../src/pushNotifications/errors/errorHelpers', () => ({ + ...jest.requireActual( + '../../../../../src/pushNotifications/errors/errorHelpers', + ), + assertIsInitialized: jest.fn(), +})); +jest.mock( + '../../../../../src/pushNotifications/providers/customer-profiles/utils', +); +jest.mock('../../../../../src/pushNotifications/utils'); + +const DEVICE_ID = 'persisted-device-id'; + +describe('registerDevice (customer-profiles, native)', () => { + const mockAssertIsInitialized = assertIsInitialized as jest.Mock; + const mockGetClientInfo = getClientInfo as jest.Mock; + const mockGetChannelType = getChannelType as jest.Mock; + const mockGetDeviceId = getDeviceId as jest.Mock; + const mockGetToken = getToken as jest.Mock; + const mockRegisterDeviceInternal = registerDeviceInternal as jest.Mock; + + beforeEach(() => { + mockGetClientInfo.mockReturnValue({ platform: 'ios' }); + mockGetChannelType.mockReturnValue(channelType); + mockGetDeviceId.mockResolvedValue(DEVICE_ID); + mockGetToken.mockReturnValue(pushToken); + mockRegisterDeviceInternal.mockResolvedValue(undefined); + }); + + afterEach(() => { + mockAssertIsInitialized.mockReset(); + mockGetClientInfo.mockReset(); + mockGetChannelType.mockReset(); + mockGetDeviceId.mockReset(); + mockGetToken.mockReset(); + mockRegisterDeviceInternal.mockReset(); + }); + + it('must be initialized', async () => { + mockAssertIsInitialized.mockImplementation(() => { + throw new Error(); + }); + await expect(registerDevice({ token: pushToken })).rejects.toThrow(); + expect(mockRegisterDeviceInternal).not.toHaveBeenCalled(); + }); + + it('registers the device with the internally-managed device fields', async () => { + await registerDevice({ token: pushToken }); + + expect(mockGetDeviceId).toHaveBeenCalledTimes(1); + expect(mockRegisterDeviceInternal).toHaveBeenCalledTimes(1); + expect(mockRegisterDeviceInternal).toHaveBeenCalledWith({ + token: pushToken, + deviceId: DEVICE_ID, + platform: 'ios', + appVersion: '', + channelType, + }); + }); + + it('falls back to the current token when none is supplied (auto-registration path)', async () => { + const built = await buildDeviceRegistration(); + expect(mockGetToken).toHaveBeenCalled(); + expect(built).toEqual( + expect.objectContaining({ token: pushToken, deviceId: DEVICE_ID }), + ); + }); + + it('throws NoToken when neither a supplied nor a current token is available', async () => { + mockGetToken.mockReturnValue(undefined); + await expect(buildDeviceRegistration()).rejects.toBeInstanceOf( + PushNotificationError, + ); + }); + + it('rejects if the register-device request rejects', async () => { + mockRegisterDeviceInternal.mockRejectedValue(new Error('service error')); + await expect(registerDevice({ token: pushToken })).rejects.toThrow( + 'service error', + ); + }); +}); diff --git a/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/apis/registerDevice.test.ts b/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/apis/registerDevice.test.ts new file mode 100644 index 00000000000..75ef00520a4 --- /dev/null +++ b/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/apis/registerDevice.test.ts @@ -0,0 +1,14 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PlatformNotSupportedError } from '@aws-amplify/core/internals/utils'; + +import { registerDevice } from '../../../../../src/pushNotifications/providers/customer-profiles/apis/registerDevice'; + +describe('registerDevice (customer-profiles, web stub)', () => { + it('throws PlatformNotSupportedError', () => { + expect(() => registerDevice({ token: 'token' })).toThrow( + new PlatformNotSupportedError(), + ); + }); +}); diff --git a/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/apis/removeDevice.native.test.ts b/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/apis/removeDevice.native.test.ts new file mode 100644 index 00000000000..6b1e9a7f9cb --- /dev/null +++ b/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/apis/removeDevice.native.test.ts @@ -0,0 +1,58 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { assertIsInitialized } from '../../../../../src/pushNotifications/errors/errorHelpers'; +import { removeDevice } from '../../../../../src/pushNotifications/providers/customer-profiles/apis/removeDevice.native'; +import { + getDeviceId, + removeDeviceInternal, +} from '../../../../../src/pushNotifications/providers/customer-profiles/utils'; + +jest.mock('@aws-amplify/react-native', () => ({ + getOperatingSystem: jest.fn(), + loadAsyncStorage: jest.fn(), +})); +jest.mock('../../../../../src/pushNotifications/errors/errorHelpers'); +jest.mock( + '../../../../../src/pushNotifications/providers/customer-profiles/utils', +); + +const DEVICE_ID = 'persisted-device-id'; + +describe('removeDevice (customer-profiles, native)', () => { + const mockAssertIsInitialized = assertIsInitialized as jest.Mock; + const mockGetDeviceId = getDeviceId as jest.Mock; + const mockRemoveDeviceInternal = removeDeviceInternal as jest.Mock; + + beforeEach(() => { + mockGetDeviceId.mockResolvedValue(DEVICE_ID); + mockRemoveDeviceInternal.mockResolvedValue(undefined); + }); + + afterEach(() => { + mockAssertIsInitialized.mockReset(); + mockGetDeviceId.mockReset(); + mockRemoveDeviceInternal.mockReset(); + }); + + it('must be initialized', async () => { + mockAssertIsInitialized.mockImplementation(() => { + throw new Error(); + }); + await expect(removeDevice()).rejects.toThrow(); + expect(mockRemoveDeviceInternal).not.toHaveBeenCalled(); + }); + + it('removes the device using the stable per-install deviceId', async () => { + await removeDevice(); + + expect(mockGetDeviceId).toHaveBeenCalledTimes(1); + expect(mockRemoveDeviceInternal).toHaveBeenCalledTimes(1); + expect(mockRemoveDeviceInternal).toHaveBeenCalledWith(DEVICE_ID); + }); + + it('rejects if the remove-device request rejects', async () => { + mockRemoveDeviceInternal.mockRejectedValue(new Error('service error')); + await expect(removeDevice()).rejects.toThrow('service error'); + }); +}); diff --git a/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/apis/removeDevice.test.ts b/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/apis/removeDevice.test.ts new file mode 100644 index 00000000000..f29d65d7a89 --- /dev/null +++ b/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/apis/removeDevice.test.ts @@ -0,0 +1,12 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PlatformNotSupportedError } from '@aws-amplify/core/internals/utils'; + +import { removeDevice } from '../../../../../src/pushNotifications/providers/customer-profiles/apis/removeDevice'; + +describe('removeDevice (customer-profiles, web stub)', () => { + it('throws PlatformNotSupportedError', () => { + expect(() => removeDevice()).toThrow(new PlatformNotSupportedError()); + }); +}); diff --git a/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/authStateTransitions.native.test.ts b/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/authStateTransitions.native.test.ts new file mode 100644 index 00000000000..7b6b02d8fb7 --- /dev/null +++ b/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/authStateTransitions.native.test.ts @@ -0,0 +1,297 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify, Hub, fetchAuthSession } from '@aws-amplify/core'; + +import { customerProfilesConfig, pushToken } from '../../../testUtils/data'; + +jest.mock('@aws-amplify/core', () => ({ + ...jest.requireActual('@aws-amplify/core'), + fetchAuthSession: jest.fn(), +})); +jest.mock('@aws-amplify/react-native', () => ({ + getOperatingSystem: jest.fn(() => 'ios'), + loadAsyncStorage: jest.fn(() => ({ + getItem: jest.fn().mockResolvedValue('persisted-device-id'), + setItem: jest.fn().mockResolvedValue(undefined), + })), + loadAmplifyPushNotification: jest.fn(() => ({ + addMessageEventListener: jest.fn(() => ({ remove: jest.fn() })), + addTokenEventListener: jest.fn(), + completeNotification: jest.fn(), + getConstants: jest.fn(() => ({ + NativeEvent: { + BACKGROUND_MESSAGE_RECEIVED: 'BackgroundMessageReceived', + FOREGROUND_MESSAGE_RECEIVED: 'ForegroundMessageReceived', + LAUNCH_NOTIFICATION_OPENED: 'LaunchNotificationOpened', + NOTIFICATION_OPENED: 'NotificationOpened', + TOKEN_RECEIVED: 'TokenReceived', + }, + NativeHeadlessTaskKey: 'PushNotificationHeadlessTaskKey', + })), + registerHeadlessTask: jest.fn(), + })), +})); +// Jest does not apply React Native platform resolution, so the initializer's +// `./registerDevice` import would resolve to the web stub. Resolve it to the real +// native implementation — the register/sign path under test is NOT mocked. +jest.mock( + '../../../../src/pushNotifications/providers/customer-profiles/apis/registerDevice', + () => + require('../../../../src/pushNotifications/providers/customer-profiles/apis/registerDevice.native'), +); + +const PROVIDER_PATH = + '../../../../src/pushNotifications/providers/customer-profiles'; +const DEVICE_ID = 'persisted-device-id'; +const GUEST_IDENTITY_ID = 'us-east-1:guest-identity-id'; +const AUTH_IDENTITY_ID = 'us-east-1:auth-identity-id'; + +const credentialsForIdentity = (identityId: string) => ({ + accessKeyId: `ASIA-${identityId}`, + secretAccessKey: `secret-${identityId}`, + sessionToken: `session-${identityId}`, +}); + +const flushMicrotasks = () => + new Promise(resolve => { + setTimeout(resolve, 0); + }); + +/** + * These tests deliberately exercise the REAL credential path — `resolveCredentials`, + * `signedFetch` and the SigV4 `signRequest` implementation are NOT mocked. Only the + * auth-session boundary (`fetchAuthSession`) and the network boundary (`fetch`) are + * stubbed, so each assertion reflects the identity that actually signs the request + * and therefore the `principalId` the backend derives to own / gate the device row. + */ +describe('customer-profiles push device auth-state transitions (native)', () => { + const mockFetchAuthSession = fetchAuthSession as jest.Mock; + const mockFetch = jest.fn(); + + interface LoadedProvider { + initializePushNotifications(): void; + setToken(token: string): void; + registerDevice(input: { token: string }): Promise; + removeDevice(): Promise; + } + + // The initializer, token manager, initialization manager and deviceId resolver + // all hold module state, so every test loads them from one fresh registry (a + // single registry per test keeps `isInitialized` shared across them). + const loadProvider = (): LoadedProvider => { + let loaded!: LoadedProvider; + jest.isolateModules(() => { + const { initializePushNotifications } = require( + `${PROVIDER_PATH}/apis/initializePushNotifications.native`, + ); + const { + setToken, + } = require('../../../../src/pushNotifications/utils/tokenManager'); + const { registerDevice } = require( + `${PROVIDER_PATH}/apis/registerDevice.native`, + ); + const { removeDevice } = require( + `${PROVIDER_PATH}/apis/removeDevice.native`, + ); + loaded = { + initializePushNotifications, + setToken, + registerDevice, + removeDevice, + }; + }); + + return loaded; + }; + + const signInAs = (identityId: string) => { + mockFetchAuthSession.mockResolvedValue({ + identityId, + credentials: credentialsForIdentity(identityId), + }); + }; + + const getAuthListener = () => + (Hub.listen as jest.Mock).mock.calls.find(call => call[0] === 'auth')![1]; + + const signedRequests = () => + mockFetch.mock.calls.map(([url, request]) => ({ + url: url as string, + authorization: (request.headers as Record).authorization, + body: JSON.parse(request.body as string), + })); + + beforeAll(() => { + (global as any).fetch = mockFetch; + }); + + beforeEach(() => { + jest.spyOn(Amplify, 'getConfig').mockReturnValue({ + Notifications: { + PushNotification: { CustomerProfiles: customerProfilesConfig } as any, + }, + }); + jest.spyOn(Hub, 'listen'); + mockFetch.mockResolvedValue({ ok: true, status: 200 }); + signInAs(AUTH_IDENTITY_ID); + }); + + afterEach(() => { + jest.restoreAllMocks(); + mockFetch.mockReset(); + mockFetchAuthSession.mockReset(); + }); + + describe('sign-in re-registration (Hub listener)', () => { + it('re-registers the device, signing with the now-authenticated identity', async () => { + const { initializePushNotifications, setToken } = loadProvider(); + initializePushNotifications(); + setToken(pushToken); + + // A returning user signs in: the push token is unchanged so the native + // token listener never fires, and the identityId flips guest -> + // authenticated. The signedIn handler is what re-homes the device. + signInAs(AUTH_IDENTITY_ID); + getAuthListener()({ payload: { event: 'signedIn' } }); + await flushMicrotasks(); + + expect(Hub.listen).toHaveBeenCalledWith('auth', expect.any(Function)); + const requests = signedRequests(); + expect(requests).toHaveLength(1); + expect(requests[0].url).toBe( + `${customerProfilesConfig.endpoint}/register-device`, + ); + expect(requests[0].body).toStrictEqual({ + device: { + token: pushToken, + deviceId: DEVICE_ID, + platform: expect.any(String), + appVersion: '', + channelType: 'APNS_SANDBOX', + }, + }); + // The request is signed with the AUTHENTICATED identity's access key, so + // the backend derives that principal as the owner of the device row. + expect(requests[0].authorization).toContain('AWS4-HMAC-SHA256'); + expect(requests[0].authorization).toContain( + `Credential=ASIA-${AUTH_IDENTITY_ID}/`, + ); + expect(requests[0].authorization).not.toContain(GUEST_IDENTITY_ID); + }); + + it('does NOT reach the network when no token has been received yet', async () => { + const { initializePushNotifications } = loadProvider(); + initializePushNotifications(); + + getAuthListener()({ payload: { event: 'signedIn' } }); + await flushMicrotasks(); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockFetchAuthSession).not.toHaveBeenCalled(); + }); + + it('does NOT attempt a removal on signedOut (removal after sign-out cannot work)', async () => { + const { initializePushNotifications, setToken } = loadProvider(); + initializePushNotifications(); + setToken(pushToken); + + // After `signOut` the session is a brand-new guest identity — a removal + // signed with it would be a silent no-op against the principal-gated + // backend, so no request must be attempted at all. + signInAs(GUEST_IDENTITY_ID); + getAuthListener()({ payload: { event: 'signedOut' } }); + await flushMicrotasks(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe('credential path: the identity that signs each call', () => { + const initializeProvider = (): LoadedProvider => { + const provider = loadProvider(); + provider.initializePushNotifications(); + provider.setToken(pushToken); + + return provider; + }; + + it('signs register-device with the identity fetchAuthSession returns', async () => { + const { registerDevice } = initializeProvider(); + signInAs(AUTH_IDENTITY_ID); + + await registerDevice({ token: pushToken }); + + const [request] = signedRequests(); + expect(request.url).toBe( + `${customerProfilesConfig.endpoint}/register-device`, + ); + expect(request.authorization).toContain( + `Credential=ASIA-${AUTH_IDENTITY_ID}/`, + ); + expect(request.authorization).toContain( + '/us-east-1/execute-api/aws4_request', + ); + // The client never sends an identity — the backend derives principalId + // from the signer. + expect(JSON.stringify(request.body)).not.toContain(AUTH_IDENTITY_ID); + }); + + it('signs remove-device with the CURRENT identity, so a guest session cannot remove the authenticated row', async () => { + const { registerDevice, removeDevice } = initializeProvider(); + + signInAs(AUTH_IDENTITY_ID); + await registerDevice({ token: pushToken }); + + // Sign-out replaces the session with a fresh guest identity. + signInAs(GUEST_IDENTITY_ID); + await removeDevice(); + + const [registerRequest, removeRequest] = signedRequests(); + expect(registerRequest.authorization).toContain( + `Credential=ASIA-${AUTH_IDENTITY_ID}/`, + ); + expect(removeRequest.url).toBe( + `${customerProfilesConfig.endpoint}/remove-device`, + ); + expect(removeRequest.body).toStrictEqual({ deviceId: DEVICE_ID }); + // Same deviceId, DIFFERENT signing principal: the backend gate means this + // removal does not delete the row registered above. This is why apps must + // call `removeDevice()` before `signOut()`. + expect(removeRequest.authorization).toContain( + `Credential=ASIA-${GUEST_IDENTITY_ID}/`, + ); + expect(removeRequest.authorization).not.toContain(AUTH_IDENTITY_ID); + }); + + it('signs remove-device with the authenticated identity when called BEFORE sign-out', async () => { + const { registerDevice, removeDevice } = initializeProvider(); + signInAs(AUTH_IDENTITY_ID); + + await registerDevice({ token: pushToken }); + await removeDevice(); + + const [registerRequest, removeRequest] = signedRequests(); + expect(registerRequest.body.device.deviceId).toBe(DEVICE_ID); + expect(removeRequest.body.deviceId).toBe(DEVICE_ID); + // Both calls sign with the SAME authenticated principal, so the backend + // gate permits the removal. + expect(removeRequest.authorization).toContain( + `Credential=ASIA-${AUTH_IDENTITY_ID}/`, + ); + }); + + it('rejects when the session has no credentials to sign with', async () => { + const { registerDevice } = initializeProvider(); + mockFetchAuthSession.mockResolvedValue({ + identityId: undefined, + credentials: undefined, + }); + + await expect(registerDevice({ token: pushToken })).rejects.toThrow( + 'Credentials should not be empty.', + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/utils/getDeviceId.test.ts b/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/utils/getDeviceId.test.ts new file mode 100644 index 00000000000..bd5dfa7cca4 --- /dev/null +++ b/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/utils/getDeviceId.test.ts @@ -0,0 +1,159 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { amplifyUuid } from '@aws-amplify/core/internals/utils'; +import { loadAsyncStorage } from '@aws-amplify/react-native'; + +jest.mock('@aws-amplify/react-native', () => ({ + loadAsyncStorage: jest.fn(), +})); +jest.mock('@aws-amplify/core/internals/utils', () => ({ + ...jest.requireActual('@aws-amplify/core/internals/utils'), + amplifyUuid: jest.fn(), +})); + +const DEVICE_ID_STORAGE_KEY = + '@aws-amplify/notifications/customer-profiles/deviceId'; + +describe('getDeviceId (customer-profiles)', () => { + const mockLoadAsyncStorage = loadAsyncStorage as jest.Mock; + const mockAmplifyUuid = amplifyUuid as jest.Mock; + const mockGetItem = jest.fn(); + const mockSetItem = jest.fn(); + + // The module memoizes its resolution at module scope, so every test needs a + // fresh module registry to exercise the first-call path. + const loadGetDeviceId = () => { + let getDeviceId!: () => Promise; + jest.isolateModules(() => { + ({ + getDeviceId, + } = require('../../../../../src/pushNotifications/providers/customer-profiles/utils/getDeviceId')); + }); + + return getDeviceId; + }; + + beforeEach(() => { + mockLoadAsyncStorage.mockReturnValue({ + getItem: mockGetItem, + setItem: mockSetItem, + }); + mockGetItem.mockResolvedValue(null); + mockSetItem.mockResolvedValue(undefined); + mockAmplifyUuid.mockReturnValue('generated-device-id'); + }); + + afterEach(() => { + mockLoadAsyncStorage.mockReset(); + mockAmplifyUuid.mockReset(); + mockGetItem.mockReset(); + mockSetItem.mockReset(); + }); + + it('returns the persisted device id without writing', async () => { + mockGetItem.mockResolvedValue('persisted-device-id'); + const getDeviceId = loadGetDeviceId(); + + await expect(getDeviceId()).resolves.toBe('persisted-device-id'); + expect(mockGetItem).toHaveBeenCalledWith(DEVICE_ID_STORAGE_KEY); + expect(mockSetItem).not.toHaveBeenCalled(); + }); + + it('generates and persists a device id when none is stored', async () => { + const getDeviceId = loadGetDeviceId(); + + await expect(getDeviceId()).resolves.toBe('generated-device-id'); + expect(mockSetItem).toHaveBeenCalledWith( + DEVICE_ID_STORAGE_KEY, + 'generated-device-id', + ); + }); + + it('reuses the resolved id on subsequent calls without re-reading storage', async () => { + const getDeviceId = loadGetDeviceId(); + + const first = await getDeviceId(); + const second = await getDeviceId(); + + expect(second).toBe(first); + expect(mockGetItem).toHaveBeenCalledTimes(1); + expect(mockSetItem).toHaveBeenCalledTimes(1); + }); + + it('dedupes CONCURRENT first-calls into a single resolution (one uuid, one write)', async () => { + // Storage is async, so without an in-flight promise each concurrent caller + // would read `null`, mint its own uuid and write it — producing divergent + // device ids for the same install. + let releaseGetItem!: (value: string | null) => void; + mockGetItem.mockImplementation( + () => + new Promise(resolve => { + releaseGetItem = resolve; + }), + ); + mockAmplifyUuid + .mockReturnValueOnce('uuid-1') + .mockReturnValueOnce('uuid-2') + .mockReturnValueOnce('uuid-3') + .mockReturnValueOnce('uuid-4') + .mockReturnValueOnce('uuid-5'); + const getDeviceId = loadGetDeviceId(); + + const pending = [ + getDeviceId(), + getDeviceId(), + getDeviceId(), + getDeviceId(), + getDeviceId(), + ]; + releaseGetItem(null); + const results = await Promise.all(pending); + + expect(new Set(results).size).toBe(1); + expect(results).toEqual(['uuid-1', 'uuid-1', 'uuid-1', 'uuid-1', 'uuid-1']); + expect(mockGetItem).toHaveBeenCalledTimes(1); + expect(mockSetItem).toHaveBeenCalledTimes(1); + expect(mockAmplifyUuid).toHaveBeenCalledTimes(1); + }); + + it('shares one resolution across concurrent calls when a value is already persisted', async () => { + mockGetItem.mockResolvedValue('persisted-device-id'); + const getDeviceId = loadGetDeviceId(); + + const results = await Promise.all([ + getDeviceId(), + getDeviceId(), + getDeviceId(), + ]); + + expect(results).toEqual([ + 'persisted-device-id', + 'persisted-device-id', + 'persisted-device-id', + ]); + expect(mockGetItem).toHaveBeenCalledTimes(1); + expect(mockSetItem).not.toHaveBeenCalled(); + }); + + it('does NOT cache a rejected resolution — a later call retries', async () => { + mockGetItem.mockRejectedValueOnce(new Error('storage unavailable')); + mockGetItem.mockResolvedValueOnce('persisted-device-id'); + const getDeviceId = loadGetDeviceId(); + + await expect(getDeviceId()).rejects.toThrow('storage unavailable'); + await expect(getDeviceId()).resolves.toBe('persisted-device-id'); + expect(mockGetItem).toHaveBeenCalledTimes(2); + }); + + it('rejects every concurrent caller when the shared resolution fails, then recovers', async () => { + mockGetItem.mockRejectedValueOnce(new Error('storage unavailable')); + mockGetItem.mockResolvedValueOnce('persisted-device-id'); + const getDeviceId = loadGetDeviceId(); + + const results = await Promise.allSettled([getDeviceId(), getDeviceId()]); + expect(results.every(({ status }) => status === 'rejected')).toBe(true); + + await expect(getDeviceId()).resolves.toBe('persisted-device-id'); + }); +}); diff --git a/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/utils/identifyUserInternal.test.ts b/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/utils/identifyUserInternal.test.ts new file mode 100644 index 00000000000..d0a7f95b894 --- /dev/null +++ b/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/utils/identifyUserInternal.test.ts @@ -0,0 +1,104 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PushNotificationAction } from '@aws-amplify/core/internals/utils'; + +import { + DeviceRegistration, + identifyUserInternal, + registerDeviceInternal, + removeDeviceInternal, +} from '../../../../../src/pushNotifications/providers/customer-profiles/utils/identifyUserInternal'; +import { signedFetch } from '../../../../../src/pushNotifications/providers/customer-profiles/utils/signedFetch'; +import { + IDENTIFY_USER_PATH, + REGISTER_DEVICE_PATH, + REMOVE_DEVICE_PATH, +} from '../../../../../src/pushNotifications/providers/customer-profiles/utils/resolveConfig'; +import { channelType } from '../../../../testUtils/data'; +import { PushNotificationValidationErrorCode } from '../../../../../src/pushNotifications/errors'; + +jest.mock( + '../../../../../src/pushNotifications/providers/customer-profiles/utils/signedFetch', +); + +describe('customer-profiles transport callers', () => { + const mockSignedFetch = signedFetch as jest.Mock; + + beforeEach(() => { + mockSignedFetch.mockResolvedValue(undefined); + }); + + afterEach(() => { + mockSignedFetch.mockReset(); + }); + + describe('identifyUserInternal', () => { + it('POSTs the userProfile to the identify-user route (no userId)', async () => { + const userProfile = { email: 'user@example.com', name: 'Jane' }; + await identifyUserInternal({ userProfile }); + + expect(mockSignedFetch).toHaveBeenCalledTimes(1); + expect(mockSignedFetch).toHaveBeenCalledWith( + IDENTIFY_USER_PATH, + { userProfile }, + PushNotificationAction.IdentifyUser, + ); + const [, body] = mockSignedFetch.mock.calls[0]; + expect(body).not.toHaveProperty('userId'); + }); + + it('defaults userProfile to an empty object when omitted', async () => { + await identifyUserInternal({}); + expect(mockSignedFetch).toHaveBeenCalledWith( + IDENTIFY_USER_PATH, + { userProfile: {} }, + PushNotificationAction.IdentifyUser, + ); + }); + + it('validates the userProfile before calling signedFetch (invalid profile short-circuits the request)', async () => { + await expect( + identifyUserInternal({ + userProfile: { customAttributes: { principalId: 'x' } }, + }), + ).rejects.toMatchObject({ + name: PushNotificationValidationErrorCode.InvalidUserProfile, + }); + expect(mockSignedFetch).not.toHaveBeenCalled(); + }); + }); + + describe('registerDeviceInternal', () => { + it('POSTs the device object (nested under device) to the register-device route', async () => { + const device: DeviceRegistration = { + token: 'device-token', + deviceId: 'device-id', + platform: 'ios', + appVersion: '', + channelType, + }; + await registerDeviceInternal(device); + + expect(mockSignedFetch).toHaveBeenCalledTimes(1); + expect(mockSignedFetch).toHaveBeenCalledWith( + REGISTER_DEVICE_PATH, + { device }, + PushNotificationAction.RegisterDevice, + ); + }); + }); + + describe('removeDeviceInternal', () => { + it('POSTs the deviceId to the remove-device route', async () => { + await removeDeviceInternal('device-id'); + + expect(mockSignedFetch).toHaveBeenCalledTimes(1); + expect(mockSignedFetch).toHaveBeenCalledWith( + REMOVE_DEVICE_PATH, + { deviceId: 'device-id' }, + PushNotificationAction.RemoveDevice, + ); + }); + }); +}); diff --git a/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/utils/resolveConfig.test.ts b/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/utils/resolveConfig.test.ts new file mode 100644 index 00000000000..af957e86350 --- /dev/null +++ b/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/utils/resolveConfig.test.ts @@ -0,0 +1,189 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; + +import { resolveConfig } from '../../../../../src/pushNotifications/providers/customer-profiles/utils/resolveConfig'; +import { + PushNotificationError, + PushNotificationValidationErrorCode, +} from '../../../../../src/pushNotifications/errors'; +import { customerProfilesConfig } from '../../../../testUtils/data'; + +describe('resolveConfig (customer-profiles)', () => { + const getConfigSpy = jest.spyOn(Amplify, 'getConfig'); + + const mockCustomerProfilesConfig = (config: unknown) => { + getConfigSpy.mockReturnValue({ + Notifications: { + PushNotification: { CustomerProfiles: config as any }, + }, + }); + }; + + const expectToThrowWithCode = (code: PushNotificationValidationErrorCode) => { + let error: unknown; + try { + resolveConfig(); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(PushNotificationError); + expect((error as PushNotificationError).name).toBe(code); + }; + + afterEach(() => { + getConfigSpy.mockReset(); + }); + + it('returns the Customer Profiles endpoint and region for an https endpoint', () => { + mockCustomerProfilesConfig(customerProfilesConfig); + expect(resolveConfig()).toStrictEqual(customerProfilesConfig); + }); + + it('throws NoEndpoint if endpoint is missing', () => { + mockCustomerProfilesConfig({ + ...customerProfilesConfig, + endpoint: undefined, + }); + expectToThrowWithCode(PushNotificationValidationErrorCode.NoEndpoint); + }); + + it('throws NoRegion if region is missing', () => { + mockCustomerProfilesConfig({ + ...customerProfilesConfig, + region: undefined, + }); + expectToThrowWithCode(PushNotificationValidationErrorCode.NoRegion); + }); + + it('throws NoEndpoint if the Customer Profiles config is absent', () => { + getConfigSpy.mockReturnValue({ + Notifications: { PushNotification: {} as any }, + }); + expectToThrowWithCode(PushNotificationValidationErrorCode.NoEndpoint); + }); + + it('throws InvalidEndpoint for an http:// (non-https) endpoint', () => { + mockCustomerProfilesConfig({ + ...customerProfilesConfig, + endpoint: 'http://abcd1234.execute-api.us-east-1.amazonaws.com/prod', + }); + expectToThrowWithCode(PushNotificationValidationErrorCode.InvalidEndpoint); + }); + + it('throws InvalidEndpoint for a non-https scheme (ftp://)', () => { + mockCustomerProfilesConfig({ + ...customerProfilesConfig, + endpoint: 'ftp://x', + }); + expectToThrowWithCode(PushNotificationValidationErrorCode.InvalidEndpoint); + }); + + it('throws InvalidEndpoint for a malformed (non-URL) endpoint', () => { + mockCustomerProfilesConfig({ + ...customerProfilesConfig, + endpoint: 'not a url', + }); + expectToThrowWithCode(PushNotificationValidationErrorCode.InvalidEndpoint); + }); + + it('accepts an execute-api host without a stage path', () => { + mockCustomerProfilesConfig({ + ...customerProfilesConfig, + endpoint: 'https://abcd1234.execute-api.us-east-1.amazonaws.com', + }); + expect(resolveConfig().endpoint).toBe( + 'https://abcd1234.execute-api.us-east-1.amazonaws.com', + ); + }); + + describe('endpoint host allowlist', () => { + // SigV4 execute-api credentials travel with every request to this + // endpoint, so a non-API-Gateway host MUST be rejected outright. + it.each([ + ['an unrelated host', 'https://evil.com'], + ['an unrelated host with a plausible path', 'https://evil.com/prod'], + [ + 'a host that only looks like execute-api', + 'https://attacker.execute-api-fake.com', + ], + [ + 'an execute-api lookalike on another domain', + 'https://abcd1234.execute-api.us-east-1.amazonaws.com.evil.com', + ], + [ + 'an execute-api host in a different region', + 'https://abcd1234.execute-api.eu-west-1.amazonaws.com', + ], + [ + 'an execute-api host with an empty api id', + 'https://execute-api.us-east-1.amazonaws.com', + ], + [ + 'credentials embedded to spoof the host', + 'https://abcd1234.execute-api.us-east-1.amazonaws.com@evil.com', + ], + ])('throws InvalidEndpoint for %s', (_, endpoint) => { + mockCustomerProfilesConfig({ ...customerProfilesConfig, endpoint }); + expectToThrowWithCode( + PushNotificationValidationErrorCode.InvalidEndpoint, + ); + }); + + it('accepts the execute-api host of the configured region', () => { + mockCustomerProfilesConfig({ + endpoint: 'https://xyz789.execute-api.eu-west-2.amazonaws.com/prod', + region: 'eu-west-2', + }); + expect(resolveConfig()).toStrictEqual({ + endpoint: 'https://xyz789.execute-api.eu-west-2.amazonaws.com/prod', + region: 'eu-west-2', + }); + }); + }); + + describe('trailing slash normalization', () => { + it('strips a trailing slash from a host-only endpoint', () => { + mockCustomerProfilesConfig({ + ...customerProfilesConfig, + endpoint: 'https://abcd1234.execute-api.us-east-1.amazonaws.com/', + }); + expect(resolveConfig().endpoint).toBe( + 'https://abcd1234.execute-api.us-east-1.amazonaws.com', + ); + }); + + it('strips trailing slashes while PRESERVING a stage path', () => { + mockCustomerProfilesConfig({ + ...customerProfilesConfig, + endpoint: 'https://abcd1234.execute-api.us-east-1.amazonaws.com/prod/', + }); + expect(resolveConfig().endpoint).toBe( + 'https://abcd1234.execute-api.us-east-1.amazonaws.com/prod', + ); + }); + + it('strips repeated trailing slashes', () => { + mockCustomerProfilesConfig({ + ...customerProfilesConfig, + endpoint: + 'https://abcd1234.execute-api.us-east-1.amazonaws.com/prod///', + }); + expect(resolveConfig().endpoint).toBe( + 'https://abcd1234.execute-api.us-east-1.amazonaws.com/prod', + ); + }); + + it('produces an endpoint that composes with a route path without a double slash', () => { + mockCustomerProfilesConfig({ + ...customerProfilesConfig, + endpoint: 'https://abcd1234.execute-api.us-east-1.amazonaws.com/', + }); + const { endpoint } = resolveConfig(); + expect(new URL(`${endpoint}/identify-user`).toString()).toBe( + 'https://abcd1234.execute-api.us-east-1.amazonaws.com/identify-user', + ); + }); + }); +}); diff --git a/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/utils/resolveCredentials.test.ts b/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/utils/resolveCredentials.test.ts new file mode 100644 index 00000000000..2cb16a93258 --- /dev/null +++ b/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/utils/resolveCredentials.test.ts @@ -0,0 +1,50 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { fetchAuthSession } from '@aws-amplify/core'; + +import { PushNotificationError } from '../../../../../src/pushNotifications/errors'; +import { resolveCredentials } from '../../../../../src/pushNotifications/providers/customer-profiles/utils/resolveCredentials'; + +jest.mock('@aws-amplify/core'); + +describe('Push Notifications Customer Profiles Provider Util: resolveCredentials', () => { + const credentials = { + accessKeyId: 'access-key-id', + secretAccessKey: 'secret-access-key', + sessionToken: 'session-token', + }; + const mockFetchAuthSession = fetchAuthSession as jest.Mock; + + beforeEach(() => { + mockFetchAuthSession.mockReset(); + }); + + it('resolves Identity Pool credentials for an authenticated session', async () => { + mockFetchAuthSession.mockResolvedValue({ + tokens: { accessToken: { toString: () => 'ignored' } }, + credentials, + identityId: 'us-east-1:auth-identity-id', + }); + expect(await resolveCredentials()).toStrictEqual({ credentials }); + }); + + it('resolves Identity Pool credentials for a guest session', async () => { + mockFetchAuthSession.mockResolvedValue({ + tokens: undefined, + credentials, + identityId: 'us-east-1:guest-identity-id', + }); + expect(await resolveCredentials()).toStrictEqual({ credentials }); + }); + + it('throws if no credentials can be resolved', async () => { + mockFetchAuthSession.mockResolvedValue({ + tokens: undefined, + credentials: undefined, + }); + await expect(resolveCredentials()).rejects.toBeInstanceOf( + PushNotificationError, + ); + }); +}); diff --git a/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/utils/signedFetch.test.ts b/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/utils/signedFetch.test.ts new file mode 100644 index 00000000000..05374041de4 --- /dev/null +++ b/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/utils/signedFetch.test.ts @@ -0,0 +1,169 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { signRequest } from '@aws-amplify/core/internals/aws-client-utils'; +import { PushNotificationAction } from '@aws-amplify/core/internals/utils'; + +import { PushNotificationError } from '../../../../../src/pushNotifications/errors'; +import { signedFetch } from '../../../../../src/pushNotifications/providers/customer-profiles/utils/signedFetch'; +import { resolveConfig } from '../../../../../src/pushNotifications/providers/customer-profiles/utils/resolveConfig'; +import { resolveCredentials } from '../../../../../src/pushNotifications/providers/customer-profiles/utils/resolveCredentials'; +import { customerProfilesConfig } from '../../../../testUtils/data'; + +jest.mock('@aws-amplify/core/internals/aws-client-utils'); +jest.mock( + '../../../../../src/pushNotifications/providers/customer-profiles/utils/resolveConfig', +); +jest.mock( + '../../../../../src/pushNotifications/providers/customer-profiles/utils/resolveCredentials', +); + +describe('signedFetch (customer-profiles transport)', () => { + const credentials = { + accessKeyId: 'access-key-id', + secretAccessKey: 'secret-access-key', + sessionToken: 'session-token', + }; + const signedHeaders = { + authorization: 'AWS4-HMAC-SHA256 Credential=...', + 'x-amz-date': '20260721T000000Z', + 'x-amz-security-token': credentials.sessionToken, + host: 'abcd1234.execute-api.us-east-1.amazonaws.com', + 'content-type': 'application/json', + }; + const mockSignRequest = signRequest as jest.Mock; + const mockResolveConfig = resolveConfig as jest.Mock; + const mockResolveCredentials = resolveCredentials as jest.Mock; + const mockFetch = jest.fn(); + + beforeAll(() => { + (global as any).fetch = mockFetch; + }); + + beforeEach(() => { + mockResolveConfig.mockReturnValue(customerProfilesConfig); + mockResolveCredentials.mockResolvedValue({ credentials }); + // signRequest returns an HttpRequest whose `url` is authoritative. + mockSignRequest.mockImplementation(request => ({ + ...request, + headers: signedHeaders, + })); + mockFetch.mockResolvedValue({ ok: true, status: 200 }); + }); + + afterEach(() => { + mockSignRequest.mockReset(); + mockResolveConfig.mockReset(); + mockResolveCredentials.mockReset(); + mockFetch.mockReset(); + }); + + it('SigV4-signs an execute-api POST and sends the signed request', async () => { + const body = { userProfile: { email: 'a@b.com' } }; + await signedFetch( + '/identify-user', + body, + PushNotificationAction.IdentifyUser, + ); + + expect(mockSignRequest).toHaveBeenCalledTimes(1); + const [request, signOptions] = mockSignRequest.mock.calls[0]; + expect(request.method).toBe('POST'); + expect(request.url.toString()).toBe( + `${customerProfilesConfig.endpoint}/identify-user`, + ); + expect(request.body).toBe(JSON.stringify(body)); + expect(signOptions).toMatchObject({ + credentials, + signingRegion: customerProfilesConfig.region, + signingService: 'execute-api', + }); + + // The Amplify telemetry user-agent is attached BEFORE signing so the + // signature covers it (well-formed: contains the aws-amplify version and + // the push-notification category tag). + expect(request.headers['x-amz-user-agent']).toBeDefined(); + expect(request.headers['x-amz-user-agent']).toContain('aws-amplify/'); + expect(request.headers['x-amz-user-agent']).toContain('pushnotification'); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, req] = mockFetch.mock.calls[0]; + expect(url).toBe(`${customerProfilesConfig.endpoint}/identify-user`); + expect(req.method).toBe('POST'); + expect(req.headers.authorization).toContain('AWS4-HMAC-SHA256'); + expect(req.body).toBe(JSON.stringify(body)); + }); + + it('does NOT send a Bearer/JWT Authorization into signing; fetch uses exactly the signer-returned headers', async () => { + await signedFetch( + '/register-device', + { device: {} }, + PushNotificationAction.RegisterDevice, + ); + expect(mockSignRequest.mock.calls[0][1]).toMatchObject({ + signingService: 'execute-api', + }); + // The request handed to the signer carries NO Authorization/authorization + // header (SigV4 only — never a Cognito Bearer JWT). + const signerRequestHeaders = mockSignRequest.mock.calls[0][0].headers; + expect(signerRequestHeaders).not.toHaveProperty('Authorization'); + expect(signerRequestHeaders).not.toHaveProperty('authorization'); + // fetch is sent with EXACTLY the headers signRequest returned (identity). + const [, req] = mockFetch.mock.calls[0]; + expect(req.headers).toBe(signedHeaders); + }); + + it('encodes the PER-ROUTE PushNotificationAction in the x-amz-user-agent', async () => { + const cases: [string, PushNotificationAction][] = [ + ['/identify-user', PushNotificationAction.IdentifyUser], + ['/register-device', PushNotificationAction.RegisterDevice], + ['/remove-device', PushNotificationAction.RemoveDevice], + ]; + for (const [path, action] of cases) { + mockSignRequest.mockClear(); + await signedFetch(path, {}, action); + const ua = mockSignRequest.mock.calls[0][0].headers['x-amz-user-agent']; + expect(ua).toContain('aws-amplify/'); + // category/action pair renders as `pushnotification/`. + expect(ua).toContain(`pushnotification/${action}`); + } + }); + + it('fetches the SIGNED request url, not the pre-signing url', async () => { + // The signer may rewrite the url (e.g. canonicalized/encoded query), and + // the signature covers the url it returns — so fetch MUST use `signed.url`. + const signedUrl = new URL( + `${customerProfilesConfig.endpoint}/identify-user?X-Amz-Signature=abc`, + ); + mockSignRequest.mockReturnValue({ + url: signedUrl, + headers: signedHeaders, + }); + + await signedFetch( + '/identify-user', + {}, + PushNotificationAction.IdentifyUser, + ); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0][0]).toBe(signedUrl.toString()); + expect(mockFetch.mock.calls[0][0]).not.toBe( + `${customerProfilesConfig.endpoint}/identify-user`, + ); + }); + + it('throws a network error when fetch rejects', async () => { + mockFetch.mockRejectedValue(new Error('offline')); + await expect( + signedFetch('/identify-user', {}, PushNotificationAction.IdentifyUser), + ).rejects.toBeInstanceOf(PushNotificationError); + }); + + it('throws when the endpoint responds with a non-2xx status', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 403 }); + await expect( + signedFetch('/remove-device', {}, PushNotificationAction.RemoveDevice), + ).rejects.toBeInstanceOf(PushNotificationError); + }); +}); diff --git a/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/utils/validateUserProfile.test.ts b/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/utils/validateUserProfile.test.ts new file mode 100644 index 00000000000..1382e834350 --- /dev/null +++ b/packages/notifications/__tests__/pushNotifications/providers/customer-profiles/utils/validateUserProfile.test.ts @@ -0,0 +1,146 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { validateUserProfile } from '../../../../../src/pushNotifications/providers/customer-profiles/utils/validateUserProfile'; +import { + PushNotificationError, + PushNotificationValidationErrorCode, +} from '../../../../../src/pushNotifications/errors'; +import { UserProfile } from '../../../../../src/pushNotifications/providers/customer-profiles/types'; + +const MAX = 255; +const atMax = 'a'.repeat(MAX); +const overMax = 'a'.repeat(MAX + 1); + +const expectInvalid = (userProfile: UserProfile) => { + let error: unknown; + try { + validateUserProfile(userProfile); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(PushNotificationError); + expect((error as PushNotificationError).name).toBe( + PushNotificationValidationErrorCode.InvalidUserProfile, + ); +}; + +describe('validateUserProfile (customer-profiles)', () => { + describe('invalid profiles', () => { + it('throws for an over-length customAttributes value (256)', () => { + expectInvalid({ customAttributes: { key: overMax } }); + }); + + it('throws for an over-length customAttributes key (256)', () => { + expectInvalid({ customAttributes: { [overMax]: 'value' } }); + }); + + it("throws for the reserved 'principalId' key", () => { + expectInvalid({ customAttributes: { principalId: 'value' } }); + }); + + it('throws for a non-string customAttributes value', () => { + expectInvalid({ + customAttributes: { key: 123 as unknown as string }, + }); + }); + + it('throws when customAttributes is not a plain object (array)', () => { + expectInvalid({ + customAttributes: ['a'] as unknown as Record, + }); + }); + + it('throws when customAttributes is null', () => { + expectInvalid({ + customAttributes: null as unknown as Record, + }); + }); + + it('throws for an over-length email', () => { + expectInvalid({ email: overMax }); + }); + + it('throws for an over-length name', () => { + expectInvalid({ name: overMax }); + }); + + it('throws for an over-length phone', () => { + expectInvalid({ phone: overMax }); + }); + + it('throws for an over-length location.city', () => { + expectInvalid({ location: { city: overMax } }); + }); + + it('throws for an over-length location.country', () => { + expectInvalid({ location: { country: overMax } }); + }); + + it('throws for an over-length location.postalCode', () => { + expectInvalid({ location: { postalCode: overMax } }); + }); + + it('throws for an over-length location.region', () => { + expectInvalid({ location: { region: overMax } }); + }); + + it('throws when location is null', () => { + expectInvalid({ + location: null as unknown as UserProfile['location'], + }); + }); + + it('throws when location is an array', () => { + expectInvalid({ + location: [] as unknown as UserProfile['location'], + }); + }); + + it('throws for a non-string top-level field', () => { + expectInvalid({ name: 42 as unknown as string }); + }); + }); + + describe('valid profiles', () => { + it('passes for an undefined userProfile', () => { + expect(() => { + validateUserProfile(undefined); + }).not.toThrow(); + }); + + it('passes for an empty userProfile (no customAttributes)', () => { + expect(() => { + validateUserProfile({}); + }).not.toThrow(); + }); + + it('passes for an empty-string customAttributes value', () => { + expect(() => { + validateUserProfile({ customAttributes: { key: '' } }); + }).not.toThrow(); + }); + + it('passes at the 255-char boundary for keys and values', () => { + expect(() => { + validateUserProfile({ customAttributes: { [atMax]: atMax } }); + }).not.toThrow(); + }); + + it('passes for email/name/phone/location at 255 chars', () => { + expect(() => { + validateUserProfile({ + email: atMax, + name: atMax, + phone: atMax, + location: { + city: atMax, + country: atMax, + postalCode: atMax, + region: atMax, + }, + }); + }).not.toThrow(); + }); + }); +}); diff --git a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/utils/createMessageEventRecorder.test.ts b/packages/notifications/__tests__/pushNotifications/providers/pinpoint/utils/createMessageEventRecorder.test.ts index 98c8cb3a0be..98949600e12 100644 --- a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/utils/createMessageEventRecorder.test.ts +++ b/packages/notifications/__tests__/pushNotifications/providers/pinpoint/utils/createMessageEventRecorder.test.ts @@ -5,7 +5,7 @@ import { record } from '@aws-amplify/core/internals/providers/pinpoint'; import { resolveCredentials } from '../../../../../src/pushNotifications/utils'; import { getAnalyticsEvent } from '../../../../../src/pushNotifications/providers/pinpoint/utils/getAnalyticsEvent'; -import { getChannelType } from '../../../../../src/pushNotifications/providers/pinpoint/utils/getChannelType'; +import { getChannelType } from '../../../../../src/pushNotifications/providers/shared/utils/getChannelType'; import { resolveConfig } from '../../../../../src/pushNotifications/providers/pinpoint/utils/resolveConfig'; import { createMessageEventRecorder } from '../../../../../src/pushNotifications/providers/pinpoint/utils/createMessageEventRecorder'; import { @@ -24,7 +24,7 @@ jest.mock( '../../../../../src/pushNotifications/providers/pinpoint/utils/getAnalyticsEvent', ); jest.mock( - '../../../../../src/pushNotifications/providers/pinpoint/utils/getChannelType', + '../../../../../src/pushNotifications/providers/shared/utils/getChannelType', ); jest.mock( '../../../../../src/pushNotifications/providers/pinpoint/utils/resolveConfig', diff --git a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/getBadgeCount.native.test.ts b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/getBadgeCount.native.test.ts similarity index 91% rename from packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/getBadgeCount.native.test.ts rename to packages/notifications/__tests__/pushNotifications/providers/shared/apis/getBadgeCount.native.test.ts index 91f302d48c5..1e72af8be8f 100644 --- a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/getBadgeCount.native.test.ts +++ b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/getBadgeCount.native.test.ts @@ -21,7 +21,7 @@ describe('getBadgeCount (native)', () => { beforeAll(() => { ({ getBadgeCount, - } = require('../../../../../src/pushNotifications/providers/pinpoint/apis/getBadgeCount.native')); + } = require('../../../../../src/pushNotifications/providers/shared/apis/getBadgeCount.native')); }); afterEach(() => { diff --git a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/getBadgeCount.test.ts b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/getBadgeCount.test.ts similarity index 90% rename from packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/getBadgeCount.test.ts rename to packages/notifications/__tests__/pushNotifications/providers/shared/apis/getBadgeCount.test.ts index 558ffaa085a..5579ebfa7cf 100644 --- a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/getBadgeCount.test.ts +++ b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/getBadgeCount.test.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { getBadgeCount } from '../../../../../src/pushNotifications/providers/pinpoint/apis/getBadgeCount'; +import { getBadgeCount } from '../../../../../src/pushNotifications/providers/shared/apis/getBadgeCount'; import { expectNotSupportedAsync } from '../../../../testUtils/expectNotSupported'; describe('getBadgeCount', () => { diff --git a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/getLaunchNotification.native.test.ts b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/getLaunchNotification.native.test.ts similarity index 92% rename from packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/getLaunchNotification.native.test.ts rename to packages/notifications/__tests__/pushNotifications/providers/shared/apis/getLaunchNotification.native.test.ts index 227b5142ba7..e127354f271 100644 --- a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/getLaunchNotification.native.test.ts +++ b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/getLaunchNotification.native.test.ts @@ -22,7 +22,7 @@ describe('getLaunchNotification (native)', () => { beforeAll(() => { ({ getLaunchNotification, - } = require('../../../../../src/pushNotifications/providers/pinpoint/apis/getLaunchNotification.native')); + } = require('../../../../../src/pushNotifications/providers/shared/apis/getLaunchNotification.native')); }); afterEach(() => { diff --git a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/getLaunchNotification.test.ts b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/getLaunchNotification.test.ts similarity index 87% rename from packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/getLaunchNotification.test.ts rename to packages/notifications/__tests__/pushNotifications/providers/shared/apis/getLaunchNotification.test.ts index a2c4838703b..4fe25cfd860 100644 --- a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/getLaunchNotification.test.ts +++ b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/getLaunchNotification.test.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { getLaunchNotification } from '../../../../../src/pushNotifications/providers/pinpoint/apis/getLaunchNotification'; +import { getLaunchNotification } from '../../../../../src/pushNotifications/providers/shared/apis/getLaunchNotification'; import { expectNotSupportedAsync } from '../../../../testUtils/expectNotSupported'; describe('getLaunchNotification', () => { diff --git a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/getPermissionStatus.native.test.ts b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/getPermissionStatus.native.test.ts similarity index 91% rename from packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/getPermissionStatus.native.test.ts rename to packages/notifications/__tests__/pushNotifications/providers/shared/apis/getPermissionStatus.native.test.ts index cfa3bdb0b40..df82e76c286 100644 --- a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/getPermissionStatus.native.test.ts +++ b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/getPermissionStatus.native.test.ts @@ -21,7 +21,7 @@ describe('getPermissionStatus (native)', () => { beforeAll(() => { ({ getPermissionStatus, - } = require('../../../../../src/pushNotifications/providers/pinpoint/apis/getPermissionStatus.native')); + } = require('../../../../../src/pushNotifications/providers/shared/apis/getPermissionStatus.native')); }); afterEach(() => { diff --git a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/getPermissionStatus.test.ts b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/getPermissionStatus.test.ts similarity index 88% rename from packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/getPermissionStatus.test.ts rename to packages/notifications/__tests__/pushNotifications/providers/shared/apis/getPermissionStatus.test.ts index da45ed60c2e..b22dec64dbe 100644 --- a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/getPermissionStatus.test.ts +++ b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/getPermissionStatus.test.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { getPermissionStatus } from '../../../../../src/pushNotifications/providers/pinpoint/apis/getPermissionStatus'; +import { getPermissionStatus } from '../../../../../src/pushNotifications/providers/shared/apis/getPermissionStatus'; import { expectNotSupportedAsync } from '../../../../testUtils/expectNotSupported'; describe('getPermissionStatus', () => { diff --git a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/onNotificationOpened.native.test.ts b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/onNotificationOpened.native.test.ts similarity index 94% rename from packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/onNotificationOpened.native.test.ts rename to packages/notifications/__tests__/pushNotifications/providers/shared/apis/onNotificationOpened.native.test.ts index 5de86a0ac9e..655f75ec27e 100644 --- a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/onNotificationOpened.native.test.ts +++ b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/onNotificationOpened.native.test.ts @@ -3,7 +3,7 @@ import { addEventListener } from '../../../../../src/eventListeners'; import { assertIsInitialized } from '../../../../../src/pushNotifications/errors/errorHelpers'; -import { onNotificationOpened } from '../../../../../src/pushNotifications/providers/pinpoint/apis/onNotificationOpened.native'; +import { onNotificationOpened } from '../../../../../src/pushNotifications/providers/shared/apis/onNotificationOpened.native'; jest.mock('../../../../../src/eventListeners'); jest.mock('../../../../../src/pushNotifications/errors/errorHelpers'); diff --git a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/onNotificationOpened.test.ts b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/onNotificationOpened.test.ts similarity index 87% rename from packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/onNotificationOpened.test.ts rename to packages/notifications/__tests__/pushNotifications/providers/shared/apis/onNotificationOpened.test.ts index 6d35ebcd2f2..14a8a9e6b79 100644 --- a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/onNotificationOpened.test.ts +++ b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/onNotificationOpened.test.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { onNotificationOpened } from '../../../../../src/pushNotifications/providers/pinpoint/apis/onNotificationOpened'; +import { onNotificationOpened } from '../../../../../src/pushNotifications/providers/shared/apis/onNotificationOpened'; import { expectNotSupported } from '../../../../testUtils/expectNotSupported'; describe('onNotificationOpened', () => { diff --git a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/onNotificationReceivedInBackground.native.test.ts b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/onNotificationReceivedInBackground.native.test.ts similarity index 92% rename from packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/onNotificationReceivedInBackground.native.test.ts rename to packages/notifications/__tests__/pushNotifications/providers/shared/apis/onNotificationReceivedInBackground.native.test.ts index 4ec43e58348..471ce43fbac 100644 --- a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/onNotificationReceivedInBackground.native.test.ts +++ b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/onNotificationReceivedInBackground.native.test.ts @@ -3,7 +3,7 @@ import { addEventListener } from '../../../../../src/eventListeners'; import { assertIsInitialized } from '../../../../../src/pushNotifications/errors/errorHelpers'; -import { onNotificationReceivedInBackground } from '../../../../../src/pushNotifications/providers/pinpoint/apis/onNotificationReceivedInBackground.native'; +import { onNotificationReceivedInBackground } from '../../../../../src/pushNotifications/providers/shared/apis/onNotificationReceivedInBackground.native'; jest.mock('../../../../../src/eventListeners'); jest.mock('../../../../../src/pushNotifications/errors/errorHelpers'); diff --git a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/onNotificationReceivedInBackground.test.ts b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/onNotificationReceivedInBackground.test.ts similarity index 83% rename from packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/onNotificationReceivedInBackground.test.ts rename to packages/notifications/__tests__/pushNotifications/providers/shared/apis/onNotificationReceivedInBackground.test.ts index 304bfb596ad..f2e1c81e3fe 100644 --- a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/onNotificationReceivedInBackground.test.ts +++ b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/onNotificationReceivedInBackground.test.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { onNotificationReceivedInBackground } from '../../../../../src/pushNotifications/providers/pinpoint/apis/onNotificationReceivedInBackground'; +import { onNotificationReceivedInBackground } from '../../../../../src/pushNotifications/providers/shared/apis/onNotificationReceivedInBackground'; import { expectNotSupported } from '../../../../testUtils/expectNotSupported'; describe('onNotificationReceivedInBackground', () => { diff --git a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/onNotificationReceivedInForeground.native.test.ts b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/onNotificationReceivedInForeground.native.test.ts similarity index 92% rename from packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/onNotificationReceivedInForeground.native.test.ts rename to packages/notifications/__tests__/pushNotifications/providers/shared/apis/onNotificationReceivedInForeground.native.test.ts index 909c2c83bdd..3a8c10cc1c1 100644 --- a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/onNotificationReceivedInForeground.native.test.ts +++ b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/onNotificationReceivedInForeground.native.test.ts @@ -3,7 +3,7 @@ import { addEventListener } from '../../../../../src/eventListeners'; import { assertIsInitialized } from '../../../../../src/pushNotifications/errors/errorHelpers'; -import { onNotificationReceivedInForeground } from '../../../../../src/pushNotifications/providers/pinpoint/apis/onNotificationReceivedInForeground.native'; +import { onNotificationReceivedInForeground } from '../../../../../src/pushNotifications/providers/shared/apis/onNotificationReceivedInForeground.native'; jest.mock('../../../../../src/eventListeners'); jest.mock('../../../../../src/pushNotifications/errors/errorHelpers'); diff --git a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/onNotificationReceivedInForeground.test.ts b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/onNotificationReceivedInForeground.test.ts similarity index 83% rename from packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/onNotificationReceivedInForeground.test.ts rename to packages/notifications/__tests__/pushNotifications/providers/shared/apis/onNotificationReceivedInForeground.test.ts index 7796a863a33..0792381acca 100644 --- a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/onNotificationReceivedInForeground.test.ts +++ b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/onNotificationReceivedInForeground.test.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { onNotificationReceivedInForeground } from '../../../../../src/pushNotifications/providers/pinpoint/apis/onNotificationReceivedInForeground'; +import { onNotificationReceivedInForeground } from '../../../../../src/pushNotifications/providers/shared/apis/onNotificationReceivedInForeground'; import { expectNotSupported } from '../../../../testUtils/expectNotSupported'; describe('onNotificationReceivedInForeground', () => { diff --git a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/onTokenReceived.native.test.ts b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/onTokenReceived.native.test.ts similarity index 95% rename from packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/onTokenReceived.native.test.ts rename to packages/notifications/__tests__/pushNotifications/providers/shared/apis/onTokenReceived.native.test.ts index 1e4c8fe722b..949ca8363ee 100644 --- a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/onTokenReceived.native.test.ts +++ b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/onTokenReceived.native.test.ts @@ -3,7 +3,7 @@ import { addEventListener } from '../../../../../src/eventListeners'; import { assertIsInitialized } from '../../../../../src/pushNotifications/errors/errorHelpers'; -import { onTokenReceived } from '../../../../../src/pushNotifications/providers/pinpoint/apis/onTokenReceived.native'; +import { onTokenReceived } from '../../../../../src/pushNotifications/providers/shared/apis/onTokenReceived.native'; jest.mock('../../../../../src/eventListeners'); jest.mock('../../../../../src/pushNotifications/errors/errorHelpers'); diff --git a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/onTokenReceived.test.ts b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/onTokenReceived.test.ts similarity index 89% rename from packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/onTokenReceived.test.ts rename to packages/notifications/__tests__/pushNotifications/providers/shared/apis/onTokenReceived.test.ts index a7ec6a26be8..2772d84de6a 100644 --- a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/onTokenReceived.test.ts +++ b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/onTokenReceived.test.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { onTokenReceived } from '../../../../../src/pushNotifications/providers/pinpoint/apis/onTokenReceived'; +import { onTokenReceived } from '../../../../../src/pushNotifications/providers/shared/apis/onTokenReceived'; import { expectNotSupported } from '../../../../testUtils/expectNotSupported'; describe('onTokenReceived', () => { diff --git a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/requestPermissions.native.test.ts b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/requestPermissions.native.test.ts similarity index 91% rename from packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/requestPermissions.native.test.ts rename to packages/notifications/__tests__/pushNotifications/providers/shared/apis/requestPermissions.native.test.ts index 84a508491e2..4da11b14b40 100644 --- a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/requestPermissions.native.test.ts +++ b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/requestPermissions.native.test.ts @@ -21,7 +21,7 @@ describe('requestPermissions (native)', () => { beforeAll(() => { ({ requestPermissions, - } = require('../../../../../src/pushNotifications/providers/pinpoint/apis/requestPermissions.native')); + } = require('../../../../../src/pushNotifications/providers/shared/apis/requestPermissions.native')); }); afterEach(() => { diff --git a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/requestPermissions.test.ts b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/requestPermissions.test.ts similarity index 88% rename from packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/requestPermissions.test.ts rename to packages/notifications/__tests__/pushNotifications/providers/shared/apis/requestPermissions.test.ts index 5ac8d8621d0..07815e2bd4d 100644 --- a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/requestPermissions.test.ts +++ b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/requestPermissions.test.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { requestPermissions } from '../../../../../src/pushNotifications/providers/pinpoint/apis/requestPermissions'; +import { requestPermissions } from '../../../../../src/pushNotifications/providers/shared/apis/requestPermissions'; import { expectNotSupportedAsync } from '../../../../testUtils/expectNotSupported'; describe('requestPermissions', () => { diff --git a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/setBadgeCount.native.test.ts b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/setBadgeCount.native.test.ts similarity index 91% rename from packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/setBadgeCount.native.test.ts rename to packages/notifications/__tests__/pushNotifications/providers/shared/apis/setBadgeCount.native.test.ts index a316937cdcf..b42205a9ea2 100644 --- a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/setBadgeCount.native.test.ts +++ b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/setBadgeCount.native.test.ts @@ -21,7 +21,7 @@ describe('setBadgeCount (native)', () => { beforeAll(() => { ({ setBadgeCount, - } = require('../../../../../src/pushNotifications/providers/pinpoint/apis/setBadgeCount.native')); + } = require('../../../../../src/pushNotifications/providers/shared/apis/setBadgeCount.native')); }); afterEach(() => { diff --git a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/setBadgeCount.test.ts b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/setBadgeCount.test.ts similarity index 90% rename from packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/setBadgeCount.test.ts rename to packages/notifications/__tests__/pushNotifications/providers/shared/apis/setBadgeCount.test.ts index c4014e53516..e13c0317a53 100644 --- a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/apis/setBadgeCount.test.ts +++ b/packages/notifications/__tests__/pushNotifications/providers/shared/apis/setBadgeCount.test.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { setBadgeCount } from '../../../../../src/pushNotifications/providers/pinpoint/apis/setBadgeCount'; +import { setBadgeCount } from '../../../../../src/pushNotifications/providers/shared/apis/setBadgeCount'; import { expectNotSupported } from '../../../../testUtils/expectNotSupported'; describe('getBadgeCount', () => { diff --git a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/utils/getChannelType.test.ts b/packages/notifications/__tests__/pushNotifications/providers/shared/utils/getChannelType.test.ts similarity index 78% rename from packages/notifications/__tests__/pushNotifications/providers/pinpoint/utils/getChannelType.test.ts rename to packages/notifications/__tests__/pushNotifications/providers/shared/utils/getChannelType.test.ts index a1704ea8403..ca67cf2cc96 100644 --- a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/utils/getChannelType.test.ts +++ b/packages/notifications/__tests__/pushNotifications/providers/shared/utils/getChannelType.test.ts @@ -22,7 +22,7 @@ describe('getChannelType', () => { jest.isolateModules(() => { ({ getChannelType, - } = require('../../../../../src/pushNotifications/providers/pinpoint/utils/getChannelType')); + } = require('../../../../../src/pushNotifications/providers/shared/utils/getChannelType')); }); expect(getChannelType()).toBe('GCM'); }); @@ -32,7 +32,7 @@ describe('getChannelType', () => { jest.isolateModules(() => { ({ getChannelType, - } = require('../../../../../src/pushNotifications/providers/pinpoint/utils/getChannelType')); + } = require('../../../../../src/pushNotifications/providers/shared/utils/getChannelType')); }); expect(getChannelType()).toBe('APNS_SANDBOX'); }); @@ -41,7 +41,7 @@ describe('getChannelType', () => { jest.isolateModules(() => { ({ getChannelType, - } = require('../../../../../src/pushNotifications/providers/pinpoint/utils/getChannelType')); + } = require('../../../../../src/pushNotifications/providers/shared/utils/getChannelType')); }); expectNotSupported(getChannelType); }); diff --git a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/utils/inflightDeviceRegistration.test.ts b/packages/notifications/__tests__/pushNotifications/providers/shared/utils/inflightDeviceRegistration.test.ts similarity index 88% rename from packages/notifications/__tests__/pushNotifications/providers/pinpoint/utils/inflightDeviceRegistration.test.ts rename to packages/notifications/__tests__/pushNotifications/providers/shared/utils/inflightDeviceRegistration.test.ts index 4267e6e6686..93258848c4c 100644 --- a/packages/notifications/__tests__/pushNotifications/providers/pinpoint/utils/inflightDeviceRegistration.test.ts +++ b/packages/notifications/__tests__/pushNotifications/providers/shared/utils/inflightDeviceRegistration.test.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { InflightDeviceRegistration } from '../../../../../src/pushNotifications/providers/pinpoint/types'; +import { InflightDeviceRegistration } from '../../../../../src/pushNotifications/providers/shared/types'; describe('inflightDeviceRegistration', () => { describe('resolveInflightDeviceRegistration', () => { @@ -11,7 +11,7 @@ describe('inflightDeviceRegistration', () => { ({ getInflightDeviceRegistration, resolveInflightDeviceRegistration, - } = require('../../../../../src/pushNotifications/providers/pinpoint/utils/inflightDeviceRegistration')); + } = require('../../../../../src/pushNotifications/providers/shared/utils/inflightDeviceRegistration')); }); it('creates a pending promise on module load', () => { @@ -42,7 +42,7 @@ describe('inflightDeviceRegistration', () => { ({ getInflightDeviceRegistration, rejectInflightDeviceRegistration, - } = require('../../../../../src/pushNotifications/providers/pinpoint/utils/inflightDeviceRegistration')); + } = require('../../../../../src/pushNotifications/providers/shared/utils/inflightDeviceRegistration')); }); it('creates a pending promise on module load', () => { diff --git a/packages/notifications/__tests__/pushNotifications/utils/deprecatePinpoint.test.ts b/packages/notifications/__tests__/pushNotifications/utils/deprecatePinpoint.test.ts new file mode 100644 index 00000000000..fb0241a4b07 --- /dev/null +++ b/packages/notifications/__tests__/pushNotifications/utils/deprecatePinpoint.test.ts @@ -0,0 +1,72 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ConsoleLogger } from '@aws-amplify/core'; + +import { deprecatePinpoint } from '../../../src/pushNotifications/utils/deprecatePinpoint'; + +describe('deprecatePinpoint', () => { + const loggerWarnSpy = jest.spyOn(ConsoleLogger.prototype, 'warn'); + + beforeEach(() => { + loggerWarnSpy.mockClear(); + }); + + it('delegates arguments and return value transparently', () => { + const impl = jest.fn((a: number, b: number) => a + b); + const wrapped = deprecatePinpoint(impl); + + const result = wrapped(2, 3); + + expect(result).toBe(5); + expect(impl).toHaveBeenCalledWith(2, 3); + }); + + it('emits the deprecation warning only once across multiple calls', () => { + const wrapped = deprecatePinpoint(jest.fn()); + + wrapped(); + wrapped(); + wrapped(); + + expect(loggerWarnSpy).toHaveBeenCalledTimes(1); + expect(loggerWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('aws-amplify/push-notifications'), + ); + expect(loggerWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Amazon Pinpoint'), + ); + expect(loggerWarnSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'aws-amplify/push-notifications/customer-profiles', + ), + ); + }); + + it('tracks the one-time warning independently per wrapped API', () => { + const wrappedA = deprecatePinpoint(jest.fn()); + const wrappedB = deprecatePinpoint(jest.fn()); + + wrappedA(); + wrappedA(); + wrappedB(); + + expect(loggerWarnSpy).toHaveBeenCalledTimes(2); + }); + + it('preserves thrown errors from the wrapped implementation', () => { + const wrapped = deprecatePinpoint(() => { + throw new Error('boom'); + }); + + expect(() => wrapped()).toThrow('boom'); + }); + + it('preserves rejected promises from the wrapped implementation', async () => { + const wrapped = deprecatePinpoint(async () => { + throw new Error('boom'); + }); + + await expect(wrapped()).rejects.toThrow('boom'); + }); +}); diff --git a/packages/notifications/__tests__/testUtils/data.ts b/packages/notifications/__tests__/testUtils/data.ts index d94fb9b6d8d..998bd8fdc56 100644 --- a/packages/notifications/__tests__/testUtils/data.ts +++ b/packages/notifications/__tests__/testUtils/data.ts @@ -312,6 +312,13 @@ export const simplePushMessage: PushNotificationMessage = { }; export const pushToken = 'foo-bar'; + +export const customerProfilesConfig = { + endpoint: 'https://abcd1234.execute-api.us-east-1.amazonaws.com/prod', + region: 'us-east-1', +}; + +export const accessToken = 'access-token'; export const pinpointCampaign = { campaign_id: 'campaign-id', campaign_activity_id: 'campaign-activity-id', diff --git a/packages/notifications/package.json b/packages/notifications/package.json index 30803d06c79..ba49f60fe09 100644 --- a/packages/notifications/package.json +++ b/packages/notifications/package.json @@ -38,6 +38,9 @@ ], "push-notifications/pinpoint": [ "./dist/esm/pushNotifications/providers/pinpoint/index.d.ts" + ], + "push-notifications/customer-profiles": [ + "./dist/esm/pushNotifications/providers/customer-profiles/index.d.ts" ] } }, @@ -71,6 +74,12 @@ "import": "./dist/esm/pushNotifications/providers/pinpoint/index.mjs", "require": "./dist/cjs/pushNotifications/providers/pinpoint/index.js" }, + "./push-notifications/customer-profiles": { + "react-native": "./dist/cjs/pushNotifications/providers/customer-profiles/index.js", + "types": "./dist/esm/pushNotifications/providers/customer-profiles/index.d.ts", + "import": "./dist/esm/pushNotifications/providers/customer-profiles/index.mjs", + "require": "./dist/cjs/pushNotifications/providers/customer-profiles/index.js" + }, "./package.json": "./package.json" }, "repository": { diff --git a/packages/notifications/push-notifications/customer-profiles/package.json b/packages/notifications/push-notifications/customer-profiles/package.json new file mode 100644 index 00000000000..2df96b3277d --- /dev/null +++ b/packages/notifications/push-notifications/customer-profiles/package.json @@ -0,0 +1,8 @@ +{ + "name": "@aws-amplify/notifications/push-notifications/customer-profiles", + "main": "../../dist/cjs/pushNotifications/providers/customer-profiles/index.js", + "browser": "../../dist/esm/pushNotifications/providers/customer-profiles/index.mjs", + "module": "../../dist/esm/pushNotifications/providers/customer-profiles/index.mjs", + "react-native": "../../dist/cjs/pushNotifications/providers/customer-profiles/index.js", + "typings": "../../dist/esm/pushNotifications/providers/customer-profiles/index.d.ts" +} diff --git a/packages/notifications/src/pushNotifications/errors/errorHelpers.ts b/packages/notifications/src/pushNotifications/errors/errorHelpers.ts index cb94a01e362..4196d296bb7 100644 --- a/packages/notifications/src/pushNotifications/errors/errorHelpers.ts +++ b/packages/notifications/src/pushNotifications/errors/errorHelpers.ts @@ -12,23 +12,45 @@ import { isInitialized } from '../utils/initializationManager'; import { PushNotificationError } from './PushNotificationError'; export enum PushNotificationValidationErrorCode { + InvalidEndpoint = 'InvalidEndpoint', + InvalidUserProfile = 'InvalidUserProfile', NoAppId = 'NoAppId', NoCredentials = 'NoCredentials', + NoEndpoint = 'NoEndpoint', NoRegion = 'NoRegion', + NoToken = 'NoToken', NotInitialized = 'NotInitialized', } const pushNotificationValidationErrorMap: AmplifyErrorMap = { + [PushNotificationValidationErrorCode.InvalidEndpoint]: { + message: 'The configured Customer Profiles endpoint is invalid.', + recoverySuggestion: + 'Ensure the endpoint in your Amplify configuration is a valid https:// URL on the API Gateway host for the configured region, for example https://.execute-api..amazonaws.com.', + }, + [PushNotificationValidationErrorCode.InvalidUserProfile]: { + message: 'The provided user profile is invalid.', + recoverySuggestion: + "Ensure each user profile field and every customAttributes key and value is a string of at most 255 characters. The 'principalId' key is reserved and cannot be used.", + }, [PushNotificationValidationErrorCode.NoAppId]: { message: 'Missing application id.', }, [PushNotificationValidationErrorCode.NoCredentials]: { message: 'Credentials should not be empty.', }, + [PushNotificationValidationErrorCode.NoEndpoint]: { + message: 'Missing endpoint.', + }, [PushNotificationValidationErrorCode.NoRegion]: { message: 'Missing region.', }, + [PushNotificationValidationErrorCode.NoToken]: { + message: 'No push notification token available.', + recoverySuggestion: + 'Pass a token to `registerDevice`, or ensure a token has been received via `onTokenReceived` before registering the device.', + }, [PushNotificationValidationErrorCode.NotInitialized]: { message: 'Push notification has not been initialized.', recoverySuggestion: diff --git a/packages/notifications/src/pushNotifications/index.ts b/packages/notifications/src/pushNotifications/index.ts index 5c7a0653de6..5baa20e5072 100644 --- a/packages/notifications/src/pushNotifications/index.ts +++ b/packages/notifications/src/pushNotifications/index.ts @@ -1,31 +1,140 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +// This entry point intentionally re-exports the deprecated Pinpoint provider +// APIs as the default Push Notifications surface. Removing them would be a +// breaking change; instead each is wrapped to emit a one-time runtime +// deprecation warning. The deprecated imports below are therefore expected. +/* eslint-disable import/no-deprecated */ +import { + getBadgeCount as getBadgeCountPinpoint, + getLaunchNotification as getLaunchNotificationPinpoint, + getPermissionStatus as getPermissionStatusPinpoint, + identifyUser as identifyUserPinpoint, + initializePushNotifications as initializePushNotificationsPinpoint, + onNotificationOpened as onNotificationOpenedPinpoint, + onNotificationReceivedInBackground as onNotificationReceivedInBackgroundPinpoint, + onNotificationReceivedInForeground as onNotificationReceivedInForegroundPinpoint, + onTokenReceived as onTokenReceivedPinpoint, + requestPermissions as requestPermissionsPinpoint, + setBadgeCount as setBadgeCountPinpoint, +} from './providers/pinpoint'; +import { deprecatePinpoint } from './utils'; + +/** + * @deprecated The default `aws-amplify/push-notifications` entry point is deprecated because it is + * backed by Amazon Pinpoint, for which AWS will end support on October 30, 2026. Import from a + * supported provider sub-path export instead — for example + * `aws-amplify/push-notifications/customer-profiles`. + */ +export const getBadgeCount = deprecatePinpoint(getBadgeCountPinpoint); + +/** + * @deprecated The default `aws-amplify/push-notifications` entry point is deprecated because it is + * backed by Amazon Pinpoint, for which AWS will end support on October 30, 2026. Import from a + * supported provider sub-path export instead — for example + * `aws-amplify/push-notifications/customer-profiles`. + */ +export const setBadgeCount = deprecatePinpoint(setBadgeCountPinpoint); + +/** + * @deprecated The default `aws-amplify/push-notifications` entry point is deprecated because it is + * backed by Amazon Pinpoint, for which AWS will end support on October 30, 2026. Import from a + * supported provider sub-path export instead — for example + * `aws-amplify/push-notifications/customer-profiles`. + */ +export const getPermissionStatus = deprecatePinpoint( + getPermissionStatusPinpoint, +); + +/** + * @deprecated The default `aws-amplify/push-notifications` entry point is deprecated because it is + * backed by Amazon Pinpoint, for which AWS will end support on October 30, 2026. Import from a + * supported provider sub-path export instead — for example + * `aws-amplify/push-notifications/customer-profiles`. + */ +export const requestPermissions = deprecatePinpoint(requestPermissionsPinpoint); + +/** + * @deprecated The default `aws-amplify/push-notifications` entry point is deprecated because it is + * backed by Amazon Pinpoint, for which AWS will end support on October 30, 2026. Import from a + * supported provider sub-path export instead — for example + * `aws-amplify/push-notifications/customer-profiles`. + */ +export const getLaunchNotification = deprecatePinpoint( + getLaunchNotificationPinpoint, +); + +/** + * @deprecated The default `aws-amplify/push-notifications` entry point is deprecated because it is + * backed by Amazon Pinpoint, for which AWS will end support on October 30, 2026. Import from a + * supported provider sub-path export instead — for example + * `aws-amplify/push-notifications/customer-profiles`. + */ +export const onNotificationReceivedInForeground = deprecatePinpoint( + onNotificationReceivedInForegroundPinpoint, +); + +/** + * @deprecated The default `aws-amplify/push-notifications` entry point is deprecated because it is + * backed by Amazon Pinpoint, for which AWS will end support on October 30, 2026. Import from a + * supported provider sub-path export instead — for example + * `aws-amplify/push-notifications/customer-profiles`. + */ +export const onNotificationReceivedInBackground = deprecatePinpoint( + onNotificationReceivedInBackgroundPinpoint, +); + +/** + * @deprecated The default `aws-amplify/push-notifications` entry point is deprecated because it is + * backed by Amazon Pinpoint, for which AWS will end support on October 30, 2026. Import from a + * supported provider sub-path export instead — for example + * `aws-amplify/push-notifications/customer-profiles`. + */ +export const onNotificationOpened = deprecatePinpoint( + onNotificationOpenedPinpoint, +); + +/** + * @deprecated The default `aws-amplify/push-notifications` entry point is deprecated because it is + * backed by Amazon Pinpoint, for which AWS will end support on October 30, 2026. Import from a + * supported provider sub-path export instead — for example + * `aws-amplify/push-notifications/customer-profiles`. + */ +export const onTokenReceived = deprecatePinpoint(onTokenReceivedPinpoint); + +/** + * @deprecated The default `aws-amplify/push-notifications` entry point is deprecated because it is + * backed by Amazon Pinpoint, for which AWS will end support on October 30, 2026. Import from a + * supported provider sub-path export instead — for example + * `aws-amplify/push-notifications/customer-profiles`. + */ +export const identifyUser = deprecatePinpoint(identifyUserPinpoint); + +/** + * @deprecated The default `aws-amplify/push-notifications` entry point is deprecated because it is + * backed by Amazon Pinpoint, for which AWS will end support on October 30, 2026. Import from a + * supported provider sub-path export instead — for example + * `aws-amplify/push-notifications/customer-profiles`. + */ +export const initializePushNotifications = deprecatePinpoint( + initializePushNotificationsPinpoint, +); + export { - getBadgeCount, GetBadgeCountOutput, - getLaunchNotification, GetLaunchNotificationOutput, - getPermissionStatus, GetPermissionStatusOutput, - identifyUser, IdentifyUserInput, - initializePushNotifications, - onNotificationOpened, OnNotificationOpenedInput, OnNotificationOpenedOutput, - onNotificationReceivedInBackground, OnNotificationReceivedInBackgroundInput, OnNotificationReceivedInBackgroundOutput, - onNotificationReceivedInForeground, OnNotificationReceivedInForegroundInput, OnNotificationReceivedInForegroundOutput, - onTokenReceived, OnTokenReceivedInput, OnTokenReceivedOutput, - requestPermissions, RequestPermissionsInput, - setBadgeCount, SetBadgeCountInput, } from './providers/pinpoint'; export { PushNotificationMessage } from './types'; diff --git a/packages/notifications/src/pushNotifications/providers/customer-profiles/apis/identifyUser.native.ts b/packages/notifications/src/pushNotifications/providers/customer-profiles/apis/identifyUser.native.ts new file mode 100644 index 00000000000..2905228570c --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/customer-profiles/apis/identifyUser.native.ts @@ -0,0 +1,32 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { identifyUserInternal } from '../utils/identifyUserInternal'; +import { IdentifyUser } from '../types'; + +/** + * Sends profile information about a user to Amazon Connect Customer Profiles. + * + * On React Native this is profile-only and no longer registers a device — the + * backend derives `principalId` server-side from the SigV4 signer identity, so + * no `userId` is sent. Use `registerDevice` / `removeDevice` for push device + * lifecycle. + * + * Intentionally does NOT call `assertIsInitialized`: identify is a pure SigV4 + * HTTP call whose only prerequisites (endpoint/region + Identity Pool + * credentials) are validated by `signedFetch` → `resolveConfig` / + * `resolveCredentials`, so it is safe to call before `initializePushNotifications`. + * (This native override exists only to drop the web build's device-registration + * step; the request shape is otherwise identical to the web `identifyUser`.) + * + * @param {IdentifyUserInput} input The input object used to construct the request + * sent to the Amazon Connect Customer Profiles endpoint. + * @throws service - Thrown when the Customer Profiles endpoint responds with a + * non-2xx status or the request fails to complete. + * @throws validation - Thrown when the provided parameters or library + * configuration is incorrect. + * @returns A promise that will resolve when the operation is complete. + */ +export const identifyUser: IdentifyUser = async ({ userProfile }) => { + await identifyUserInternal({ userProfile }); +}; diff --git a/packages/notifications/src/pushNotifications/providers/customer-profiles/apis/identifyUser.ts b/packages/notifications/src/pushNotifications/providers/customer-profiles/apis/identifyUser.ts new file mode 100644 index 00000000000..81ccc8ed0e0 --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/customer-profiles/apis/identifyUser.ts @@ -0,0 +1,37 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { identifyUserInternal } from '../utils/identifyUserInternal'; +import { IdentifyUser } from '../types'; + +/** + * Sends profile information about a user to Amazon Connect Customer Profiles. + * This associates the caller's `userProfile` with their Customer Profile. The + * backend derives the `principalId` server-side from the SigV4 signer identity, + * so no `userId` is sent by the client. + * + * This API is profile-only on all platforms and performs no device work — to + * register/de-register a push device use `registerDevice` / `removeDevice` + * (React Native only). + * + * @param {IdentifyUserInput} input The input object used to construct the request + * sent to the Amazon Connect Customer Profiles endpoint. + * @throws service - Thrown when the Customer Profiles endpoint responds with a + * non-2xx status or the request fails to complete. + * @throws validation - Thrown when the provided parameters or library + * configuration is incorrect. + * @returns A promise that will resolve when the operation is complete. + * @example + * ```ts + * await identifyUser({ + * userProfile: { + * email: 'userEmail@example.com', + * name: 'Jane Doe', + * location: { city: 'Seattle', country: 'US' }, + * }, + * }); + * ``` + */ +export const identifyUser: IdentifyUser = async ({ userProfile }) => { + await identifyUserInternal({ userProfile }); +}; diff --git a/packages/notifications/src/pushNotifications/providers/customer-profiles/apis/index.ts b/packages/notifications/src/pushNotifications/providers/customer-profiles/apis/index.ts new file mode 100644 index 00000000000..3dabeb8a4a6 --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/customer-profiles/apis/index.ts @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { + getBadgeCount, + getLaunchNotification, + getPermissionStatus, + onNotificationOpened, + onNotificationReceivedInBackground, + onNotificationReceivedInForeground, + onTokenReceived, + requestPermissions, + setBadgeCount, +} from '../../shared/apis'; +export { identifyUser } from './identifyUser'; +export { initializePushNotifications } from './initializePushNotifications'; +export { registerDevice } from './registerDevice'; +export { removeDevice } from './removeDevice'; diff --git a/packages/notifications/src/pushNotifications/providers/customer-profiles/apis/initializePushNotifications.native.ts b/packages/notifications/src/pushNotifications/providers/customer-profiles/apis/initializePushNotifications.native.ts new file mode 100644 index 00000000000..1e0718e0201 --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/customer-profiles/apis/initializePushNotifications.native.ts @@ -0,0 +1,192 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ConsoleLogger, Hub } from '@aws-amplify/core'; +import { loadAmplifyPushNotification } from '@aws-amplify/react-native'; + +import { + notifyEventListeners, + notifyEventListenersAndAwaitHandlers, +} from '../../../../eventListeners'; +import { getToken, initialize, isInitialized, setToken } from '../../../utils'; +import { + rejectInflightDeviceRegistration, + resolveInflightDeviceRegistration, +} from '../utils'; + +import { registerDevice } from './registerDevice'; + +const { + addMessageEventListener, + addTokenEventListener, + completeNotification, + getConstants, + registerHeadlessTask, +} = loadAmplifyPushNotification(); + +const logger = new ConsoleLogger('Notifications.PushNotification'); + +const BACKGROUND_TASK_TIMEOUT = 25; // seconds + +export const initializePushNotifications = (): void => { + if (isInitialized()) { + logger.info('Push notifications have already been enabled'); + + return; + } + addNativeListeners(); + addAuthListener(); + initialize(); +}; + +const addNativeListeners = (): void => { + let launchNotificationOpenedListener: + | ReturnType + | undefined; + const { NativeEvent, NativeHeadlessTaskKey } = getConstants(); + const { + BACKGROUND_MESSAGE_RECEIVED, + FOREGROUND_MESSAGE_RECEIVED, + LAUNCH_NOTIFICATION_OPENED, + NOTIFICATION_OPENED, + TOKEN_RECEIVED, + } = NativeEvent; + // on platforms that can handle headless tasks, register one to broadcast background message received to + // library listeners + if (NativeHeadlessTaskKey) { + registerHeadlessTask(async message => { + // keep headless task running until handlers have completed their work + await notifyEventListenersAndAwaitHandlers( + 'backgroundMessageReceived', + message, + ); + }); + } else if (BACKGROUND_MESSAGE_RECEIVED) { + // on platforms that can't handle headless tasks, listen for native background message received event and + // broadcast to library listeners + addMessageEventListener( + BACKGROUND_MESSAGE_RECEIVED, + async (message, completionHandlerId) => { + // keep background task running until handlers have completed their work + try { + await Promise.race([ + notifyEventListenersAndAwaitHandlers( + 'backgroundMessageReceived', + message, + ), + // background tasks will get suspended and all future tasks be deprioritized by the OS if they run for + // more than 30 seconds so we reject with a error in a shorter amount of time to prevent this from + // happening + new Promise((_resolve, reject) => { + setTimeout(() => { + reject( + new Error( + `onNotificationReceivedInBackground handlers should complete their work within ${BACKGROUND_TASK_TIMEOUT} seconds, but they did not.`, + ), + ); + }, BACKGROUND_TASK_TIMEOUT * 1000); + }), + ]); + } catch (err) { + logger.error(err); + } finally { + // notify native module that handlers have completed their work (or timed out) + if (completionHandlerId) { + completeNotification(completionHandlerId); + } + } + }, + ); + } + + addMessageEventListener( + // listen for native foreground message received event and broadcast to library listeners + FOREGROUND_MESSAGE_RECEIVED, + message => { + notifyEventListeners('foregroundMessageReceived', message); + }, + ); + + launchNotificationOpenedListener = LAUNCH_NOTIFICATION_OPENED + ? addMessageEventListener( + // listen for native notification opened app (user tapped on notification, opening the app from quit - + // not background - state) event. This is broadcasted to an internal listener only as it is not intended + // for use otherwise as it produces inconsistent results when used within React Native app context + LAUNCH_NOTIFICATION_OPENED, + message => { + notifyEventListeners('launchNotificationOpened', message); + // once we are done with it we can remove the listener + launchNotificationOpenedListener?.remove(); + launchNotificationOpenedListener = undefined; + }, + ) + : undefined; + + addMessageEventListener( + // listen for native notification opened (user tapped on notification, opening the app from background - + // not quit - state) event and broadcast to library listeners + NOTIFICATION_OPENED, + message => { + notifyEventListeners('notificationOpened', message); + // if we are in this state, we no longer need the listener as the app was launched via some other means + launchNotificationOpenedListener?.remove(); + }, + ); + + addTokenEventListener( + // listen for native new token event, automatically re-register device with provider using new token and + // broadcast to library listeners + TOKEN_RECEIVED, + async token => { + // avoid a race condition where two registrations are created with the same token on a fresh install + if (getToken() === token) { + return; + } + setToken(token); + notifyEventListeners('tokenReceived', token); + try { + await registerReceivedDevice(token); + } catch (err) { + logger.error('Failed to register device for push notifications', err); + throw err; + } + }, + ); +}; + +const addAuthListener = (): void => { + // Re-register the device at sign-in so it is re-homed from the guest + // principal to the now-authenticated one. The push token is unchanged across + // a sign-in, so the native token listener short-circuits and would never + // re-register on its own. The backend register-device is an idempotent + // last-writer-wins upsert keyed on `deviceId`, so this moves the existing + // registration rather than creating a duplicate. Best-effort — failures are + // logged. + Hub.listen('auth', ({ payload }) => { + if (payload.event === 'signedIn') { + const token = getToken(); + if (!token) { + // no token yet — the TOKEN_RECEIVED path performs first registration + return; + } + registerDevice({ token }).catch(err => { + logger.error( + 'Failed to re-register device for push notifications on sign-in', + err, + ); + }); + } + }); +}; + +const registerReceivedDevice = async (token: string): Promise => { + try { + await registerDevice({ token }); + // always resolve inflight device registration promise here even though the promise is only awaited on by + // consumers when device registration is still in flight + resolveInflightDeviceRegistration(); + } catch (underlyingError) { + rejectInflightDeviceRegistration(underlyingError); + throw underlyingError; + } +}; diff --git a/packages/notifications/src/pushNotifications/providers/customer-profiles/apis/initializePushNotifications.ts b/packages/notifications/src/pushNotifications/providers/customer-profiles/apis/initializePushNotifications.ts new file mode 100644 index 00000000000..7b003a1459a --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/customer-profiles/apis/initializePushNotifications.ts @@ -0,0 +1,26 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PlatformNotSupportedError } from '@aws-amplify/core/internals/utils'; + +import { InitializePushNotifications } from '../types'; + +/** + * Initialize and set up the push notification category. The category must be first initialized before all other + * functionalities become available. + * + * + * @throws platform: {@link PlatformNotSupportedError} - Thrown if called against an unsupported platform. Currently, + * only React Native is supported by this API. + * @remarks + * It is recommended that this be called as early in your app as possible at the root of your application to allow + * background processing of notifications. + * @example + * ```ts + * Amplify.configure(config); + * initializePushNotifications(); + * ``` + */ +export const initializePushNotifications: InitializePushNotifications = () => { + throw new PlatformNotSupportedError(); +}; diff --git a/packages/notifications/src/pushNotifications/providers/customer-profiles/apis/registerDevice.native.ts b/packages/notifications/src/pushNotifications/providers/customer-profiles/apis/registerDevice.native.ts new file mode 100644 index 00000000000..15e2346a7df --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/customer-profiles/apis/registerDevice.native.ts @@ -0,0 +1,68 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getClientInfo } from '@aws-amplify/core/internals/utils'; + +import { PushNotificationError } from '../../../errors'; +import { + PushNotificationValidationErrorCode, + assertIsInitialized, +} from '../../../errors/errorHelpers'; +import { getToken } from '../../../utils'; +import { + DeviceRegistration, + getChannelType, + getDeviceId, + registerDeviceInternal, +} from '../utils'; +import { RegisterDevice } from '../types'; + +/** + * Builds the device-registration wire object. The stable per-install `deviceId` + * (find-or-create key) and OS-derived `channelType` / `platform` are resolved + * internally. `appVersion` is included for mobile parity but is currently sent + * empty (unsourced) — sourcing a real bundle version would require a native + * bridge / third-party dependency and is deliberately deferred. + * + * @throws validation: {@link PushNotificationError} - `NoToken` when neither an + * explicit token nor a previously-received token is available. + * @internal + */ +export const buildDeviceRegistration = async ( + token?: string, +): Promise => { + const resolved = token ?? getToken(); + if (!resolved) { + throw new PushNotificationError({ + name: PushNotificationValidationErrorCode.NoToken, + message: 'No push notification token available.', + recoverySuggestion: + 'Pass a token to `registerDevice`, or ensure a token has been received via `onTokenReceived` before registering the device.', + }); + } + + return { + token: resolved, + deviceId: await getDeviceId(), + platform: getClientInfo().platform ?? '', + appVersion: '', + channelType: getChannelType(), + }; +}; + +/** + * Registers a push device with Amazon Connect Customer Profiles. The device is + * keyed on the caller's server-derived `principalId` (from the SigV4 signer + * identity). The SDK internally supplies the remaining device fields + * (`deviceId`, `platform`, `appVersion`, `channelType`). + * + * @param {RegisterDeviceInput} input The input containing the push `token`. + * @throws service - Thrown when the Customer Profiles endpoint responds with a + * non-2xx status or the request fails to complete. + * @throws validation - Thrown when the library configuration is incorrect. + * @returns A promise that will resolve when the operation is complete. + */ +export const registerDevice: RegisterDevice = async ({ token }) => { + assertIsInitialized(); + await registerDeviceInternal(await buildDeviceRegistration(token)); +}; diff --git a/packages/notifications/src/pushNotifications/providers/customer-profiles/apis/registerDevice.ts b/packages/notifications/src/pushNotifications/providers/customer-profiles/apis/registerDevice.ts new file mode 100644 index 00000000000..e9fa268abf4 --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/customer-profiles/apis/registerDevice.ts @@ -0,0 +1,17 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PlatformNotSupportedError } from '@aws-amplify/core/internals/utils'; + +import { RegisterDevice } from '../types'; + +/** + * Registers a push device with Amazon Connect Customer Profiles. + * + * @throws platform: {@link PlatformNotSupportedError} - Thrown if called against + * an unsupported platform. Currently, only React Native is supported by this + * API. + */ +export const registerDevice: RegisterDevice = () => { + throw new PlatformNotSupportedError(); +}; diff --git a/packages/notifications/src/pushNotifications/providers/customer-profiles/apis/removeDevice.native.ts b/packages/notifications/src/pushNotifications/providers/customer-profiles/apis/removeDevice.native.ts new file mode 100644 index 00000000000..08d268bd259 --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/customer-profiles/apis/removeDevice.native.ts @@ -0,0 +1,37 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { assertIsInitialized } from '../../../errors/errorHelpers'; +import { getDeviceId, removeDeviceInternal } from '../utils'; +import { RemoveDevice } from '../types'; + +/** + * De-registers the current push device from Amazon Connect Customer Profiles. + * The stable per-install `deviceId` is resolved internally, and the backend + * gates removal on the caller's server-derived `principalId` (so a device can + * only be removed by the principal it is registered to). The persisted + * `deviceId` is intentionally NOT cleared — it is stable per install. + * + * @remarks + * Call this API while the user is still signed in — de-registration is signed + * with the current credentials, and the backend only removes a device that the + * calling principal owns. Once `signOut` has completed those credentials are + * gone and the caller signs as a new guest identity, so a removal at that point + * cannot de-register the signed-in user's device. To stop delivery to a device + * on sign-out, await `removeDevice()` **before** calling `signOut()`. + * + * @throws service - Thrown when the Customer Profiles endpoint responds with a + * non-2xx status or the request fails to complete. + * @throws validation - Thrown when the library configuration is incorrect. + * @returns A promise that will resolve when the operation is complete. + * @example + * ```ts + * // de-register the device before ending the session + * await removeDevice(); + * await signOut(); + * ``` + */ +export const removeDevice: RemoveDevice = async () => { + assertIsInitialized(); + await removeDeviceInternal(await getDeviceId()); +}; diff --git a/packages/notifications/src/pushNotifications/providers/customer-profiles/apis/removeDevice.ts b/packages/notifications/src/pushNotifications/providers/customer-profiles/apis/removeDevice.ts new file mode 100644 index 00000000000..d2f130d5abc --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/customer-profiles/apis/removeDevice.ts @@ -0,0 +1,23 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PlatformNotSupportedError } from '@aws-amplify/core/internals/utils'; + +import { RemoveDevice } from '../types'; + +/** + * De-registers the current push device from Amazon Connect Customer Profiles. + * + * @remarks + * Call this API while the user is still signed in — de-registration is signed + * with the current credentials and the backend only removes a device that the + * calling principal owns. To stop delivery to a device on sign-out, await + * `removeDevice()` **before** calling `signOut()`. + * + * @throws platform: {@link PlatformNotSupportedError} - Thrown if called against + * an unsupported platform. Currently, only React Native is supported by this + * API. + */ +export const removeDevice: RemoveDevice = () => { + throw new PlatformNotSupportedError(); +}; diff --git a/packages/notifications/src/pushNotifications/providers/customer-profiles/index.ts b/packages/notifications/src/pushNotifications/providers/customer-profiles/index.ts new file mode 100644 index 00000000000..26a4eafa8cc --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/customer-profiles/index.ts @@ -0,0 +1,39 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { + getBadgeCount, + getLaunchNotification, + getPermissionStatus, + identifyUser, + initializePushNotifications, + onNotificationOpened, + onNotificationReceivedInBackground, + onNotificationReceivedInForeground, + onTokenReceived, + registerDevice, + removeDevice, + requestPermissions, + setBadgeCount, +} from './apis'; +export { + IdentifyUserInput, + OnNotificationOpenedInput, + OnNotificationReceivedInBackgroundInput, + OnNotificationReceivedInForegroundInput, + OnTokenReceivedInput, + RegisterDeviceInput, + RequestPermissionsInput, + SetBadgeCountInput, + UserProfile, + UserProfileLocation, +} from './types'; +export { + GetBadgeCountOutput, + GetLaunchNotificationOutput, + GetPermissionStatusOutput, + OnNotificationOpenedOutput, + OnNotificationReceivedInBackgroundOutput, + OnNotificationReceivedInForegroundOutput, + OnTokenReceivedOutput, +} from './types/outputs'; diff --git a/packages/notifications/src/pushNotifications/providers/customer-profiles/types/apis.ts b/packages/notifications/src/pushNotifications/providers/customer-profiles/types/apis.ts new file mode 100644 index 00000000000..feec850f372 --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/customer-profiles/types/apis.ts @@ -0,0 +1,23 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { IdentifyUserInput, RegisterDeviceInput } from './inputs'; + +export { + GetBadgeCount, + GetLaunchNotification, + GetPermissionStatus, + InitializePushNotifications, + OnNotificationOpened, + OnNotificationReceivedInBackground, + OnNotificationReceivedInForeground, + OnTokenReceived, + RequestPermissions, + SetBadgeCount, +} from '../../shared/types'; + +export type IdentifyUser = (input: IdentifyUserInput) => Promise; + +export type RegisterDevice = (input: RegisterDeviceInput) => Promise; + +export type RemoveDevice = () => Promise; diff --git a/packages/notifications/src/pushNotifications/providers/customer-profiles/types/index.ts b/packages/notifications/src/pushNotifications/providers/customer-profiles/types/index.ts new file mode 100644 index 00000000000..867a21a21d5 --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/customer-profiles/types/index.ts @@ -0,0 +1,43 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { + GetBadgeCount, + GetLaunchNotification, + GetPermissionStatus, + IdentifyUser, + InitializePushNotifications, + OnNotificationOpened, + OnNotificationReceivedInBackground, + OnNotificationReceivedInForeground, + OnTokenReceived, + RegisterDevice, + RemoveDevice, + RequestPermissions, + SetBadgeCount, +} from './apis'; +export { + IdentifyUserInput, + OnNotificationOpenedInput, + OnNotificationReceivedInBackgroundInput, + OnNotificationReceivedInForegroundInput, + OnTokenReceivedInput, + RegisterDeviceInput, + RequestPermissionsInput, + SetBadgeCountInput, +} from './inputs'; +export { + GetLaunchNotificationOutput, + GetPermissionStatusOutput, + OnNotificationOpenedOutput, + OnNotificationReceivedInBackgroundOutput, + OnNotificationReceivedInForegroundOutput, + OnTokenReceivedOutput, +} from './outputs'; +export { + ChannelType, + InflightDeviceRegistration, + InflightDeviceRegistrationResolver, + UserProfile, + UserProfileLocation, +} from './pushNotifications'; diff --git a/packages/notifications/src/pushNotifications/providers/customer-profiles/types/inputs.ts b/packages/notifications/src/pushNotifications/providers/customer-profiles/types/inputs.ts new file mode 100644 index 00000000000..60b9dce8ba3 --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/customer-profiles/types/inputs.ts @@ -0,0 +1,30 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { UserProfile } from './pushNotifications'; + +export { + OnNotificationOpenedInput, + OnNotificationReceivedInBackgroundInput, + OnNotificationReceivedInForegroundInput, + OnTokenReceivedInput, + RequestPermissionsInput, + SetBadgeCountInput, +} from '../../shared/types'; + +/** + * Input for `identifyUser`. Profile-only: the Customer Profiles backend derives + * the caller's `principalId` server-side from the SigV4 signer identity, so no + * `userId` is sent by the client. + */ +export interface IdentifyUserInput { + userProfile: UserProfile; +} + +/** + * Input for `registerDevice`. The SDK internally supplies the remaining device + * fields (`deviceId`, `platform`, `appVersion`, `channelType`). + */ +export interface RegisterDeviceInput { + token: string; +} diff --git a/packages/notifications/src/pushNotifications/providers/customer-profiles/types/outputs.ts b/packages/notifications/src/pushNotifications/providers/customer-profiles/types/outputs.ts new file mode 100644 index 00000000000..fcf443a8a0f --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/customer-profiles/types/outputs.ts @@ -0,0 +1,13 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { + GetBadgeCountOutput, + GetLaunchNotificationOutput, + GetPermissionStatusOutput, + OnNotificationOpenedOutput, + OnNotificationReceivedInBackgroundOutput, + OnNotificationReceivedInForegroundOutput, + OnTokenReceivedOutput, + RequestPermissionsOutput, +} from '../../shared/types'; diff --git a/packages/notifications/src/pushNotifications/providers/customer-profiles/types/pushNotifications.ts b/packages/notifications/src/pushNotifications/providers/customer-profiles/types/pushNotifications.ts new file mode 100644 index 00000000000..4bfc1a4a085 --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/customer-profiles/types/pushNotifications.ts @@ -0,0 +1,38 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { + ChannelType, + InflightDeviceRegistration, + InflightDeviceRegistrationResolver, +} from '../../shared/types'; + +/** + * Geographic location associated with a Customer Profile. Mirrors the backend + * `Address` block on the Amazon Connect Customer Profile. + */ +export interface UserProfileLocation { + city?: string; + country?: string; + postalCode?: string; + region?: string; +} + +/** + * Profile information sent to Amazon Connect Customer Profiles by `identifyUser`. + * + * This is a provider-scoped shape that intentionally does NOT reuse the shared + * `@aws-amplify/core` (Pinpoint) `UserProfile`. It maps directly to the backend + * Customer Profiles contract. + */ +export interface UserProfile { + email?: string; + name?: string; + phone?: string; + /** + * Each key and value must be ≤ 255 characters. 'principalId' is reserved + * and rejected. + */ + customAttributes?: Record; + location?: UserProfileLocation; +} diff --git a/packages/notifications/src/pushNotifications/providers/customer-profiles/utils/getDeviceId.ts b/packages/notifications/src/pushNotifications/providers/customer-profiles/utils/getDeviceId.ts new file mode 100644 index 00000000000..465f28e2e18 --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/customer-profiles/utils/getDeviceId.ts @@ -0,0 +1,55 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { amplifyUuid } from '@aws-amplify/core/internals/utils'; +import { loadAsyncStorage } from '@aws-amplify/react-native'; + +const DEVICE_ID_STORAGE_KEY = + '@aws-amplify/notifications/customer-profiles/deviceId'; + +// In-module cache of the in-flight (and, once settled, resolved) deviceId +// resolution. Lives for the lifetime of the JS module instance (the app +// session): created on first call, shared by all later calls, and naturally +// discarded on app reload/restart (the persisted AsyncStorage value is then +// re-read). Never invalidated at runtime on success because the per-install +// deviceId is immutable; cleared on failure so a transient storage error does +// not permanently wedge subsequent calls. +let deviceIdPromise: Promise | undefined; + +const resolveOrCreateDeviceId = async (): Promise => { + const asyncStorage = loadAsyncStorage(); + const stored = await asyncStorage.getItem(DEVICE_ID_STORAGE_KEY); + if (stored) { + return stored; + } + + const deviceId = amplifyUuid(); + await asyncStorage.setItem(DEVICE_ID_STORAGE_KEY, deviceId); + + return deviceId; +}; + +/** + * Resolves a stable, per-install device identifier used as the UNIQUE key for + * the device object registered with Amazon Connect Customer Profiles. Because + * the backend upserts the device object by this `deviceId`, it MUST be stable + * across launches and token refreshes so a refreshed token replaces the same + * device object rather than creating a duplicate. + * + * The id is generated once (UUID v4) and persisted to AsyncStorage; subsequent + * calls return the persisted value. The resolution is memoized as a single + * in-flight promise, so concurrent first-calls share one resolution and cannot + * each generate and persist a different id. + * + * @internal + */ +export const getDeviceId = (): Promise => { + if (!deviceIdPromise) { + deviceIdPromise = resolveOrCreateDeviceId().catch(error => { + deviceIdPromise = undefined; + throw error; + }); + } + + return deviceIdPromise; +}; diff --git a/packages/notifications/src/pushNotifications/providers/customer-profiles/utils/identifyUserInternal.ts b/packages/notifications/src/pushNotifications/providers/customer-profiles/utils/identifyUserInternal.ts new file mode 100644 index 00000000000..0451c6c9781 --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/customer-profiles/utils/identifyUserInternal.ts @@ -0,0 +1,79 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PushNotificationAction } from '@aws-amplify/core/internals/utils'; + +import { ChannelType, UserProfile } from '../types'; + +import { + IDENTIFY_USER_PATH, + REGISTER_DEVICE_PATH, + REMOVE_DEVICE_PATH, +} from './resolveConfig'; +import { signedFetch } from './signedFetch'; +import { validateUserProfile } from './validateUserProfile'; + +/** + * The push-device object registered with Amazon Connect Customer Profiles. + * Matches the backend `register-device` wire contract. The `deviceId` is the + * stable per-install key the backend upserts on; `appVersion` is included for + * mobile parity but is currently sent empty (unsourced) by the client. + * + * @internal + */ +export interface DeviceRegistration { + token: string; + deviceId: string; + platform: string; + appVersion: string; + channelType: ChannelType; +} + +/** + * Sends a profile-only identify-user request. The backend derives `principalId` + * from the SigV4 signer identity, so no `userId` is sent. + * + * @internal + */ +export const identifyUserInternal = async ({ + userProfile, +}: { + userProfile?: UserProfile; +}): Promise => { + validateUserProfile(userProfile); + await signedFetch( + IDENTIFY_USER_PATH, + { userProfile: userProfile ?? {} }, + PushNotificationAction.IdentifyUser, + ); +}; + +/** + * Registers (upserts) a push device object. The wire shape nests the device + * fields under `device` to match the backend `register-device` contract. + * + * @internal + */ +export const registerDeviceInternal = async ( + device: DeviceRegistration, +): Promise => { + await signedFetch( + REGISTER_DEVICE_PATH, + { device }, + PushNotificationAction.RegisterDevice, + ); +}; + +/** + * De-registers a push device object. The backend gates removal on the caller's + * server-derived `principalId`. + * + * @internal + */ +export const removeDeviceInternal = async (deviceId: string): Promise => { + await signedFetch( + REMOVE_DEVICE_PATH, + { deviceId }, + PushNotificationAction.RemoveDevice, + ); +}; diff --git a/packages/notifications/src/pushNotifications/providers/customer-profiles/utils/index.ts b/packages/notifications/src/pushNotifications/providers/customer-profiles/utils/index.ts new file mode 100644 index 00000000000..1137697533a --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/customer-profiles/utils/index.ts @@ -0,0 +1,25 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { getDeviceId } from './getDeviceId'; +export { + getChannelType, + getInflightDeviceRegistration, + rejectInflightDeviceRegistration, + resolveInflightDeviceRegistration, +} from '../../shared/utils'; +export { + DeviceRegistration, + identifyUserInternal, + registerDeviceInternal, + removeDeviceInternal, +} from './identifyUserInternal'; +export { signedFetch } from './signedFetch'; +export { validateUserProfile } from './validateUserProfile'; +export { + resolveConfig, + IDENTIFY_USER_PATH, + REGISTER_DEVICE_PATH, + REMOVE_DEVICE_PATH, +} from './resolveConfig'; +export { resolveCredentials } from './resolveCredentials'; diff --git a/packages/notifications/src/pushNotifications/providers/customer-profiles/utils/resolveConfig.ts b/packages/notifications/src/pushNotifications/providers/customer-profiles/utils/resolveConfig.ts new file mode 100644 index 00000000000..07ecc72457d --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/customer-profiles/utils/resolveConfig.ts @@ -0,0 +1,88 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; + +import { + PushNotificationError, + PushNotificationValidationErrorCode, + assert, +} from '../../../errors'; + +/** + * Path of the identify-user route on the Amazon Connect Customer Profiles REST + * endpoint. Associates the caller's `userProfile` with their Customer Profile. + * The backend derives `principalId` server-side from the SigV4 signer identity. + * + * @internal + */ +export const IDENTIFY_USER_PATH = '/identify-user'; + +/** + * Path of the register-device route. Registers (upserts) a push device object, + * keyed on the caller's server-derived `principalId`. + * + * @internal + */ +export const REGISTER_DEVICE_PATH = '/register-device'; + +/** + * Path of the remove-device route. De-registers a push device object. The + * backend gates removal on the caller's server-derived `principalId`. + * + * @internal + */ +export const REMOVE_DEVICE_PATH = '/remove-device'; + +const escapeRegExp = (value: string) => + value.replace(/[.*+?^${}()|[\]\\]/g, matched => `\\${matched}`); + +/** + * Requests to the configured endpoint are SigV4-signed for `execute-api`, so the + * host is restricted to the API Gateway host of the resolved region — + * `.execute-api..amazonaws.com`. Without this, a misconfigured + * or attacker-supplied endpoint would receive the signed credentials. + */ +const buildAllowedHostRegExp = (region: string) => + new RegExp( + `^[a-z0-9-]+\\.execute-api\\.${escapeRegExp(region)}\\.amazonaws\\.com$`, + ); + +/** + * @internal + */ +export const resolveConfig = () => { + const { endpoint, region } = + Amplify.getConfig().Notifications?.PushNotification?.CustomerProfiles ?? {}; + assert(!!endpoint, PushNotificationValidationErrorCode.NoEndpoint); + assert(!!region, PushNotificationValidationErrorCode.NoRegion); + + let parsedEndpoint: URL; + try { + parsedEndpoint = new URL(endpoint); + } catch (underlyingError) { + throw new PushNotificationError({ + name: PushNotificationValidationErrorCode.InvalidEndpoint, + message: 'The configured Customer Profiles endpoint is invalid.', + recoverySuggestion: + 'Ensure the endpoint in your Amplify configuration is a valid https:// URL on the API Gateway host for the configured region, for example https://.execute-api..amazonaws.com.', + underlyingError, + }); + } + assert( + parsedEndpoint.protocol === 'https:', + PushNotificationValidationErrorCode.InvalidEndpoint, + ); + assert( + buildAllowedHostRegExp(region).test(parsedEndpoint.hostname), + PushNotificationValidationErrorCode.InvalidEndpoint, + ); + + // Only trailing slashes are stripped, so `{endpoint}{path}` never produces a + // double slash. `origin` is deliberately not used: it would drop an API + // Gateway stage path such as `/prod`. + return { + endpoint: parsedEndpoint.href.replace(/\/+$/, ''), + region, + }; +}; diff --git a/packages/notifications/src/pushNotifications/providers/customer-profiles/utils/resolveCredentials.ts b/packages/notifications/src/pushNotifications/providers/customer-profiles/utils/resolveCredentials.ts new file mode 100644 index 00000000000..430477da5ae --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/customer-profiles/utils/resolveCredentials.ts @@ -0,0 +1,34 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { fetchAuthSession } from '@aws-amplify/core'; + +import { PushNotificationError } from '../../../errors'; +import { PushNotificationValidationErrorCode } from '../../../errors/errorHelpers'; + +/** + * Resolves the Identity Pool credentials used to SigV4-sign requests to the + * Amazon Connect Customer Profiles endpoint. + * + * The same credentials are used for authenticated (Cognito user-pool users + * assume the Identity Pool auth role) and guest (Identity Pool unauth role) + * callers — both are signed identically with `execute-api` SigV4. The backend + * derives `principalId` from the signer identity, so no identity field is sent + * by the client. + * + * @internal + */ +export const resolveCredentials = async () => { + const { credentials } = await fetchAuthSession(); + + // Explicit throw (not assert) so TypeScript narrows `credentials` to + // non-undefined for the returned value. + if (!credentials) { + throw new PushNotificationError({ + name: PushNotificationValidationErrorCode.NoCredentials, + message: 'Credentials should not be empty.', + }); + } + + return { credentials }; +}; diff --git a/packages/notifications/src/pushNotifications/providers/customer-profiles/utils/signedFetch.ts b/packages/notifications/src/pushNotifications/providers/customer-profiles/utils/signedFetch.ts new file mode 100644 index 00000000000..52b6634d8ea --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/customer-profiles/utils/signedFetch.ts @@ -0,0 +1,90 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { signRequest } from '@aws-amplify/core/internals/aws-client-utils'; +import { + Category, + PushNotificationAction, + getAmplifyUserAgent, +} from '@aws-amplify/core/internals/utils'; + +import { PushNotificationError } from '../../../errors'; + +import { resolveConfig } from './resolveConfig'; +import { resolveCredentials } from './resolveCredentials'; + +const CONTENT_TYPE = 'application/json'; +const SIGNING_SERVICE = 'execute-api'; +const USER_AGENT_HEADER = 'x-amz-user-agent'; + +/** + * SigV4-signs (`execute-api`) and POSTs a JSON body to `{endpoint}{path}` on the + * Amazon Connect Customer Profiles REST endpoint. A single signer serves all + * three routes for both authenticated and guest callers — the Identity Pool + * credentials resolved from the current auth session are used to sign, and the + * backend derives `principalId` from the signer identity. + * + * @throws service/network: {@link PushNotificationError} - Thrown when the + * request cannot be completed or the endpoint responds with a non-2xx status. + * + * @internal + */ +export const signedFetch = async ( + path: string, + body: unknown, + action: PushNotificationAction, +): Promise => { + const { endpoint, region } = resolveConfig(); + const { credentials } = await resolveCredentials(); + + const serializedBody = JSON.stringify(body); + const url = new URL(`${endpoint}${path}`); + const signed = signRequest( + { + method: 'POST', + url, + headers: { + 'content-type': CONTENT_TYPE, + // Attach the Amplify telemetry user-agent BEFORE signing so it is + // covered by the SigV4 signature and the sent headers match. + [USER_AGENT_HEADER]: getAmplifyUserAgent({ + category: Category.PushNotification, + action, + }), + }, + body: serializedBody, + }, + { + credentials, + signingRegion: region, + signingService: SIGNING_SERVICE, + }, + ); + + let response: Response; + try { + response = await fetch(signed.url.toString(), { + method: 'POST', + headers: signed.headers, + body: serializedBody, + }); + } catch (underlyingError) { + throw new PushNotificationError({ + name: 'NetworkException', + message: + 'The request to the Amazon Connect Customer Profiles endpoint failed to complete.', + recoverySuggestion: + 'Check your network connection and ensure the configured Customer Profiles endpoint is reachable.', + underlyingError, + }); + } + + if (!response.ok) { + throw new PushNotificationError({ + name: 'ServiceException', + message: `The Amazon Connect Customer Profiles endpoint responded with status ${response.status}.`, + recoverySuggestion: + 'Ensure the configured Customer Profiles endpoint is reachable and that the request is authorized.', + }); + } +}; diff --git a/packages/notifications/src/pushNotifications/providers/customer-profiles/utils/validateUserProfile.ts b/packages/notifications/src/pushNotifications/providers/customer-profiles/utils/validateUserProfile.ts new file mode 100644 index 00000000000..fa20639f97c --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/customer-profiles/utils/validateUserProfile.ts @@ -0,0 +1,97 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PushNotificationValidationErrorCode, assert } from '../../../errors'; +import { UserProfile } from '../types'; + +// Mirrors amplify-backend validateIdentifyUser — keep in sync. +const MAX_ATTRIBUTE_LENGTH = 255; +const RESERVED_ATTRIBUTE_KEYS = new Set(['principalId']); + +const isValidOptionalString = (value: unknown): boolean => + value === undefined || + (typeof value === 'string' && value.length <= MAX_ATTRIBUTE_LENGTH); + +/** + * Validates a {@link UserProfile} before it is sent to Amazon Connect Customer + * Profiles. This is a client-side defense-in-depth check that mirrors the + * backend `validateIdentifyUser` bounds exactly (no drift): every string field + * must be at most 255 characters, and `customAttributes` must be a plain object + * whose keys/values are strings of at most 255 characters, excluding the + * reserved `principalId` key. + * + * @param userProfile - The profile to validate. `undefined` is allowed. + * @throws validation: {@link PushNotificationValidationErrorCode.InvalidUserProfile} + * - Thrown when any field violates the length/type/reserved-key constraints. + * + * @internal + */ +export const validateUserProfile = (userProfile?: UserProfile): void => { + if (userProfile == null) { + return; + } + + const { email, name, phone, location, customAttributes } = userProfile; + + assert( + isValidOptionalString(email), + PushNotificationValidationErrorCode.InvalidUserProfile, + ); + assert( + isValidOptionalString(name), + PushNotificationValidationErrorCode.InvalidUserProfile, + ); + assert( + isValidOptionalString(phone), + PushNotificationValidationErrorCode.InvalidUserProfile, + ); + + if (location !== undefined) { + assert( + typeof location === 'object' && + location !== null && + !Array.isArray(location), + PushNotificationValidationErrorCode.InvalidUserProfile, + ); + assert( + isValidOptionalString(location.city), + PushNotificationValidationErrorCode.InvalidUserProfile, + ); + assert( + isValidOptionalString(location.country), + PushNotificationValidationErrorCode.InvalidUserProfile, + ); + assert( + isValidOptionalString(location.postalCode), + PushNotificationValidationErrorCode.InvalidUserProfile, + ); + assert( + isValidOptionalString(location.region), + PushNotificationValidationErrorCode.InvalidUserProfile, + ); + } + + if (customAttributes !== undefined) { + assert( + typeof customAttributes === 'object' && + customAttributes !== null && + !Array.isArray(customAttributes), + PushNotificationValidationErrorCode.InvalidUserProfile, + ); + + for (const [key, value] of Object.entries(customAttributes)) { + assert( + !RESERVED_ATTRIBUTE_KEYS.has(key), + PushNotificationValidationErrorCode.InvalidUserProfile, + ); + assert( + key.length <= MAX_ATTRIBUTE_LENGTH, + PushNotificationValidationErrorCode.InvalidUserProfile, + ); + assert( + typeof value === 'string' && value.length <= MAX_ATTRIBUTE_LENGTH, + PushNotificationValidationErrorCode.InvalidUserProfile, + ); + } + } +}; diff --git a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/index.ts b/packages/notifications/src/pushNotifications/providers/pinpoint/apis/index.ts index 3d33df41ce3..d47975d7d69 100644 --- a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/index.ts +++ b/packages/notifications/src/pushNotifications/providers/pinpoint/apis/index.ts @@ -1,14 +1,16 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export { getBadgeCount } from './getBadgeCount'; -export { getLaunchNotification } from './getLaunchNotification'; -export { getPermissionStatus } from './getPermissionStatus'; +export { + getBadgeCount, + getLaunchNotification, + getPermissionStatus, + onNotificationOpened, + onNotificationReceivedInBackground, + onNotificationReceivedInForeground, + onTokenReceived, + requestPermissions, + setBadgeCount, +} from '../../shared/apis'; export { identifyUser } from './identifyUser'; export { initializePushNotifications } from './initializePushNotifications'; -export { onNotificationOpened } from './onNotificationOpened'; -export { onNotificationReceivedInBackground } from './onNotificationReceivedInBackground'; -export { onNotificationReceivedInForeground } from './onNotificationReceivedInForeground'; -export { onTokenReceived } from './onTokenReceived'; -export { requestPermissions } from './requestPermissions'; -export { setBadgeCount } from './setBadgeCount'; diff --git a/packages/notifications/src/pushNotifications/providers/pinpoint/types/apis.ts b/packages/notifications/src/pushNotifications/providers/pinpoint/types/apis.ts index caeb6022b3a..5311de74274 100644 --- a/packages/notifications/src/pushNotifications/providers/pinpoint/types/apis.ts +++ b/packages/notifications/src/pushNotifications/providers/pinpoint/types/apis.ts @@ -1,54 +1,19 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { - IdentifyUserInput, - OnNotificationOpenedInput, - OnNotificationReceivedInBackgroundInput, - OnNotificationReceivedInForegroundInput, - OnTokenReceivedInput, - RequestPermissionsInput, - SetBadgeCountInput, -} from './inputs'; -import { - GetBadgeCountOutput, - GetLaunchNotificationOutput, - GetPermissionStatusOutput, - OnNotificationOpenedOutput, - OnNotificationReceivedInBackgroundOutput, - OnNotificationReceivedInForegroundOutput, - OnTokenReceivedOutput, - RequestPermissionsOutput, -} from './outputs'; - -export type GetBadgeCount = () => Promise; - -export type GetLaunchNotification = () => Promise; - -export type GetPermissionStatus = () => Promise; +import { IdentifyUserInput } from './inputs'; + +export { + GetBadgeCount, + GetLaunchNotification, + GetPermissionStatus, + InitializePushNotifications, + OnNotificationOpened, + OnNotificationReceivedInBackground, + OnNotificationReceivedInForeground, + OnTokenReceived, + RequestPermissions, + SetBadgeCount, +} from '../../shared/types'; export type IdentifyUser = (input: IdentifyUserInput) => Promise; - -export type InitializePushNotifications = () => void; - -export type RequestPermissions = ( - input?: RequestPermissionsInput, -) => Promise; - -export type SetBadgeCount = (input: SetBadgeCountInput) => void; - -export type OnNotificationOpened = ( - input: OnNotificationOpenedInput, -) => OnNotificationOpenedOutput; - -export type OnNotificationReceivedInBackground = ( - input: OnNotificationReceivedInBackgroundInput, -) => OnNotificationReceivedInBackgroundOutput; - -export type OnNotificationReceivedInForeground = ( - input: OnNotificationReceivedInForegroundInput, -) => OnNotificationReceivedInForegroundOutput; - -export type OnTokenReceived = ( - input: OnTokenReceivedInput, -) => OnTokenReceivedOutput; diff --git a/packages/notifications/src/pushNotifications/providers/pinpoint/types/inputs.ts b/packages/notifications/src/pushNotifications/providers/pinpoint/types/inputs.ts index fc508f6285e..f7faadcb389 100644 --- a/packages/notifications/src/pushNotifications/providers/pinpoint/types/inputs.ts +++ b/packages/notifications/src/pushNotifications/providers/pinpoint/types/inputs.ts @@ -1,32 +1,18 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { - PushNotificationIdentifyUserInput, - PushNotificationOnNotificationOpenedInput, - PushNotificationOnNotificationReceivedInBackgroundInput, - PushNotificationOnNotificationReceivedInForegroundInput, - PushNotificationOnTokenReceivedInput, - PushNotificationRequestPermissionsInput, - PushNotificationSetBadgeCountInput, -} from '../../../types'; +import { PushNotificationIdentifyUserInput } from '../../../types'; import { IdentifyUserOptions } from './options'; +export { + OnNotificationOpenedInput, + OnNotificationReceivedInBackgroundInput, + OnNotificationReceivedInForegroundInput, + OnTokenReceivedInput, + RequestPermissionsInput, + SetBadgeCountInput, +} from '../../shared/types'; + export type IdentifyUserInput = PushNotificationIdentifyUserInput; - -export type RequestPermissionsInput = PushNotificationRequestPermissionsInput; - -export type SetBadgeCountInput = PushNotificationSetBadgeCountInput; - -export type OnNotificationOpenedInput = - PushNotificationOnNotificationOpenedInput; - -export type OnNotificationReceivedInBackgroundInput = - PushNotificationOnNotificationReceivedInBackgroundInput; - -export type OnNotificationReceivedInForegroundInput = - PushNotificationOnNotificationReceivedInForegroundInput; - -export type OnTokenReceivedInput = PushNotificationOnTokenReceivedInput; diff --git a/packages/notifications/src/pushNotifications/providers/pinpoint/types/outputs.ts b/packages/notifications/src/pushNotifications/providers/pinpoint/types/outputs.ts index a28106178e6..fcf443a8a0f 100644 --- a/packages/notifications/src/pushNotifications/providers/pinpoint/types/outputs.ts +++ b/packages/notifications/src/pushNotifications/providers/pinpoint/types/outputs.ts @@ -1,34 +1,13 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { - PushNotificationGetBadgeCountOutput, - PushNotificationGetLaunchNotificationOutput, - PushNotificationGetPermissionStatusOutput, - PushNotificationOnNotificationOpenedOutput, - PushNotificationOnNotificationReceivedInBackgroundOutput, - PushNotificationOnNotificationReceivedInForegroundOutput, - PushNotificationOnTokenReceivedOutput, - PushNotificationRequestPermissionsOutput, -} from '../../../types'; - -export type GetBadgeCountOutput = PushNotificationGetBadgeCountOutput; - -export type GetLaunchNotificationOutput = - PushNotificationGetLaunchNotificationOutput; - -export type GetPermissionStatusOutput = - PushNotificationGetPermissionStatusOutput; - -export type RequestPermissionsOutput = PushNotificationRequestPermissionsOutput; - -export type OnNotificationOpenedOutput = - PushNotificationOnNotificationOpenedOutput; - -export type OnNotificationReceivedInBackgroundOutput = - PushNotificationOnNotificationReceivedInBackgroundOutput; - -export type OnNotificationReceivedInForegroundOutput = - PushNotificationOnNotificationReceivedInForegroundOutput; - -export type OnTokenReceivedOutput = PushNotificationOnTokenReceivedOutput; +export { + GetBadgeCountOutput, + GetLaunchNotificationOutput, + GetPermissionStatusOutput, + OnNotificationOpenedOutput, + OnNotificationReceivedInBackgroundOutput, + OnNotificationReceivedInForegroundOutput, + OnTokenReceivedOutput, + RequestPermissionsOutput, +} from '../../shared/types'; diff --git a/packages/notifications/src/pushNotifications/providers/pinpoint/types/pushNotifications.ts b/packages/notifications/src/pushNotifications/providers/pinpoint/types/pushNotifications.ts index bc4590edc00..57c722c50ad 100644 --- a/packages/notifications/src/pushNotifications/providers/pinpoint/types/pushNotifications.ts +++ b/packages/notifications/src/pushNotifications/providers/pinpoint/types/pushNotifications.ts @@ -1,15 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { updateEndpoint } from '@aws-amplify/core/internals/providers/pinpoint'; - -import { PushNotificationError } from '../../../errors'; - -export type ChannelType = Parameters[0]['channelType']; - -export type InflightDeviceRegistration = Promise | undefined; - -export interface InflightDeviceRegistrationResolver { - resolve?(): void; - reject?(error: PushNotificationError): void; -} +export { + ChannelType, + InflightDeviceRegistration, + InflightDeviceRegistrationResolver, +} from '../../shared/types'; diff --git a/packages/notifications/src/pushNotifications/providers/pinpoint/utils/createMessageEventRecorder.ts b/packages/notifications/src/pushNotifications/providers/pinpoint/utils/createMessageEventRecorder.ts index c02baee6f2a..c6bf76cfae5 100644 --- a/packages/notifications/src/pushNotifications/providers/pinpoint/utils/createMessageEventRecorder.ts +++ b/packages/notifications/src/pushNotifications/providers/pinpoint/utils/createMessageEventRecorder.ts @@ -11,9 +11,9 @@ import { PushNotificationMessage, } from '../../../types'; import { resolveCredentials } from '../../../utils'; +import { getChannelType } from '../../shared/utils'; import { getAnalyticsEvent } from './getAnalyticsEvent'; -import { getChannelType } from './getChannelType'; import { resolveConfig } from './resolveConfig'; const logger = new ConsoleLogger('PushNotification.recordMessageEvent'); diff --git a/packages/notifications/src/pushNotifications/providers/pinpoint/utils/index.ts b/packages/notifications/src/pushNotifications/providers/pinpoint/utils/index.ts index 8551b1e1e02..8b29c73557b 100644 --- a/packages/notifications/src/pushNotifications/providers/pinpoint/utils/index.ts +++ b/packages/notifications/src/pushNotifications/providers/pinpoint/utils/index.ts @@ -3,10 +3,10 @@ export { createMessageEventRecorder } from './createMessageEventRecorder'; export { getAnalyticsEvent } from './getAnalyticsEvent'; -export { getChannelType } from './getChannelType'; export { + getChannelType, getInflightDeviceRegistration, rejectInflightDeviceRegistration, resolveInflightDeviceRegistration, -} from './inflightDeviceRegistration'; +} from '../../shared/utils'; export { resolveConfig } from './resolveConfig'; diff --git a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/getBadgeCount.native.ts b/packages/notifications/src/pushNotifications/providers/shared/apis/getBadgeCount.native.ts similarity index 100% rename from packages/notifications/src/pushNotifications/providers/pinpoint/apis/getBadgeCount.native.ts rename to packages/notifications/src/pushNotifications/providers/shared/apis/getBadgeCount.native.ts diff --git a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/getBadgeCount.ts b/packages/notifications/src/pushNotifications/providers/shared/apis/getBadgeCount.ts similarity index 91% rename from packages/notifications/src/pushNotifications/providers/pinpoint/apis/getBadgeCount.ts rename to packages/notifications/src/pushNotifications/providers/shared/apis/getBadgeCount.ts index bee35f8c391..d6830251152 100644 --- a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/getBadgeCount.ts +++ b/packages/notifications/src/pushNotifications/providers/shared/apis/getBadgeCount.ts @@ -9,8 +9,6 @@ import { GetBadgeCount } from '../types'; * Returns the current badge count (the number next to your app's icon). This function is safe to call (but will be * ignored) even when your React Native app is running on platforms where badges are not supported. * - * @deprecated AWS will end support for Amazon Pinpoint on October 30, 2026. - * * @throws platform: {@link PlatformNotSupportedError} - Thrown if called against an unsupported platform. Currently, * only React Native is supported by this API. * @returns A promise that resolves to a number representing the current count displayed on the app badge. diff --git a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/getLaunchNotification.native.ts b/packages/notifications/src/pushNotifications/providers/shared/apis/getLaunchNotification.native.ts similarity index 100% rename from packages/notifications/src/pushNotifications/providers/pinpoint/apis/getLaunchNotification.native.ts rename to packages/notifications/src/pushNotifications/providers/shared/apis/getLaunchNotification.native.ts diff --git a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/getLaunchNotification.ts b/packages/notifications/src/pushNotifications/providers/shared/apis/getLaunchNotification.ts similarity index 94% rename from packages/notifications/src/pushNotifications/providers/pinpoint/apis/getLaunchNotification.ts rename to packages/notifications/src/pushNotifications/providers/shared/apis/getLaunchNotification.ts index af6b67e89bb..60d72444520 100644 --- a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/getLaunchNotification.ts +++ b/packages/notifications/src/pushNotifications/providers/shared/apis/getLaunchNotification.ts @@ -13,8 +13,6 @@ import { GetLaunchNotification, GetLaunchNotificationOutput } from '../types'; * 2. Another notification was opened while your app was running (either in foreground or background) * 3. Your app was brought back to the foreground by some other means (e.g. user tapped the app icon) * - * @deprecated AWS will end support for Amazon Pinpoint on October 30, 2026. - * * @throws platform: {@link PlatformNotSupportedError} - Thrown if called against an unsupported platform. Currently, * only React Native is supported by this API. * @returns {Promise} - a promise resolving to {@link PushNotificationMessage} if there is diff --git a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/getPermissionStatus.native.ts b/packages/notifications/src/pushNotifications/providers/shared/apis/getPermissionStatus.native.ts similarity index 100% rename from packages/notifications/src/pushNotifications/providers/pinpoint/apis/getPermissionStatus.native.ts rename to packages/notifications/src/pushNotifications/providers/shared/apis/getPermissionStatus.native.ts diff --git a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/getPermissionStatus.ts b/packages/notifications/src/pushNotifications/providers/shared/apis/getPermissionStatus.ts similarity index 95% rename from packages/notifications/src/pushNotifications/providers/pinpoint/apis/getPermissionStatus.ts rename to packages/notifications/src/pushNotifications/providers/shared/apis/getPermissionStatus.ts index ed56e420be4..a776273817b 100644 --- a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/getPermissionStatus.ts +++ b/packages/notifications/src/pushNotifications/providers/shared/apis/getPermissionStatus.ts @@ -22,8 +22,6 @@ import { GetPermissionStatus, GetPermissionStatusOutput } from '../types'; * trigger a permission dialog. Your app should now either degrade gracefully or prompt your user to grant the * permissions needed in their device settings. * - * @deprecated AWS will end support for Amazon Pinpoint on October 30, 2026. - * * @throws platform: {@link PlatformNotSupportedError} - Thrown if called against an unsupported platform. Currently, * only React Native is supported by this API. * @return {Promise} a promise resolving to a string representing the current status of user diff --git a/packages/notifications/src/pushNotifications/providers/shared/apis/index.ts b/packages/notifications/src/pushNotifications/providers/shared/apis/index.ts new file mode 100644 index 00000000000..7c4cf889fb7 --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/shared/apis/index.ts @@ -0,0 +1,12 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { getBadgeCount } from './getBadgeCount'; +export { getLaunchNotification } from './getLaunchNotification'; +export { getPermissionStatus } from './getPermissionStatus'; +export { onNotificationOpened } from './onNotificationOpened'; +export { onNotificationReceivedInBackground } from './onNotificationReceivedInBackground'; +export { onNotificationReceivedInForeground } from './onNotificationReceivedInForeground'; +export { onTokenReceived } from './onTokenReceived'; +export { requestPermissions } from './requestPermissions'; +export { setBadgeCount } from './setBadgeCount'; diff --git a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/onNotificationOpened.native.ts b/packages/notifications/src/pushNotifications/providers/shared/apis/onNotificationOpened.native.ts similarity index 100% rename from packages/notifications/src/pushNotifications/providers/pinpoint/apis/onNotificationOpened.native.ts rename to packages/notifications/src/pushNotifications/providers/shared/apis/onNotificationOpened.native.ts diff --git a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/onNotificationOpened.ts b/packages/notifications/src/pushNotifications/providers/shared/apis/onNotificationOpened.ts similarity index 93% rename from packages/notifications/src/pushNotifications/providers/pinpoint/apis/onNotificationOpened.ts rename to packages/notifications/src/pushNotifications/providers/shared/apis/onNotificationOpened.ts index 3387cb0d53b..217637e00b1 100644 --- a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/onNotificationOpened.ts +++ b/packages/notifications/src/pushNotifications/providers/shared/apis/onNotificationOpened.ts @@ -13,8 +13,6 @@ import { /** * Registers a listener that will be triggered when a notification is opened by user. * - * @deprecated AWS will end support for Amazon Pinpoint on October 30, 2026. - * * @param {OnNotificationOpenedInput} input - A callback handler to be invoked with the opened * {@link PushNotificationMessage}. * @returns {OnNotificationOpenedOutput} - An object with a remove function to remove the listener. diff --git a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/onNotificationReceivedInBackground.native.ts b/packages/notifications/src/pushNotifications/providers/shared/apis/onNotificationReceivedInBackground.native.ts similarity index 100% rename from packages/notifications/src/pushNotifications/providers/pinpoint/apis/onNotificationReceivedInBackground.native.ts rename to packages/notifications/src/pushNotifications/providers/shared/apis/onNotificationReceivedInBackground.native.ts diff --git a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/onNotificationReceivedInBackground.ts b/packages/notifications/src/pushNotifications/providers/shared/apis/onNotificationReceivedInBackground.ts similarity index 96% rename from packages/notifications/src/pushNotifications/providers/pinpoint/apis/onNotificationReceivedInBackground.ts rename to packages/notifications/src/pushNotifications/providers/shared/apis/onNotificationReceivedInBackground.ts index 0c10250ebac..87d63a72194 100644 --- a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/onNotificationReceivedInBackground.ts +++ b/packages/notifications/src/pushNotifications/providers/shared/apis/onNotificationReceivedInBackground.ts @@ -13,8 +13,6 @@ import { /** * Registers a listener that will be triggered when a notification is received while app is in a background state. * - * @deprecated AWS will end support for Amazon Pinpoint on October 30, 2026. - * * @throws platform: {@link PlatformNotSupportedError} - Thrown if called against an unsupported platform. Currently, * only React Native is supported by this API. * @param {OnNotificationReceivedInBackgroundInput} input - A callback handler to be invoked with the received diff --git a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/onNotificationReceivedInForeground.native.ts b/packages/notifications/src/pushNotifications/providers/shared/apis/onNotificationReceivedInForeground.native.ts similarity index 100% rename from packages/notifications/src/pushNotifications/providers/pinpoint/apis/onNotificationReceivedInForeground.native.ts rename to packages/notifications/src/pushNotifications/providers/shared/apis/onNotificationReceivedInForeground.native.ts diff --git a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/onNotificationReceivedInForeground.ts b/packages/notifications/src/pushNotifications/providers/shared/apis/onNotificationReceivedInForeground.ts similarity index 94% rename from packages/notifications/src/pushNotifications/providers/pinpoint/apis/onNotificationReceivedInForeground.ts rename to packages/notifications/src/pushNotifications/providers/shared/apis/onNotificationReceivedInForeground.ts index 66335c15dd6..9d032712c8d 100644 --- a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/onNotificationReceivedInForeground.ts +++ b/packages/notifications/src/pushNotifications/providers/shared/apis/onNotificationReceivedInForeground.ts @@ -13,8 +13,6 @@ import { /** * Registers a listener that will be triggered when a notification is received while app is in a foreground state. * - * @deprecated AWS will end support for Amazon Pinpoint on October 30, 2026. - * * @param {OnNotificationReceivedInForegroundInput} input - A callback handler to be invoked with the received * {@link PushNotificationMessage}. * @returns {OnNotificationReceivedInForegroundOutput} - An object with a remove function to remove the listener. diff --git a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/onTokenReceived.native.ts b/packages/notifications/src/pushNotifications/providers/shared/apis/onTokenReceived.native.ts similarity index 100% rename from packages/notifications/src/pushNotifications/providers/pinpoint/apis/onTokenReceived.native.ts rename to packages/notifications/src/pushNotifications/providers/shared/apis/onTokenReceived.native.ts diff --git a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/onTokenReceived.ts b/packages/notifications/src/pushNotifications/providers/shared/apis/onTokenReceived.ts similarity index 93% rename from packages/notifications/src/pushNotifications/providers/pinpoint/apis/onTokenReceived.ts rename to packages/notifications/src/pushNotifications/providers/shared/apis/onTokenReceived.ts index 92b45aef9bb..cf0fd2910ea 100644 --- a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/onTokenReceived.ts +++ b/packages/notifications/src/pushNotifications/providers/shared/apis/onTokenReceived.ts @@ -14,8 +14,6 @@ import { * 1. On every app launch, including the first install * 2. When a token changes (this may happen if the service invalidates the token for any reason) * - * @deprecated AWS will end support for Amazon Pinpoint on October 30, 2026. - * * @param {OnTokenReceivedInput} input - A callback handler to be invoked with the token. * @returns {OnTokenReceivedOutput} - An object with a remove function to remove the listener. * @example diff --git a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/requestPermissions.native.ts b/packages/notifications/src/pushNotifications/providers/shared/apis/requestPermissions.native.ts similarity index 100% rename from packages/notifications/src/pushNotifications/providers/pinpoint/apis/requestPermissions.native.ts rename to packages/notifications/src/pushNotifications/providers/shared/apis/requestPermissions.native.ts diff --git a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/requestPermissions.ts b/packages/notifications/src/pushNotifications/providers/shared/apis/requestPermissions.ts similarity index 95% rename from packages/notifications/src/pushNotifications/providers/pinpoint/apis/requestPermissions.ts rename to packages/notifications/src/pushNotifications/providers/shared/apis/requestPermissions.ts index 450c28320c1..fc6b1579a2b 100644 --- a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/requestPermissions.ts +++ b/packages/notifications/src/pushNotifications/providers/shared/apis/requestPermissions.ts @@ -17,8 +17,6 @@ import { RequestPermissions } from '../types'; * * * `badge`: When set to true, requests the ability to update the app's badge. * - * @deprecated AWS will end support for Amazon Pinpoint on October 30, 2026. - * * @throws platform: {@link PlatformNotSupportedError} - Thrown if called against an unsupported platform. Currently, * only React Native is supported by this API. * @returns A promise that resolves to true if requested permissions are granted or have already previously been diff --git a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/setBadgeCount.native.ts b/packages/notifications/src/pushNotifications/providers/shared/apis/setBadgeCount.native.ts similarity index 100% rename from packages/notifications/src/pushNotifications/providers/pinpoint/apis/setBadgeCount.native.ts rename to packages/notifications/src/pushNotifications/providers/shared/apis/setBadgeCount.native.ts diff --git a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/setBadgeCount.ts b/packages/notifications/src/pushNotifications/providers/shared/apis/setBadgeCount.ts similarity index 91% rename from packages/notifications/src/pushNotifications/providers/pinpoint/apis/setBadgeCount.ts rename to packages/notifications/src/pushNotifications/providers/shared/apis/setBadgeCount.ts index 91f4ec66512..9f58eb8b010 100644 --- a/packages/notifications/src/pushNotifications/providers/pinpoint/apis/setBadgeCount.ts +++ b/packages/notifications/src/pushNotifications/providers/shared/apis/setBadgeCount.ts @@ -10,8 +10,6 @@ import { SetBadgeCount } from '../types'; * to 0 (zero) will remove the badge from your app's icon. This function is safe to call (but will be ignored) even * when your React Native app is running on platforms where badges are not supported. * - * @deprecated AWS will end support for Amazon Pinpoint on October 30, 2026. - * * @throws platform: {@link PlatformNotSupportedError} - Thrown if called against an unsupported platform. Currently, * only React Native is supported by this API. * @example diff --git a/packages/notifications/src/pushNotifications/providers/shared/index.ts b/packages/notifications/src/pushNotifications/providers/shared/index.ts new file mode 100644 index 00000000000..e859731bfb5 --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/shared/index.ts @@ -0,0 +1,49 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { + getBadgeCount, + getLaunchNotification, + getPermissionStatus, + onNotificationOpened, + onNotificationReceivedInBackground, + onNotificationReceivedInForeground, + onTokenReceived, + requestPermissions, + setBadgeCount, +} from './apis'; +export { + getChannelType, + getInflightDeviceRegistration, + rejectInflightDeviceRegistration, + resolveInflightDeviceRegistration, +} from './utils'; +export { + ChannelType, + GetBadgeCount, + GetBadgeCountOutput, + GetLaunchNotification, + GetLaunchNotificationOutput, + GetPermissionStatus, + GetPermissionStatusOutput, + InflightDeviceRegistration, + InflightDeviceRegistrationResolver, + InitializePushNotifications, + OnNotificationOpened, + OnNotificationOpenedInput, + OnNotificationOpenedOutput, + OnNotificationReceivedInBackground, + OnNotificationReceivedInBackgroundInput, + OnNotificationReceivedInBackgroundOutput, + OnNotificationReceivedInForeground, + OnNotificationReceivedInForegroundInput, + OnNotificationReceivedInForegroundOutput, + OnTokenReceived, + OnTokenReceivedInput, + OnTokenReceivedOutput, + RequestPermissions, + RequestPermissionsInput, + RequestPermissionsOutput, + SetBadgeCount, + SetBadgeCountInput, +} from './types'; diff --git a/packages/notifications/src/pushNotifications/providers/shared/types/apis.ts b/packages/notifications/src/pushNotifications/providers/shared/types/apis.ts new file mode 100644 index 00000000000..3c3ff206f96 --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/shared/types/apis.ts @@ -0,0 +1,51 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + OnNotificationOpenedInput, + OnNotificationReceivedInBackgroundInput, + OnNotificationReceivedInForegroundInput, + OnTokenReceivedInput, + RequestPermissionsInput, + SetBadgeCountInput, +} from './inputs'; +import { + GetBadgeCountOutput, + GetLaunchNotificationOutput, + GetPermissionStatusOutput, + OnNotificationOpenedOutput, + OnNotificationReceivedInBackgroundOutput, + OnNotificationReceivedInForegroundOutput, + OnTokenReceivedOutput, + RequestPermissionsOutput, +} from './outputs'; + +export type GetBadgeCount = () => Promise; + +export type GetLaunchNotification = () => Promise; + +export type GetPermissionStatus = () => Promise; + +export type InitializePushNotifications = () => void; + +export type RequestPermissions = ( + input?: RequestPermissionsInput, +) => Promise; + +export type SetBadgeCount = (input: SetBadgeCountInput) => void; + +export type OnNotificationOpened = ( + input: OnNotificationOpenedInput, +) => OnNotificationOpenedOutput; + +export type OnNotificationReceivedInBackground = ( + input: OnNotificationReceivedInBackgroundInput, +) => OnNotificationReceivedInBackgroundOutput; + +export type OnNotificationReceivedInForeground = ( + input: OnNotificationReceivedInForegroundInput, +) => OnNotificationReceivedInForegroundOutput; + +export type OnTokenReceived = ( + input: OnTokenReceivedInput, +) => OnTokenReceivedOutput; diff --git a/packages/notifications/src/pushNotifications/providers/shared/types/index.ts b/packages/notifications/src/pushNotifications/providers/shared/types/index.ts new file mode 100644 index 00000000000..596e359fb88 --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/shared/types/index.ts @@ -0,0 +1,38 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { + GetBadgeCount, + GetLaunchNotification, + GetPermissionStatus, + InitializePushNotifications, + OnNotificationOpened, + OnNotificationReceivedInBackground, + OnNotificationReceivedInForeground, + OnTokenReceived, + RequestPermissions, + SetBadgeCount, +} from './apis'; +export { + OnNotificationOpenedInput, + OnNotificationReceivedInBackgroundInput, + OnNotificationReceivedInForegroundInput, + OnTokenReceivedInput, + RequestPermissionsInput, + SetBadgeCountInput, +} from './inputs'; +export { + GetBadgeCountOutput, + GetLaunchNotificationOutput, + GetPermissionStatusOutput, + OnNotificationOpenedOutput, + OnNotificationReceivedInBackgroundOutput, + OnNotificationReceivedInForegroundOutput, + OnTokenReceivedOutput, + RequestPermissionsOutput, +} from './outputs'; +export { + ChannelType, + InflightDeviceRegistration, + InflightDeviceRegistrationResolver, +} from './pushNotifications'; diff --git a/packages/notifications/src/pushNotifications/providers/shared/types/inputs.ts b/packages/notifications/src/pushNotifications/providers/shared/types/inputs.ts new file mode 100644 index 00000000000..9a17036a1a1 --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/shared/types/inputs.ts @@ -0,0 +1,26 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + PushNotificationOnNotificationOpenedInput, + PushNotificationOnNotificationReceivedInBackgroundInput, + PushNotificationOnNotificationReceivedInForegroundInput, + PushNotificationOnTokenReceivedInput, + PushNotificationRequestPermissionsInput, + PushNotificationSetBadgeCountInput, +} from '../../../types'; + +export type RequestPermissionsInput = PushNotificationRequestPermissionsInput; + +export type SetBadgeCountInput = PushNotificationSetBadgeCountInput; + +export type OnNotificationOpenedInput = + PushNotificationOnNotificationOpenedInput; + +export type OnNotificationReceivedInBackgroundInput = + PushNotificationOnNotificationReceivedInBackgroundInput; + +export type OnNotificationReceivedInForegroundInput = + PushNotificationOnNotificationReceivedInForegroundInput; + +export type OnTokenReceivedInput = PushNotificationOnTokenReceivedInput; diff --git a/packages/notifications/src/pushNotifications/providers/shared/types/outputs.ts b/packages/notifications/src/pushNotifications/providers/shared/types/outputs.ts new file mode 100644 index 00000000000..a28106178e6 --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/shared/types/outputs.ts @@ -0,0 +1,34 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + PushNotificationGetBadgeCountOutput, + PushNotificationGetLaunchNotificationOutput, + PushNotificationGetPermissionStatusOutput, + PushNotificationOnNotificationOpenedOutput, + PushNotificationOnNotificationReceivedInBackgroundOutput, + PushNotificationOnNotificationReceivedInForegroundOutput, + PushNotificationOnTokenReceivedOutput, + PushNotificationRequestPermissionsOutput, +} from '../../../types'; + +export type GetBadgeCountOutput = PushNotificationGetBadgeCountOutput; + +export type GetLaunchNotificationOutput = + PushNotificationGetLaunchNotificationOutput; + +export type GetPermissionStatusOutput = + PushNotificationGetPermissionStatusOutput; + +export type RequestPermissionsOutput = PushNotificationRequestPermissionsOutput; + +export type OnNotificationOpenedOutput = + PushNotificationOnNotificationOpenedOutput; + +export type OnNotificationReceivedInBackgroundOutput = + PushNotificationOnNotificationReceivedInBackgroundOutput; + +export type OnNotificationReceivedInForegroundOutput = + PushNotificationOnNotificationReceivedInForegroundOutput; + +export type OnTokenReceivedOutput = PushNotificationOnTokenReceivedOutput; diff --git a/packages/notifications/src/pushNotifications/providers/shared/types/pushNotifications.ts b/packages/notifications/src/pushNotifications/providers/shared/types/pushNotifications.ts new file mode 100644 index 00000000000..bc4590edc00 --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/shared/types/pushNotifications.ts @@ -0,0 +1,15 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { updateEndpoint } from '@aws-amplify/core/internals/providers/pinpoint'; + +import { PushNotificationError } from '../../../errors'; + +export type ChannelType = Parameters[0]['channelType']; + +export type InflightDeviceRegistration = Promise | undefined; + +export interface InflightDeviceRegistrationResolver { + resolve?(): void; + reject?(error: PushNotificationError): void; +} diff --git a/packages/notifications/src/pushNotifications/providers/pinpoint/utils/getChannelType.ts b/packages/notifications/src/pushNotifications/providers/shared/utils/getChannelType.ts similarity index 100% rename from packages/notifications/src/pushNotifications/providers/pinpoint/utils/getChannelType.ts rename to packages/notifications/src/pushNotifications/providers/shared/utils/getChannelType.ts diff --git a/packages/notifications/src/pushNotifications/providers/shared/utils/index.ts b/packages/notifications/src/pushNotifications/providers/shared/utils/index.ts new file mode 100644 index 00000000000..f6f9161c4aa --- /dev/null +++ b/packages/notifications/src/pushNotifications/providers/shared/utils/index.ts @@ -0,0 +1,9 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { getChannelType } from './getChannelType'; +export { + getInflightDeviceRegistration, + rejectInflightDeviceRegistration, + resolveInflightDeviceRegistration, +} from './inflightDeviceRegistration'; diff --git a/packages/notifications/src/pushNotifications/providers/pinpoint/utils/inflightDeviceRegistration.ts b/packages/notifications/src/pushNotifications/providers/shared/utils/inflightDeviceRegistration.ts similarity index 100% rename from packages/notifications/src/pushNotifications/providers/pinpoint/utils/inflightDeviceRegistration.ts rename to packages/notifications/src/pushNotifications/providers/shared/utils/inflightDeviceRegistration.ts diff --git a/packages/notifications/src/pushNotifications/utils/deprecatePinpoint.ts b/packages/notifications/src/pushNotifications/utils/deprecatePinpoint.ts new file mode 100644 index 00000000000..d741e8b5af2 --- /dev/null +++ b/packages/notifications/src/pushNotifications/utils/deprecatePinpoint.ts @@ -0,0 +1,45 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ConsoleLogger } from '@aws-amplify/core'; + +const logger = new ConsoleLogger('PushNotification'); + +const DEPRECATION_MESSAGE = + 'The default `aws-amplify/push-notifications` entry point is deprecated because it is ' + + 'backed by Amazon Pinpoint, for which AWS will end support on October 30, 2026. ' + + 'Import from a supported provider sub-path export instead: ' + + 'Amazon Connect Customer Profiles (`aws-amplify/push-notifications/customer-profiles`).'; + +/** + * Wraps a Push Notifications API exported from the deprecated default (Amazon + * Pinpoint backed) entry point so that invoking it emits a one-time runtime + * deprecation warning before delegating to the underlying implementation. The + * returned function is a transparent proxy — it preserves the exact parameters + * and return value of the wrapped API, including synchronous throws and + * rejected promises. + * + * The warning names the deprecated entry point rather than the individual API, + * because most of these APIs are transport-agnostic and are re-exported + * unchanged by every provider — it is the default entry point, not the + * behaviour of the call, that customers need to migrate away from. + * + * The warning is emitted at most once per wrapped API for the lifetime of the + * module, so repeated calls do not spam the console. + * + * @internal + */ +export const deprecatePinpoint = ( + fn: (...args: TArgs) => TReturn, +): ((...args: TArgs) => TReturn) => { + let warned = false; + + return (...args: TArgs): TReturn => { + if (!warned) { + warned = true; + logger.warn(DEPRECATION_MESSAGE); + } + + return fn(...args); + }; +}; diff --git a/packages/notifications/src/pushNotifications/utils/index.ts b/packages/notifications/src/pushNotifications/utils/index.ts index d08eedb5d53..a57e5c929c0 100644 --- a/packages/notifications/src/pushNotifications/utils/index.ts +++ b/packages/notifications/src/pushNotifications/utils/index.ts @@ -1,6 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +export { deprecatePinpoint } from './deprecatePinpoint'; export { getPushNotificationUserAgentString } from './getPushNotificationUserAgentString'; export { initialize, isInitialized } from './initializationManager'; export { resolveCredentials } from './resolveCredentials';