diff --git a/keyserver/src/responders/responder-validators.test.js b/keyserver/src/responders/responder-validators.test.js new file mode 100644 index 000000000..d20d2ee70 --- /dev/null +++ b/keyserver/src/responders/responder-validators.test.js @@ -0,0 +1,333 @@ +// @flow + +import { + logInResponseValidator, + registerResponseValidator, + logOutResponseValidator, +} from './user-responders.js'; + +describe('user responder validators', () => { + it('should validate logout response', () => { + const response = { currentUserInfo: { id: '93078', anonymous: true } }; + expect(logOutResponseValidator.is(response)).toBe(true); + response.currentUserInfo.anonymous = false; + expect(logOutResponseValidator.is(response)).toBe(false); + }); + + it('should validate register response', () => { + const response = { + id: '93079', + rawMessageInfos: [ + { + type: 1, + threadID: '93095', + creatorID: '93079', + time: 1682086407469, + initialThreadState: { + type: 6, + name: null, + parentThreadID: '1', + color: '648caa', + memberIDs: ['256', '93079'], + }, + id: '93110', + }, + { + type: 0, + threadID: '93095', + creatorID: '256', + time: 1682086407575, + text: 'welcome to Comm!', + id: '93113', + }, + ], + currentUserInfo: { id: '93079', username: 'user' }, + cookieChange: { + threadInfos: { + '1': { + id: '1', + type: 12, + name: 'GENESIS', + description: 'desc', + color: 'c85000', + creationTime: 1672934346213, + parentThreadID: null, + members: [ + { + id: '256', + role: '83796', + permissions: { + know_of: { value: true, source: '1' }, + membership: { value: false, source: null }, + visible: { value: true, source: '1' }, + voiced: { value: true, source: '1' }, + edit_entries: { value: true, source: '1' }, + edit_thread: { value: true, source: '1' }, + edit_thread_description: { value: true, source: '1' }, + edit_thread_color: { value: true, source: '1' }, + delete_thread: { value: true, source: '1' }, + create_subthreads: { value: true, source: '1' }, + create_sidebars: { value: true, source: '1' }, + join_thread: { value: false, source: null }, + edit_permissions: { value: false, source: null }, + add_members: { value: true, source: '1' }, + remove_members: { value: true, source: '1' }, + change_role: { value: true, source: '1' }, + leave_thread: { value: false, source: null }, + react_to_message: { value: true, source: '1' }, + edit_message: { value: true, source: '1' }, + }, + isSender: false, + }, + ], + roles: { + '83795': { + id: '83795', + name: 'Members', + permissions: { + know_of: true, + visible: true, + descendant_open_know_of: true, + descendant_open_visible: true, + descendant_opentoplevel_join_thread: true, + }, + isDefault: true, + }, + }, + currentUser: { + role: '83795', + permissions: { + know_of: { value: true, source: '1' }, + membership: { value: false, source: null }, + visible: { value: true, source: '1' }, + voiced: { value: false, source: null }, + edit_entries: { value: false, source: null }, + edit_thread: { value: false, source: null }, + edit_thread_description: { value: false, source: null }, + edit_thread_color: { value: false, source: null }, + delete_thread: { value: false, source: null }, + create_subthreads: { value: false, source: null }, + create_sidebars: { value: false, source: null }, + join_thread: { value: false, source: null }, + edit_permissions: { value: false, source: null }, + add_members: { value: false, source: null }, + remove_members: { value: false, source: null }, + change_role: { value: false, source: null }, + leave_thread: { value: false, source: null }, + react_to_message: { value: false, source: null }, + edit_message: { value: false, source: null }, + }, + subscription: { home: true, pushNotifs: true }, + unread: true, + }, + repliesCount: 0, + containingThreadID: null, + community: null, + }, + }, + userInfos: [ + { id: '5', username: 'commbot' }, + { id: '256', username: 'ashoat' }, + { id: '93079', username: 'temp_user7' }, + ], + }, + }; + + expect(registerResponseValidator.is(response)).toBe(true); + response.cookieChange.userInfos = undefined; + expect(registerResponseValidator.is(response)).toBe(false); + }); + + it('should validate login response', () => { + const response = { + currentUserInfo: { id: '93079', username: 'temp_user7' }, + rawMessageInfos: [ + { + type: 0, + id: '93115', + threadID: '93094', + time: 1682086407577, + creatorID: '5', + text: 'This is your private chat, where you can set', + }, + { + type: 1, + id: '93111', + threadID: '93094', + time: 1682086407467, + creatorID: '93079', + initialThreadState: { + type: 7, + name: 'temp_user7', + parentThreadID: '1', + color: '575757', + memberIDs: ['93079'], + }, + }, + ], + truncationStatuses: { '93094': 'exhaustive', '93095': 'exhaustive' }, + serverTime: 1682086579416, + userInfos: [ + { id: '5', username: 'commbot' }, + { id: '256', username: 'ashoat' }, + { id: '93079', username: 'temp_user7' }, + ], + cookieChange: { + threadInfos: { + '1': { + id: '1', + type: 12, + name: 'GENESIS', + description: + 'This is the first community on Comm. In the future it will', + color: 'c85000', + creationTime: 1672934346213, + parentThreadID: null, + members: [ + { + id: '256', + role: '83796', + permissions: { + know_of: { value: true, source: '1' }, + membership: { value: false, source: null }, + visible: { value: true, source: '1' }, + voiced: { value: true, source: '1' }, + edit_entries: { value: true, source: '1' }, + edit_thread: { value: true, source: '1' }, + edit_thread_description: { value: true, source: '1' }, + edit_thread_color: { value: true, source: '1' }, + delete_thread: { value: true, source: '1' }, + create_subthreads: { value: true, source: '1' }, + create_sidebars: { value: true, source: '1' }, + join_thread: { value: false, source: null }, + edit_permissions: { value: false, source: null }, + add_members: { value: true, source: '1' }, + remove_members: { value: true, source: '1' }, + change_role: { value: true, source: '1' }, + leave_thread: { value: false, source: null }, + react_to_message: { value: true, source: '1' }, + edit_message: { value: true, source: '1' }, + }, + isSender: false, + }, + { + id: '93079', + role: '83795', + permissions: { + know_of: { value: true, source: '1' }, + membership: { value: false, source: null }, + visible: { value: true, source: '1' }, + voiced: { value: false, source: null }, + edit_entries: { value: false, source: null }, + edit_thread: { value: false, source: null }, + edit_thread_description: { value: false, source: null }, + edit_thread_color: { value: false, source: null }, + delete_thread: { value: false, source: null }, + create_subthreads: { value: false, source: null }, + create_sidebars: { value: false, source: null }, + join_thread: { value: false, source: null }, + edit_permissions: { value: false, source: null }, + add_members: { value: false, source: null }, + remove_members: { value: false, source: null }, + change_role: { value: false, source: null }, + leave_thread: { value: false, source: null }, + react_to_message: { value: false, source: null }, + edit_message: { value: false, source: null }, + }, + isSender: false, + }, + ], + roles: { + '83795': { + id: '83795', + name: 'Members', + permissions: { + know_of: true, + visible: true, + descendant_open_know_of: true, + descendant_open_visible: true, + descendant_opentoplevel_join_thread: true, + }, + isDefault: true, + }, + '83796': { + id: '83796', + name: 'Admins', + permissions: { + know_of: true, + visible: true, + voiced: true, + react_to_message: true, + edit_message: true, + edit_entries: true, + edit_thread: true, + edit_thread_color: true, + edit_thread_description: true, + create_subthreads: true, + create_sidebars: true, + add_members: true, + delete_thread: true, + remove_members: true, + change_role: true, + descendant_know_of: true, + descendant_visible: true, + descendant_toplevel_join_thread: true, + child_join_thread: true, + descendant_voiced: true, + descendant_edit_entries: true, + descendant_edit_thread: true, + descendant_edit_thread_color: true, + descendant_edit_thread_description: true, + descendant_toplevel_create_subthreads: true, + descendant_toplevel_create_sidebars: true, + descendant_add_members: true, + descendant_delete_thread: true, + descendant_edit_permissions: true, + descendant_remove_members: true, + descendant_change_role: true, + }, + isDefault: false, + }, + }, + currentUser: { + role: '83795', + permissions: { + know_of: { value: true, source: '1' }, + membership: { value: false, source: null }, + visible: { value: true, source: '1' }, + voiced: { value: false, source: null }, + edit_entries: { value: false, source: null }, + edit_thread: { value: false, source: null }, + edit_thread_description: { value: false, source: null }, + edit_thread_color: { value: false, source: null }, + delete_thread: { value: false, source: null }, + create_subthreads: { value: false, source: null }, + create_sidebars: { value: false, source: null }, + join_thread: { value: false, source: null }, + edit_permissions: { value: false, source: null }, + add_members: { value: false, source: null }, + remove_members: { value: false, source: null }, + change_role: { value: false, source: null }, + leave_thread: { value: false, source: null }, + react_to_message: { value: false, source: null }, + edit_message: { value: false, source: null }, + }, + subscription: { home: true, pushNotifs: true }, + unread: true, + }, + repliesCount: 0, + containingThreadID: null, + community: null, + }, + }, + userInfos: [], + }, + rawEntryInfos: [], + }; + + expect(logInResponseValidator.is(response)).toBe(true); + expect( + logInResponseValidator.is({ ...response, currentUserInfo: undefined }), + ).toBe(false); + }); +}); diff --git a/keyserver/src/responders/user-responders.js b/keyserver/src/responders/user-responders.js index d1378d0e8..b2d07d0ed 100644 --- a/keyserver/src/responders/user-responders.js +++ b/keyserver/src/responders/user-responders.js @@ -1,655 +1,717 @@ // @flow import type { Utility as OlmUtility } from '@commapp/olm'; import invariant from 'invariant'; import { ErrorTypes, SiweMessage } from 'siwe'; -import t from 'tcomb'; +import t, { type TInterface } from 'tcomb'; import bcrypt from 'twin-bcrypt'; -import { baseLegalPolicies, policies } from 'lib/facts/policies.js'; +import { + baseLegalPolicies, + policies, + policyTypeValidator, +} from 'lib/facts/policies.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { ResetPasswordRequest, LogOutResponse, DeleteAccountRequest, RegisterResponse, RegisterRequest, LogInResponse, LogInRequest, UpdatePasswordRequest, UpdateUserSettingsRequest, PolicyAcknowledgmentRequest, } from 'lib/types/account-types.js'; import { userSettingsTypes, notificationTypeValues, logInActionSources, } from 'lib/types/account-types.js'; import type { ClientAvatar, UpdateUserAvatarRequest, UpdateUserAvatarResponse, } from 'lib/types/avatar-types.js'; import type { IdentityKeysBlob, SignedIdentityKeysBlob, } from 'lib/types/crypto-types.js'; -import type { CalendarQuery } from 'lib/types/entry-types.js'; -import { defaultNumberPerThread } from 'lib/types/message-types.js'; +import { + type CalendarQuery, + rawEntryInfoValidator, +} from 'lib/types/entry-types.js'; +import { + defaultNumberPerThread, + rawMessageInfoValidator, + messageTruncationStatusesValidator, +} from 'lib/types/message-types.js'; import type { SIWEAuthRequest, SIWEMessage, SIWESocialProof, } from 'lib/types/siwe-types.js'; -import type { - SubscriptionUpdateRequest, - SubscriptionUpdateResponse, +import { + type SubscriptionUpdateRequest, + type SubscriptionUpdateResponse, + threadSubscriptionValidator, } from 'lib/types/subscription-types.js'; -import type { PasswordUpdate } from 'lib/types/user-types.js'; +import { rawThreadInfoValidator } from 'lib/types/thread-types.js'; +import { + type PasswordUpdate, + loggedOutUserInfoValidator, + loggedInUserInfoValidator, + oldLoggedInUserInfoValidator, + userInfoValidator, +} from 'lib/types/user-types.js'; import { updateUserAvatarRequestValidator } from 'lib/utils/avatar-utils.js'; import { identityKeysBlobValidator, signedIdentityKeysBlobValidator, } from 'lib/utils/crypto-utils.js'; import { ServerError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { promiseAll } from 'lib/utils/promises.js'; import { getPublicKeyFromSIWEStatement, isValidSIWEMessage, isValidSIWEStatementWithPublicKey, primaryIdentityPublicKeyRegex, } from 'lib/utils/siwe-utils.js'; import { tShape, tPlatformDetails, tPassword, tEmail, tOldValidUsername, tRegex, + tID, } from 'lib/utils/validation-utils.js'; import { entryQueryInputValidator, newEntryQueryInputValidator, normalizeCalendarQuery, verifyCalendarQueryThreadIDs, } from './entry-responders.js'; import { createAccount, processSIWEAccountCreation, } from '../creators/account-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { deleteAccount } from '../deleters/account-deleters.js'; import { deleteCookie } from '../deleters/cookie-deleters.js'; import { checkAndInvalidateSIWENonceEntry } from '../deleters/siwe-nonce-deleters.js'; import { fetchEntryInfos } from '../fetchers/entry-fetchers.js'; import { fetchMessageInfos } from '../fetchers/message-fetchers.js'; import { fetchNotAcknowledgedPolicies } from '../fetchers/policy-acknowledgment-fetchers.js'; import { fetchThreadInfos } from '../fetchers/thread-fetchers.js'; import { fetchKnownUserInfos, fetchLoggedInUserInfo, fetchUserIDForEthereumAddress, } from '../fetchers/user-fetchers.js'; import { createNewAnonymousCookie, createNewUserCookie, setNewSession, } from '../session/cookies.js'; import type { Viewer } from '../session/viewer.js'; import { accountUpdater, checkAndSendVerificationEmail, checkAndSendPasswordResetEmail, updatePassword, updateUserSettings, updateUserAvatar, } from '../updaters/account-updaters.js'; import { userSubscriptionUpdater } from '../updaters/user-subscription-updaters.js'; import { viewerAcknowledgmentUpdater } from '../updaters/viewer-acknowledgment-updater.js'; import { getOlmUtility } from '../utils/olm-utils.js'; import { validateInput } from '../utils/validation-utils.js'; const subscriptionUpdateRequestInputValidator = tShape({ threadID: t.String, updatedFields: tShape({ pushNotifs: t.maybe(t.Boolean), home: t.maybe(t.Boolean), }), }); +export const subscriptionUpdateResponseValidator: TInterface = + tShape({ + threadSubscription: threadSubscriptionValidator, + }); + async function userSubscriptionUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: SubscriptionUpdateRequest = input; await validateInput(viewer, subscriptionUpdateRequestInputValidator, request); const threadSubscription = await userSubscriptionUpdater(viewer, request); return { threadSubscription }; } const accountUpdateInputValidator = tShape({ updatedFields: tShape({ email: t.maybe(tEmail), password: t.maybe(tPassword), }), currentPassword: tPassword, }); async function passwordUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: PasswordUpdate = input; await validateInput(viewer, accountUpdateInputValidator, request); await accountUpdater(viewer, request); } async function sendVerificationEmailResponder(viewer: Viewer): Promise { await validateInput(viewer, null, null); await checkAndSendVerificationEmail(viewer); } const resetPasswordRequestInputValidator = tShape({ usernameOrEmail: t.union([tEmail, tOldValidUsername]), }); async function sendPasswordResetEmailResponder( viewer: Viewer, input: any, ): Promise { const request: ResetPasswordRequest = input; await validateInput(viewer, resetPasswordRequestInputValidator, request); await checkAndSendPasswordResetEmail(request); } +export const logOutResponseValidator: TInterface = + tShape({ + currentUserInfo: loggedOutUserInfoValidator, + }); + async function logOutResponder(viewer: Viewer): Promise { await validateInput(viewer, null, null); if (viewer.loggedIn) { const [anonymousViewerData] = await Promise.all([ createNewAnonymousCookie({ platformDetails: viewer.platformDetails, deviceToken: viewer.deviceToken, }), deleteCookie(viewer.cookieID), ]); viewer.setNewCookie(anonymousViewerData); } return { currentUserInfo: { id: viewer.id, anonymous: true, }, }; } const deleteAccountRequestInputValidator = tShape({ password: t.maybe(tPassword), }); async function accountDeletionResponder( viewer: Viewer, input: any, ): Promise { const request: DeleteAccountRequest = input; await validateInput(viewer, deleteAccountRequestInputValidator, request); const result = await deleteAccount(viewer, request); invariant(result, 'deleteAccount should return result if handed request'); return result; } const deviceTokenUpdateRequestInputValidator = tShape({ deviceType: t.maybe(t.enums.of(['ios', 'android'])), deviceToken: t.String, }); const registerRequestInputValidator = tShape({ username: t.String, email: t.maybe(tEmail), password: tPassword, calendarQuery: t.maybe(newEntryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, // We include `primaryIdentityPublicKey` to avoid breaking // old clients, but we no longer do anything with it. primaryIdentityPublicKey: t.maybe(tRegex(primaryIdentityPublicKeyRegex)), signedIdentityKeysBlob: t.maybe(signedIdentityKeysBlobValidator), }); +export const registerResponseValidator: TInterface = + tShape({ + id: t.String, + rawMessageInfos: t.list(rawMessageInfoValidator), + currentUserInfo: t.union([ + oldLoggedInUserInfoValidator, + loggedInUserInfoValidator, + ]), + cookieChange: tShape({ + threadInfos: t.dict(t.String, rawThreadInfoValidator), + userInfos: t.list(userInfoValidator), + }), + }); + async function accountCreationResponder( viewer: Viewer, input: any, ): Promise { const request: RegisterRequest = input; await validateInput(viewer, registerRequestInputValidator, request); const { signedIdentityKeysBlob } = request; if (signedIdentityKeysBlob) { const identityKeys: IdentityKeysBlob = JSON.parse( signedIdentityKeysBlob.payload, ); if (!identityKeysBlobValidator.is(identityKeys)) { throw new ServerError('invalid_identity_keys_blob'); } const olmUtil: OlmUtility = getOlmUtility(); try { olmUtil.ed25519_verify( identityKeys.primaryIdentityPublicKeys.ed25519, signedIdentityKeysBlob.payload, signedIdentityKeysBlob.signature, ); } catch (e) { throw new ServerError('invalid_signature'); } } return await createAccount(viewer, request); } type ProcessSuccessfulLoginParams = { +viewer: Viewer, +input: any, +userID: string, +calendarQuery: ?CalendarQuery, +socialProof?: ?SIWESocialProof, +signedIdentityKeysBlob?: ?SignedIdentityKeysBlob, }; async function processSuccessfulLogin( params: ProcessSuccessfulLoginParams, ): Promise { const { viewer, input, userID, calendarQuery, socialProof, signedIdentityKeysBlob, } = params; const request: LogInRequest = input; const newServerTime = Date.now(); const deviceToken = request.deviceTokenUpdateRequest ? request.deviceTokenUpdateRequest.deviceToken : viewer.deviceToken; const [userViewerData, notAcknowledgedPolicies] = await Promise.all([ createNewUserCookie(userID, { platformDetails: request.platformDetails, deviceToken, socialProof, signedIdentityKeysBlob, }), fetchNotAcknowledgedPolicies(userID, baseLegalPolicies), deleteCookie(viewer.cookieID), ]); viewer.setNewCookie(userViewerData); if ( notAcknowledgedPolicies.length && hasMinCodeVersion(viewer.platformDetails, 181) ) { const currentUserInfo = await fetchLoggedInUserInfo(viewer); return { notAcknowledgedPolicies, currentUserInfo: currentUserInfo, rawMessageInfos: [], truncationStatuses: {}, userInfos: [], rawEntryInfos: [], serverTime: 0, cookieChange: { threadInfos: {}, userInfos: [], }, }; } if (calendarQuery) { await setNewSession(viewer, calendarQuery, newServerTime); } const threadCursors = {}; for (const watchedThreadID of request.watchedIDs) { threadCursors[watchedThreadID] = null; } const messageSelectionCriteria = { threadCursors, joinedThreads: true }; const [ threadsResult, messagesResult, entriesResult, userInfos, currentUserInfo, ] = await Promise.all([ fetchThreadInfos(viewer), fetchMessageInfos(viewer, messageSelectionCriteria, defaultNumberPerThread), calendarQuery ? fetchEntryInfos(viewer, [calendarQuery]) : undefined, fetchKnownUserInfos(viewer), fetchLoggedInUserInfo(viewer), ]); const rawEntryInfos = entriesResult ? entriesResult.rawEntryInfos : null; const response: LogInResponse = { currentUserInfo, rawMessageInfos: messagesResult.rawMessageInfos, truncationStatuses: messagesResult.truncationStatuses, serverTime: newServerTime, userInfos: values(userInfos), cookieChange: { threadInfos: threadsResult.threadInfos, userInfos: [], }, }; if (rawEntryInfos) { return { ...response, rawEntryInfos, }; } return response; } const logInRequestInputValidator = tShape({ username: t.maybe(t.String), usernameOrEmail: t.maybe(t.union([tEmail, tOldValidUsername])), password: tPassword, watchedIDs: t.list(t.String), calendarQuery: t.maybe(entryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, source: t.maybe(t.enums.of(values(logInActionSources))), // We include `primaryIdentityPublicKey` to avoid breaking // old clients, but we no longer do anything with it. primaryIdentityPublicKey: t.maybe(tRegex(primaryIdentityPublicKeyRegex)), signedIdentityKeysBlob: t.maybe(signedIdentityKeysBlobValidator), }); +export const logInResponseValidator: TInterface = + tShape({ + currentUserInfo: t.union([ + loggedInUserInfoValidator, + oldLoggedInUserInfoValidator, + ]), + rawMessageInfos: t.list(rawMessageInfoValidator), + truncationStatuses: messageTruncationStatusesValidator, + userInfos: t.list(userInfoValidator), + rawEntryInfos: t.maybe(t.list(rawEntryInfoValidator)), + serverTime: t.Number, + cookieChange: tShape({ + threadInfos: t.dict(tID, rawThreadInfoValidator), + userInfos: t.list(userInfoValidator), + }), + notAcknowledgedPolicies: t.maybe(t.list(policyTypeValidator)), + }); + async function logInResponder( viewer: Viewer, input: any, ): Promise { await validateInput(viewer, logInRequestInputValidator, input); const request: LogInRequest = input; let identityKeys: ?IdentityKeysBlob; const { signedIdentityKeysBlob } = request; if (signedIdentityKeysBlob) { identityKeys = JSON.parse(signedIdentityKeysBlob.payload); const olmUtil: OlmUtility = getOlmUtility(); try { olmUtil.ed25519_verify( identityKeys.primaryIdentityPublicKeys.ed25519, signedIdentityKeysBlob.payload, signedIdentityKeysBlob.signature, ); } catch (e) { throw new ServerError('invalid_signature'); } } const calendarQuery = request.calendarQuery ? normalizeCalendarQuery(request.calendarQuery) : null; const promises = {}; if (calendarQuery) { promises.verifyCalendarQueryThreadIDs = verifyCalendarQueryThreadIDs(calendarQuery); } const username = request.username ?? request.usernameOrEmail; if (!username) { if (hasMinCodeVersion(viewer.platformDetails, 150)) { throw new ServerError('invalid_credentials'); } else { throw new ServerError('invalid_parameters'); } } const userQuery = SQL` SELECT id, hash, username FROM users WHERE LCASE(username) = LCASE(${username}) `; promises.userQuery = dbQuery(userQuery); const { userQuery: [userResult], } = await promiseAll(promises); if (userResult.length === 0) { if (hasMinCodeVersion(viewer.platformDetails, 150)) { throw new ServerError('invalid_credentials'); } else { throw new ServerError('invalid_parameters'); } } const userRow = userResult[0]; if (!userRow.hash || !bcrypt.compareSync(request.password, userRow.hash)) { throw new ServerError('invalid_credentials'); } const id = userRow.id.toString(); return await processSuccessfulLogin({ viewer, input, userID: id, calendarQuery, signedIdentityKeysBlob, }); } const siweAuthRequestInputValidator = tShape({ signature: t.String, message: t.String, calendarQuery: entryQueryInputValidator, deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, watchedIDs: t.list(t.String), signedIdentityKeysBlob: t.maybe(signedIdentityKeysBlobValidator), }); async function siweAuthResponder( viewer: Viewer, input: any, ): Promise { await validateInput(viewer, siweAuthRequestInputValidator, input); const request: SIWEAuthRequest = input; const { message, signature, deviceTokenUpdateRequest, platformDetails, signedIdentityKeysBlob, } = request; const calendarQuery = normalizeCalendarQuery(request.calendarQuery); // 1. Ensure that `message` is a well formed Comm SIWE Auth message. const siweMessage: SIWEMessage = new SiweMessage(message); if (!isValidSIWEMessage(siweMessage)) { throw new ServerError('invalid_parameters'); } // 2. Ensure that the `nonce` exists in the `siwe_nonces` table // AND hasn't expired. If those conditions are met, delete the entry to // ensure that the same `nonce` can't be re-used in a future request. const wasNonceCheckedAndInvalidated = await checkAndInvalidateSIWENonceEntry( siweMessage.nonce, ); if (!wasNonceCheckedAndInvalidated) { throw new ServerError('invalid_parameters'); } // 3. Validate SIWEMessage signature and handle possible errors. try { await siweMessage.validate(signature); } catch (error) { if (error === ErrorTypes.EXPIRED_MESSAGE) { // Thrown when the `expirationTime` is present and in the past. throw new ServerError('expired_message'); } else if (error === ErrorTypes.INVALID_SIGNATURE) { // Thrown when the `validate()` function can't verify the message. throw new ServerError('invalid_signature'); } else if (error === ErrorTypes.MALFORMED_SESSION) { // Thrown when some required field is missing. throw new ServerError('malformed_session'); } else { throw new ServerError('unknown_error'); } } // 4. Pull `primaryIdentityPublicKey` out from SIWEMessage `statement`. // We expect it to be included for BOTH native and web clients. const { statement } = siweMessage; const primaryIdentityPublicKey = statement && isValidSIWEStatementWithPublicKey(statement) ? getPublicKeyFromSIWEStatement(statement) : null; if (!primaryIdentityPublicKey) { throw new ServerError('invalid_siwe_statement_public_key'); } // 5. Verify `signedIdentityKeysBlob.payload` with included `signature` // if `signedIdentityKeysBlob` was included in the `SIWEAuthRequest`. let identityKeys: ?IdentityKeysBlob; if (signedIdentityKeysBlob) { identityKeys = JSON.parse(signedIdentityKeysBlob.payload); if (!identityKeysBlobValidator.is(identityKeys)) { throw new ServerError('invalid_identity_keys_blob'); } const olmUtil: OlmUtility = getOlmUtility(); try { olmUtil.ed25519_verify( identityKeys.primaryIdentityPublicKeys.ed25519, signedIdentityKeysBlob.payload, signedIdentityKeysBlob.signature, ); } catch (e) { throw new ServerError('invalid_signature'); } } // 6. Ensure that `primaryIdentityPublicKeys.ed25519` matches SIWE // statement `primaryIdentityPublicKey` if `identityKeys` exists. if ( identityKeys && identityKeys.primaryIdentityPublicKeys.ed25519 !== primaryIdentityPublicKey ) { throw new ServerError('primary_public_key_mismatch'); } // 7. Construct `SIWESocialProof` object with the stringified // SIWEMessage and the corresponding signature. const socialProof: SIWESocialProof = { siweMessage: siweMessage.toMessage(), siweMessageSignature: signature, }; // 8. Create account with call to `processSIWEAccountCreation(...)` // if address does not correspond to an existing user. let userID = await fetchUserIDForEthereumAddress(siweMessage.address); if (!userID) { const siweAccountCreationRequest = { address: siweMessage.address, calendarQuery, deviceTokenUpdateRequest, platformDetails, socialProof, }; userID = await processSIWEAccountCreation( viewer, siweAccountCreationRequest, ); } // 9. Complete login with call to `processSuccessfulLogin(...)`. return await processSuccessfulLogin({ viewer, input, userID, calendarQuery, socialProof, signedIdentityKeysBlob, }); } const updatePasswordRequestInputValidator = tShape({ code: t.String, password: tPassword, watchedIDs: t.list(t.String), calendarQuery: t.maybe(entryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, }); async function oldPasswordUpdateResponder( viewer: Viewer, input: any, ): Promise { await validateInput(viewer, updatePasswordRequestInputValidator, input); const request: UpdatePasswordRequest = input; if (request.calendarQuery) { request.calendarQuery = normalizeCalendarQuery(request.calendarQuery); } return await updatePassword(viewer, request); } const updateUserSettingsInputValidator = tShape({ name: t.irreducible( userSettingsTypes.DEFAULT_NOTIFICATIONS, x => x === userSettingsTypes.DEFAULT_NOTIFICATIONS, ), data: t.enums.of(notificationTypeValues), }); async function updateUserSettingsResponder( viewer: Viewer, input: any, ): Promise { const request: UpdateUserSettingsRequest = input; await validateInput(viewer, updateUserSettingsInputValidator, request); return await updateUserSettings(viewer, request); } const policyAcknowledgmentRequestInputValidator = tShape({ policy: t.maybe(t.enums.of(policies)), }); async function policyAcknowledgmentResponder( viewer: Viewer, input: any, ): Promise { const request: PolicyAcknowledgmentRequest = input; await validateInput( viewer, policyAcknowledgmentRequestInputValidator, request, ); await viewerAcknowledgmentUpdater(viewer, request.policy); } async function updateUserAvatarResponder( viewer: Viewer, input: any, ): Promise { const request: UpdateUserAvatarRequest = input; await validateInput(viewer, updateUserAvatarRequestValidator, request); return await updateUserAvatar(viewer, request); } export { userSubscriptionUpdateResponder, passwordUpdateResponder, sendVerificationEmailResponder, sendPasswordResetEmailResponder, logOutResponder, accountDeletionResponder, accountCreationResponder, logInResponder, siweAuthResponder, oldPasswordUpdateResponder, updateUserSettingsResponder, policyAcknowledgmentResponder, updateUserAvatarResponder, }; diff --git a/lib/facts/policies.js b/lib/facts/policies.js index fc33fb626..b37fe9e62 100644 --- a/lib/facts/policies.js +++ b/lib/facts/policies.js @@ -1,13 +1,16 @@ // @flow +import t, { type TEnums } from 'tcomb'; + import { values } from '../utils/objects.js'; export const policyTypes = Object.freeze({ tosAndPrivacyPolicy: 'TERMS_OF_USE_AND_PRIVACY_POLICY', }); export const policies: $ReadOnlyArray = values(policyTypes); export type PolicyType = $Values; +export const policyTypeValidator: TEnums = t.enums.of(policies); export const baseLegalPolicies = [policyTypes.tosAndPrivacyPolicy]; diff --git a/lib/types/account-types.js b/lib/types/account-types.js index ae417c9ea..6e20017c9 100644 --- a/lib/types/account-types.js +++ b/lib/types/account-types.js @@ -1,191 +1,199 @@ // @flow +import t, { type TInterface } from 'tcomb'; + import type { SignedIdentityKeysBlob } from './crypto-types.js'; import type { PlatformDetails } from './device-types.js'; import type { CalendarQuery, CalendarResult, RawEntryInfo, } from './entry-types.js'; -import type { - RawMessageInfo, - MessageTruncationStatuses, - GenericMessagesResult, +import { + type RawMessageInfo, + type MessageTruncationStatuses, + type GenericMessagesResult, } from './message-types.js'; import type { PreRequestUserState } from './session-types.js'; -import type { RawThreadInfo } from './thread-types.js'; -import type { - UserInfo, - LoggedOutUserInfo, - LoggedInUserInfo, - OldLoggedInUserInfo, +import { type RawThreadInfo } from './thread-types.js'; +import { + type UserInfo, + type LoggedOutUserInfo, + type LoggedInUserInfo, + type OldLoggedInUserInfo, } from './user-types.js'; import type { PolicyType } from '../facts/policies.js'; import { values } from '../utils/objects.js'; +import { tShape } from '../utils/validation-utils.js'; export type ResetPasswordRequest = { +usernameOrEmail: string, }; export type LogOutResult = { +currentUserInfo: ?LoggedOutUserInfo, +preRequestUserState: PreRequestUserState, }; export type LogOutResponse = { +currentUserInfo: LoggedOutUserInfo, }; export type RegisterInfo = { ...LogInExtraInfo, +username: string, +password: string, }; type DeviceTokenUpdateRequest = { +deviceToken: string, }; export type RegisterRequest = { +username: string, +password: string, +calendarQuery?: ?CalendarQuery, +deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, +platformDetails: PlatformDetails, +signedIdentityKeysBlob?: SignedIdentityKeysBlob, }; export type RegisterResponse = { id: string, rawMessageInfos: $ReadOnlyArray, currentUserInfo: OldLoggedInUserInfo | LoggedInUserInfo, cookieChange: { threadInfos: { +[id: string]: RawThreadInfo }, userInfos: $ReadOnlyArray, }, }; export type RegisterResult = { +currentUserInfo: LoggedInUserInfo, +rawMessageInfos: $ReadOnlyArray, +threadInfos: { +[id: string]: RawThreadInfo }, +userInfos: $ReadOnlyArray, +calendarQuery: CalendarQuery, }; export type DeleteAccountRequest = { +password: ?string, }; export const logInActionSources = Object.freeze({ cookieInvalidationResolutionAttempt: 'COOKIE_INVALIDATION_RESOLUTION_ATTEMPT', appStartCookieLoggedInButInvalidRedux: 'APP_START_COOKIE_LOGGED_IN_BUT_INVALID_REDUX', appStartReduxLoggedInButInvalidCookie: 'APP_START_REDUX_LOGGED_IN_BUT_INVALID_COOKIE', socketAuthErrorResolutionAttempt: 'SOCKET_AUTH_ERROR_RESOLUTION_ATTEMPT', sqliteOpFailure: 'SQLITE_OP_FAILURE', sqliteLoadFailure: 'SQLITE_LOAD_FAILURE', logInFromWebForm: 'LOG_IN_FROM_WEB_FORM', logInFromNativeForm: 'LOG_IN_FROM_NATIVE_FORM', logInFromNativeSIWE: 'LOG_IN_FROM_NATIVE_SIWE', corruptedDatabaseDeletion: 'CORRUPTED_DATABASE_DELETION', refetchUserDataAfterAcknowledgment: 'REFETCH_USER_DATA_AFTER_ACKNOWLEDGMENT', }); export type LogInActionSource = $Values; export type LogInStartingPayload = { +calendarQuery: CalendarQuery, +logInActionSource?: LogInActionSource, }; export type LogInExtraInfo = { +calendarQuery: CalendarQuery, +deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, +signedIdentityKeysBlob?: SignedIdentityKeysBlob, }; export type LogInInfo = { ...LogInExtraInfo, +username: string, +password: string, +logInActionSource: LogInActionSource, }; export type LogInRequest = { +usernameOrEmail?: ?string, +username?: ?string, +password: string, +calendarQuery?: ?CalendarQuery, +deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, +platformDetails: PlatformDetails, +watchedIDs: $ReadOnlyArray, +source?: LogInActionSource, +signedIdentityKeysBlob?: SignedIdentityKeysBlob, }; export type LogInResponse = { +currentUserInfo: LoggedInUserInfo | OldLoggedInUserInfo, +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, +userInfos: $ReadOnlyArray, +rawEntryInfos?: ?$ReadOnlyArray, +serverTime: number, +cookieChange: { +threadInfos: { +[id: string]: RawThreadInfo }, +userInfos: $ReadOnlyArray, }, +notAcknowledgedPolicies?: $ReadOnlyArray, }; export type LogInResult = { +threadInfos: { +[id: string]: RawThreadInfo }, +currentUserInfo: LoggedInUserInfo, +messagesResult: GenericMessagesResult, +userInfos: $ReadOnlyArray, +calendarResult: CalendarResult, +updatesCurrentAsOf: number, +logInActionSource: LogInActionSource, +notAcknowledgedPolicies?: $ReadOnlyArray, }; export type UpdatePasswordRequest = { code: string, password: string, calendarQuery?: ?CalendarQuery, deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, platformDetails: PlatformDetails, watchedIDs: $ReadOnlyArray, }; export type PolicyAcknowledgmentRequest = { +policy: PolicyType, }; export type EmailSubscriptionRequest = { +email: string, }; export type UpdateUserSettingsRequest = { +name: 'default_user_notifications', +data: NotificationTypes, }; export const userSettingsTypes = Object.freeze({ DEFAULT_NOTIFICATIONS: 'default_user_notifications', }); -export type DefaultNotificationPayload = { - +default_user_notifications: ?NotificationTypes, -}; - export const notificationTypes = Object.freeze({ FOCUSED: 'focused', BADGE_ONLY: 'badge_only', BACKGROUND: 'background', }); export type NotificationTypes = $Values; export const notificationTypeValues: $ReadOnlyArray = values(notificationTypes); + +export type DefaultNotificationPayload = { + +default_user_notifications: ?NotificationTypes, +}; + +export const defaultNotificationPayloadValidator: TInterface = + tShape({ + default_user_notifications: t.maybe(t.enums.of(notificationTypeValues)), + }); diff --git a/lib/types/message-types.js b/lib/types/message-types.js index 1b573bce5..bb2f0c706 100644 --- a/lib/types/message-types.js +++ b/lib/types/message-types.js @@ -1,676 +1,687 @@ // @flow import invariant from 'invariant'; -import t, { type TUnion, type TInterface } from 'tcomb'; +import t, { + type TUnion, + type TDict, + type TEnums, + type TInterface, +} from 'tcomb'; import { type ClientDBMediaInfo } from './media-types.js'; import { messageTypes, type MessageType } from './message-types-enum.js'; import { type AddMembersMessageData, type AddMembersMessageInfo, type RawAddMembersMessageInfo, rawAddMembersMessageInfoValidator, } from './messages/add-members.js'; import { type ChangeRoleMessageData, type ChangeRoleMessageInfo, type RawChangeRoleMessageInfo, rawChangeRoleMessageInfoValidator, } from './messages/change-role.js'; import { type ChangeSettingsMessageData, type ChangeSettingsMessageInfo, type RawChangeSettingsMessageInfo, rawChangeSettingsMessageInfoValidator, } from './messages/change-settings.js'; import { type CreateEntryMessageData, type CreateEntryMessageInfo, type RawCreateEntryMessageInfo, rawCreateEntryMessageInfoValidator, } from './messages/create-entry.js'; import { type CreateSidebarMessageData, type CreateSidebarMessageInfo, type RawCreateSidebarMessageInfo, rawCreateSidebarMessageInfoValidator, } from './messages/create-sidebar.js'; import { type CreateSubthreadMessageData, type CreateSubthreadMessageInfo, type RawCreateSubthreadMessageInfo, rawCreateSubthreadMessageInfoValidator, } from './messages/create-subthread.js'; import { type CreateThreadMessageData, type CreateThreadMessageInfo, type RawCreateThreadMessageInfo, rawCreateThreadMessageInfoValidator, } from './messages/create-thread.js'; import { type DeleteEntryMessageData, type DeleteEntryMessageInfo, type RawDeleteEntryMessageInfo, rawDeleteEntryMessageInfoValidator, } from './messages/delete-entry.js'; import { type EditEntryMessageData, type EditEntryMessageInfo, type RawEditEntryMessageInfo, rawEditEntryMessageInfoValidator, } from './messages/edit-entry.js'; import { type RawEditMessageInfo, rawEditMessageInfoValidator, type EditMessageData, type EditMessageInfo, } from './messages/edit.js'; import { type ImagesMessageData, type ImagesMessageInfo, type RawImagesMessageInfo, rawImagesMessageInfoValidator, } from './messages/images.js'; import { type JoinThreadMessageData, type JoinThreadMessageInfo, type RawJoinThreadMessageInfo, rawJoinThreadMessageInfoValidator, } from './messages/join-thread.js'; import { type LeaveThreadMessageData, type LeaveThreadMessageInfo, type RawLeaveThreadMessageInfo, rawLeaveThreadMessageInfoValidator, } from './messages/leave-thread.js'; import { type MediaMessageData, type MediaMessageInfo, type MediaMessageServerDBContent, type RawMediaMessageInfo, rawMediaMessageInfoValidator, } from './messages/media.js'; import { type ReactionMessageData, type RawReactionMessageInfo, rawReactionMessageInfoValidator, type ReactionMessageInfo, } from './messages/reaction.js'; import { type RawRemoveMembersMessageInfo, rawRemoveMembersMessageInfoValidator, type RemoveMembersMessageData, type RemoveMembersMessageInfo, } from './messages/remove-members.js'; import { type RawRestoreEntryMessageInfo, rawRestoreEntryMessageInfoValidator, type RestoreEntryMessageData, type RestoreEntryMessageInfo, } from './messages/restore-entry.js'; import { type RawTextMessageInfo, rawTextMessageInfoValidator, type TextMessageData, type TextMessageInfo, } from './messages/text.js'; import { type TogglePinMessageData, type TogglePinMessageInfo, type RawTogglePinMessageInfo, rawTogglePinMessageInfoValidator, } from './messages/toggle-pin.js'; import { type RawUnsupportedMessageInfo, rawUnsupportedMessageInfoValidator, type UnsupportedMessageInfo, } from './messages/unsupported.js'; import { type RawUpdateRelationshipMessageInfo, rawUpdateRelationshipMessageInfoValidator, type UpdateRelationshipMessageData, type UpdateRelationshipMessageInfo, } from './messages/update-relationship.js'; import { type RelativeUserInfo, type UserInfos } from './user-types.js'; import type { CallServerEndpointResultInfoInterface } from '../utils/call-server-endpoint.js'; +import { values } from '../utils/objects.js'; import { tNumber, tShape, tID } from '../utils/validation-utils.js'; const composableMessageTypes = new Set([ messageTypes.TEXT, messageTypes.IMAGES, messageTypes.MULTIMEDIA, ]); export function isComposableMessageType(ourMessageType: MessageType): boolean { return composableMessageTypes.has(ourMessageType); } export function assertComposableMessageType( ourMessageType: MessageType, ): MessageType { invariant( isComposableMessageType(ourMessageType), 'MessageType is not composed', ); return ourMessageType; } export function assertComposableRawMessage( message: RawMessageInfo, ): RawComposableMessageInfo { invariant( message.type === messageTypes.TEXT || message.type === messageTypes.IMAGES || message.type === messageTypes.MULTIMEDIA, 'Message is not composable', ); return message; } export function messageDataLocalID(messageData: MessageData): ?string { if ( messageData.type !== messageTypes.TEXT && messageData.type !== messageTypes.IMAGES && messageData.type !== messageTypes.MULTIMEDIA && messageData.type !== messageTypes.REACTION ) { return null; } return messageData.localID; } export function isMessageSidebarSourceReactionOrEdit( message: RawMessageInfo | MessageInfo, ): boolean %checks { return ( message.type === messageTypes.SIDEBAR_SOURCE || message.type === messageTypes.REACTION || message.type === messageTypes.EDIT_MESSAGE ); } const mediaMessageTypes = new Set([ messageTypes.IMAGES, messageTypes.MULTIMEDIA, ]); export function isMediaMessageType(ourMessageType: MessageType): boolean { return mediaMessageTypes.has(ourMessageType); } export function assertMediaMessageType( ourMessageType: MessageType, ): MessageType { invariant(isMediaMessageType(ourMessageType), 'MessageType is not media'); return ourMessageType; } // *MessageData = passed to createMessages function to insert into database // Raw*MessageInfo = used by server, and contained in client's local store // *MessageInfo = used by client in UI code export type SidebarSourceMessageData = { +type: 17, +threadID: string, +creatorID: string, +time: number, +sourceMessage?: RawComposableMessageInfo | RawRobotextMessageInfo, }; export type MessageData = | TextMessageData | CreateThreadMessageData | AddMembersMessageData | CreateSubthreadMessageData | ChangeSettingsMessageData | RemoveMembersMessageData | ChangeRoleMessageData | LeaveThreadMessageData | JoinThreadMessageData | CreateEntryMessageData | EditEntryMessageData | DeleteEntryMessageData | RestoreEntryMessageData | ImagesMessageData | MediaMessageData | UpdateRelationshipMessageData | SidebarSourceMessageData | CreateSidebarMessageData | ReactionMessageData | EditMessageData | TogglePinMessageData; export type MultimediaMessageData = ImagesMessageData | MediaMessageData; export type RawMultimediaMessageInfo = | RawImagesMessageInfo | RawMediaMessageInfo; export const rawMultimediaMessageInfoValidator: TUnion = t.union([rawImagesMessageInfoValidator, rawMediaMessageInfoValidator]); export type RawComposableMessageInfo = | RawTextMessageInfo | RawMultimediaMessageInfo; const rawComposableMessageInfoValidator = t.union([ rawTextMessageInfoValidator, rawMultimediaMessageInfoValidator, ]); export type RawRobotextMessageInfo = | RawCreateThreadMessageInfo | RawAddMembersMessageInfo | RawCreateSubthreadMessageInfo | RawChangeSettingsMessageInfo | RawRemoveMembersMessageInfo | RawChangeRoleMessageInfo | RawLeaveThreadMessageInfo | RawJoinThreadMessageInfo | RawCreateEntryMessageInfo | RawEditEntryMessageInfo | RawDeleteEntryMessageInfo | RawRestoreEntryMessageInfo | RawUpdateRelationshipMessageInfo | RawCreateSidebarMessageInfo | RawUnsupportedMessageInfo | RawTogglePinMessageInfo; const rawRobotextMessageInfoValidator = t.union([ rawCreateThreadMessageInfoValidator, rawAddMembersMessageInfoValidator, rawCreateSubthreadMessageInfoValidator, rawChangeSettingsMessageInfoValidator, rawRemoveMembersMessageInfoValidator, rawChangeRoleMessageInfoValidator, rawLeaveThreadMessageInfoValidator, rawJoinThreadMessageInfoValidator, rawCreateEntryMessageInfoValidator, rawEditEntryMessageInfoValidator, rawDeleteEntryMessageInfoValidator, rawRestoreEntryMessageInfoValidator, rawUpdateRelationshipMessageInfoValidator, rawCreateSidebarMessageInfoValidator, rawUnsupportedMessageInfoValidator, rawTogglePinMessageInfoValidator, ]); export type RawSidebarSourceMessageInfo = { ...SidebarSourceMessageData, id: string, }; export const rawSidebarSourceMessageInfoValidator: TInterface = tShape({ type: tNumber(messageTypes.SIDEBAR_SOURCE), threadID: tID, creatorID: t.String, time: t.Number, sourceMessage: t.maybe( t.union([ rawComposableMessageInfoValidator, rawRobotextMessageInfoValidator, ]), ), id: tID, }); export type RawMessageInfo = | RawComposableMessageInfo | RawRobotextMessageInfo | RawSidebarSourceMessageInfo | RawReactionMessageInfo | RawEditMessageInfo; export const rawMessageInfoValidator: TUnion = t.union([ rawComposableMessageInfoValidator, rawRobotextMessageInfoValidator, rawSidebarSourceMessageInfoValidator, rawReactionMessageInfoValidator, rawEditMessageInfoValidator, ]); export type LocallyComposedMessageInfo = | ({ ...RawImagesMessageInfo, +localID: string, } & RawImagesMessageInfo) | ({ ...RawMediaMessageInfo, +localID: string, } & RawMediaMessageInfo) | ({ ...RawTextMessageInfo, +localID: string, } & RawTextMessageInfo) | ({ ...RawReactionMessageInfo, +localID: string, } & RawReactionMessageInfo); export type MultimediaMessageInfo = ImagesMessageInfo | MediaMessageInfo; export type ComposableMessageInfo = TextMessageInfo | MultimediaMessageInfo; export type RobotextMessageInfo = | CreateThreadMessageInfo | AddMembersMessageInfo | CreateSubthreadMessageInfo | ChangeSettingsMessageInfo | RemoveMembersMessageInfo | ChangeRoleMessageInfo | LeaveThreadMessageInfo | JoinThreadMessageInfo | CreateEntryMessageInfo | EditEntryMessageInfo | DeleteEntryMessageInfo | RestoreEntryMessageInfo | UnsupportedMessageInfo | UpdateRelationshipMessageInfo | CreateSidebarMessageInfo | TogglePinMessageInfo; export type PreviewableMessageInfo = | RobotextMessageInfo | MultimediaMessageInfo | ReactionMessageInfo; export type SidebarSourceMessageInfo = { +type: 17, +id: string, +threadID: string, +creator: RelativeUserInfo, +time: number, +sourceMessage: ComposableMessageInfo | RobotextMessageInfo, }; export type MessageInfo = | ComposableMessageInfo | RobotextMessageInfo | SidebarSourceMessageInfo | ReactionMessageInfo | EditMessageInfo; export type ThreadMessageInfo = { messageIDs: string[], startReached: boolean, lastNavigatedTo: number, // millisecond timestamp lastPruned: number, // millisecond timestamp }; // Tracks client-local information about a message that hasn't been assigned an // ID by the server yet. As soon as the client gets an ack from the server for // this message, it will clear the LocalMessageInfo. export type LocalMessageInfo = { +sendFailed?: boolean, }; export type MessageStoreThreads = { +[threadID: string]: ThreadMessageInfo, }; export type MessageStore = { +messages: { +[id: string]: RawMessageInfo }, +threads: MessageStoreThreads, +local: { +[id: string]: LocalMessageInfo }, +currentAsOf: number, }; // MessageStore messages ops export type RemoveMessageOperation = { +type: 'remove', +payload: { +ids: $ReadOnlyArray }, }; export type RemoveMessagesForThreadsOperation = { +type: 'remove_messages_for_threads', +payload: { +threadIDs: $ReadOnlyArray }, }; export type ReplaceMessageOperation = { +type: 'replace', +payload: { +id: string, +messageInfo: RawMessageInfo }, }; export type RekeyMessageOperation = { +type: 'rekey', +payload: { +from: string, +to: string }, }; export type RemoveAllMessagesOperation = { +type: 'remove_all', }; // MessageStore threads ops export type ReplaceMessageStoreThreadsOperation = { +type: 'replace_threads', +payload: { +threads: MessageStoreThreads }, }; export type RemoveMessageStoreThreadsOperation = { +type: 'remove_threads', +payload: { +ids: $ReadOnlyArray }, }; export type RemoveMessageStoreAllThreadsOperation = { +type: 'remove_all_threads', }; // We were initially using `number`s` for `thread`, `type`, `future_type`, etc. // However, we ended up changing `thread` to `string` to account for thread IDs // including information about the keyserver (eg 'GENESIS|123') in the future. // // At that point we discussed whether we should switch the remaining `number` // fields to `string`s for consistency and flexibility. We researched whether // there was any performance cost to using `string`s instead of `number`s and // found the differences to be negligible. We also concluded using `string`s // may be safer after considering `jsi::Number` and the various C++ number // representations on the CommCoreModule side. export type ClientDBMessageInfo = { +id: string, +local_id: ?string, +thread: string, +user: string, +type: string, +future_type: ?string, +content: ?string, +time: string, +media_infos: ?$ReadOnlyArray, }; export type ClientDBReplaceMessageOperation = { +type: 'replace', +payload: ClientDBMessageInfo, }; export type ClientDBThreadMessageInfo = { +id: string, +start_reached: string, +last_navigated_to: string, +last_pruned: string, }; export type ClientDBReplaceThreadsOperation = { +type: 'replace_threads', +payload: { +threads: $ReadOnlyArray }, }; export type MessageStoreOperation = | RemoveMessageOperation | ReplaceMessageOperation | RekeyMessageOperation | RemoveMessagesForThreadsOperation | RemoveAllMessagesOperation | ReplaceMessageStoreThreadsOperation | RemoveMessageStoreThreadsOperation | RemoveMessageStoreAllThreadsOperation; export type ClientDBMessageStoreOperation = | RemoveMessageOperation | ClientDBReplaceMessageOperation | RekeyMessageOperation | RemoveMessagesForThreadsOperation | RemoveAllMessagesOperation | ClientDBReplaceThreadsOperation | RemoveMessageStoreThreadsOperation | RemoveMessageStoreAllThreadsOperation; export const messageTruncationStatus = Object.freeze({ // EXHAUSTIVE means we've reached the start of the thread. Either the result // set includes the very first message for that thread, or there is nothing // behind the cursor you queried for. Given that the client only ever issues // ranged queries whose range, when unioned with what is in state, represent // the set of all messages for a given thread, we can guarantee that getting // EXHAUSTIVE means the start has been reached. EXHAUSTIVE: 'exhaustive', // TRUNCATED is rare, and means that the server can't guarantee that the // result set for a given thread is contiguous with what the client has in its // state. If the client can't verify the contiguousness itself, it needs to // replace its Redux store's contents with what it is in this payload. // 1) getMessageInfosSince: Result set for thread is equal to max, and the // truncation status isn't EXHAUSTIVE (ie. doesn't include the very first // message). // 2) getMessageInfos: MessageSelectionCriteria does not specify cursors, the // result set for thread is equal to max, and the truncation status isn't // EXHAUSTIVE. If cursors are specified, we never return truncated, since // the cursor given us guarantees the contiguousness of the result set. // Note that in the reducer, we can guarantee contiguousness if there is any // intersection between messageIDs in the result set and the set currently in // the Redux store. TRUNCATED: 'truncated', // UNCHANGED means the result set is guaranteed to be contiguous with what the // client has in its state, but is not EXHAUSTIVE. Basically, it's anything // that isn't either EXHAUSTIVE or TRUNCATED. UNCHANGED: 'unchanged', }); export type MessageTruncationStatus = $Values; export function assertMessageTruncationStatus( ourMessageTruncationStatus: string, ): MessageTruncationStatus { invariant( ourMessageTruncationStatus === 'truncated' || ourMessageTruncationStatus === 'unchanged' || ourMessageTruncationStatus === 'exhaustive', 'string is not ourMessageTruncationStatus enum', ); return ourMessageTruncationStatus; } +export const messageTruncationStatusValidator: TEnums = t.enums.of( + values(messageTruncationStatus), +); export type MessageTruncationStatuses = { [threadID: string]: MessageTruncationStatus, }; +export const messageTruncationStatusesValidator: TDict = + t.dict(tID, messageTruncationStatusValidator); export type ThreadCursors = { +[threadID: string]: ?string }; export type MessageSelectionCriteria = { +threadCursors?: ?ThreadCursors, +joinedThreads?: ?boolean, +newerThan?: ?number, }; export type FetchMessageInfosRequest = { +cursors: ThreadCursors, +numberPerThread?: ?number, }; export type FetchMessageInfosResponse = { +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, +userInfos: UserInfos, }; export type FetchMessageInfosResult = { +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, }; export type FetchMessageInfosPayload = { +threadID: string, +rawMessageInfos: $ReadOnlyArray, +truncationStatus: MessageTruncationStatus, }; export type MessagesResponse = { +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, +currentAsOf: number, }; export type SimpleMessagesPayload = { +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, }; export const defaultNumberPerThread = 20; export const defaultMaxMessageAge = 14 * 24 * 60 * 60 * 1000; // 2 weeks export type SendMessageResponse = { +newMessageInfo: RawMessageInfo, }; export type SendMessageResult = { +id: string, +time: number, +interface: CallServerEndpointResultInfoInterface, }; export type SendMessagePayload = { +localID: string, +serverID: string, +threadID: string, +time: number, +interface: CallServerEndpointResultInfoInterface, }; export type SendTextMessageRequest = { +threadID: string, +localID?: string, +text: string, +sidebarCreation?: boolean, }; export type SendMultimediaMessageRequest = // This option is only used for messageTypes.IMAGES | { +threadID: string, +localID: string, +sidebarCreation?: boolean, +mediaIDs: $ReadOnlyArray, } | { +threadID: string, +localID: string, +sidebarCreation?: boolean, +mediaMessageContents: $ReadOnlyArray, }; export type SendReactionMessageRequest = { +threadID: string, +localID?: string, +targetMessageID: string, +reaction: string, +action: 'add_reaction' | 'remove_reaction', }; export type SendEditMessageRequest = { +targetMessageID: string, +text: string, }; export type SendEditMessageResponse = { +newMessageInfos: $ReadOnlyArray, }; export type EditMessagePayload = SendEditMessageResponse; export type SendEditMessageResult = SendEditMessageResponse; export type EditMessageContent = { +text: string, }; // Used for the message info included in log-in type actions export type GenericMessagesResult = { +messageInfos: RawMessageInfo[], +truncationStatus: MessageTruncationStatuses, +watchedIDsAtRequestTime: $ReadOnlyArray, +currentAsOf: number, }; export type SaveMessagesPayload = { +rawMessageInfos: $ReadOnlyArray, +updatesCurrentAsOf: number, }; export type NewMessagesPayload = { +messagesResult: MessagesResponse, }; export type MessageStorePrunePayload = { +threadIDs: $ReadOnlyArray, }; export type FetchPinnedMessagesRequest = { +threadID: string, }; export type FetchPinnedMessagesResult = { +pinnedMessages: $ReadOnlyArray, }; diff --git a/lib/types/relationship-types.js b/lib/types/relationship-types.js index f03553799..cc0275701 100644 --- a/lib/types/relationship-types.js +++ b/lib/types/relationship-types.js @@ -1,76 +1,82 @@ // @flow +import type { TRefinement } from 'tcomb'; + import type { AccountUserInfo } from './user-types.js'; import { values } from '../utils/objects.js'; +import { tNumEnum } from '../utils/validation-utils.js'; export const undirectedStatus = Object.freeze({ KNOW_OF: 0, FRIEND: 2, }); export type UndirectedStatus = $Values; export const directedStatus = Object.freeze({ PENDING_FRIEND: 1, BLOCKED: 3, }); export type DirectedStatus = $Values; export const userRelationshipStatus = Object.freeze({ REQUEST_SENT: 1, REQUEST_RECEIVED: 2, FRIEND: 3, BLOCKED_BY_VIEWER: 4, BLOCKED_VIEWER: 5, BOTH_BLOCKED: 6, }); export type UserRelationshipStatus = $Values; +export const userRelationshipStatusValidator: TRefinement = tNumEnum( + values(userRelationshipStatus), +); export const relationshipActions = Object.freeze({ FRIEND: 'friend', UNFRIEND: 'unfriend', BLOCK: 'block', UNBLOCK: 'unblock', }); export type RelationshipAction = $Values; export const relationshipActionsList: $ReadOnlyArray = values(relationshipActions); export const relationshipButtons = Object.freeze({ FRIEND: 'friend', UNFRIEND: 'unfriend', BLOCK: 'block', UNBLOCK: 'unblock', ACCEPT: 'accept', WITHDRAW: 'withdraw', REJECT: 'reject', }); export type RelationshipButton = $Values; export type RelationshipRequest = { action: RelationshipAction, userIDs: $ReadOnlyArray, }; type SharedRelationshipRow = { user1: string, user2: string, }; export type DirectedRelationshipRow = { ...SharedRelationshipRow, status: DirectedStatus, }; export type UndirectedRelationshipRow = { ...SharedRelationshipRow, status: UndirectedStatus, }; export type RelationshipErrors = $Shape<{ invalid_user: string[], already_friends: string[], user_blocked: string[], }>; export type UserRelationships = { +friends: $ReadOnlyArray, +blocked: $ReadOnlyArray, }; diff --git a/lib/types/user-types.js b/lib/types/user-types.js index 6ecb57dfa..837ccca9d 100644 --- a/lib/types/user-types.js +++ b/lib/types/user-types.js @@ -1,84 +1,115 @@ // @flow -import type { DefaultNotificationPayload } from './account-types.js'; -import type { ClientAvatar } from './avatar-types.js'; -import type { UserRelationshipStatus } from './relationship-types.js'; +import t, { type TInterface } from 'tcomb'; + +import { + type DefaultNotificationPayload, + defaultNotificationPayloadValidator, +} from './account-types.js'; +import { type ClientAvatar, clientAvatarValidator } from './avatar-types.js'; +import { + type UserRelationshipStatus, + userRelationshipStatusValidator, +} from './relationship-types.js'; import type { UserInconsistencyReportCreationRequest } from './report-types.js'; +import { tBool, tShape } from '../utils/validation-utils.js'; export type GlobalUserInfo = { +id: string, +username: ?string, +avatar?: ?ClientAvatar, }; export type GlobalAccountUserInfo = { +id: string, +username: string, +avatar?: ?ClientAvatar, }; export type UserInfo = { +id: string, +username: ?string, +relationshipStatus?: UserRelationshipStatus, +avatar?: ?ClientAvatar, }; +export const userInfoValidator: TInterface = tShape({ + id: t.String, + username: t.maybe(t.String), + relationshipStatus: t.maybe(userRelationshipStatusValidator), + avatar: t.maybe(clientAvatarValidator), +}); export type UserInfos = { +[id: string]: UserInfo }; export type AccountUserInfo = { +id: string, +username: string, +relationshipStatus?: UserRelationshipStatus, +avatar?: ?ClientAvatar, }; export type UserStore = { +userInfos: UserInfos, +inconsistencyReports: $ReadOnlyArray, }; export type RelativeUserInfo = { +id: string, +username: ?string, +isViewer: boolean, +avatar?: ?ClientAvatar, }; export type OldLoggedInUserInfo = { +id: string, +username: string, +email: string, +emailVerified: boolean, }; +export const oldLoggedInUserInfoValidator: TInterface = + tShape({ + id: t.String, + username: t.String, + email: t.String, + emailVerified: t.Boolean, + }); export type LoggedInUserInfo = { +id: string, +username: string, +settings?: DefaultNotificationPayload, +avatar?: ?ClientAvatar, }; +export const loggedInUserInfoValidator: TInterface = + tShape({ + id: t.String, + username: t.String, + settings: t.maybe(defaultNotificationPayloadValidator), + avatar: t.maybe(clientAvatarValidator), + }); export type LoggedOutUserInfo = { +id: string, +anonymous: true, }; +export const loggedOutUserInfoValidator: TInterface = + tShape({ id: t.String, anonymous: tBool(true) }); export type OldCurrentUserInfo = OldLoggedInUserInfo | LoggedOutUserInfo; export type CurrentUserInfo = LoggedInUserInfo | LoggedOutUserInfo; export type PasswordUpdate = { +updatedFields: { +password?: ?string, }, +currentPassword: string, }; export type UserListItem = { +id: string, +username: string, +disabled?: boolean, +notice?: string, +alertText?: string, +alertTitle?: string, +avatar?: ?ClientAvatar, };