From bcdc02ba2a1b9b8e6ab8b384a4586cf9605b41c1 Mon Sep 17 00:00:00 2001 From: Michael Sober Date: Thu, 30 Jul 2026 08:50:34 +0200 Subject: [PATCH 1/2] feat(notifications): add Amazon Connect Customer Profiles provider for Push Notifications (#14866) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(notifications): add Connect Customer Profiles push provider as new subpath export Add an opt-in Push Notifications provider backed by Amazon Connect Customer Profiles, exposed as a new subpath export alongside the existing Pinpoint provider (mirroring the Analytics Customer Profiles provider from #14864): - aws-amplify/push-notifications/customer-profiles - @aws-amplify/notifications/push-notifications/customer-profiles Device-token registration (initializePushNotifications) and identifyUser register the device with Connect Customer Profiles instead of calling the Pinpoint UpdateEndpoint API. Authorization mode is selected automatically from the resolved auth session: - Authenticated (Cognito user-pool): POST {endpoint}/identify-user with Authorization: Bearer ; backend keys the profile on `sub`. - Guest (Identity Pool unauthenticated): POST {endpoint}/identify-user-guest, SigV4-signed (execute-api) with the guest credentials; backend keys the profile on `identityId`. Enables registering a device token before sign-in. On a later authenticated call, options.previousGuestIdentityId folds the guest profile (and its devices) into the authenticated profile. Configured via the new notifications.amazon_connect_customer_profiles amplify_outputs key (Notifications.PushNotification.CustomerProfiles = { endpoint, region }). All public method signatures, the native event bus, and the token lifecycle match the Pinpoint provider 1:1; IdentifyUserInput options additionally accept optional deviceId/platform/appVersion/ previousGuestIdentityId device-registration fields. Pinpoint engagement telemetry (_campaign.*/_journey.*) is intentionally not ported (Connect Customer Profiles has no client-side event API). Additive and non-breaking: the default push-notifications export still resolves to Pinpoint; core gains an optional CustomerProfiles config type parsed side-by-side with Pinpoint. * fix(notifications): align Customer Profiles device registration to backend contract The guest/authed device-registration request built a body with flat top-level deviceToken/channelType and no userProfile. The deployed identify-user Lambda requires userProfile to be an object and reads device fields from options (options.deviceId, options.address, options.channelType), so guest registration returned HTTP 400 ("userProfile is required and must be an object"). Build the body as { userId, userProfile: userProfile ?? {}, options: { ...options, deviceId, address: deviceToken, channelType } }. Add getDeviceId(): a stable per-install UUID persisted in AsyncStorage so token refreshes upsert the same device object. channelType mapping (Android -> GCM) and resolveConfig unchanged. * refactor(notifications): extract transport-agnostic push logic into shared provider module Both push-notification providers (pinpoint, customer-profiles) previously duplicated ~80% of their surface. Extract the transport-agnostic logic into a new internal providers/shared/ module and have both providers consume it. Shared (providers/shared/): getBadgeCount, getLaunchNotification, getPermissionStatus, requestPermissions, setBadgeCount, onNotificationOpened, onNotificationReceivedInBackground/Foreground, onTokenReceived (.ts + .native.ts), plus getChannelType, inflightDeviceRegistration, and the shared API/input/output types. Each provider's apis/index.ts and utils/index.ts now re-export from ../../shared/...; provider types re-export shared types and keep only IdentifyUser/IdentifyUserInput/IdentifyUserOptions local. Provider-specific and unchanged: identifyUser + initializePushNotifications (pinpoint wires createMessageEventRecorder analytics listeners; customer-profiles omits them). Pinpoint external behavior is identical after the extraction. Also relocate the transport-agnostic unit tests to __tests__/.../shared/ and fix the stale registerDeviceWithCustomerProfiles test (mock @aws-amplify/react-native and getDeviceId; assert the current nested options:{deviceId,address,channelType} backend contract). Net -1349/+250 lines. Build (esm+cjs) and 36 pushNotifications test suites (118 tests) pass. * ci(release-preid): trigger preid release on feat/connect-customer-profiles-push Add the feature branch to the release-preid push trigger so every push publishes a prerelease. The preid label is derived from the 2nd branch path segment by the existing parse-preid job, yielding npm dist-tag 'connect-customer-profiles-push' (passes the forbidden-preid check). No change to release behavior for any other branch. * chore(notifications): remove changeset from CP push PR * feat(notifications): support browser identifyUser for Customer Profiles and rename internal engine to identifyUserInternal - Browser `identifyUser` now performs a device-less profile identify (e.g. identify end-users by email); the web build no longer throws PlatformNotSupportedError. The browser has no push token, so no device is registered. - Rename the internal engine `registerDeviceWithCustomerProfiles` -> `identifyUserInternal` and make it device-optional: device fields (deviceId/address/channelType) and the stable per-install deviceId are only attached when a device token is present, preserving find-or-create. - Both callers funnel through the one engine, mirroring the Pinpoint two-callers-of-one-core pattern: native `identifyUser` and the token-received `registerDevice` helper call `identifyUserInternal` directly (registerDevice does not route through identifyUser). - Keep `@aws-amplify/react-native` out of the web bundle by lazily importing `getDeviceId` only on the device-registration path. - Engine failure surfaces as PushNotificationError name `IdentifyUserException`. * fix(notifications): resolve deviceId in native layer to avoid __importStar dynamic-import crash in RN The lazy await import('./getDeviceId') in identifyUserInternal transpiled (CJS) to the __importStar tslib helper, which is not injected in the Hermes/React Native runtime -> ReferenceError on the device-registration path (DeviceRegistrationFailed). Remove the runtime dynamic import entirely: the engine no longer imports getDeviceId (static or dynamic). Instead the native callers (identifyUser.native and the token-received registerDevice helper) resolve the stable per-install deviceId via getDeviceId and inject it as options.deviceId. Web identifyUser stays device-less and RN-free without any dynamic import. * refactor(notifications): rename previousGuestIdentityId to guestIdentityId Rename the guest-merge wire-contract field previousGuestIdentityId to guestIdentityId across the Customer Profiles push identify flow, matching the backend contract rename. The IdentifyUserOptions field name is the JSON key sent on the wire in the identify-user request body's options, so this must stay in lockstep with the backend. * refactor(notifications): remove guestIdentityId from customer-profiles IdentifyUserOptions The backend no longer merges guest and authenticated Customer Profiles; guest and authenticated profiles are treated as completely separate profiles. Remove the now-dead client-side guestIdentityId guest->auth linking field from IdentifyUserOptions, its passthrough documentation in identifyUserInternal, and the related test assertion. * feat(notifications): align CP push client to SigV4 identify/register/remove backend contract Rework the customer-profiles push provider to the new backend contract: profile-only identifyUser (drops userId), new registerDevice/removeDevice native APIs, and a single SigV4 (execute-api) signedFetch transport for all three routes for both authenticated and guest Identity Pool sessions. Server derives principalId from the signer identity; the Bearer-JWT and guest-route paths are removed. initializePushNotifications wires TOKEN_RECEIVED->registerDevice and Hub 'signedOut'->removeDevice. * feat(notifications): attach Amplify user-agent header to CP push signedFetch Add the x-amz-user-agent telemetry header (getAmplifyUserAgent with Category.PushNotification + PushNotificationAction.IdentifyUser) to the customer-profiles signedFetch transport for all three routes, matching the prior Pinpoint/SDK path. The header is set before SigV4 signing so it is covered by the signature and the sent headers stay consistent. * refactor(core): rename amplify_outputs notifications key amazon_connect_customer_profiles -> amazon_connect Aligns the client parser with the backend-notifications OUTPUT_KEY rename and client-config schema v1.5. Updates AmplifyOutputsNotificationsProperties, the parseNotifications destructuring, and parseAmplifyOutputs test fixtures. The internal ResourcesConfig.PushNotification.CustomerProfiles key is unchanged. * fix(notifications,core): address CP-push review findings - core: add RegisterDevice + RemoveDevice to PushNotificationAction enum. - signedFetch: take an `action` arg; identify/register/remove pass the correct PushNotificationAction so the x-amz-user-agent encodes the per-route action. - buildDeviceRegistration: guard the token (throw PushNotificationError NoToken when neither a supplied nor a current token is available); return non-optional token. Add a new NoToken validation error code. - resolveCredentials: replace assert with an explicit if-throw so TS narrows credentials to non-undefined. - Tests: assert per-route action in the user-agent; assert signing receives no Authorization/Bearer header and fetch uses exactly the signer-returned headers; add buildDeviceRegistration fallback + no-token throw tests (drop the `as unknown as string` hack). - Docs: note identifyUser.native is intentionally pre-init-callable; document the getDeviceId module cache lifecycle. * fix(notifications): enforce https endpoint in Customer Profiles resolveConfig to prevent SigV4 token exfiltration resolveConfig is the single choke point for all three Customer Profiles push routes. The endpoint flows untrusted from amplify_outputs.json (amazon_connect.endpoint) into signedFetch, which SigV4-signs requests carrying the Identity Pool x-amz-security-token to whatever host it resolves to. Parse the endpoint with new URL() and assert https: scheme (new InvalidEndpoint validation code), rejecting http/ftp/malformed URLs before any credentialed request is signed. Scheme-only enforcement (no hostname allowlist) to stay compatible with custom domains, aws-cn/aws-us-gov, FIPS, and VPC endpoints. * fix(notifications): add client-side UserProfile/customAttributes validation mirroring backend bounds (defense-in-depth) * feat(notifications): warn on the deprecated default push-notifications entry point The default `aws-amplify/push-notifications` entry point is backed by Amazon Pinpoint, which reaches end-of-support on October 30, 2026. It cannot be removed without a breaking change, so all 11 of its runtime APIs now emit a one-time ConsoleLogger warning steering customers to the Customer Profiles sub-path export (`aws-amplify/push-notifications/customer-profiles`), which exposes an equivalent for every one of them. The warning names the deprecated entry point rather than the individual API: 9 of the 11 are transport-agnostic and are re-exported unchanged by every provider, so it is the entry point, not the behaviour of the call, that customers need to migrate away from. Mirrors the Analytics precedent (#14877). Reuses the existing ConsoleLogger convention rather than raw console.warn. Non-breaking: the wrapped consts preserve the exact names, types, and signatures of the underlying APIs (verified by a bidirectional type-assignability check over all 11), and synchronous throws and rejected promises both reach the caller untouched. * chore(notifications): add changeset for the Customer Profiles push provider Covers @aws-amplify/core, @aws-amplify/notifications, and aws-amplify as minor for the new Customer Profiles push-notifications provider and its sub-path exports, plus the default entry point deprecation notice. Follows the repo precedent of a minor bump for a new provider surface. * ci(release-preid): drop the Customer Profiles push branch from the preid trigger list The branch-specific preid publish trigger was only needed while iterating on this feature branch. Removing it keeps the workflow free of branch names that disappear once the feature merges, restoring parity with main. * fix(notifications): restrict Customer Profiles endpoint to execute-api host + normalize trailing slash Requests to the configured endpoint are SigV4-signed for execute-api, so the endpoint host is now validated against the API Gateway host of the resolved region (.execute-api..amazonaws.com). Previously only the presence of endpoint/region and an https: protocol were checked, so a misconfigured or attacker-supplied endpoint would have received the signed credentials. Also strips trailing slashes from the endpoint so {endpoint}{path} never yields a double slash. URL.origin is deliberately not used, as it would drop an API Gateway stage path such as /prod. * fix(notifications): fetch the signed request url signRequest returns an HttpRequest whose url is authoritative and covered by the signature. Fetching the pre-signing url instead could send a url that does not match what was signed. * fix(notifications): dedupe concurrent getDeviceId via in-flight promise AsyncStorage reads are async, so concurrent first-calls could each read no stored id, mint their own UUID and write it, producing divergent device ids for one install. The resolution is now memoized as a single in-flight promise so concurrent callers share one resolution (single UUID, single write). The cached promise is cleared on failure so a transient storage error does not permanently wedge subsequent calls. * fix(notifications): re-register push device on sign-in; drop ineffective sign-out de-registration The auth Hub listener now re-registers the device on `signedIn`. A returning user's push token is unchanged across sign-in, so the native TOKEN_RECEIVED listener short-circuits and nothing re-registered the device — it stayed homed to the guest principal. Backend register-device is an idempotent last-writer-wins upsert on `deviceId`, so re-registering re-homes the existing row instead of creating a duplicate. The `signedOut` -> `removeDevice()` branch is removed. It could not work: `signOut()` clears credentials before the `signedOut` event fires, so the listener signed as a brand-new guest identity, and the backend's principal-gated delete returns 200 for a non-owner (no-info-leak invariant). The removal silently no-opped and `.catch` never ran. `removeDevice` remains a public API for applications to call before `signOut()`. Adds un-mocked credential-path coverage that drives the real resolveCredentials/signedFetch/SigV4 chain (only fetchAuthSession and fetch are stubbed) and asserts which identity signs each register/remove call. * docs(notifications): document the pre-signOut removeDevice contract De-registration is signed with the caller's current credentials and the backend only removes a device the calling principal owns, so `removeDevice()` must be awaited BEFORE `signOut()`. Documents this on both platform variants of the API and describes the device lifecycle in the provider changeset. --- ...ications-connect-customer-profiles-push.md | 13 + packages/aws-amplify/package.json | 9 + .../customer-profiles/index.ts | 8 + .../__tests__/parseAmplifyOutputs.test.ts | 55 +++ packages/core/src/Platform/types.ts | 2 + packages/core/src/parseAmplifyOutputs.ts | 40 +- .../src/singleton/AmplifyOutputs/types.ts | 10 +- .../Notifications/PushNotification/types.ts | 18 +- .../__tests__/pushNotifications/index.test.ts | 59 +++ .../apis/identifyUser.native.test.ts | 57 +++ .../apis/identifyUser.test.ts | 57 +++ ...initializePushNotifications.native.test.ts | 418 ++++++++++++++++++ .../apis/registerDevice.native.test.ts | 106 +++++ .../apis/registerDevice.test.ts | 14 + .../apis/removeDevice.native.test.ts | 58 +++ .../apis/removeDevice.test.ts | 12 + .../authStateTransitions.native.test.ts | 297 +++++++++++++ .../utils/getDeviceId.test.ts | 159 +++++++ .../utils/identifyUserInternal.test.ts | 104 +++++ .../utils/resolveConfig.test.ts | 189 ++++++++ .../utils/resolveCredentials.test.ts | 50 +++ .../utils/signedFetch.test.ts | 169 +++++++ .../utils/validateUserProfile.test.ts | 146 ++++++ .../utils/createMessageEventRecorder.test.ts | 4 +- .../apis/getBadgeCount.native.test.ts | 2 +- .../apis/getBadgeCount.test.ts | 2 +- .../apis/getLaunchNotification.native.test.ts | 2 +- .../apis/getLaunchNotification.test.ts | 2 +- .../apis/getPermissionStatus.native.test.ts | 2 +- .../apis/getPermissionStatus.test.ts | 2 +- .../apis/onNotificationOpened.native.test.ts | 2 +- .../apis/onNotificationOpened.test.ts | 2 +- ...icationReceivedInBackground.native.test.ts | 2 +- ...onNotificationReceivedInBackground.test.ts | 2 +- ...icationReceivedInForeground.native.test.ts | 2 +- ...onNotificationReceivedInForeground.test.ts | 2 +- .../apis/onTokenReceived.native.test.ts | 2 +- .../apis/onTokenReceived.test.ts | 2 +- .../apis/requestPermissions.native.test.ts | 2 +- .../apis/requestPermissions.test.ts | 2 +- .../apis/setBadgeCount.native.test.ts | 2 +- .../apis/setBadgeCount.test.ts | 2 +- .../utils/getChannelType.test.ts | 6 +- .../utils/inflightDeviceRegistration.test.ts | 6 +- .../utils/deprecatePinpoint.test.ts | 72 +++ .../notifications/__tests__/testUtils/data.ts | 7 + packages/notifications/package.json | 9 + .../customer-profiles/package.json | 8 + .../pushNotifications/errors/errorHelpers.ts | 22 + .../src/pushNotifications/index.ts | 131 +++++- .../apis/identifyUser.native.ts | 32 ++ .../customer-profiles/apis/identifyUser.ts | 37 ++ .../providers/customer-profiles/apis/index.ts | 18 + .../initializePushNotifications.native.ts | 192 ++++++++ .../apis/initializePushNotifications.ts | 26 ++ .../apis/registerDevice.native.ts | 68 +++ .../customer-profiles/apis/registerDevice.ts | 17 + .../apis/removeDevice.native.ts | 37 ++ .../customer-profiles/apis/removeDevice.ts | 23 + .../providers/customer-profiles/index.ts | 39 ++ .../providers/customer-profiles/types/apis.ts | 23 + .../customer-profiles/types/index.ts | 43 ++ .../customer-profiles/types/inputs.ts | 30 ++ .../customer-profiles/types/outputs.ts | 13 + .../types/pushNotifications.ts | 38 ++ .../customer-profiles/utils/getDeviceId.ts | 55 +++ .../utils/identifyUserInternal.ts | 79 ++++ .../customer-profiles/utils/index.ts | 25 ++ .../customer-profiles/utils/resolveConfig.ts | 88 ++++ .../utils/resolveCredentials.ts | 34 ++ .../customer-profiles/utils/signedFetch.ts | 90 ++++ .../utils/validateUserProfile.ts | 97 ++++ .../providers/pinpoint/apis/index.ts | 20 +- .../providers/pinpoint/types/apis.ts | 63 +-- .../providers/pinpoint/types/inputs.ts | 34 +- .../providers/pinpoint/types/outputs.ts | 41 +- .../pinpoint/types/pushNotifications.ts | 17 +- .../utils/createMessageEventRecorder.ts | 2 +- .../providers/pinpoint/utils/index.ts | 4 +- .../apis/getBadgeCount.native.ts | 0 .../apis/getBadgeCount.ts | 2 - .../apis/getLaunchNotification.native.ts | 0 .../apis/getLaunchNotification.ts | 2 - .../apis/getPermissionStatus.native.ts | 0 .../apis/getPermissionStatus.ts | 2 - .../providers/shared/apis/index.ts | 12 + .../apis/onNotificationOpened.native.ts | 0 .../apis/onNotificationOpened.ts | 2 - ...NotificationReceivedInBackground.native.ts | 0 .../onNotificationReceivedInBackground.ts | 2 - ...NotificationReceivedInForeground.native.ts | 0 .../onNotificationReceivedInForeground.ts | 2 - .../apis/onTokenReceived.native.ts | 0 .../apis/onTokenReceived.ts | 2 - .../apis/requestPermissions.native.ts | 0 .../apis/requestPermissions.ts | 2 - .../apis/setBadgeCount.native.ts | 0 .../apis/setBadgeCount.ts | 2 - .../providers/shared/index.ts | 49 ++ .../providers/shared/types/apis.ts | 51 +++ .../providers/shared/types/index.ts | 38 ++ .../providers/shared/types/inputs.ts | 26 ++ .../providers/shared/types/outputs.ts | 34 ++ .../shared/types/pushNotifications.ts | 15 + .../utils/getChannelType.ts | 0 .../providers/shared/utils/index.ts | 9 + .../utils/inflightDeviceRegistration.ts | 0 .../utils/deprecatePinpoint.ts | 45 ++ .../src/pushNotifications/utils/index.ts | 1 + 109 files changed, 3736 insertions(+), 198 deletions(-) create mode 100644 .changeset/notifications-connect-customer-profiles-push.md create mode 100644 packages/aws-amplify/src/push-notifications/customer-profiles/index.ts create mode 100644 packages/notifications/__tests__/pushNotifications/index.test.ts create mode 100644 packages/notifications/__tests__/pushNotifications/providers/customer-profiles/apis/identifyUser.native.test.ts create mode 100644 packages/notifications/__tests__/pushNotifications/providers/customer-profiles/apis/identifyUser.test.ts create mode 100644 packages/notifications/__tests__/pushNotifications/providers/customer-profiles/apis/initializePushNotifications.native.test.ts create mode 100644 packages/notifications/__tests__/pushNotifications/providers/customer-profiles/apis/registerDevice.native.test.ts create mode 100644 packages/notifications/__tests__/pushNotifications/providers/customer-profiles/apis/registerDevice.test.ts create mode 100644 packages/notifications/__tests__/pushNotifications/providers/customer-profiles/apis/removeDevice.native.test.ts create mode 100644 packages/notifications/__tests__/pushNotifications/providers/customer-profiles/apis/removeDevice.test.ts create mode 100644 packages/notifications/__tests__/pushNotifications/providers/customer-profiles/authStateTransitions.native.test.ts create mode 100644 packages/notifications/__tests__/pushNotifications/providers/customer-profiles/utils/getDeviceId.test.ts create mode 100644 packages/notifications/__tests__/pushNotifications/providers/customer-profiles/utils/identifyUserInternal.test.ts create mode 100644 packages/notifications/__tests__/pushNotifications/providers/customer-profiles/utils/resolveConfig.test.ts create mode 100644 packages/notifications/__tests__/pushNotifications/providers/customer-profiles/utils/resolveCredentials.test.ts create mode 100644 packages/notifications/__tests__/pushNotifications/providers/customer-profiles/utils/signedFetch.test.ts create mode 100644 packages/notifications/__tests__/pushNotifications/providers/customer-profiles/utils/validateUserProfile.test.ts rename packages/notifications/__tests__/pushNotifications/providers/{pinpoint => shared}/apis/getBadgeCount.native.test.ts (91%) rename packages/notifications/__tests__/pushNotifications/providers/{pinpoint => shared}/apis/getBadgeCount.test.ts (90%) rename packages/notifications/__tests__/pushNotifications/providers/{pinpoint => shared}/apis/getLaunchNotification.native.test.ts (92%) rename packages/notifications/__tests__/pushNotifications/providers/{pinpoint => shared}/apis/getLaunchNotification.test.ts (87%) rename packages/notifications/__tests__/pushNotifications/providers/{pinpoint => shared}/apis/getPermissionStatus.native.test.ts (91%) rename packages/notifications/__tests__/pushNotifications/providers/{pinpoint => shared}/apis/getPermissionStatus.test.ts (88%) rename packages/notifications/__tests__/pushNotifications/providers/{pinpoint => shared}/apis/onNotificationOpened.native.test.ts (94%) rename packages/notifications/__tests__/pushNotifications/providers/{pinpoint => shared}/apis/onNotificationOpened.test.ts (87%) rename packages/notifications/__tests__/pushNotifications/providers/{pinpoint => shared}/apis/onNotificationReceivedInBackground.native.test.ts (92%) rename packages/notifications/__tests__/pushNotifications/providers/{pinpoint => shared}/apis/onNotificationReceivedInBackground.test.ts (83%) rename packages/notifications/__tests__/pushNotifications/providers/{pinpoint => shared}/apis/onNotificationReceivedInForeground.native.test.ts (92%) rename packages/notifications/__tests__/pushNotifications/providers/{pinpoint => shared}/apis/onNotificationReceivedInForeground.test.ts (83%) rename packages/notifications/__tests__/pushNotifications/providers/{pinpoint => shared}/apis/onTokenReceived.native.test.ts (95%) rename packages/notifications/__tests__/pushNotifications/providers/{pinpoint => shared}/apis/onTokenReceived.test.ts (89%) rename packages/notifications/__tests__/pushNotifications/providers/{pinpoint => shared}/apis/requestPermissions.native.test.ts (91%) rename packages/notifications/__tests__/pushNotifications/providers/{pinpoint => shared}/apis/requestPermissions.test.ts (88%) rename packages/notifications/__tests__/pushNotifications/providers/{pinpoint => shared}/apis/setBadgeCount.native.test.ts (91%) rename packages/notifications/__tests__/pushNotifications/providers/{pinpoint => shared}/apis/setBadgeCount.test.ts (90%) rename packages/notifications/__tests__/pushNotifications/providers/{pinpoint => shared}/utils/getChannelType.test.ts (78%) rename packages/notifications/__tests__/pushNotifications/providers/{pinpoint => shared}/utils/inflightDeviceRegistration.test.ts (88%) create mode 100644 packages/notifications/__tests__/pushNotifications/utils/deprecatePinpoint.test.ts create mode 100644 packages/notifications/push-notifications/customer-profiles/package.json create mode 100644 packages/notifications/src/pushNotifications/providers/customer-profiles/apis/identifyUser.native.ts create mode 100644 packages/notifications/src/pushNotifications/providers/customer-profiles/apis/identifyUser.ts create mode 100644 packages/notifications/src/pushNotifications/providers/customer-profiles/apis/index.ts create mode 100644 packages/notifications/src/pushNotifications/providers/customer-profiles/apis/initializePushNotifications.native.ts create mode 100644 packages/notifications/src/pushNotifications/providers/customer-profiles/apis/initializePushNotifications.ts create mode 100644 packages/notifications/src/pushNotifications/providers/customer-profiles/apis/registerDevice.native.ts create mode 100644 packages/notifications/src/pushNotifications/providers/customer-profiles/apis/registerDevice.ts create mode 100644 packages/notifications/src/pushNotifications/providers/customer-profiles/apis/removeDevice.native.ts create mode 100644 packages/notifications/src/pushNotifications/providers/customer-profiles/apis/removeDevice.ts create mode 100644 packages/notifications/src/pushNotifications/providers/customer-profiles/index.ts create mode 100644 packages/notifications/src/pushNotifications/providers/customer-profiles/types/apis.ts create mode 100644 packages/notifications/src/pushNotifications/providers/customer-profiles/types/index.ts create mode 100644 packages/notifications/src/pushNotifications/providers/customer-profiles/types/inputs.ts create mode 100644 packages/notifications/src/pushNotifications/providers/customer-profiles/types/outputs.ts create mode 100644 packages/notifications/src/pushNotifications/providers/customer-profiles/types/pushNotifications.ts create mode 100644 packages/notifications/src/pushNotifications/providers/customer-profiles/utils/getDeviceId.ts create mode 100644 packages/notifications/src/pushNotifications/providers/customer-profiles/utils/identifyUserInternal.ts create mode 100644 packages/notifications/src/pushNotifications/providers/customer-profiles/utils/index.ts create mode 100644 packages/notifications/src/pushNotifications/providers/customer-profiles/utils/resolveConfig.ts create mode 100644 packages/notifications/src/pushNotifications/providers/customer-profiles/utils/resolveCredentials.ts create mode 100644 packages/notifications/src/pushNotifications/providers/customer-profiles/utils/signedFetch.ts create mode 100644 packages/notifications/src/pushNotifications/providers/customer-profiles/utils/validateUserProfile.ts rename packages/notifications/src/pushNotifications/providers/{pinpoint => shared}/apis/getBadgeCount.native.ts (100%) rename packages/notifications/src/pushNotifications/providers/{pinpoint => shared}/apis/getBadgeCount.ts (91%) rename packages/notifications/src/pushNotifications/providers/{pinpoint => shared}/apis/getLaunchNotification.native.ts (100%) rename packages/notifications/src/pushNotifications/providers/{pinpoint => shared}/apis/getLaunchNotification.ts (94%) rename packages/notifications/src/pushNotifications/providers/{pinpoint => shared}/apis/getPermissionStatus.native.ts (100%) rename packages/notifications/src/pushNotifications/providers/{pinpoint => shared}/apis/getPermissionStatus.ts (95%) create mode 100644 packages/notifications/src/pushNotifications/providers/shared/apis/index.ts rename packages/notifications/src/pushNotifications/providers/{pinpoint => shared}/apis/onNotificationOpened.native.ts (100%) rename packages/notifications/src/pushNotifications/providers/{pinpoint => shared}/apis/onNotificationOpened.ts (93%) rename packages/notifications/src/pushNotifications/providers/{pinpoint => shared}/apis/onNotificationReceivedInBackground.native.ts (100%) rename packages/notifications/src/pushNotifications/providers/{pinpoint => shared}/apis/onNotificationReceivedInBackground.ts (96%) rename packages/notifications/src/pushNotifications/providers/{pinpoint => shared}/apis/onNotificationReceivedInForeground.native.ts (100%) rename packages/notifications/src/pushNotifications/providers/{pinpoint => shared}/apis/onNotificationReceivedInForeground.ts (94%) rename packages/notifications/src/pushNotifications/providers/{pinpoint => shared}/apis/onTokenReceived.native.ts (100%) rename packages/notifications/src/pushNotifications/providers/{pinpoint => shared}/apis/onTokenReceived.ts (93%) rename packages/notifications/src/pushNotifications/providers/{pinpoint => shared}/apis/requestPermissions.native.ts (100%) rename packages/notifications/src/pushNotifications/providers/{pinpoint => shared}/apis/requestPermissions.ts (95%) rename packages/notifications/src/pushNotifications/providers/{pinpoint => shared}/apis/setBadgeCount.native.ts (100%) rename packages/notifications/src/pushNotifications/providers/{pinpoint => shared}/apis/setBadgeCount.ts (91%) create mode 100644 packages/notifications/src/pushNotifications/providers/shared/index.ts create mode 100644 packages/notifications/src/pushNotifications/providers/shared/types/apis.ts create mode 100644 packages/notifications/src/pushNotifications/providers/shared/types/index.ts create mode 100644 packages/notifications/src/pushNotifications/providers/shared/types/inputs.ts create mode 100644 packages/notifications/src/pushNotifications/providers/shared/types/outputs.ts create mode 100644 packages/notifications/src/pushNotifications/providers/shared/types/pushNotifications.ts rename packages/notifications/src/pushNotifications/providers/{pinpoint => shared}/utils/getChannelType.ts (100%) create mode 100644 packages/notifications/src/pushNotifications/providers/shared/utils/index.ts rename packages/notifications/src/pushNotifications/providers/{pinpoint => shared}/utils/inflightDeviceRegistration.ts (100%) create mode 100644 packages/notifications/src/pushNotifications/utils/deprecatePinpoint.ts 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/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'; From e1067a95918799ed1b39c95b9649ad76246ca67a Mon Sep 17 00:00:00 2001 From: Michael Sober Date: Thu, 30 Jul 2026 10:40:11 +0200 Subject: [PATCH 2/2] fix(push-notifications): add customer-profiles subpath package.json for legacy/Metro resolution (#14899) --- .../push-notifications/customer-profiles/package.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 packages/aws-amplify/push-notifications/customer-profiles/package.json 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" +}