diff --git a/keyserver/src/push/crypto.js b/keyserver/src/push/crypto.js --- a/keyserver/src/push/crypto.js +++ b/keyserver/src/push/crypto.js @@ -6,15 +6,6 @@ import _cloneDeep from 'lodash/fp/cloneDeep.js'; import type { - PlainTextWebNotification, - PlainTextWebNotificationPayload, - WebNotification, - PlainTextWNSNotification, - WNSNotification, - AndroidVisualNotification, - AndroidVisualNotificationPayload, - AndroidBadgeOnlyNotification, - AndroidNotificationRescind, NotificationTargetDevice, SenderDeviceDescriptor, EncryptedNotifUtilsAPI, @@ -132,245 +123,6 @@ } } -async function encryptAndroidNotificationPayload( - encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, - cookieID: string, - senderDeviceDescriptor: SenderDeviceDescriptor, - unencryptedPayload: T, - payloadSizeValidator?: ( - T | $ReadOnly<{ ...SenderDeviceDescriptor, +encryptedPayload: string }>, - ) => boolean, -): Promise<{ - +resultPayload: - | T - | $ReadOnly<{ ...SenderDeviceDescriptor, +encryptedPayload: string }>, - +payloadSizeExceeded: boolean, - +encryptionOrder?: number, -}> { - try { - const unencryptedSerializedPayload = JSON.stringify(unencryptedPayload); - if (!unencryptedSerializedPayload) { - return { - resultPayload: unencryptedPayload, - payloadSizeExceeded: payloadSizeValidator - ? payloadSizeValidator(unencryptedPayload) - : false, - }; - } - - let dbPersistCondition; - if (payloadSizeValidator) { - dbPersistCondition = (serializedPayload: string) => - payloadSizeValidator({ - encryptedPayload: serializedPayload, - ...senderDeviceDescriptor, - }); - } - - const { - encryptedData: serializedPayload, - sizeLimitViolated: dbPersistConditionViolated, - encryptionOrder, - } = await encryptedNotifUtilsAPI.encryptSerializedNotifPayload( - cookieID, - unencryptedSerializedPayload, - dbPersistCondition, - ); - - return { - resultPayload: { - encryptedPayload: serializedPayload.body, - ...senderDeviceDescriptor, - }, - payloadSizeExceeded: !!dbPersistConditionViolated, - encryptionOrder, - }; - } catch (e) { - console.log('Notification encryption failed: ' + e); - const resultPayload = { - encryptionFailed: '1', - ...unencryptedPayload, - }; - return { - resultPayload, - payloadSizeExceeded: payloadSizeValidator - ? payloadSizeValidator(resultPayload) - : false, - }; - } -} - -async function encryptAndroidVisualNotification( - encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, - senderDeviceDescriptor: SenderDeviceDescriptor, - cookieID: string, - notification: AndroidVisualNotification, - notificationSizeValidator?: AndroidVisualNotification => boolean, - blobHolder?: ?string, -): Promise<{ - +notification: AndroidVisualNotification, - +payloadSizeExceeded: boolean, - +encryptionOrder?: number, -}> { - const { id, ...rest } = notification.data; - - let unencryptedData = {}; - if (id) { - unencryptedData = { id }; - } - - let unencryptedPayload = rest; - if (blobHolder) { - unencryptedPayload = { ...unencryptedPayload, blobHolder }; - } - - let payloadSizeValidator; - if (notificationSizeValidator) { - payloadSizeValidator = ( - payload: - | AndroidVisualNotificationPayload - | $ReadOnly<{ ...SenderDeviceDescriptor, +encryptedPayload: string }>, - ) => { - return notificationSizeValidator({ - data: { ...unencryptedData, ...payload }, - }); - }; - } - const { resultPayload, payloadSizeExceeded, encryptionOrder } = - await encryptAndroidNotificationPayload( - encryptedNotifUtilsAPI, - cookieID, - senderDeviceDescriptor, - unencryptedPayload, - payloadSizeValidator, - ); - return { - notification: { - data: { - ...unencryptedData, - ...resultPayload, - }, - }, - payloadSizeExceeded, - encryptionOrder, - }; -} - -async function encryptAndroidSilentNotification( - encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, - cookieID: string, - senderDeviceDescriptor: SenderDeviceDescriptor, - notification: AndroidNotificationRescind | AndroidBadgeOnlyNotification, -): Promise { - // We don't validate payload size for rescind - // since they are expected to be small and - // never exceed any FCM limit - const { ...unencryptedPayload } = notification.data; - const { resultPayload } = await encryptAndroidNotificationPayload( - encryptedNotifUtilsAPI, - cookieID, - senderDeviceDescriptor, - unencryptedPayload, - ); - if (resultPayload.encryptedPayload) { - return { - data: { ...resultPayload }, - }; - } - - if (resultPayload.rescind) { - return { - data: { ...resultPayload }, - }; - } - - return { - data: { - ...resultPayload, - }, - }; -} - -async function encryptBasicPayload( - encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, - cookieID: string, - senderDeviceDescriptor: SenderDeviceDescriptor, - basicPayload: T, -): Promise< - | $ReadOnly<{ - ...SenderDeviceDescriptor, - +encryptedPayload: string, - +encryptionOrder?: number, - }> - | { ...T, +encryptionFailed: '1' }, -> { - const unencryptedSerializedPayload = JSON.stringify(basicPayload); - - if (!unencryptedSerializedPayload) { - return { ...basicPayload, encryptionFailed: '1' }; - } - - try { - const { encryptedData: serializedPayload, encryptionOrder } = - await encryptedNotifUtilsAPI.encryptSerializedNotifPayload( - cookieID, - unencryptedSerializedPayload, - ); - - return { - ...senderDeviceDescriptor, - encryptedPayload: serializedPayload.body, - encryptionOrder, - }; - } catch (e) { - console.log('Notification encryption failed: ' + e); - return { - ...basicPayload, - encryptionFailed: '1', - }; - } -} - -async function encryptWebNotification( - encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, - cookieID: string, - senderDeviceDescriptor: SenderDeviceDescriptor, - notification: PlainTextWebNotification, -): Promise<{ +notification: WebNotification, +encryptionOrder?: number }> { - const { id, ...payloadSansId } = notification; - const { encryptionOrder, ...encryptionResult } = - await encryptBasicPayload( - encryptedNotifUtilsAPI, - cookieID, - senderDeviceDescriptor, - payloadSansId, - ); - - return { - notification: { id, ...encryptionResult }, - encryptionOrder, - }; -} - -async function encryptWNSNotification( - encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, - cookieID: string, - senderDeviceDescriptor: SenderDeviceDescriptor, - notification: PlainTextWNSNotification, -): Promise<{ +notification: WNSNotification, +encryptionOrder?: number }> { - const { encryptionOrder, ...encryptionResult } = - await encryptBasicPayload( - encryptedNotifUtilsAPI, - cookieID, - senderDeviceDescriptor, - notification, - ); - return { - notification: { ...encryptionResult }, - encryptionOrder, - }; -} - function prepareEncryptedAPNsNotifications( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, senderDeviceDescriptor: SenderDeviceDescriptor, @@ -433,118 +185,6 @@ return Promise.all(notificationPromises); } -function prepareEncryptedAndroidVisualNotifications( - encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, - senderDeviceDescriptor: SenderDeviceDescriptor, - devices: $ReadOnlyArray, - notification: AndroidVisualNotification, - notificationSizeValidator?: ( - notification: AndroidVisualNotification, - ) => boolean, -): Promise< - $ReadOnlyArray<{ - +cookieID: string, - +deviceToken: string, - +notification: AndroidVisualNotification, - +payloadSizeExceeded: boolean, - +encryptionOrder?: number, - }>, -> { - const notificationPromises = devices.map( - async ({ deviceToken, cookieID, blobHolder }) => { - const notif = await encryptAndroidVisualNotification( - encryptedNotifUtilsAPI, - senderDeviceDescriptor, - cookieID, - notification, - notificationSizeValidator, - blobHolder, - ); - return { deviceToken, cookieID, ...notif }; - }, - ); - return Promise.all(notificationPromises); -} - -function prepareEncryptedAndroidSilentNotifications( - encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, - senderDeviceDescriptor: SenderDeviceDescriptor, - devices: $ReadOnlyArray, - notification: AndroidNotificationRescind | AndroidBadgeOnlyNotification, -): Promise< - $ReadOnlyArray<{ - +cookieID: string, - +deviceToken: string, - +notification: AndroidNotificationRescind | AndroidBadgeOnlyNotification, - +encryptionOrder?: number, - }>, -> { - const notificationPromises = devices.map( - async ({ deviceToken, cookieID }) => { - const notif = await encryptAndroidSilentNotification( - encryptedNotifUtilsAPI, - cookieID, - senderDeviceDescriptor, - notification, - ); - return { deviceToken, cookieID, notification: notif }; - }, - ); - return Promise.all(notificationPromises); -} - -function prepareEncryptedWebNotifications( - encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, - senderDeviceDescriptor: SenderDeviceDescriptor, - devices: $ReadOnlyArray, - notification: PlainTextWebNotification, -): Promise< - $ReadOnlyArray<{ - +deviceToken: string, - +notification: WebNotification, - +encryptionOrder?: number, - }>, -> { - const notificationPromises = devices.map( - async ({ deviceToken, cookieID }) => { - const notif = await encryptWebNotification( - encryptedNotifUtilsAPI, - cookieID, - senderDeviceDescriptor, - notification, - ); - return { ...notif, deviceToken }; - }, - ); - return Promise.all(notificationPromises); -} - -function prepareEncryptedWNSNotifications( - encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, - senderDeviceDescriptor: SenderDeviceDescriptor, - devices: $ReadOnlyArray, - notification: PlainTextWNSNotification, -): Promise< - $ReadOnlyArray<{ - +deviceToken: string, - +notification: WNSNotification, - +encryptionOrder?: number, - }>, -> { - const notificationPromises = devices.map( - async ({ deviceToken, cookieID }) => { - const notif = await encryptWNSNotification( - encryptedNotifUtilsAPI, - cookieID, - senderDeviceDescriptor, - notification, - ); - return { ...notif, deviceToken }; - }, - ); - return Promise.all(notificationPromises); -} - async function encryptBlobPayload(payload: string): Promise<{ +encryptionKey: string, +encryptedPayload: Blob, @@ -574,9 +214,5 @@ export { prepareEncryptedAPNsNotifications, prepareEncryptedIOSNotificationRescind, - prepareEncryptedAndroidVisualNotifications, - prepareEncryptedAndroidSilentNotifications, - prepareEncryptedWebNotifications, - prepareEncryptedWNSNotifications, encryptBlobPayload, }; diff --git a/keyserver/src/push/encrypted-notif-utils-api.js b/keyserver/src/push/encrypted-notif-utils-api.js --- a/keyserver/src/push/encrypted-notif-utils-api.js +++ b/keyserver/src/push/encrypted-notif-utils-api.js @@ -11,7 +11,10 @@ encryptSerializedNotifPayload: async ( cryptoID: string, unencryptedPayload: string, - encryptedPayloadSizeValidator?: (encryptedPayload: string) => boolean, + encryptedPayloadSizeValidator?: ( + encryptedPayload: string, + type: '1' | '0', + ) => boolean, ) => { let dbPersistCondition; if (encryptedPayloadSizeValidator) { @@ -19,7 +22,11 @@ serializedPayload, }: { +[string]: EncryptResult, - }) => encryptedPayloadSizeValidator(serializedPayload.body); + }) => + encryptedPayloadSizeValidator( + serializedPayload.body, + serializedPayload.type ? '1' : '0', + ); } const { diff --git a/keyserver/src/push/rescind.js b/keyserver/src/push/rescind.js --- a/keyserver/src/push/rescind.js +++ b/keyserver/src/push/rescind.js @@ -5,6 +5,7 @@ import type { FirebaseError } from 'firebase-admin'; import invariant from 'invariant'; +import { prepareEncryptedAndroidSilentNotifications } from 'lib/push/crypto.js'; import type { PlatformDetails } from 'lib/types/device-types.js'; import type { NotificationTargetDevice, @@ -17,10 +18,7 @@ import { promiseAll } from 'lib/utils/promises.js'; import { tID } from 'lib/utils/validation-utils.js'; -import { - prepareEncryptedAndroidSilentNotifications, - prepareEncryptedIOSNotificationRescind, -} from './crypto.js'; +import { prepareEncryptedIOSNotificationRescind } from './crypto.js'; import encryptedNotifUtilsAPI from './encrypted-notif-utils-api.js'; import { getAPNsNotificationTopic } from './providers.js'; import type { TargetedAPNsNotification } from './types.js'; diff --git a/keyserver/src/push/send.js b/keyserver/src/push/send.js --- a/keyserver/src/push/send.js +++ b/keyserver/src/push/send.js @@ -12,6 +12,22 @@ import t from 'tcomb'; import uuidv4 from 'uuid/v4.js'; +import { + type AndroidNotifInputData, + androidNotifInputDataValidator, + createAndroidVisualNotification, +} from 'lib/push/android-notif-creators.js'; +import { prepareEncryptedAndroidSilentNotifications } from 'lib/push/crypto.js'; +import { + type WebNotifInputData, + webNotifInputDataValidator, + createWebNotification, +} from 'lib/push/web-notif-creators.js'; +import { + type WNSNotifInputData, + wnsNotifInputDataValidator, + createWNSNotification, +} from 'lib/push/wns-notif-creators.js'; import { oldValidUsernameRegex } from 'lib/shared/account-utils.js'; import { isUserMentioned } from 'lib/shared/mention-utils.js'; import { @@ -35,7 +51,6 @@ } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { - AndroidVisualNotification, NotificationTargetDevice, TargetedAndroidNotification, TargetedWebNotification, @@ -49,13 +64,7 @@ import { values } from 'lib/utils/objects.js'; import { tID, tPlatformDetails, tShape } from 'lib/utils/validation-utils.js'; -import { - prepareEncryptedAndroidVisualNotifications, - prepareEncryptedAndroidSilentNotifications, - prepareEncryptedAPNsNotifications, - prepareEncryptedWebNotifications, - prepareEncryptedWNSNotifications, -} from './crypto.js'; +import { prepareEncryptedAPNsNotifications } from './crypto.js'; import encryptedNotifUtilsAPI from './encrypted-notif-utils-api.js'; import { getAPNsNotificationTopic } from './providers.js'; import { rescindPushNotifs } from './rescind.js'; @@ -63,12 +72,10 @@ import { apnMaxNotificationPayloadByteSize, apnPush, - fcmMaxNotificationPayloadByteSize, fcmPush, getUnreadCounts, webPush, type WebPushError, - wnsMaxNotificationPayloadByteSize, wnsPush, type WNSPushError, } from './utils.js'; @@ -370,14 +377,14 @@ (async () => { const targetedNotifications = await prepareAndroidVisualNotification( { - keyserverID, + senderDeviceDescriptor: { keyserverID }, notifTexts, newRawMessageInfos: shimmedNewRawMessageInfos, threadID: threadInfo.id, collapseKey: notifInfo.collapseKey, unreadCount, platformDetails, - dbID, + notifID: dbID, }, devices, ); @@ -410,9 +417,10 @@ { notifTexts, threadID: threadInfo.id, - keyserverID, + senderDeviceDescriptor: { keyserverID }, unreadCount, platformDetails, + id: uuidv4(), }, devices, ); @@ -490,7 +498,7 @@ const targetedNotifications = await prepareWNSNotification(devices, { notifTexts, threadID: threadInfo.id, - keyserverID, + senderDeviceDescriptor: { keyserverID }, unreadCount, platformDetails, }); @@ -1168,14 +1176,6 @@ ]; } -type AndroidNotifInputData = { - ...CommonNativeNotifInputData, - +dbID: string, -}; -const androidNotifInputDataValidator = tShape({ - ...commonNativeNotifInputDataValidator.meta.props, - dbID: t.String, -}); async function prepareAndroidVisualNotification( inputData: AndroidNotifInputData, devices: $ReadOnlyArray, @@ -1185,202 +1185,14 @@ androidNotifInputDataValidator, inputData, ); - const { - keyserverID, - notifTexts, - newRawMessageInfos, - threadID, - collapseKey, - unreadCount, - platformDetails, - dbID, - } = convertedData; - - const canDecryptNonCollapsibleTextNotifs = hasMinCodeVersion( - platformDetails, - { native: 228 }, - ); - const isNonCollapsibleTextNotif = - newRawMessageInfos.every( - newRawMessageInfo => newRawMessageInfo.type === messageTypes.TEXT, - ) && !collapseKey; - - const canDecryptAllNotifTypes = hasMinCodeVersion(platformDetails, { - native: 267, - }); - - const shouldBeEncrypted = - canDecryptAllNotifTypes || - (canDecryptNonCollapsibleTextNotifs && isNonCollapsibleTextNotif); - - const { merged, ...rest } = notifTexts; - const notification = { - data: { - badge: unreadCount.toString(), - ...rest, - threadID, - }, - }; - - let notifID; - if (collapseKey && canDecryptAllNotifTypes) { - notifID = dbID; - notification.data = { - ...notification.data, - collapseKey, - }; - } else if (collapseKey) { - notifID = collapseKey; - } else { - notifID = dbID; - } - - notification.data = { - ...notification.data, - id: notifID, - badgeOnly: '0', - }; - - const messageInfos = JSON.stringify(newRawMessageInfos); - const copyWithMessageInfos = { - ...notification, - data: { ...notification.data, messageInfos }, - }; - - const priority = 'high'; - if (!shouldBeEncrypted) { - const notificationToSend = - encryptedNotifUtilsAPI.getNotifByteSize( - JSON.stringify(copyWithMessageInfos), - ) <= fcmMaxNotificationPayloadByteSize - ? copyWithMessageInfos - : notification; - - return devices.map(({ deviceToken }) => ({ - priority, - notification: notificationToSend, - deviceToken, - })); - } - - const notificationsSizeValidator = (notif: AndroidVisualNotification) => { - const serializedNotif = JSON.stringify(notif); - return ( - !serializedNotif || - encryptedNotifUtilsAPI.getNotifByteSize(serializedNotif) <= - fcmMaxNotificationPayloadByteSize - ); - }; - - const notifsWithMessageInfos = - await prepareEncryptedAndroidVisualNotifications( - encryptedNotifUtilsAPI, - { keyserverID }, - devices, - copyWithMessageInfos, - notificationsSizeValidator, - ); - - const devicesWithExcessiveSizeNoHolders = notifsWithMessageInfos - .filter(({ payloadSizeExceeded }) => payloadSizeExceeded) - .map(({ cookieID, deviceToken }) => ({ cookieID, deviceToken })); - - if (devicesWithExcessiveSizeNoHolders.length === 0) { - return notifsWithMessageInfos.map( - ({ notification: notif, deviceToken, encryptionOrder }) => ({ - priority, - notification: notif, - deviceToken, - encryptionOrder, - }), - ); - } - - const canQueryBlobService = hasMinCodeVersion(platformDetails, { - native: 331, - }); - - let blobHash, blobHolders, encryptionKey, blobUploadError; - if (canQueryBlobService) { - ({ blobHash, blobHolders, encryptionKey, blobUploadError } = - await encryptedNotifUtilsAPI.uploadLargeNotifPayload( - JSON.stringify(copyWithMessageInfos.data), - devicesWithExcessiveSizeNoHolders.length, - )); - } - if (blobUploadError) { - console.warn( - `Failed to upload payload of notification: ${notifID} ` + - `due to error: ${blobUploadError}`, - ); - } - - let devicesWithExcessiveSize = devicesWithExcessiveSizeNoHolders; - if ( - blobHash && - encryptionKey && - blobHolders && - blobHolders.length === devicesWithExcessiveSizeNoHolders.length - ) { - notification.data = { - ...notification.data, - blobHash, - encryptionKey, - }; - - devicesWithExcessiveSize = blobHolders.map((holder, idx) => ({ - ...devicesWithExcessiveSize[idx], - blobHolder: holder, - })); - } - - const notifsWithoutMessageInfos = - await prepareEncryptedAndroidVisualNotifications( - encryptedNotifUtilsAPI, - { keyserverID }, - devicesWithExcessiveSize, - notification, - ); - - const targetedNotifsWithMessageInfos = notifsWithMessageInfos - .filter(({ payloadSizeExceeded }) => !payloadSizeExceeded) - .map(({ notification: notif, deviceToken, encryptionOrder }) => ({ - priority, - notification: notif, - deviceToken, - encryptionOrder, - })); - - const targetedNotifsWithoutMessageInfos = notifsWithoutMessageInfos.map( - ({ notification: notif, deviceToken, encryptionOrder }) => ({ - priority, - notification: notif, - deviceToken, - encryptionOrder, - }), + return createAndroidVisualNotification( + encryptedNotifUtilsAPI, + convertedData, + devices, ); - - return [ - ...targetedNotifsWithMessageInfos, - ...targetedNotifsWithoutMessageInfos, - ]; } -type WebNotifInputData = { - +notifTexts: ResolvedNotifTexts, - +threadID: string, - +keyserverID: string, - +unreadCount: number, - +platformDetails: PlatformDetails, -}; -const webNotifInputDataValidator = tShape({ - notifTexts: resolvedNotifTextsValidator, - threadID: tID, - keyserverID: t.String, - unreadCount: t.Number, - platformDetails: tPlatformDetails, -}); async function prepareWebNotification( inputData: WebNotifInputData, devices: $ReadOnlyArray, @@ -1390,46 +1202,10 @@ webNotifInputDataValidator, inputData, ); - const { notifTexts, threadID, unreadCount, keyserverID } = convertedData; - const id = uuidv4(); - const { merged, ...rest } = notifTexts; - const notification = { - ...rest, - unreadCount, - id, - threadID, - }; - - const shouldBeEncrypted = hasMinCodeVersion(convertedData.platformDetails, { - web: 43, - }); - - if (!shouldBeEncrypted) { - return devices.map(({ deviceToken }) => ({ deviceToken, notification })); - } - return prepareEncryptedWebNotifications( - encryptedNotifUtilsAPI, - { keyserverID }, - devices, - notification, - ); + return createWebNotification(encryptedNotifUtilsAPI, convertedData, devices); } -type WNSNotifInputData = { - +notifTexts: ResolvedNotifTexts, - +threadID: string, - +keyserverID: string, - +unreadCount: number, - +platformDetails: PlatformDetails, -}; -const wnsNotifInputDataValidator = tShape({ - notifTexts: resolvedNotifTextsValidator, - threadID: tID, - keyserverID: t.String, - unreadCount: t.Number, - platformDetails: tPlatformDetails, -}); async function prepareWNSNotification( devices: $ReadOnlyArray, inputData: WNSNotifInputData, @@ -1439,37 +1215,7 @@ wnsNotifInputDataValidator, inputData, ); - const { notifTexts, threadID, unreadCount, keyserverID } = convertedData; - const { merged, ...rest } = notifTexts; - const notification = { - ...rest, - unreadCount, - threadID, - }; - - if ( - encryptedNotifUtilsAPI.getNotifByteSize(JSON.stringify(notification)) > - wnsMaxNotificationPayloadByteSize - ) { - console.warn('WNS notification exceeds size limit'); - } - - const shouldBeEncrypted = hasMinCodeVersion(inputData.platformDetails, { - majorDesktop: 10, - }); - - if (!shouldBeEncrypted) { - return devices.map(({ deviceToken }) => ({ - deviceToken, - notification, - })); - } - return await prepareEncryptedWNSNotifications( - encryptedNotifUtilsAPI, - { keyserverID }, - devices, - notification, - ); + return createWNSNotification(encryptedNotifUtilsAPI, convertedData, devices); } type NotificationInfo = diff --git a/keyserver/src/push/utils.js b/keyserver/src/push/utils.js --- a/keyserver/src/push/utils.js +++ b/keyserver/src/push/utils.js @@ -8,6 +8,8 @@ import uuid from 'uuid'; import webpush from 'web-push'; +import { fcmMaxNotificationPayloadByteSize } from 'lib/push/android-notif-creators.js'; +import { wnsMaxNotificationPayloadByteSize } from 'lib/push/wns-notif-creators.js'; import type { PlatformDetails } from 'lib/types/device-types.js'; import type { TargetedAndroidNotification, @@ -34,14 +36,13 @@ 'messaging/registration-token-not-registered', 'messaging/invalid-registration-token', ]); -const fcmMaxNotificationPayloadByteSize = 4000; + const apnTokenInvalidationErrorCode = 410; const apnBadRequestErrorCode = 400; const apnBadTokenErrorString = 'BadDeviceToken'; const apnMaxNotificationPayloadByteSize = 4096; const webInvalidTokenErrorCodes = [404, 410]; const wnsInvalidTokenErrorCodes = [404, 410]; -const wnsMaxNotificationPayloadByteSize = 5000; export type APNPushResult = | { +success: true } diff --git a/lib/push/android-notif-creators.js b/lib/push/android-notif-creators.js new file mode 100644 --- /dev/null +++ b/lib/push/android-notif-creators.js @@ -0,0 +1,251 @@ +// @flow + +import t, { type TInterface } from 'tcomb'; + +import { prepareEncryptedAndroidVisualNotifications } from './crypto.js'; +import { hasMinCodeVersion } from '../shared/version-utils.js'; +import type { PlatformDetails } from '../types/device-types.js'; +import { messageTypes } from '../types/message-types-enum.js'; +import { + type RawMessageInfo, + rawMessageInfoValidator, +} from '../types/message-types.js'; +import { + type AndroidVisualNotification, + type NotificationTargetDevice, + type TargetedAndroidNotification, + type ResolvedNotifTexts, + resolvedNotifTextsValidator, + type SenderDeviceDescriptor, + senderDeviceDescriptorValidator, + type EncryptedNotifUtilsAPI, +} from '../types/notif-types.js'; +import { tID, tPlatformDetails, tShape } from '../utils/validation-utils.js'; + +export const fcmMaxNotificationPayloadByteSize = 4000; + +type CommonNativeNotifInputData = $ReadOnly<{ + +senderDeviceDescriptor: SenderDeviceDescriptor, + +notifTexts: ResolvedNotifTexts, + +newRawMessageInfos: RawMessageInfo[], + +threadID: string, + +collapseKey: ?string, + +unreadCount?: number, + +platformDetails: PlatformDetails, +}>; + +const commonNativeNotifInputDataValidator = tShape({ + senderDeviceDescriptor: senderDeviceDescriptorValidator, + notifTexts: resolvedNotifTextsValidator, + newRawMessageInfos: t.list(rawMessageInfoValidator), + threadID: tID, + collapseKey: t.maybe(t.String), + unreadCount: t.maybe(t.Number), + platformDetails: tPlatformDetails, +}); + +export type AndroidNotifInputData = { + ...CommonNativeNotifInputData, + +notifID: string, +}; + +export const androidNotifInputDataValidator: TInterface = + tShape({ + ...commonNativeNotifInputDataValidator.meta.props, + notifID: t.String, + }); + +async function createAndroidVisualNotification( + encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, + inputData: AndroidNotifInputData, + devices: $ReadOnlyArray, +): Promise<$ReadOnlyArray> { + const { + senderDeviceDescriptor, + notifTexts, + newRawMessageInfos, + threadID, + collapseKey, + unreadCount, + platformDetails, + notifID, + } = inputData; + + const canDecryptNonCollapsibleTextNotifs = hasMinCodeVersion( + platformDetails, + { native: 228 }, + ); + const isNonCollapsibleTextNotif = + newRawMessageInfos.every( + newRawMessageInfo => newRawMessageInfo.type === messageTypes.TEXT, + ) && !collapseKey; + + const canDecryptAllNotifTypes = hasMinCodeVersion(platformDetails, { + native: 267, + }); + + const shouldBeEncrypted = + canDecryptAllNotifTypes || + (canDecryptNonCollapsibleTextNotifs && isNonCollapsibleTextNotif); + + const { merged, ...rest } = notifTexts; + const notification = { + data: { + ...rest, + threadID, + }, + }; + + if (unreadCount) { + notification.data = { + ...notification.data, + badge: unreadCount.toString(), + }; + } + + let id; + if (collapseKey && canDecryptAllNotifTypes) { + id = notifID; + notification.data = { + ...notification.data, + collapseKey, + }; + } else if (collapseKey) { + id = collapseKey; + } else { + id = notifID; + } + + notification.data = { + ...notification.data, + id, + badgeOnly: '0', + }; + + const messageInfos = JSON.stringify(newRawMessageInfos); + const copyWithMessageInfos = { + ...notification, + data: { ...notification.data, messageInfos }, + }; + + const priority = 'high'; + if (!shouldBeEncrypted) { + const notificationToSend = + encryptedNotifUtilsAPI.getNotifByteSize( + JSON.stringify(copyWithMessageInfos), + ) <= fcmMaxNotificationPayloadByteSize + ? copyWithMessageInfos + : notification; + + return devices.map(({ deviceToken }) => ({ + priority, + notification: notificationToSend, + deviceToken, + })); + } + + const notificationsSizeValidator = (notif: AndroidVisualNotification) => { + const serializedNotif = JSON.stringify(notif); + return ( + !serializedNotif || + encryptedNotifUtilsAPI.getNotifByteSize(serializedNotif) <= + fcmMaxNotificationPayloadByteSize + ); + }; + + const notifsWithMessageInfos = + await prepareEncryptedAndroidVisualNotifications( + encryptedNotifUtilsAPI, + senderDeviceDescriptor, + devices, + copyWithMessageInfos, + notificationsSizeValidator, + ); + + const devicesWithExcessiveSizeNoHolders = notifsWithMessageInfos + .filter(({ payloadSizeExceeded }) => payloadSizeExceeded) + .map(({ cookieID, deviceToken }) => ({ cookieID, deviceToken })); + + if (devicesWithExcessiveSizeNoHolders.length === 0) { + return notifsWithMessageInfos.map( + ({ notification: notif, deviceToken, encryptionOrder }) => ({ + priority, + notification: notif, + deviceToken, + encryptionOrder, + }), + ); + } + + const canQueryBlobService = hasMinCodeVersion(platformDetails, { + native: 331, + }); + + let blobHash, blobHolders, encryptionKey, blobUploadError; + if (canQueryBlobService) { + ({ blobHash, blobHolders, encryptionKey, blobUploadError } = + await encryptedNotifUtilsAPI.uploadLargeNotifPayload( + JSON.stringify(copyWithMessageInfos.data), + devicesWithExcessiveSizeNoHolders.length, + )); + } + + if (blobUploadError) { + console.warn( + `Failed to upload payload of notification: ${notifID} ` + + `due to error: ${blobUploadError}`, + ); + } + + let devicesWithExcessiveSize = devicesWithExcessiveSizeNoHolders; + if ( + blobHash && + encryptionKey && + blobHolders && + blobHolders.length === devicesWithExcessiveSizeNoHolders.length + ) { + notification.data = { + ...notification.data, + blobHash, + encryptionKey, + }; + + devicesWithExcessiveSize = blobHolders.map((holder, idx) => ({ + ...devicesWithExcessiveSize[idx], + blobHolder: holder, + })); + } + + const notifsWithoutMessageInfos = + await prepareEncryptedAndroidVisualNotifications( + encryptedNotifUtilsAPI, + senderDeviceDescriptor, + devicesWithExcessiveSize, + notification, + ); + + const targetedNotifsWithMessageInfos = notifsWithMessageInfos + .filter(({ payloadSizeExceeded }) => !payloadSizeExceeded) + .map(({ notification: notif, deviceToken, encryptionOrder }) => ({ + priority, + notification: notif, + deviceToken, + encryptionOrder, + })); + + const targetedNotifsWithoutMessageInfos = notifsWithoutMessageInfos.map( + ({ notification: notif, deviceToken, encryptionOrder }) => ({ + priority, + notification: notif, + deviceToken, + encryptionOrder, + }), + ); + + return [ + ...targetedNotifsWithMessageInfos, + ...targetedNotifsWithoutMessageInfos, + ]; +} + +export { createAndroidVisualNotification }; diff --git a/keyserver/src/push/crypto.js b/lib/push/crypto.js copy from keyserver/src/push/crypto.js copy to lib/push/crypto.js --- a/keyserver/src/push/crypto.js +++ b/lib/push/crypto.js @@ -1,10 +1,5 @@ // @flow -import apn from '@parse/node-apn'; -import crypto from 'crypto'; -import invariant from 'invariant'; -import _cloneDeep from 'lodash/fp/cloneDeep.js'; - import type { PlainTextWebNotification, PlainTextWebNotificationPayload, @@ -18,119 +13,7 @@ NotificationTargetDevice, SenderDeviceDescriptor, EncryptedNotifUtilsAPI, -} from 'lib/types/notif-types.js'; -import { toBase64URL } from 'lib/utils/base64.js'; - -import { encrypt, generateKey } from '../utils/aes-crypto-utils.js'; -import { getOlmUtility } from '../utils/olm-utils.js'; - -async function encryptAPNsNotification( - encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, - cookieID: string, - senderDeviceDescriptor: SenderDeviceDescriptor, - notification: apn.Notification, - codeVersion?: ?number, - notificationSizeValidator?: apn.Notification => boolean, - blobHolder?: ?string, -): Promise<{ - +notification: apn.Notification, - +payloadSizeExceeded: boolean, - +encryptedPayloadHash?: string, - +encryptionOrder?: number, -}> { - invariant( - !notification.collapseId, - `Collapse ID can't be directly stored in apn.Notification object due ` + - `to security reasons. Please put it in payload property`, - ); - - const encryptedNotification = new apn.Notification(); - - encryptedNotification.id = notification.id; - encryptedNotification.payload.id = notification.id; - - if (blobHolder) { - encryptedNotification.payload.blobHolder = blobHolder; - } - - encryptedNotification.payload.keyserverID = notification.payload.keyserverID; - encryptedNotification.topic = notification.topic; - encryptedNotification.sound = notification.aps.sound; - encryptedNotification.pushType = 'alert'; - encryptedNotification.mutableContent = true; - - const { id, ...payloadSansUnencryptedData } = notification.payload; - const unencryptedPayload = { - ...payloadSansUnencryptedData, - badge: notification.aps.badge.toString(), - merged: notification.body, - }; - - try { - const unencryptedSerializedPayload = JSON.stringify(unencryptedPayload); - - let dbPersistCondition; - if (notificationSizeValidator) { - dbPersistCondition = (serializedPayload: string) => { - const notifCopy = _cloneDeep(encryptedNotification); - notifCopy.payload.encryptedPayload = serializedPayload; - return notificationSizeValidator(notifCopy); - }; - } - const { - encryptedData: serializedPayload, - sizeLimitViolated: dbPersistConditionViolated, - encryptionOrder, - } = await encryptedNotifUtilsAPI.encryptSerializedNotifPayload( - cookieID, - unencryptedSerializedPayload, - dbPersistCondition, - ); - - encryptedNotification.payload.encryptedPayload = serializedPayload.body; - encryptedNotification.payload = { - ...encryptedNotification.payload, - ...senderDeviceDescriptor, - }; - - if (codeVersion && codeVersion >= 254 && codeVersion % 2 === 0) { - encryptedNotification.aps = { - alert: { body: 'ENCRYPTED' }, - ...encryptedNotification.aps, - }; - } - - const encryptedPayloadHash = getOlmUtility().sha256(serializedPayload.body); - return { - notification: encryptedNotification, - payloadSizeExceeded: !!dbPersistConditionViolated, - encryptedPayloadHash, - encryptionOrder, - }; - } catch (e) { - console.log('Notification encryption failed: ' + e); - - encryptedNotification.body = notification.body; - encryptedNotification.threadId = notification.payload.threadID; - invariant( - typeof notification.aps.badge === 'number', - 'Unencrypted notification must have badge as a number', - ); - encryptedNotification.badge = notification.aps.badge; - - encryptedNotification.payload = { - ...encryptedNotification.payload, - ...notification.payload, - encryptionFailed: 1, - }; - return { - notification: encryptedNotification, - payloadSizeExceeded: notificationSizeValidator - ? notificationSizeValidator(_cloneDeep(encryptedNotification)) - : false, - }; - } -} +} from '../types/notif-types.js'; async function encryptAndroidNotificationPayload( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, @@ -138,12 +21,21 @@ senderDeviceDescriptor: SenderDeviceDescriptor, unencryptedPayload: T, payloadSizeValidator?: ( - T | $ReadOnly<{ ...SenderDeviceDescriptor, +encryptedPayload: string }>, + | T + | $ReadOnly<{ + ...SenderDeviceDescriptor, + +encryptedPayload: string, + +type: '1' | '0', + }>, ) => boolean, ): Promise<{ +resultPayload: | T - | $ReadOnly<{ ...SenderDeviceDescriptor, +encryptedPayload: string }>, + | $ReadOnly<{ + ...SenderDeviceDescriptor, + +encryptedPayload: string, + +type: '1' | '0', + }>, +payloadSizeExceeded: boolean, +encryptionOrder?: number, }> { @@ -160,9 +52,10 @@ let dbPersistCondition; if (payloadSizeValidator) { - dbPersistCondition = (serializedPayload: string) => + dbPersistCondition = (serializedPayload: string, type: '1' | '0') => payloadSizeValidator({ encryptedPayload: serializedPayload, + type, ...senderDeviceDescriptor, }); } @@ -179,8 +72,9 @@ return { resultPayload: { - encryptedPayload: serializedPayload.body, ...senderDeviceDescriptor, + encryptedPayload: serializedPayload.body, + type: serializedPayload.type ? '1' : '0', }, payloadSizeExceeded: !!dbPersistConditionViolated, encryptionOrder, @@ -229,7 +123,11 @@ payloadSizeValidator = ( payload: | AndroidVisualNotificationPayload - | $ReadOnly<{ ...SenderDeviceDescriptor, +encryptedPayload: string }>, + | $ReadOnly<{ + ...SenderDeviceDescriptor, + +encryptedPayload: string, + +type: '0' | '1', + }>, ) => { return notificationSizeValidator({ data: { ...unencryptedData, ...payload }, @@ -300,6 +198,7 @@ | $ReadOnly<{ ...SenderDeviceDescriptor, +encryptedPayload: string, + +type: '1' | '0', +encryptionOrder?: number, }> | { ...T, +encryptionFailed: '1' }, @@ -320,6 +219,7 @@ return { ...senderDeviceDescriptor, encryptedPayload: serializedPayload.body, + type: serializedPayload.type ? '1' : '0', encryptionOrder, }; } catch (e) { @@ -371,68 +271,6 @@ }; } -function prepareEncryptedAPNsNotifications( - encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, - senderDeviceDescriptor: SenderDeviceDescriptor, - devices: $ReadOnlyArray, - notification: apn.Notification, - codeVersion?: ?number, - notificationSizeValidator?: apn.Notification => boolean, -): Promise< - $ReadOnlyArray<{ - +cookieID: string, - +deviceToken: string, - +notification: apn.Notification, - +payloadSizeExceeded: boolean, - +encryptedPayloadHash?: string, - +encryptionOrder?: number, - }>, -> { - const notificationPromises = devices.map( - async ({ cookieID, deviceToken, blobHolder }) => { - const notif = await encryptAPNsNotification( - encryptedNotifUtilsAPI, - cookieID, - senderDeviceDescriptor, - notification, - codeVersion, - notificationSizeValidator, - blobHolder, - ); - return { cookieID, deviceToken, ...notif }; - }, - ); - return Promise.all(notificationPromises); -} - -function prepareEncryptedIOSNotificationRescind( - encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, - senderDeviceDescriptor: SenderDeviceDescriptor, - devices: $ReadOnlyArray, - notification: apn.Notification, - codeVersion?: ?number, -): Promise< - $ReadOnlyArray<{ - +cookieID: string, - +deviceToken: string, - +notification: apn.Notification, - }>, -> { - const notificationPromises = devices.map( - async ({ deviceToken, cookieID }) => { - const { notification: notif } = await encryptAPNsNotification( - encryptedNotifUtilsAPI, - cookieID, - senderDeviceDescriptor, - notification, - codeVersion, - ); - return { deviceToken, cookieID, notification: notif }; - }, - ); - return Promise.all(notificationPromises); -} - function prepareEncryptedAndroidVisualNotifications( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, senderDeviceDescriptor: SenderDeviceDescriptor, @@ -545,38 +383,9 @@ return Promise.all(notificationPromises); } -async function encryptBlobPayload(payload: string): Promise<{ - +encryptionKey: string, - +encryptedPayload: Blob, - +encryptedPayloadHash: string, -}> { - const encryptionKey = await generateKey(); - const encryptedPayload = await encrypt( - encryptionKey, - new TextEncoder().encode(payload), - ); - const encryptedPayloadBuffer = Buffer.from(encryptedPayload); - const blobHashBase64 = await crypto - .createHash('sha256') - .update(encryptedPayloadBuffer) - .digest('base64'); - const blobHash = toBase64URL(blobHashBase64); - - const payloadBlob = new Blob([encryptedPayloadBuffer]); - const encryptionKeyString = Buffer.from(encryptionKey).toString('base64'); - return { - encryptionKey: encryptionKeyString, - encryptedPayload: payloadBlob, - encryptedPayloadHash: blobHash, - }; -} - export { - prepareEncryptedAPNsNotifications, - prepareEncryptedIOSNotificationRescind, prepareEncryptedAndroidVisualNotifications, prepareEncryptedAndroidSilentNotifications, prepareEncryptedWebNotifications, prepareEncryptedWNSNotifications, - encryptBlobPayload, }; diff --git a/lib/push/web-notif-creators.js b/lib/push/web-notif-creators.js new file mode 100644 --- /dev/null +++ b/lib/push/web-notif-creators.js @@ -0,0 +1,70 @@ +// @flow + +import t, { type TInterface } from 'tcomb'; + +import { prepareEncryptedWebNotifications } from './crypto.js'; +import { hasMinCodeVersion } from '../shared/version-utils.js'; +import type { PlatformDetails } from '../types/device-types.js'; +import { + type NotificationTargetDevice, + type TargetedWebNotification, + type ResolvedNotifTexts, + resolvedNotifTextsValidator, + type SenderDeviceDescriptor, + senderDeviceDescriptorValidator, + type EncryptedNotifUtilsAPI, +} from '../types/notif-types.js'; +import { tID, tPlatformDetails, tShape } from '../utils/validation-utils.js'; + +export type WebNotifInputData = { + +id: string, + +notifTexts: ResolvedNotifTexts, + +threadID: string, + +senderDeviceDescriptor: SenderDeviceDescriptor, + +unreadCount: number, + +platformDetails: PlatformDetails, +}; + +export const webNotifInputDataValidator: TInterface = + tShape({ + id: t.String, + notifTexts: resolvedNotifTextsValidator, + threadID: tID, + senderDeviceDescriptor: senderDeviceDescriptorValidator, + unreadCount: t.Number, + platformDetails: tPlatformDetails, + }); + +async function createWebNotification( + encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, + inputData: WebNotifInputData, + devices: $ReadOnlyArray, +): Promise<$ReadOnlyArray> { + const { id, notifTexts, threadID, unreadCount, senderDeviceDescriptor } = + inputData; + + const { merged, ...rest } = notifTexts; + const notification = { + ...rest, + unreadCount, + id, + threadID, + }; + + const shouldBeEncrypted = hasMinCodeVersion(inputData.platformDetails, { + web: 43, + }); + + if (!shouldBeEncrypted) { + return devices.map(({ deviceToken }) => ({ deviceToken, notification })); + } + + return prepareEncryptedWebNotifications( + encryptedNotifUtilsAPI, + senderDeviceDescriptor, + devices, + notification, + ); +} + +export { createWebNotification }; diff --git a/lib/push/wns-notif-creators.js b/lib/push/wns-notif-creators.js new file mode 100644 --- /dev/null +++ b/lib/push/wns-notif-creators.js @@ -0,0 +1,77 @@ +// @flow + +import t, { type TInterface } from 'tcomb'; + +import { prepareEncryptedWNSNotifications } from './crypto.js'; +import { hasMinCodeVersion } from '../shared/version-utils.js'; +import type { PlatformDetails } from '../types/device-types.js'; +import { + type NotificationTargetDevice, + type TargetedWNSNotification, + type ResolvedNotifTexts, + resolvedNotifTextsValidator, + type SenderDeviceDescriptor, + senderDeviceDescriptorValidator, + type EncryptedNotifUtilsAPI, +} from '../types/notif-types.js'; +import { tID, tPlatformDetails, tShape } from '../utils/validation-utils.js'; + +export const wnsMaxNotificationPayloadByteSize = 5000; + +export type WNSNotifInputData = { + +notifTexts: ResolvedNotifTexts, + +threadID: string, + +senderDeviceDescriptor: SenderDeviceDescriptor, + +unreadCount: number, + +platformDetails: PlatformDetails, +}; + +export const wnsNotifInputDataValidator: TInterface = + tShape({ + notifTexts: resolvedNotifTextsValidator, + threadID: tID, + senderDeviceDescriptor: senderDeviceDescriptorValidator, + unreadCount: t.Number, + platformDetails: tPlatformDetails, + }); + +async function createWNSNotification( + encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, + inputData: WNSNotifInputData, + devices: $ReadOnlyArray, +): Promise<$ReadOnlyArray> { + const { notifTexts, threadID, unreadCount, senderDeviceDescriptor } = + inputData; + const { merged, ...rest } = notifTexts; + const notification = { + ...rest, + unreadCount, + threadID, + }; + + if ( + encryptedNotifUtilsAPI.getNotifByteSize(JSON.stringify(notification)) > + wnsMaxNotificationPayloadByteSize + ) { + console.warn('WNS notification exceeds size limit'); + } + + const shouldBeEncrypted = hasMinCodeVersion(inputData.platformDetails, { + majorDesktop: 10, + }); + + if (!shouldBeEncrypted) { + return devices.map(({ deviceToken }) => ({ + deviceToken, + notification, + })); + } + return await prepareEncryptedWNSNotifications( + encryptedNotifUtilsAPI, + senderDeviceDescriptor, + devices, + notification, + ); +} + +export { createWNSNotification }; diff --git a/lib/types/notif-types.js b/lib/types/notif-types.js --- a/lib/types/notif-types.js +++ b/lib/types/notif-types.js @@ -1,7 +1,7 @@ // @flow import type { EncryptResult } from '@commapp/olm'; -import t, { type TInterface } from 'tcomb'; +import t, { type TInterface, type TUnion } from 'tcomb'; import type { EntityText, ThreadEntity } from '../utils/entity-text.js'; import { tShape } from '../utils/validation-utils.js'; @@ -31,6 +31,12 @@ | { +keyserverID: string } | { +senderDeviceID: string }; +export const senderDeviceDescriptorValidator: TUnion = + t.union([ + tShape({ keyserverID: t.String }), + tShape({ senderDeviceID: t.String }), + ]); + export type PlainTextWebNotificationPayload = { +body: string, +prefix?: string, @@ -49,7 +55,7 @@ ...SenderDeviceDescriptor, +id: string, +encryptedPayload: string, - +type?: '0' | '1', + +type: '0' | '1', }>; export type WebNotification = @@ -68,7 +74,7 @@ export type EncryptedWNSNotification = $ReadOnly<{ ...SenderDeviceDescriptor, +encryptedPayload: string, - +type?: '0' | '1', + +type: '0' | '1', }>; export type WNSNotification = @@ -76,7 +82,7 @@ | EncryptedWNSNotification; export type AndroidVisualNotificationPayloadBase = $ReadOnly<{ - +badge: string, + +badge?: string, +body: string, +title: string, +prefix?: string, @@ -104,13 +110,13 @@ type EncryptedThinThreadPayload = { +keyserverID: string, +encryptedPayload: string, - +type?: '0' | '1', + +type: '0' | '1', }; type EncryptedThickThreadPayload = { +senderDeviceID: string, +encryptedPayload: string, - +type?: '0' | '1', + +type: '0' | '1', }; export type AndroidVisualNotification = { @@ -191,7 +197,10 @@ +encryptSerializedNotifPayload: ( cryptoID: string, unencryptedPayload: string, - encryptedPayloadSizeValidator?: (encryptedPayload: string) => boolean, + encryptedPayloadSizeValidator?: ( + encryptedPayload: string, + type: '1' | '0', + ) => boolean, ) => Promise<{ +encryptedData: EncryptResult, +sizeLimitViolated?: boolean,