diff --git a/keyserver/src/socket/socket.js b/keyserver/src/socket/socket.js index 5a7114a7e..2ccec9408 100644 --- a/keyserver/src/socket/socket.js +++ b/keyserver/src/socket/socket.js @@ -1,835 +1,843 @@ // @flow import type { $Request } from 'express'; import invariant from 'invariant'; import _debounce from 'lodash/debounce.js'; import t from 'tcomb'; import WebSocket from 'ws'; import { baseLegalPolicies } from 'lib/facts/policies.js'; import { mostRecentMessageTimestamp } from 'lib/shared/message-utils.js'; import { serverRequestSocketTimeout, serverResponseTimeout, } from 'lib/shared/timeouts.js'; import { mostRecentUpdateTimestamp } from 'lib/shared/update-utils.js'; import type { Shape } from 'lib/types/core.js'; import { endpointIsSocketSafe } from 'lib/types/endpoints.js'; import { defaultNumberPerThread } from 'lib/types/message-types.js'; import { redisMessageTypes, type RedisMessage } from 'lib/types/redis-types.js'; import { serverRequestTypes } from 'lib/types/request-types.js'; import { cookieSources, sessionCheckFrequency, stateCheckInactivityActivationInterval, } from 'lib/types/session-types.js'; import { type ClientSocketMessage, type InitialClientSocketMessage, type ResponsesClientSocketMessage, type ServerStateSyncFullSocketPayload, type ServerServerSocketMessage, type ErrorServerSocketMessage, type AuthErrorServerSocketMessage, type PingClientSocketMessage, type AckUpdatesClientSocketMessage, type APIRequestClientSocketMessage, clientSocketMessageTypes, stateSyncPayloadTypes, serverSocketMessageTypes, + serverServerSocketMessageValidator, } from 'lib/types/socket-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { promiseAll } from 'lib/utils/promises.js'; import SequentialPromiseResolver from 'lib/utils/sequential-promise-resolver.js'; import sleep from 'lib/utils/sleep.js'; import { tShape, tCookie } from 'lib/utils/validation-utils.js'; import { RedisSubscriber } from './redis.js'; import { clientResponseInputValidator, processClientResponses, initializeSession, checkState, } from './session-utils.js'; import { fetchUpdateInfosWithRawUpdateInfos } from '../creators/update-creator.js'; import { deleteActivityForViewerSession } from '../deleters/activity-deleters.js'; import { deleteCookie } from '../deleters/cookie-deleters.js'; import { deleteUpdatesBeforeTimeTargetingSession } from '../deleters/update-deleters.js'; import { jsonEndpoints } from '../endpoints.js'; import { fetchEntryInfos } from '../fetchers/entry-fetchers.js'; import { fetchMessageInfosSince, getMessageFetchResultFromRedisMessages, } from '../fetchers/message-fetchers.js'; import { fetchThreadInfos } from '../fetchers/thread-fetchers.js'; import { fetchUpdateInfos } from '../fetchers/update-fetchers.js'; import { fetchCurrentUserInfo, fetchKnownUserInfos, } from '../fetchers/user-fetchers.js'; import { newEntryQueryInputValidator, verifyCalendarQueryThreadIDs, } from '../responders/entry-responders.js'; import { handleAsyncPromise } from '../responders/handlers.js'; import { fetchViewerForSocket, extendCookieLifespan, createNewAnonymousCookie, isCookieMissingSignedIdentityKeysBlob, } from '../session/cookies.js'; import { Viewer } from '../session/viewer.js'; import { commitSessionUpdate } from '../updaters/session-updaters.js'; import { assertSecureRequest } from '../utils/security-utils.js'; import { checkInputValidator, checkClientSupported, policiesValidator, + validateOutput, } from '../utils/validation-utils.js'; const clientSocketMessageInputValidator = t.union([ tShape({ type: t.irreducible( 'clientSocketMessageTypes.INITIAL', x => x === clientSocketMessageTypes.INITIAL, ), id: t.Number, payload: tShape({ sessionIdentification: tShape({ cookie: t.maybe(tCookie), sessionID: t.maybe(t.String), }), sessionState: tShape({ calendarQuery: newEntryQueryInputValidator, messagesCurrentAsOf: t.Number, updatesCurrentAsOf: t.Number, watchedIDs: t.list(t.String), }), clientResponses: t.list(clientResponseInputValidator), }), }), tShape({ type: t.irreducible( 'clientSocketMessageTypes.RESPONSES', x => x === clientSocketMessageTypes.RESPONSES, ), id: t.Number, payload: tShape({ clientResponses: t.list(clientResponseInputValidator), }), }), tShape({ type: t.irreducible( 'clientSocketMessageTypes.PING', x => x === clientSocketMessageTypes.PING, ), id: t.Number, }), tShape({ type: t.irreducible( 'clientSocketMessageTypes.ACK_UPDATES', x => x === clientSocketMessageTypes.ACK_UPDATES, ), id: t.Number, payload: tShape({ currentAsOf: t.Number, }), }), tShape({ type: t.irreducible( 'clientSocketMessageTypes.API_REQUEST', x => x === clientSocketMessageTypes.API_REQUEST, ), id: t.Number, payload: tShape({ endpoint: t.String, input: t.maybe(t.Object), }), }), ]); function onConnection(ws: WebSocket, req: $Request) { assertSecureRequest(req); new Socket(ws, req); } type StateCheckConditions = { activityRecentlyOccurred: boolean, stateCheckOngoing: boolean, }; class Socket { ws: WebSocket; httpRequest: $Request; viewer: ?Viewer; redis: ?RedisSubscriber; redisPromiseResolver: SequentialPromiseResolver; stateCheckConditions: StateCheckConditions = { activityRecentlyOccurred: true, stateCheckOngoing: false, }; stateCheckTimeoutID: ?TimeoutID; constructor(ws: WebSocket, httpRequest: $Request) { this.ws = ws; this.httpRequest = httpRequest; ws.on('message', this.onMessage); ws.on('close', this.onClose); this.resetTimeout(); this.redisPromiseResolver = new SequentialPromiseResolver(this.sendMessage); } onMessage = async ( messageString: string | Buffer | ArrayBuffer | Array, ) => { invariant(typeof messageString === 'string', 'message should be string'); let clientSocketMessage: ?ClientSocketMessage; try { this.resetTimeout(); clientSocketMessage = JSON.parse(messageString); checkInputValidator( clientSocketMessageInputValidator, clientSocketMessage, ); if (clientSocketMessage.type === clientSocketMessageTypes.INITIAL) { if (this.viewer) { // This indicates that the user sent multiple INITIAL messages. throw new ServerError('socket_already_initialized'); } this.viewer = await fetchViewerForSocket( this.httpRequest, clientSocketMessage, ); if (!this.viewer) { // This indicates that the cookie was invalid, but the client is using // cookieSources.HEADER and thus can't accept a new cookie over // WebSockets. See comment under catch block for socket_deauthorized. throw new ServerError('socket_deauthorized'); } } const { viewer } = this; if (!viewer) { // This indicates a non-INITIAL message was sent by the client before // the INITIAL message. throw new ServerError('socket_uninitialized'); } if (viewer.sessionChanged) { // This indicates that the cookie was invalid, and we've assigned a new // anonymous one. throw new ServerError('socket_deauthorized'); } if (!viewer.loggedIn) { // This indicates that the specified cookie was an anonymous one. throw new ServerError('not_logged_in'); } await checkClientSupported( viewer, clientSocketMessageInputValidator, clientSocketMessage, ); await policiesValidator(viewer, baseLegalPolicies); const serverResponses = await this.handleClientSocketMessage( clientSocketMessage, ); if (!this.redis) { this.redis = new RedisSubscriber( { userID: viewer.userID, sessionID: viewer.session }, this.onRedisMessage, ); } if (viewer.sessionChanged) { // This indicates that something has caused the session to change, which // shouldn't happen from inside a WebSocket since we can't handle cookie // invalidation. throw new ServerError('session_mutated_from_socket'); } if (clientSocketMessage.type !== clientSocketMessageTypes.PING) { handleAsyncPromise(extendCookieLifespan(viewer.cookieID)); } for (const response of serverResponses) { this.sendMessage(response); } if (clientSocketMessage.type === clientSocketMessageTypes.INITIAL) { this.onSuccessfulConnection(); } } catch (error) { console.warn(error); if (!(error instanceof ServerError)) { const errorMessage: ErrorServerSocketMessage = { type: serverSocketMessageTypes.ERROR, message: error.message, }; const responseTo = clientSocketMessage ? clientSocketMessage.id : null; if (responseTo !== null) { errorMessage.responseTo = responseTo; } this.markActivityOccurred(); this.sendMessage(errorMessage); return; } invariant(clientSocketMessage, 'should be set'); const responseTo = clientSocketMessage.id; if (error.message === 'socket_deauthorized') { const authErrorMessage: AuthErrorServerSocketMessage = { type: serverSocketMessageTypes.AUTH_ERROR, responseTo, message: error.message, }; if (this.viewer) { // viewer should only be falsey for cookieSources.HEADER (web) // clients. Usually if the cookie is invalid we construct a new // anonymous Viewer with a new cookie, and then pass the cookie down // in the error. But we can't pass HTTP cookies in WebSocket messages. authErrorMessage.sessionChange = { cookie: this.viewer.cookiePairString, currentUserInfo: { id: this.viewer.cookieID, anonymous: true, }, }; } this.sendMessage(authErrorMessage); this.ws.close(4100, error.message); return; } else if (error.message === 'client_version_unsupported') { const { viewer } = this; invariant(viewer, 'should be set'); const promises = {}; promises.deleteCookie = deleteCookie(viewer.cookieID); if (viewer.cookieSource !== cookieSources.BODY) { promises.anonymousViewerData = createNewAnonymousCookie({ platformDetails: error.platformDetails, deviceToken: viewer.deviceToken, }); } const { anonymousViewerData } = await promiseAll(promises); const authErrorMessage: AuthErrorServerSocketMessage = { type: serverSocketMessageTypes.AUTH_ERROR, responseTo, message: error.message, }; if (anonymousViewerData) { // It is normally not safe to pass the result of // createNewAnonymousCookie to the Viewer constructor. That is because // createNewAnonymousCookie leaves several fields of // AnonymousViewerData unset, and consequently Viewer will throw when // access is attempted. It is only safe here because we can guarantee // that only cookiePairString and cookieID are accessed on anonViewer // below. const anonViewer = new Viewer(anonymousViewerData); authErrorMessage.sessionChange = { cookie: anonViewer.cookiePairString, currentUserInfo: { id: anonViewer.cookieID, anonymous: true, }, }; } this.sendMessage(authErrorMessage); this.ws.close(4101, error.message); return; } if (error.payload) { this.sendMessage({ type: serverSocketMessageTypes.ERROR, responseTo, message: error.message, payload: error.payload, }); } else { this.sendMessage({ type: serverSocketMessageTypes.ERROR, responseTo, message: error.message, }); } if (error.message === 'not_logged_in') { this.ws.close(4102, error.message); } else if (error.message === 'session_mutated_from_socket') { this.ws.close(4103, error.message); } else { this.markActivityOccurred(); } } }; onClose = async () => { this.clearStateCheckTimeout(); this.resetTimeout.cancel(); this.debouncedAfterActivity.cancel(); if (this.viewer && this.viewer.hasSessionInfo) { await deleteActivityForViewerSession(this.viewer); } if (this.redis) { this.redis.quit(); this.redis = null; } }; sendMessage = (message: ServerServerSocketMessage) => { invariant( this.ws.readyState > 0, "shouldn't send message until connection established", ); if (this.ws.readyState === 1) { - this.ws.send(JSON.stringify(message)); + const { viewer } = this; + const validatedMessage = validateOutput( + viewer, + serverServerSocketMessageValidator, + message, + ); + this.ws.send(JSON.stringify(validatedMessage)); } }; async handleClientSocketMessage( message: ClientSocketMessage, ): Promise { const resultPromise = (async () => { if (message.type === clientSocketMessageTypes.INITIAL) { this.markActivityOccurred(); return await this.handleInitialClientSocketMessage(message); } else if (message.type === clientSocketMessageTypes.RESPONSES) { this.markActivityOccurred(); return await this.handleResponsesClientSocketMessage(message); } else if (message.type === clientSocketMessageTypes.PING) { return this.handlePingClientSocketMessage(message); } else if (message.type === clientSocketMessageTypes.ACK_UPDATES) { this.markActivityOccurred(); return await this.handleAckUpdatesClientSocketMessage(message); } else if (message.type === clientSocketMessageTypes.API_REQUEST) { this.markActivityOccurred(); return await this.handleAPIRequestClientSocketMessage(message); } return []; })(); const timeoutPromise = (async () => { await sleep(serverResponseTimeout); throw new ServerError('socket_response_timeout'); })(); return await Promise.race([resultPromise, timeoutPromise]); } async handleInitialClientSocketMessage( message: InitialClientSocketMessage, ): Promise { const { viewer } = this; invariant(viewer, 'should be set'); const responses = []; const { sessionState, clientResponses } = message.payload; const { calendarQuery, updatesCurrentAsOf: oldUpdatesCurrentAsOf, messagesCurrentAsOf: oldMessagesCurrentAsOf, watchedIDs, } = sessionState; await verifyCalendarQueryThreadIDs(calendarQuery); const sessionInitializationResult = await initializeSession( viewer, calendarQuery, oldUpdatesCurrentAsOf, ); const threadCursors = {}; for (const watchedThreadID of watchedIDs) { threadCursors[watchedThreadID] = null; } const messageSelectionCriteria = { threadCursors, joinedThreads: true, newerThan: oldMessagesCurrentAsOf, }; const [fetchMessagesResult, { serverRequests, activityUpdateResult }] = await Promise.all([ fetchMessageInfosSince( viewer, messageSelectionCriteria, defaultNumberPerThread, ), processClientResponses(viewer, clientResponses), ]); const messagesResult = { rawMessageInfos: fetchMessagesResult.rawMessageInfos, truncationStatuses: fetchMessagesResult.truncationStatuses, currentAsOf: mostRecentMessageTimestamp( fetchMessagesResult.rawMessageInfos, oldMessagesCurrentAsOf, ), }; if ( viewer.userAgent?.includes('Electron') && viewer.platform === 'web' && !serverRequests.find( request => request.type === serverRequestTypes.PLATFORM_DETAILS, ) ) { serverRequests.push({ type: serverRequestTypes.PLATFORM_DETAILS }); } const isCookieMissingSignedIdentityKeysBlobPromise = isCookieMissingSignedIdentityKeysBlob(viewer.cookieID); if (!sessionInitializationResult.sessionContinued) { const [threadsResult, entriesResult, currentUserInfo, knownUserInfos] = await Promise.all([ fetchThreadInfos(viewer), fetchEntryInfos(viewer, [calendarQuery]), fetchCurrentUserInfo(viewer), fetchKnownUserInfos(viewer), ]); const payload: ServerStateSyncFullSocketPayload = { type: stateSyncPayloadTypes.FULL, messagesResult, threadInfos: threadsResult.threadInfos, currentUserInfo, rawEntryInfos: entriesResult.rawEntryInfos, userInfos: values(knownUserInfos), updatesCurrentAsOf: oldUpdatesCurrentAsOf, }; if (viewer.sessionChanged) { // If initializeSession encounters, // sessionIdentifierTypes.BODY_SESSION_ID but the session // is unspecified or expired, // it will set a new sessionID and specify viewer.sessionChanged const { sessionID } = viewer; invariant( sessionID !== null && sessionID !== undefined, 'should be set', ); payload.sessionID = sessionID; viewer.sessionChanged = false; } responses.push({ type: serverSocketMessageTypes.STATE_SYNC, responseTo: message.id, payload, }); } else { const { sessionUpdate, deltaEntryInfoResult } = sessionInitializationResult; const promises = {}; promises.deleteExpiredUpdates = deleteUpdatesBeforeTimeTargetingSession( viewer, oldUpdatesCurrentAsOf, ); promises.fetchUpdateResult = fetchUpdateInfos( viewer, oldUpdatesCurrentAsOf, calendarQuery, ); promises.sessionUpdate = commitSessionUpdate(viewer, sessionUpdate); const { fetchUpdateResult } = await promiseAll(promises); const { updateInfos, userInfos } = fetchUpdateResult; const newUpdatesCurrentAsOf = mostRecentUpdateTimestamp( [...updateInfos], oldUpdatesCurrentAsOf, ); const updatesResult = { newUpdates: updateInfos, currentAsOf: newUpdatesCurrentAsOf, }; responses.push({ type: serverSocketMessageTypes.STATE_SYNC, responseTo: message.id, payload: { type: stateSyncPayloadTypes.INCREMENTAL, messagesResult, updatesResult, deltaEntryInfos: deltaEntryInfoResult.rawEntryInfos, deletedEntryIDs: deltaEntryInfoResult.deletedEntryIDs, userInfos: values(userInfos), }, }); } const signedIdentityKeysBlobMissing = await isCookieMissingSignedIdentityKeysBlobPromise; if (signedIdentityKeysBlobMissing) { serverRequests.push({ type: serverRequestTypes.SIGNED_IDENTITY_KEYS_BLOB, }); } if (serverRequests.length > 0 || clientResponses.length > 0) { // We send this message first since the STATE_SYNC triggers the client's // connection status to shift to "connected", and we want to make sure the // client responses are cleared from Redux before that happens responses.unshift({ type: serverSocketMessageTypes.REQUESTS, responseTo: message.id, payload: { serverRequests }, }); } if (activityUpdateResult) { // Same reason for unshifting as above responses.unshift({ type: serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE, responseTo: message.id, payload: activityUpdateResult, }); } return responses; } async handleResponsesClientSocketMessage( message: ResponsesClientSocketMessage, ): Promise { const { viewer } = this; invariant(viewer, 'should be set'); const { clientResponses } = message.payload; const { stateCheckStatus } = await processClientResponses( viewer, clientResponses, ); const serverRequests = []; if (stateCheckStatus && stateCheckStatus.status !== 'state_check') { const { sessionUpdate, checkStateRequest } = await checkState( viewer, stateCheckStatus, viewer.calendarQuery, ); if (sessionUpdate) { await commitSessionUpdate(viewer, sessionUpdate); this.setStateCheckConditions({ stateCheckOngoing: false }); } if (checkStateRequest) { serverRequests.push(checkStateRequest); } } // We send a response message regardless of whether we have any requests, // since we need to ack the client's responses return [ { type: serverSocketMessageTypes.REQUESTS, responseTo: message.id, payload: { serverRequests }, }, ]; } handlePingClientSocketMessage( message: PingClientSocketMessage, ): ServerServerSocketMessage[] { return [ { type: serverSocketMessageTypes.PONG, responseTo: message.id, }, ]; } async handleAckUpdatesClientSocketMessage( message: AckUpdatesClientSocketMessage, ): Promise { const { viewer } = this; invariant(viewer, 'should be set'); const { currentAsOf } = message.payload; await Promise.all([ deleteUpdatesBeforeTimeTargetingSession(viewer, currentAsOf), commitSessionUpdate(viewer, { lastUpdate: currentAsOf }), ]); return []; } async handleAPIRequestClientSocketMessage( message: APIRequestClientSocketMessage, ): Promise { if (!endpointIsSocketSafe(message.payload.endpoint)) { throw new ServerError('endpoint_unsafe_for_socket'); } const { viewer } = this; invariant(viewer, 'should be set'); const responder = jsonEndpoints[message.payload.endpoint]; await policiesValidator(viewer, responder.requiredPolicies); const response = await responder.responder(viewer, message.payload.input); return [ { type: serverSocketMessageTypes.API_RESPONSE, responseTo: message.id, payload: response, }, ]; } onRedisMessage = async (message: RedisMessage) => { try { await this.processRedisMessage(message); } catch (e) { console.warn(e); } }; async processRedisMessage(message: RedisMessage) { if (message.type === redisMessageTypes.START_SUBSCRIPTION) { this.ws.terminate(); } else if (message.type === redisMessageTypes.NEW_UPDATES) { const { viewer } = this; invariant(viewer, 'should be set'); if (message.ignoreSession && message.ignoreSession === viewer.session) { return; } const rawUpdateInfos = message.updates; this.redisPromiseResolver.add( (async () => { const { updateInfos, userInfos } = await fetchUpdateInfosWithRawUpdateInfos(rawUpdateInfos, { viewer, }); if (updateInfos.length === 0) { console.warn( 'could not get any UpdateInfos from redisMessageTypes.NEW_UPDATES', ); return null; } this.markActivityOccurred(); return { type: serverSocketMessageTypes.UPDATES, payload: { updatesResult: { currentAsOf: mostRecentUpdateTimestamp([...updateInfos], 0), newUpdates: updateInfos, }, userInfos: values(userInfos), }, }; })(), ); } else if (message.type === redisMessageTypes.NEW_MESSAGES) { const { viewer } = this; invariant(viewer, 'should be set'); const rawMessageInfos = message.messages; const messageFetchResult = getMessageFetchResultFromRedisMessages( viewer, rawMessageInfos, ); if (messageFetchResult.rawMessageInfos.length === 0) { console.warn( 'could not get any rawMessageInfos from ' + 'redisMessageTypes.NEW_MESSAGES', ); return; } this.redisPromiseResolver.add( (async () => { this.markActivityOccurred(); return { type: serverSocketMessageTypes.MESSAGES, payload: { messagesResult: { rawMessageInfos: messageFetchResult.rawMessageInfos, truncationStatuses: messageFetchResult.truncationStatuses, currentAsOf: mostRecentMessageTimestamp( messageFetchResult.rawMessageInfos, 0, ), }, }, }; })(), ); } } onSuccessfulConnection() { if (this.ws.readyState !== 1) { return; } this.handleStateCheckConditionsUpdate(); } // The Socket will timeout by calling this.ws.terminate() // serverRequestSocketTimeout milliseconds after the last // time resetTimeout is called resetTimeout = _debounce( () => this.ws.terminate(), serverRequestSocketTimeout, ); debouncedAfterActivity = _debounce( () => this.setStateCheckConditions({ activityRecentlyOccurred: false }), stateCheckInactivityActivationInterval, ); markActivityOccurred = () => { if (this.ws.readyState !== 1) { return; } this.setStateCheckConditions({ activityRecentlyOccurred: true }); this.debouncedAfterActivity(); }; clearStateCheckTimeout() { const { stateCheckTimeoutID } = this; if (stateCheckTimeoutID) { clearTimeout(stateCheckTimeoutID); this.stateCheckTimeoutID = null; } } setStateCheckConditions(newConditions: Shape) { this.stateCheckConditions = { ...this.stateCheckConditions, ...newConditions, }; this.handleStateCheckConditionsUpdate(); } get stateCheckCanStart() { return Object.values(this.stateCheckConditions).every(cond => !cond); } handleStateCheckConditionsUpdate() { if (!this.stateCheckCanStart) { this.clearStateCheckTimeout(); return; } if (this.stateCheckTimeoutID) { return; } const { viewer } = this; if (!viewer) { return; } const timeUntilStateCheck = viewer.sessionLastValidated + sessionCheckFrequency - Date.now(); if (timeUntilStateCheck <= 0) { this.initiateStateCheck(); } else { this.stateCheckTimeoutID = setTimeout( this.initiateStateCheck, timeUntilStateCheck, ); } } initiateStateCheck = async () => { this.setStateCheckConditions({ stateCheckOngoing: true }); const { viewer } = this; invariant(viewer, 'should be set'); const { checkStateRequest } = await checkState( viewer, { status: 'state_check' }, viewer.calendarQuery, ); invariant(checkStateRequest, 'should be set'); this.sendMessage({ type: serverSocketMessageTypes.REQUESTS, payload: { serverRequests: [checkStateRequest] }, }); }; } export { onConnection }; diff --git a/keyserver/src/utils/validation-utils.js b/keyserver/src/utils/validation-utils.js index b48b3a388..d4b029520 100644 --- a/keyserver/src/utils/validation-utils.js +++ b/keyserver/src/utils/validation-utils.js @@ -1,343 +1,343 @@ // @flow import _mapKeys from 'lodash/fp/mapKeys.js'; import _mapValues from 'lodash/fp/mapValues.js'; import type { TType, TInterface } from 'tcomb'; import type { PolicyType } from 'lib/facts/policies.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import { isWebPlatform } from 'lib/types/device-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { tCookie, tPassword, tPlatform, tPlatformDetails, assertWithValidator, tID, } from 'lib/utils/validation-utils.js'; import { fetchNotAcknowledgedPolicies } from '../fetchers/policy-acknowledgment-fetchers.js'; import { verifyClientSupported } from '../session/version.js'; import type { Viewer } from '../session/viewer.js'; const convertToNewIDSchema = false; const keyserverPrefixID = '256'; async function validateInput( viewer: Viewer, inputValidator: TType, input: mixed, ): Promise { if (!viewer.isSocket) { await checkClientSupported(viewer, inputValidator, input); } const convertedInput = checkInputValidator(inputValidator, input); if ( hasMinCodeVersion(viewer.platformDetails, 1000) && !isWebPlatform(viewer.platformDetails?.platform) && convertToNewIDSchema ) { return convertClientIDsToServerIDs( keyserverPrefixID, inputValidator, convertedInput, ); } return convertedInput; } function validateOutput( - viewer: Viewer, + viewer: ?Viewer, outputValidator: TType, data: T, ): T { if (!outputValidator.is(data)) { console.trace( 'Output validation failed, validator is:', outputValidator.displayName, ); return data; } if ( - hasMinCodeVersion(viewer.platformDetails, 1000) && - !isWebPlatform(viewer.platformDetails?.platform) && + hasMinCodeVersion(viewer?.platformDetails, 1000) && + !isWebPlatform(viewer?.platformDetails?.platform) && convertToNewIDSchema ) { return convertServerIDsToClientIDs( keyserverPrefixID, outputValidator, data, ); } return data; } function convertServerIDsToClientIDs( serverPrefixID: string, outputValidator: TType, data: T, ): T { const conversionFunction = id => { if (id.indexOf('|') !== -1) { console.warn(`Server id '${id}' already has a prefix`); return id; } return `${serverPrefixID}|${id}`; }; return convertObject(outputValidator, data, [tID], conversionFunction); } function convertClientIDsToServerIDs( serverPrefixID: string, outputValidator: TType, data: T, ): T { const prefix = serverPrefixID + '|'; const conversionFunction = id => { if (id.startsWith(prefix)) { return id.substr(prefix.length); } throw new ServerError('invalid_client_id_prefix'); }; return convertObject(outputValidator, data, [tID], conversionFunction); } function checkInputValidator(inputValidator: TType, input: mixed): T { if (inputValidator.is(input)) { return assertWithValidator(input, inputValidator); } const error = new ServerError('invalid_parameters'); error.sanitizedInput = input ? sanitizeInput(inputValidator, input) : null; throw error; } async function checkClientSupported( viewer: Viewer, inputValidator: ?TType, input: T, ) { let platformDetails; if (inputValidator) { platformDetails = findFirstInputMatchingValidator( inputValidator, tPlatformDetails, input, ); } if (!platformDetails && inputValidator) { const platform = findFirstInputMatchingValidator( inputValidator, tPlatform, input, ); if (platform) { platformDetails = { platform }; } } if (!platformDetails) { ({ platformDetails } = viewer); } await verifyClientSupported(viewer, platformDetails); } const redactedString = '********'; const redactedTypes = [tPassword, tCookie]; function sanitizeInput(inputValidator: TType, input: T): T { return convertObject( inputValidator, input, redactedTypes, () => redactedString, ); } function findFirstInputMatchingValidator( wholeInputValidator: *, inputValidatorToMatch: *, input: *, ): any { if (!wholeInputValidator || input === null || input === undefined) { return null; } if ( wholeInputValidator === inputValidatorToMatch && wholeInputValidator.is(input) ) { return input; } if (wholeInputValidator.meta.kind === 'maybe') { return findFirstInputMatchingValidator( wholeInputValidator.meta.type, inputValidatorToMatch, input, ); } if ( wholeInputValidator.meta.kind === 'interface' && typeof input === 'object' ) { for (const key in input) { const value = input[key]; const validator = wholeInputValidator.meta.props[key]; const innerResult = findFirstInputMatchingValidator( validator, inputValidatorToMatch, value, ); if (innerResult) { return innerResult; } } } if (wholeInputValidator.meta.kind === 'union') { for (const validator of wholeInputValidator.meta.types) { if (validator.is(input)) { return findFirstInputMatchingValidator( validator, inputValidatorToMatch, input, ); } } } if (wholeInputValidator.meta.kind === 'list' && Array.isArray(input)) { const validator = wholeInputValidator.meta.type; for (const value of input) { const innerResult = findFirstInputMatchingValidator( validator, inputValidatorToMatch, value, ); if (innerResult) { return innerResult; } } } return null; } function convertObject( validator: TType, input: I, typesToConvert: $ReadOnlyArray>, conversionFunction: T => T, ): I { if (input === null || input === undefined) { return input; } // While they should be the same runtime object, // `TValidator` is `TType` and `validator` is `TType`. // Having them have different types allows us to use `assertWithValidator` // to change `input` flow type const TValidator = typesToConvert[typesToConvert.indexOf(validator)]; if (TValidator && TValidator.is(input)) { const TInput = assertWithValidator(input, TValidator); const converted = conversionFunction(TInput); return assertWithValidator(converted, validator); } if (validator.meta.kind === 'maybe' || validator.meta.kind === 'subtype') { return convertObject( validator.meta.type, input, typesToConvert, conversionFunction, ); } if (validator.meta.kind === 'interface' && typeof input === 'object') { const recastValidator: TInterface = (validator: any); const result = {}; for (const key in input) { const innerValidator = recastValidator.meta.props[key]; result[key] = convertObject( innerValidator, input[key], typesToConvert, conversionFunction, ); } return assertWithValidator(result, recastValidator); } if (validator.meta.kind === 'union') { for (const innerValidator of validator.meta.types) { if (innerValidator.is(input)) { return convertObject( innerValidator, input, typesToConvert, conversionFunction, ); } } return input; } if (validator.meta.kind === 'list' && Array.isArray(input)) { const innerValidator = validator.meta.type; return (input.map(value => convertObject(innerValidator, value, typesToConvert, conversionFunction), ): any); } if (validator.meta.kind === 'dict' && typeof input === 'object') { const domainValidator = validator.meta.domain; const codomainValidator = validator.meta.codomain; if (typesToConvert.includes(domainValidator)) { input = _mapKeys(key => conversionFunction(key))(input); } return _mapValues(value => convertObject( codomainValidator, value, typesToConvert, conversionFunction, ), )(input); } return input; } async function policiesValidator( viewer: Viewer, policies: $ReadOnlyArray, ) { if (!policies.length) { return; } if (!hasMinCodeVersion(viewer.platformDetails, 181)) { return; } const notAcknowledgedPolicies = await fetchNotAcknowledgedPolicies( viewer.id, policies, ); if (notAcknowledgedPolicies.length) { throw new ServerError('policies_not_accepted', { notAcknowledgedPolicies, }); } } export { validateInput, validateOutput, checkInputValidator, redactedString, sanitizeInput, findFirstInputMatchingValidator, checkClientSupported, convertServerIDsToClientIDs, convertClientIDsToServerIDs, convertObject, policiesValidator, }; diff --git a/lib/types/message-types.js b/lib/types/message-types.js index bb2f0c706..0920a1556 100644 --- a/lib/types/message-types.js +++ b/lib/types/message-types.js @@ -1,687 +1,697 @@ // @flow import invariant from 'invariant'; 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 const messagesResponseValidator: TInterface = + tShape({ + rawMessageInfos: t.list(rawMessageInfoValidator), + truncationStatuses: messageTruncationStatusesValidator, + currentAsOf: t.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 const newMessagesPayloadValidator: TInterface = + tShape({ + messagesResult: messagesResponseValidator, + }); export type MessageStorePrunePayload = { +threadIDs: $ReadOnlyArray, }; export type FetchPinnedMessagesRequest = { +threadID: string, }; export type FetchPinnedMessagesResult = { +pinnedMessages: $ReadOnlyArray, }; diff --git a/lib/types/request-types.js b/lib/types/request-types.js index b6d300103..6ef0dc864 100644 --- a/lib/types/request-types.js +++ b/lib/types/request-types.js @@ -1,199 +1,261 @@ // @flow import invariant from 'invariant'; +import t, { type TUnion } from 'tcomb'; import { type ActivityUpdate } from './activity-types.js'; import type { Shape } from './core.js'; import type { SignedIdentityKeysBlob } from './crypto-types.js'; import type { Platform, PlatformDetails } from './device-types.js'; -import type { RawEntryInfo, CalendarQuery } from './entry-types.js'; +import { + type RawEntryInfo, + type CalendarQuery, + rawEntryInfoValidator, +} from './entry-types.js'; import type { ThreadInconsistencyReportShape, EntryInconsistencyReportShape, ClientThreadInconsistencyReportShape, ClientEntryInconsistencyReportShape, } from './report-types.js'; -import type { RawThreadInfo } from './thread-types.js'; -import type { - CurrentUserInfo, - OldCurrentUserInfo, - AccountUserInfo, +import { type RawThreadInfo, rawThreadInfoValidator } from './thread-types.js'; +import { + type CurrentUserInfo, + currentUserInfoValidator, + type OldCurrentUserInfo, + oldCurrentUserInfoValidator, + type AccountUserInfo, + accountUserInfoValidator, } from './user-types.js'; +import { tNumber, tShape, tID } from '../utils/validation-utils.js'; // "Server requests" are requests for information that the server delivers to // clients. Clients then respond to those requests with a "client response". export const serverRequestTypes = Object.freeze({ PLATFORM: 0, //DEVICE_TOKEN: 1, (DEPRECATED) THREAD_INCONSISTENCY: 2, PLATFORM_DETAILS: 3, //INITIAL_ACTIVITY_UPDATE: 4, (DEPRECATED) ENTRY_INCONSISTENCY: 5, CHECK_STATE: 6, INITIAL_ACTIVITY_UPDATES: 7, MORE_ONE_TIME_KEYS: 8, SIGNED_IDENTITY_KEYS_BLOB: 9, }); type ServerRequestType = $Values; export function assertServerRequestType( serverRequestType: number, ): ServerRequestType { invariant( serverRequestType === 0 || serverRequestType === 2 || serverRequestType === 3 || serverRequestType === 5 || serverRequestType === 6 || serverRequestType === 7 || serverRequestType === 8 || serverRequestType === 9, 'number is not ServerRequestType enum', ); return serverRequestType; } type PlatformServerRequest = { +type: 0, }; +const platformServerRequestValidator = tShape({ + type: tNumber(serverRequestTypes.PLATFORM), +}); type PlatformClientResponse = { +type: 0, +platform: Platform, }; export type ThreadInconsistencyClientResponse = { ...ThreadInconsistencyReportShape, +type: 2, }; type PlatformDetailsServerRequest = { type: 3, }; +const platformDetailsServerRequestValidator = + tShape({ + type: tNumber(serverRequestTypes.PLATFORM_DETAILS), + }); + type PlatformDetailsClientResponse = { type: 3, platformDetails: PlatformDetails, }; export type EntryInconsistencyClientResponse = { type: 5, ...EntryInconsistencyReportShape, }; export type ServerCheckStateServerRequest = { +type: 6, +hashesToCheck: { +[key: string]: number }, +failUnmentioned?: Shape<{ +threadInfos: boolean, +entryInfos: boolean, +userInfos: boolean, }>, +stateChanges?: Shape<{ +rawThreadInfos: RawThreadInfo[], +rawEntryInfos: RawEntryInfo[], +currentUserInfo: CurrentUserInfo | OldCurrentUserInfo, +userInfos: AccountUserInfo[], +deleteThreadIDs: string[], +deleteEntryIDs: string[], +deleteUserInfoIDs: string[], }>, }; +const serverCheckStateServerRequestValidator = + tShape({ + type: tNumber(serverRequestTypes.CHECK_STATE), + hashesToCheck: t.dict(t.String, t.Number), + failUnmentioned: t.maybe( + tShape({ + threadInfos: t.maybe(t.Boolean), + entryInfos: t.maybe(t.Boolean), + userInfos: t.maybe(t.Boolean), + }), + ), + stateChanges: t.maybe( + tShape({ + rawThreadInfos: t.maybe(t.list(rawThreadInfoValidator)), + rawEntryInfos: t.maybe(t.list(rawEntryInfoValidator)), + currentUserInfo: t.maybe( + t.union([currentUserInfoValidator, oldCurrentUserInfoValidator]), + ), + userInfos: t.maybe(t.list(accountUserInfoValidator)), + deleteThreadIDs: t.maybe(t.list(tID)), + deleteEntryIDs: t.maybe(t.list(tID)), + deleteUserInfoIDs: t.maybe(t.list(t.String)), + }), + ), + }); + type CheckStateClientResponse = { +type: 6, +hashResults: { +[key: string]: boolean }, }; type InitialActivityUpdatesClientResponse = { +type: 7, +activityUpdates: $ReadOnlyArray, }; type MoreOneTimeKeysServerRequest = { +type: 8, }; +const moreOneTimeKeysServerRequestValidator = + tShape({ + type: tNumber(serverRequestTypes.MORE_ONE_TIME_KEYS), + }); + type MoreOneTimeKeysClientResponse = { +type: 8, +keys: $ReadOnlyArray, }; type SignedIdentityKeysBlobServerRequest = { +type: 9, }; +const signedIdentityKeysBlobServerRequestValidator = + tShape({ + type: tNumber(serverRequestTypes.SIGNED_IDENTITY_KEYS_BLOB), + }); + type SignedIdentityKeysBlobClientResponse = { +type: 9, +signedIdentityKeysBlob: SignedIdentityKeysBlob, }; export type ServerServerRequest = | PlatformServerRequest | PlatformDetailsServerRequest | ServerCheckStateServerRequest | MoreOneTimeKeysServerRequest | SignedIdentityKeysBlobServerRequest; +export const serverServerRequestValidator: TUnion = + t.union([ + platformServerRequestValidator, + platformDetailsServerRequestValidator, + serverCheckStateServerRequestValidator, + moreOneTimeKeysServerRequestValidator, + signedIdentityKeysBlobServerRequestValidator, + ]); + export type ClientResponse = | PlatformClientResponse | ThreadInconsistencyClientResponse | PlatformDetailsClientResponse | EntryInconsistencyClientResponse | CheckStateClientResponse | InitialActivityUpdatesClientResponse | MoreOneTimeKeysClientResponse | SignedIdentityKeysBlobClientResponse; export type ClientCheckStateServerRequest = { +type: 6, +hashesToCheck: { +[key: string]: number }, +failUnmentioned?: Shape<{ +threadInfos: boolean, +entryInfos: boolean, +userInfos: boolean, }>, +stateChanges?: Shape<{ +rawThreadInfos: RawThreadInfo[], +rawEntryInfos: RawEntryInfo[], +currentUserInfo: CurrentUserInfo, +userInfos: AccountUserInfo[], +deleteThreadIDs: string[], +deleteEntryIDs: string[], +deleteUserInfoIDs: string[], }>, }; export type ClientServerRequest = | PlatformServerRequest | PlatformDetailsServerRequest | ClientCheckStateServerRequest | MoreOneTimeKeysServerRequest | SignedIdentityKeysBlobServerRequest; // This is just the client variant of ClientResponse. The server needs to handle // multiple client versions so the type supports old versions of certain client // responses, but the client variant only need to support the latest version. type ClientThreadInconsistencyClientResponse = { ...ClientThreadInconsistencyReportShape, +type: 2, }; type ClientEntryInconsistencyClientResponse = { +type: 5, ...ClientEntryInconsistencyReportShape, }; export type ClientClientResponse = | PlatformClientResponse | ClientThreadInconsistencyClientResponse | PlatformDetailsClientResponse | ClientEntryInconsistencyClientResponse | CheckStateClientResponse | InitialActivityUpdatesClientResponse | MoreOneTimeKeysClientResponse | SignedIdentityKeysBlobClientResponse; export type ClientInconsistencyResponse = | ClientThreadInconsistencyClientResponse | ClientEntryInconsistencyClientResponse; export const processServerRequestsActionType = 'PROCESS_SERVER_REQUESTS'; export type ProcessServerRequestsPayload = { +serverRequests: $ReadOnlyArray, +calendarQuery: CalendarQuery, }; export type GetSessionPublicKeysArgs = { +session: string, }; diff --git a/lib/types/socket-types.js b/lib/types/socket-types.js index ac01455a4..4b44b3f7f 100644 --- a/lib/types/socket-types.js +++ b/lib/types/socket-types.js @@ -1,377 +1,512 @@ // @flow import invariant from 'invariant'; +import t, { type TInterface, type TUnion } from 'tcomb'; import { type ActivityUpdate, type UpdateActivityResult, + updateActivityResultValidator, } from './activity-types.js'; import type { Platform } from './device-types.js'; import type { APIRequest } from './endpoints.js'; import { type RawEntryInfo, + rawEntryInfoValidator, type CalendarQuery, defaultCalendarQuery, } from './entry-types.js'; -import type { MessagesResponse, NewMessagesPayload } from './message-types.js'; -import type { - ServerServerRequest, - ClientServerRequest, - ClientResponse, - ClientClientResponse, +import { + type MessagesResponse, + messagesResponseValidator, + type NewMessagesPayload, + newMessagesPayloadValidator, +} from './message-types.js'; +import { + type ServerServerRequest, + serverServerRequestValidator, + type ClientServerRequest, + type ClientResponse, + type ClientClientResponse, } from './request-types.js'; import type { SessionState, SessionIdentification } from './session-types.js'; -import type { RawThreadInfo } from './thread-types.js'; -import type { - ClientUpdatesResult, - ClientUpdatesResultWithUserInfos, - ServerUpdatesResult, - ServerUpdatesResultWithUserInfos, +import { type RawThreadInfo, rawThreadInfoValidator } from './thread-types.js'; +import { + type ClientUpdatesResult, + type ClientUpdatesResultWithUserInfos, + type ServerUpdatesResult, + serverUpdatesResultValidator, + type ServerUpdatesResultWithUserInfos, + serverUpdatesResultWithUserInfosValidator, } from './update-types.js'; -import type { - UserInfo, - CurrentUserInfo, - OldCurrentUserInfo, - LoggedOutUserInfo, +import { + type UserInfo, + userInfoValidator, + type CurrentUserInfo, + currentUserInfoValidator, + type OldCurrentUserInfo, + oldCurrentUserInfoValidator, + type LoggedOutUserInfo, + loggedOutUserInfoValidator, } from './user-types.js'; +import { tShape, tNumber, tID } from '../utils/validation-utils.js'; // The types of messages that the client sends across the socket export const clientSocketMessageTypes = Object.freeze({ INITIAL: 0, RESPONSES: 1, //ACTIVITY_UPDATES: 2, (DEPRECATED) PING: 3, ACK_UPDATES: 4, API_REQUEST: 5, }); export type ClientSocketMessageType = $Values; export function assertClientSocketMessageType( ourClientSocketMessageType: number, ): ClientSocketMessageType { invariant( ourClientSocketMessageType === 0 || ourClientSocketMessageType === 1 || ourClientSocketMessageType === 3 || ourClientSocketMessageType === 4 || ourClientSocketMessageType === 5, 'number is not ClientSocketMessageType enum', ); return ourClientSocketMessageType; } export type InitialClientSocketMessage = { +type: 0, +id: number, +payload: { +sessionIdentification: SessionIdentification, +sessionState: SessionState, +clientResponses: $ReadOnlyArray, }, }; export type ResponsesClientSocketMessage = { +type: 1, +id: number, +payload: { +clientResponses: $ReadOnlyArray, }, }; export type PingClientSocketMessage = { +type: 3, +id: number, }; export type AckUpdatesClientSocketMessage = { +type: 4, +id: number, +payload: { +currentAsOf: number, }, }; export type APIRequestClientSocketMessage = { +type: 5, +id: number, +payload: APIRequest, }; export type ClientSocketMessage = | InitialClientSocketMessage | ResponsesClientSocketMessage | PingClientSocketMessage | AckUpdatesClientSocketMessage | APIRequestClientSocketMessage; export type ClientInitialClientSocketMessage = { +type: 0, +id: number, +payload: { +sessionIdentification: SessionIdentification, +sessionState: SessionState, +clientResponses: $ReadOnlyArray, }, }; export type ClientResponsesClientSocketMessage = { +type: 1, +id: number, +payload: { +clientResponses: $ReadOnlyArray, }, }; export type ClientClientSocketMessage = | ClientInitialClientSocketMessage | ClientResponsesClientSocketMessage | PingClientSocketMessage | AckUpdatesClientSocketMessage | APIRequestClientSocketMessage; export type ClientSocketMessageWithoutID = $Diff< ClientClientSocketMessage, { id: number }, >; // The types of messages that the server sends across the socket export const serverSocketMessageTypes = Object.freeze({ STATE_SYNC: 0, REQUESTS: 1, ERROR: 2, AUTH_ERROR: 3, ACTIVITY_UPDATE_RESPONSE: 4, PONG: 5, UPDATES: 6, MESSAGES: 7, API_RESPONSE: 8, }); export type ServerSocketMessageType = $Values; export function assertServerSocketMessageType( ourServerSocketMessageType: number, ): ServerSocketMessageType { invariant( ourServerSocketMessageType === 0 || ourServerSocketMessageType === 1 || ourServerSocketMessageType === 2 || ourServerSocketMessageType === 3 || ourServerSocketMessageType === 4 || ourServerSocketMessageType === 5 || ourServerSocketMessageType === 6 || ourServerSocketMessageType === 7 || ourServerSocketMessageType === 8, 'number is not ServerSocketMessageType enum', ); return ourServerSocketMessageType; } export const stateSyncPayloadTypes = Object.freeze({ FULL: 0, INCREMENTAL: 1, }); export const fullStateSyncActionType = 'FULL_STATE_SYNC'; export type BaseFullStateSync = { +messagesResult: MessagesResponse, +threadInfos: { +[id: string]: RawThreadInfo }, +rawEntryInfos: $ReadOnlyArray, +userInfos: $ReadOnlyArray, +updatesCurrentAsOf: number, }; +const baseFullStateSyncValidator = tShape({ + messagesResult: messagesResponseValidator, + threadInfos: t.dict(tID, rawThreadInfoValidator), + rawEntryInfos: t.list(rawEntryInfoValidator), + userInfos: t.list(userInfoValidator), + updatesCurrentAsOf: t.Number, +}); export type ClientFullStateSync = { ...BaseFullStateSync, +currentUserInfo: CurrentUserInfo, }; export type StateSyncFullActionPayload = { ...ClientFullStateSync, +calendarQuery: CalendarQuery, }; export type ClientStateSyncFullSocketPayload = { ...ClientFullStateSync, +type: 0, // Included iff client is using sessionIdentifierTypes.BODY_SESSION_ID +sessionID?: string, }; export type ServerFullStateSync = { ...BaseFullStateSync, +currentUserInfo: CurrentUserInfo | OldCurrentUserInfo, }; +const serverFullStateSyncValidator = tShape({ + ...baseFullStateSyncValidator.meta.props, + currentUserInfo: t.union([ + currentUserInfoValidator, + oldCurrentUserInfoValidator, + ]), +}); + export type ServerStateSyncFullSocketPayload = { ...ServerFullStateSync, +type: 0, // Included iff client is using sessionIdentifierTypes.BODY_SESSION_ID +sessionID?: string, }; +const serverStateSyncFullSocketPayloadValidator = + tShape({ + ...serverFullStateSyncValidator.meta.props, + type: tNumber(stateSyncPayloadTypes.FULL), + sessionID: t.maybe(t.String), + }); export const incrementalStateSyncActionType = 'INCREMENTAL_STATE_SYNC'; export type BaseIncrementalStateSync = { +messagesResult: MessagesResponse, +deltaEntryInfos: $ReadOnlyArray, +deletedEntryIDs: $ReadOnlyArray, +userInfos: $ReadOnlyArray, }; +const baseIncrementalStateSyncValidator = tShape({ + messagesResult: messagesResponseValidator, + deltaEntryInfos: t.list(rawEntryInfoValidator), + deletedEntryIDs: t.list(tID), + userInfos: t.list(userInfoValidator), +}); export type ClientIncrementalStateSync = { ...BaseIncrementalStateSync, +updatesResult: ClientUpdatesResult, }; export type StateSyncIncrementalActionPayload = { ...ClientIncrementalStateSync, +calendarQuery: CalendarQuery, }; type ClientStateSyncIncrementalSocketPayload = { +type: 1, ...ClientIncrementalStateSync, }; export type ServerIncrementalStateSync = { ...BaseIncrementalStateSync, +updatesResult: ServerUpdatesResult, }; +const serverIncrementalStateSyncValidator = tShape({ + ...baseIncrementalStateSyncValidator.meta.props, + updatesResult: serverUpdatesResultValidator, +}); + type ServerStateSyncIncrementalSocketPayload = { +type: 1, ...ServerIncrementalStateSync, }; +const serverStateSyncIncrementalSocketPayloadValidator = + tShape({ + type: tNumber(stateSyncPayloadTypes.INCREMENTAL), + ...serverIncrementalStateSyncValidator.meta.props, + }); export type ClientStateSyncSocketPayload = | ClientStateSyncFullSocketPayload | ClientStateSyncIncrementalSocketPayload; export type ServerStateSyncSocketPayload = | ServerStateSyncFullSocketPayload | ServerStateSyncIncrementalSocketPayload; +const serverStateSyncSocketPayloadValidator = t.union([ + serverStateSyncFullSocketPayloadValidator, + serverStateSyncIncrementalSocketPayloadValidator, +]); export type ServerStateSyncServerSocketMessage = { +type: 0, +responseTo: number, +payload: ServerStateSyncSocketPayload, }; +export const serverStateSyncServerSocketMessageValidator: TInterface = + tShape({ + type: tNumber(serverSocketMessageTypes.STATE_SYNC), + responseTo: t.Number, + payload: serverStateSyncSocketPayloadValidator, + }); + export type ServerRequestsServerSocketMessage = { +type: 1, +responseTo?: number, +payload: { +serverRequests: $ReadOnlyArray, }, }; +export const serverRequestsServerSocketMessageValidator: TInterface = + tShape({ + type: tNumber(serverSocketMessageTypes.REQUESTS), + responseTo: t.maybe(t.Number), + payload: tShape({ + serverRequests: t.list(serverServerRequestValidator), + }), + }); + export type ErrorServerSocketMessage = { type: 2, responseTo?: number, message: string, payload?: Object, }; +export const errorServerSocketMessageValidator: TInterface = + tShape({ + type: tNumber(serverSocketMessageTypes.ERROR), + responseTo: t.maybe(t.Number), + message: t.String, + payload: t.maybe(t.Object), + }); + export type AuthErrorServerSocketMessage = { type: 3, responseTo: number, message: string, // If unspecified, it is because the client is using cookieSources.HEADER, // which means the server can't update the cookie from a socket message. sessionChange?: { cookie: string, currentUserInfo: LoggedOutUserInfo, }, }; +export const authErrorServerSocketMessageValidator: TInterface = + tShape({ + type: tNumber(serverSocketMessageTypes.AUTH_ERROR), + responseTo: t.Number, + message: t.String, + sessionChange: t.maybe( + tShape({ cookie: t.String, currentUserInfo: loggedOutUserInfoValidator }), + ), + }); + export type ActivityUpdateResponseServerSocketMessage = { +type: 4, +responseTo: number, +payload: UpdateActivityResult, }; +export const activityUpdateResponseServerSocketMessageValidator: TInterface = + tShape({ + type: tNumber(serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE), + responseTo: t.Number, + payload: updateActivityResultValidator, + }); + export type PongServerSocketMessage = { +type: 5, +responseTo: number, }; +export const pongServerSocketMessageValidator: TInterface = + tShape({ + type: tNumber(serverSocketMessageTypes.PONG), + responseTo: t.Number, + }); + export type ServerUpdatesServerSocketMessage = { +type: 6, +payload: ServerUpdatesResultWithUserInfos, }; +export const serverUpdatesServerSocketMessageValidator: TInterface = + tShape({ + type: tNumber(serverSocketMessageTypes.UPDATES), + payload: serverUpdatesResultWithUserInfosValidator, + }); + export type MessagesServerSocketMessage = { +type: 7, +payload: NewMessagesPayload, }; +export const messagesServerSocketMessageValidator: TInterface = + tShape({ + type: tNumber(serverSocketMessageTypes.MESSAGES), + payload: newMessagesPayloadValidator, + }); + export type APIResponseServerSocketMessage = { +type: 8, +responseTo: number, - +payload: Object, -}; + +payload?: Object, +}; +export const apiResponseServerSocketMessageValidator: TInterface = + tShape({ + type: tNumber(serverSocketMessageTypes.API_RESPONSE), + responseTo: t.Number, + payload: t.maybe(t.Object), + }); + export type ServerServerSocketMessage = | ServerStateSyncServerSocketMessage | ServerRequestsServerSocketMessage | ErrorServerSocketMessage | AuthErrorServerSocketMessage | ActivityUpdateResponseServerSocketMessage | PongServerSocketMessage | ServerUpdatesServerSocketMessage | MessagesServerSocketMessage | APIResponseServerSocketMessage; +export const serverServerSocketMessageValidator: TUnion = + t.union([ + serverStateSyncServerSocketMessageValidator, + serverRequestsServerSocketMessageValidator, + errorServerSocketMessageValidator, + authErrorServerSocketMessageValidator, + activityUpdateResponseServerSocketMessageValidator, + pongServerSocketMessageValidator, + serverUpdatesServerSocketMessageValidator, + messagesServerSocketMessageValidator, + apiResponseServerSocketMessageValidator, + ]); export type ClientRequestsServerSocketMessage = { +type: 1, +responseTo?: number, +payload: { +serverRequests: $ReadOnlyArray, }, }; export type ClientStateSyncServerSocketMessage = { +type: 0, +responseTo: number, +payload: ClientStateSyncSocketPayload, }; export type ClientUpdatesServerSocketMessage = { +type: 6, +payload: ClientUpdatesResultWithUserInfos, }; export type ClientServerSocketMessage = | ClientStateSyncServerSocketMessage | ClientRequestsServerSocketMessage | ErrorServerSocketMessage | AuthErrorServerSocketMessage | ActivityUpdateResponseServerSocketMessage | PongServerSocketMessage | ClientUpdatesServerSocketMessage | MessagesServerSocketMessage | APIResponseServerSocketMessage; export type SocketListener = (message: ClientServerSocketMessage) => void; export type ConnectionStatus = | 'connecting' | 'connected' | 'reconnecting' | 'disconnecting' | 'forcedDisconnecting' | 'disconnected'; export type ConnectionInfo = { +status: ConnectionStatus, +queuedActivityUpdates: $ReadOnlyArray, +actualizedCalendarQuery: CalendarQuery, +lateResponses: $ReadOnlyArray, +showDisconnectedBar: boolean, }; export const defaultConnectionInfo = ( platform: Platform, timeZone?: ?string, ): ConnectionInfo => ({ status: 'connecting', queuedActivityUpdates: [], actualizedCalendarQuery: defaultCalendarQuery(platform, timeZone), lateResponses: [], showDisconnectedBar: false, }: ConnectionInfo); export const updateConnectionStatusActionType = 'UPDATE_CONNECTION_STATUS'; export type UpdateConnectionStatusPayload = { +status: ConnectionStatus, }; export const setLateResponseActionType = 'SET_LATE_RESPONSE'; export type SetLateResponsePayload = { +messageID: number, +isLate: boolean, }; export const updateDisconnectedBarActionType = 'UPDATE_DISCONNECTED_BAR'; export type UpdateDisconnectedBarPayload = { +visible: boolean }; export type OneTimeKeyGenerator = (inc: number) => string; export type GRPCStream = { readyState: number, onopen: (ev: any) => mixed, onmessage: (ev: MessageEvent) => mixed, onclose: (ev: CloseEvent) => mixed, close(code?: number, reason?: string): void, send(data: string | Blob | ArrayBuffer | $ArrayBufferView): void, }; export type CommTransportLayer = GRPCStream | WebSocket; diff --git a/lib/types/update-types.js b/lib/types/update-types.js index 7b6ac5d75..78353ada8 100644 --- a/lib/types/update-types.js +++ b/lib/types/update-types.js @@ -1,421 +1,432 @@ // @flow import invariant from 'invariant'; import t, { type TUnion, type TInterface } from 'tcomb'; import { type RawEntryInfo, rawEntryInfoValidator } from './entry-types.js'; import { type RawMessageInfo, rawMessageInfoValidator, type MessageTruncationStatus, messageTruncationStatusValidator, } from './message-types.js'; import { type RawThreadInfo, rawThreadInfoValidator } from './thread-types.js'; import { type UserInfo, userInfoValidator, type UserInfos, userInfosValidator, type LoggedInUserInfo, loggedInUserInfoValidator, type OldLoggedInUserInfo, oldLoggedInUserInfoValidator, } from './user-types.js'; import { tNumber, tShape, tID } from '../utils/validation-utils.js'; export const updateTypes = Object.freeze({ DELETE_ACCOUNT: 0, UPDATE_THREAD: 1, UPDATE_THREAD_READ_STATUS: 2, DELETE_THREAD: 3, JOIN_THREAD: 4, BAD_DEVICE_TOKEN: 5, UPDATE_ENTRY: 6, UPDATE_CURRENT_USER: 7, UPDATE_USER: 8, }); export type UpdateType = $Values; export function assertUpdateType(ourUpdateType: number): UpdateType { invariant( ourUpdateType === 0 || ourUpdateType === 1 || ourUpdateType === 2 || ourUpdateType === 3 || ourUpdateType === 4 || ourUpdateType === 5 || ourUpdateType === 6 || ourUpdateType === 7 || ourUpdateType === 8, 'number is not UpdateType enum', ); return ourUpdateType; } type AccountDeletionData = { +deletedUserID: string, }; type ThreadData = { +threadID: string, }; type ThreadReadStatusData = { +threadID: string, +unread: boolean, }; type ThreadDeletionData = { +threadID: string, }; type ThreadJoinData = { +threadID: string, }; type BadDeviceTokenData = { +deviceToken: string, }; type EntryData = { +entryID: string, }; type CurrentUserData = {}; type UserData = { // ID of the UserInfo being updated +updatedUserID: string, }; type SharedUpdateData = { +userID: string, +time: number, }; type AccountDeletionUpdateData = { ...SharedUpdateData, ...AccountDeletionData, +type: 0, }; type ThreadUpdateData = { ...SharedUpdateData, ...ThreadData, +type: 1, +targetSession?: string, }; type ThreadReadStatusUpdateData = { ...SharedUpdateData, ...ThreadReadStatusData, +type: 2, }; type ThreadDeletionUpdateData = { ...SharedUpdateData, ...ThreadDeletionData, +type: 3, }; type ThreadJoinUpdateData = { ...SharedUpdateData, ...ThreadJoinData, +type: 4, +targetSession?: string, }; type BadDeviceTokenUpdateData = { ...SharedUpdateData, ...BadDeviceTokenData, +type: 5, +targetCookie: string, }; type EntryUpdateData = { ...SharedUpdateData, ...EntryData, +type: 6, +targetSession: string, }; type CurrentUserUpdateData = { ...SharedUpdateData, ...CurrentUserData, +type: 7, }; type UserUpdateData = { ...SharedUpdateData, ...UserData, +type: 8, +targetSession?: string, }; export type UpdateData = | AccountDeletionUpdateData | ThreadUpdateData | ThreadReadStatusUpdateData | ThreadDeletionUpdateData | ThreadJoinUpdateData | BadDeviceTokenUpdateData | EntryUpdateData | CurrentUserUpdateData | UserUpdateData; type SharedRawUpdateInfo = { +id: string, +time: number, }; type AccountDeletionRawUpdateInfo = { ...SharedRawUpdateInfo, ...AccountDeletionData, +type: 0, }; type ThreadRawUpdateInfo = { ...SharedRawUpdateInfo, ...ThreadData, +type: 1, }; type ThreadReadStatusRawUpdateInfo = { ...SharedRawUpdateInfo, ...ThreadReadStatusData, +type: 2, }; type ThreadDeletionRawUpdateInfo = { ...SharedRawUpdateInfo, ...ThreadDeletionData, +type: 3, }; type ThreadJoinRawUpdateInfo = { ...SharedRawUpdateInfo, ...ThreadJoinData, +type: 4, }; type BadDeviceTokenRawUpdateInfo = { ...SharedRawUpdateInfo, ...BadDeviceTokenData, +type: 5, }; type EntryRawUpdateInfo = { ...SharedRawUpdateInfo, ...EntryData, +type: 6, }; type CurrentUserRawUpdateInfo = { ...SharedRawUpdateInfo, ...CurrentUserData, +type: 7, }; type UserRawUpdateInfo = { ...SharedRawUpdateInfo, ...UserData, +type: 8, }; export type RawUpdateInfo = | AccountDeletionRawUpdateInfo | ThreadRawUpdateInfo | ThreadReadStatusRawUpdateInfo | ThreadDeletionRawUpdateInfo | ThreadJoinRawUpdateInfo | BadDeviceTokenRawUpdateInfo | EntryRawUpdateInfo | CurrentUserRawUpdateInfo | UserRawUpdateInfo; type AccountDeletionUpdateInfo = { +type: 0, +id: string, +time: number, +deletedUserID: string, }; export const accountDeletionUpdateInfoValidator: TInterface = tShape({ type: tNumber(updateTypes.DELETE_ACCOUNT), id: t.String, time: t.Number, deletedUserID: t.String, }); type ThreadUpdateInfo = { +type: 1, +id: string, +time: number, +threadInfo: RawThreadInfo, }; export const threadUpdateInfoValidator: TInterface = tShape({ type: tNumber(updateTypes.UPDATE_THREAD), id: t.String, time: t.Number, threadInfo: rawThreadInfoValidator, }); type ThreadReadStatusUpdateInfo = { +type: 2, +id: string, +time: number, +threadID: string, +unread: boolean, }; export const threadReadStatusUpdateInfoValidator: TInterface = tShape({ type: tNumber(updateTypes.UPDATE_THREAD_READ_STATUS), id: t.String, time: t.Number, threadID: tID, unread: t.Boolean, }); type ThreadDeletionUpdateInfo = { +type: 3, +id: string, +time: number, +threadID: string, }; export const threadDeletionUpdateInfoValidator: TInterface = tShape({ type: tNumber(updateTypes.DELETE_THREAD), id: t.String, time: t.Number, threadID: tID, }); type ThreadJoinUpdateInfo = { +type: 4, +id: string, +time: number, +threadInfo: RawThreadInfo, +rawMessageInfos: $ReadOnlyArray, +truncationStatus: MessageTruncationStatus, +rawEntryInfos: $ReadOnlyArray, }; export const threadJoinUpdateInfoValidator: TInterface = tShape({ type: tNumber(updateTypes.JOIN_THREAD), id: t.String, time: t.Number, threadInfo: rawThreadInfoValidator, rawMessageInfos: t.list(rawMessageInfoValidator), truncationStatus: messageTruncationStatusValidator, rawEntryInfos: t.list(rawEntryInfoValidator), }); type BadDeviceTokenUpdateInfo = { +type: 5, +id: string, +time: number, +deviceToken: string, }; export const badDeviceTokenUpdateInfoValidator: TInterface = tShape({ type: tNumber(updateTypes.BAD_DEVICE_TOKEN), id: t.String, time: t.Number, deviceToken: t.String, }); type EntryUpdateInfo = { +type: 6, +id: string, +time: number, +entryInfo: RawEntryInfo, }; export const entryUpdateInfoValidator: TInterface = tShape({ type: tNumber(updateTypes.UPDATE_ENTRY), id: t.String, time: t.Number, entryInfo: rawEntryInfoValidator, }); type CurrentUserUpdateInfo = { +type: 7, +id: string, +time: number, +currentUserInfo: LoggedInUserInfo, }; type UserUpdateInfo = { +type: 8, +id: string, +time: number, // Updated UserInfo is already contained within the UpdatesResultWithUserInfos +updatedUserID: string, }; export const userUpdateInfoValidator: TInterface = tShape({ type: tNumber(updateTypes.UPDATE_USER), id: t.String, time: t.Number, updatedUserID: t.String, }); export type ClientUpdateInfo = | AccountDeletionUpdateInfo | ThreadUpdateInfo | ThreadReadStatusUpdateInfo | ThreadDeletionUpdateInfo | ThreadJoinUpdateInfo | BadDeviceTokenUpdateInfo | EntryUpdateInfo | CurrentUserUpdateInfo | UserUpdateInfo; type ServerCurrentUserUpdateInfo = { +type: 7, +id: string, +time: number, +currentUserInfo: LoggedInUserInfo | OldLoggedInUserInfo, }; export const serverCurrentUserUpdateInfoValidator: TInterface = tShape({ type: tNumber(updateTypes.UPDATE_CURRENT_USER), id: t.String, time: t.Number, currentUserInfo: t.union([ loggedInUserInfoValidator, oldLoggedInUserInfoValidator, ]), }); export type ServerUpdateInfo = | AccountDeletionUpdateInfo | ThreadUpdateInfo | ThreadReadStatusUpdateInfo | ThreadDeletionUpdateInfo | ThreadJoinUpdateInfo | BadDeviceTokenUpdateInfo | EntryUpdateInfo | ServerCurrentUserUpdateInfo | UserUpdateInfo; export const serverUpdateInfoValidator: TUnion = t.union([ accountDeletionUpdateInfoValidator, threadUpdateInfoValidator, threadReadStatusUpdateInfoValidator, threadDeletionUpdateInfoValidator, threadJoinUpdateInfoValidator, badDeviceTokenUpdateInfoValidator, entryUpdateInfoValidator, serverCurrentUserUpdateInfoValidator, userUpdateInfoValidator, ]); export type ServerUpdatesResult = { +currentAsOf: number, +newUpdates: $ReadOnlyArray, }; +export const serverUpdatesResultValidator: TInterface = + tShape({ + currentAsOf: t.Number, + newUpdates: t.list(serverUpdateInfoValidator), + }); + export type ServerUpdatesResultWithUserInfos = { +updatesResult: ServerUpdatesResult, +userInfos: $ReadOnlyArray, }; +export const serverUpdatesResultWithUserInfosValidator: TInterface = + tShape({ + updatesResult: serverUpdatesResultValidator, + userInfos: t.list(userInfoValidator), + }); export type ClientUpdatesResult = { +currentAsOf: number, +newUpdates: $ReadOnlyArray, }; export type ClientUpdatesResultWithUserInfos = { +updatesResult: ClientUpdatesResult, +userInfos: $ReadOnlyArray, }; export type CreateUpdatesResult = { +viewerUpdates: $ReadOnlyArray, +userInfos: UserInfos, }; export const createUpdatesResultValidator: TInterface = tShape({ viewerUpdates: t.list(serverUpdateInfoValidator), userInfos: userInfosValidator, }); export type ServerCreateUpdatesResponse = { +viewerUpdates: $ReadOnlyArray, +userInfos: $ReadOnlyArray, }; export const serverCreateUpdatesResponseValidator: TInterface = tShape({ viewerUpdates: t.list(serverUpdateInfoValidator), userInfos: t.list(userInfoValidator), }); export type ClientCreateUpdatesResponse = { +viewerUpdates: $ReadOnlyArray, +userInfos: $ReadOnlyArray, }; export const processUpdatesActionType = 'PROCESS_UPDATES'; diff --git a/lib/types/user-types.js b/lib/types/user-types.js index e23e524fd..0a37b364c 100644 --- a/lib/types/user-types.js +++ b/lib/types/user-types.js @@ -1,132 +1,141 @@ // @flow -import t, { type TInterface, type TDict } from 'tcomb'; +import t, { type TInterface, type TDict, type TUnion } 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 const globalAccountUserInfoValidator: TInterface = tShape({ id: t.String, username: t.String, avatar: t.maybe(clientAvatarValidator), }); 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 const userInfosValidator: TDict = t.dict( t.String, userInfoValidator, ); export type AccountUserInfo = { +id: string, +username: string, +relationshipStatus?: UserRelationshipStatus, +avatar?: ?ClientAvatar, }; export const accountUserInfoValidator: TInterface = tShape({ id: t.String, username: t.String, relationshipStatus: t.maybe(userRelationshipStatusValidator), avatar: t.maybe(clientAvatarValidator), }); 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 const oldCurrentUserInfoValidator: TUnion = t.union([ + oldLoggedInUserInfoValidator, + loggedOutUserInfoValidator, +]); + export type CurrentUserInfo = LoggedInUserInfo | LoggedOutUserInfo; +export const currentUserInfoValidator: TUnion = t.union([ + loggedInUserInfoValidator, + loggedOutUserInfoValidator, +]); 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, }; diff --git a/lib/types/validation.test.js b/lib/types/validation.test.js index 7f673246f..d0f00b9af 100644 --- a/lib/types/validation.test.js +++ b/lib/types/validation.test.js @@ -1,768 +1,903 @@ // @flow import _findKey from 'lodash/fp/findKey.js'; import { rawEntryInfoValidator } from './entry-types.js'; import { imageValidator, videoValidator, mediaValidator, } from './media-types.js'; import { messageTypes } from './message-types-enum.js'; +import { + activityUpdateResponseServerSocketMessageValidator, + apiResponseServerSocketMessageValidator, + authErrorServerSocketMessageValidator, + errorServerSocketMessageValidator, + messagesServerSocketMessageValidator, + pongServerSocketMessageValidator, + serverRequestsServerSocketMessageValidator, + serverSocketMessageTypes, + serverStateSyncServerSocketMessageValidator, + serverUpdatesServerSocketMessageValidator, +} from './socket-types.js'; import { threadTypes, rawThreadInfoValidator } from './thread-types.js'; import { updateTypes, accountDeletionUpdateInfoValidator, badDeviceTokenUpdateInfoValidator, entryUpdateInfoValidator, serverCurrentUserUpdateInfoValidator, threadDeletionUpdateInfoValidator, threadJoinUpdateInfoValidator, threadReadStatusUpdateInfoValidator, threadUpdateInfoValidator, userUpdateInfoValidator, } from './update-types.js'; import { messageSpecs } from '../shared/messages/message-specs.js'; describe('media validation', () => { const photo = { id: '92696', type: 'photo', uri: 'http://0.0.0.0:3000/comm/upload/92696/0fb272bd1c75d976', dimensions: { width: 340, height: 288, }, }; const video = { type: 'video', id: '92769', uri: 'http://0.0.0.0:3000/comm/upload/92769/4bcc6987b25b2f66', dimensions: { width: 480, height: 270, }, thumbnailID: '92770', thumbnailURI: 'http://0.0.0.0:3000/comm/upload/92770/d56466051dcef1db', }; it('should validate correct media', () => { expect(mediaValidator.is(photo)).toBe(true); expect(imageValidator.is(photo)).toBe(true); expect(mediaValidator.is(video)).toBe(true); expect(videoValidator.is(video)).toBe(true); }); it('should not validate incorrect media', () => { expect(imageValidator.is(video)).toBe(false); expect(videoValidator.is(photo)).toBe(false); expect(mediaValidator.is({ ...photo, type: undefined })).toBe(false); expect(mediaValidator.is({ ...video, dimensions: undefined })).toBe(false); }); }); const messages = [ { type: messageTypes.TEXT, threadID: '83859', creatorID: '83853', time: 1682077048858, text: 'text', localID: 'local1', id: '92837', }, { type: messageTypes.CREATE_THREAD, id: '83876', threadID: '83859', time: 1673561105839, creatorID: '83853', initialThreadState: { type: 6, name: null, parentThreadID: '1', color: '57697f', memberIDs: ['256', '83853'], }, }, { type: messageTypes.ADD_MEMBERS, id: '4754380', threadID: '4746046', time: 1680179819346, creatorID: '256', addedUserIDs: ['518252', '1329299', '1559042'], }, { type: messageTypes.CREATE_SUB_THREAD, threadID: '87111', creatorID: '83928', time: 1682083573756, childThreadID: '92993', id: '93000', }, { type: messageTypes.CHANGE_SETTINGS, threadID: '83859', creatorID: '83853', time: 1682082984605, field: 'color', value: 'b8753d', id: '92880', }, { type: messageTypes.REMOVE_MEMBERS, threadID: '92993', creatorID: '83928', time: 1682083613415, removedUserIDs: ['83890'], id: '93012', }, { type: messageTypes.CHANGE_ROLE, threadID: '85027', creatorID: '256', time: 1632393331694, userIDs: ['85081'], newRole: 'role', id: '85431', }, { type: messageTypes.LEAVE_THREAD, id: '93027', threadID: '92993', time: 1682083651037, creatorID: '83928', }, { type: messageTypes.JOIN_THREAD, threadID: '92993', creatorID: '83928', time: 1682083678595, id: '93035', }, { type: messageTypes.CREATE_ENTRY, threadID: '84695', creatorID: '83928', time: 1682083217395, entryID: '92917', date: '2023-04-02', text: 'text', id: '92920', }, { type: messageTypes.EDIT_ENTRY, threadID: '84695', creatorID: '83928', time: 1682083374471, entryID: '92917', date: '2023-04-02', text: 'text', id: '92950', }, { type: messageTypes.DELETE_ENTRY, threadID: '86033', creatorID: '83928', time: 1682083220296, entryID: '92904', date: '2023-04-02', text: 'text', id: '92932', }, { type: messageTypes.RESTORE_ENTRY, id: '92962', threadID: '86033', time: 1682083414244, creatorID: '83928', entryID: '92904', date: '2023-04-02', text: 'text', }, { type: messageTypes.UNSUPPORTED, threadID: '87080', creatorID: '256', time: 1640733462322, robotext: 'unsupported message', unsupportedMessageInfo: { type: 105, threadID: '97489', creatorID: '256', time: 1640773011289, id: '97672', }, id: '97730', }, { type: messageTypes.IMAGES, threadID: '92796', creatorID: '83928', time: 1682083469079, media: [ { id: '92974', uri: 'http://0.0.0.0:3000/comm/upload/92974/ff3d02ded71e2762', type: 'photo', dimensions: { width: 220, height: 220, }, }, ], localID: 'local0', id: '92976', }, { type: messageTypes.MULTIMEDIA, threadID: '89644', creatorID: '83853', time: 1682076177257, media: [ { type: 'video', id: '92769', uri: 'http://0.0.0.0:3000/comm/upload/92769/4bcc6987b25b2f66', dimensions: { width: 480, height: 270, }, thumbnailID: '92770', thumbnailURI: 'http://0.0.0.0:3000/comm/upload/92770/d56466051dcef1db', }, ], id: '92771', }, { type: messageTypes.UPDATE_RELATIONSHIP, threadID: '92796', creatorID: '83928', targetID: '83853', time: 1682083716312, operation: 'request_sent', id: '93039', }, { type: messageTypes.SIDEBAR_SOURCE, threadID: '93044', creatorID: '83928', time: 1682083756831, sourceMessage: { type: 0, id: '92816', threadID: '92796', time: 1682076737518, creatorID: '83928', text: 'text', }, id: '93049', }, { type: messageTypes.CREATE_SIDEBAR, threadID: '93044', creatorID: '83928', time: 1682083756831, sourceMessageAuthorID: '83928', initialThreadState: { name: 'text', parentThreadID: '92796', color: 'aa4b4b', memberIDs: ['83853', '83928'], }, id: '93050', }, { type: messageTypes.REACTION, threadID: '86033', localID: 'local8', creatorID: '83928', time: 1682083295820, targetMessageID: '91607', reaction: '😂', action: 'add_reaction', id: '92943', }, { type: messageTypes.EDIT_MESSAGE, threadID: '86033', creatorID: '83928', time: 1682083295820, targetMessageID: '91607', text: 'text', id: '92943', }, { type: messageTypes.TOGGLE_PIN, threadID: '86033', targetMessageID: '91607', action: 'pin', pinnedContent: 'text', creatorID: '83928', time: 1682083295820, id: '92943', }, ]; describe('message validation', () => { for (const validatorMessageTypeName in messageTypes) { const validatorMessageType = messageTypes[validatorMessageTypeName]; const validator = messageSpecs[validatorMessageType].validator; for (const message of messages) { const messageTypeName = _findKey(e => e === message.type)(messageTypes); if (validatorMessageType === message.type) { it(`${validatorMessageTypeName} should validate ${messageTypeName}`, () => { expect(validator.is(message)).toBe(true); }); } else if ( !( (validatorMessageType === messageTypes.IMAGES && message.type === messageTypes.MULTIMEDIA) || (validatorMessageType === messageTypes.MULTIMEDIA && message.type === messageTypes.IMAGES) ) ) { it(`${validatorMessageTypeName} shouldn't validate ${messageTypeName}`, () => { expect(validator.is(message)).toBe(false); }); } } } }); const thread = { id: '85171', type: threadTypes.PERSONAL, name: '', description: '', color: '6d49ab', creationTime: 1675887298557, parentThreadID: '1', members: [ { id: '256', role: null, 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: true, source: '1', }, edit_permissions: { value: true, source: '1', }, 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: false, source: null, }, edit_message: { value: false, source: null, }, manage_pins: { value: true, source: '1', }, }, isSender: false, }, { id: '83853', role: '85172', permissions: { know_of: { value: true, source: '85171', }, membership: { value: false, source: null, }, visible: { value: true, source: '85171', }, voiced: { value: true, source: '85171', }, edit_entries: { value: true, source: '85171', }, edit_thread: { value: true, source: '85171', }, edit_thread_description: { value: true, source: '85171', }, edit_thread_color: { value: true, source: '85171', }, delete_thread: { value: false, source: null, }, create_subthreads: { value: false, source: null, }, create_sidebars: { value: true, source: '85171', }, 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: true, source: '85171', }, edit_message: { value: true, source: '85171', }, manage_pins: { value: false, source: null, }, }, isSender: true, }, ], roles: { '85172': { id: '85172', name: 'Members', 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_sidebars: true, descendant_open_know_of: true, descendant_open_visible: true, child_open_join_thread: true, }, isDefault: true, }, }, currentUser: { role: '85172', permissions: { know_of: { value: true, source: '85171', }, membership: { value: false, source: null, }, visible: { value: true, source: '85171', }, voiced: { value: true, source: '85171', }, edit_entries: { value: true, source: '85171', }, edit_thread: { value: true, source: '85171', }, edit_thread_description: { value: true, source: '85171', }, edit_thread_color: { value: true, source: '85171', }, delete_thread: { value: false, source: null, }, create_subthreads: { value: false, source: null, }, create_sidebars: { value: true, source: '85171', }, 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: true, source: '85171', }, edit_message: { value: true, source: '85171', }, manage_pins: { value: false, source: null, }, }, subscription: { home: true, pushNotifs: true, }, unread: false, }, repliesCount: 0, containingThreadID: '1', community: '1', pinnedCount: 0, }; describe('thread validation', () => { it('should validate correct thread', () => { expect(rawThreadInfoValidator.is(thread)).toBe(true); }); it('should not validate incorrect thread', () => { expect( rawThreadInfoValidator.is({ ...thread, creationTime: undefined }), ).toBe(false); }); }); const entry = { id: '92860', threadID: '85068', text: 'text', year: 2023, month: 4, day: 2, creationTime: 1682082939882, creatorID: '83853', deleted: false, }; describe('entry validation', () => { it('should validate correct entry', () => { expect(rawEntryInfoValidator.is(entry)).toBe(true); }); it('should not validate incorrect entry', () => { expect(rawEntryInfoValidator.is({ ...entry, threadID: 0 })).toBe(false); }); }); -describe('server update validation', () => { - const updates = [ - { - type: updateTypes.DELETE_ACCOUNT, - id: '98424', - time: 1640870111106, - deletedUserID: '98262', - }, - { - type: updateTypes.UPDATE_THREAD, - id: '97948', - time: 1640868525494, - threadInfo: thread, - }, - { - type: updateTypes.UPDATE_THREAD_READ_STATUS, - id: '98002', - time: 1640869373326, - threadID: '83794', - unread: true, - }, - { - type: updateTypes.DELETE_THREAD, - id: '98208', - time: 1640869773339, - threadID: '97852', - }, - { - type: updateTypes.JOIN_THREAD, - id: '98126', - time: 1640869494461, - threadInfo: thread, - rawMessageInfos: messages, - truncationStatus: 'exhaustive', - rawEntryInfos: [entry], - }, - { - type: updateTypes.BAD_DEVICE_TOKEN, - id: '98208', - time: 1640869773495, - deviceToken: 'some-device-token', - }, - { - type: updateTypes.UPDATE_ENTRY, - id: '98233', - time: 1640869844908, - entryInfo: entry, - }, - { - type: updateTypes.UPDATE_CURRENT_USER, - id: '98237', - time: 1640869934058, - currentUserInfo: { - id: '256', - username: 'ashoat', - }, - }, - { - type: updateTypes.UPDATE_USER, - id: '97988', - time: 1640869211822, - updatedUserID: '86565', +const updates = [ + { + type: updateTypes.DELETE_ACCOUNT, + id: '98424', + time: 1640870111106, + deletedUserID: '98262', + }, + { + type: updateTypes.UPDATE_THREAD, + id: '97948', + time: 1640868525494, + threadInfo: thread, + }, + { + type: updateTypes.UPDATE_THREAD_READ_STATUS, + id: '98002', + time: 1640869373326, + threadID: '83794', + unread: true, + }, + { + type: updateTypes.DELETE_THREAD, + id: '98208', + time: 1640869773339, + threadID: '97852', + }, + { + type: updateTypes.JOIN_THREAD, + id: '98126', + time: 1640869494461, + threadInfo: thread, + rawMessageInfos: messages, + truncationStatus: 'exhaustive', + rawEntryInfos: [entry], + }, + { + type: updateTypes.BAD_DEVICE_TOKEN, + id: '98208', + time: 1640869773495, + deviceToken: 'some-device-token', + }, + { + type: updateTypes.UPDATE_ENTRY, + id: '98233', + time: 1640869844908, + entryInfo: entry, + }, + { + type: updateTypes.UPDATE_CURRENT_USER, + id: '98237', + time: 1640869934058, + currentUserInfo: { + id: '256', + username: 'ashoat', }, - ]; + }, + { + type: updateTypes.UPDATE_USER, + id: '97988', + time: 1640869211822, + updatedUserID: '86565', + }, +]; +describe('server update validation', () => { const validatorByUpdateType = { [updateTypes.DELETE_ACCOUNT]: accountDeletionUpdateInfoValidator, [updateTypes.UPDATE_THREAD]: threadUpdateInfoValidator, [updateTypes.UPDATE_THREAD_READ_STATUS]: threadReadStatusUpdateInfoValidator, [updateTypes.DELETE_THREAD]: threadDeletionUpdateInfoValidator, [updateTypes.JOIN_THREAD]: threadJoinUpdateInfoValidator, [updateTypes.BAD_DEVICE_TOKEN]: badDeviceTokenUpdateInfoValidator, [updateTypes.UPDATE_ENTRY]: entryUpdateInfoValidator, [updateTypes.UPDATE_CURRENT_USER]: serverCurrentUserUpdateInfoValidator, [updateTypes.UPDATE_USER]: userUpdateInfoValidator, }; for (const validatorUpdateType in validatorByUpdateType) { const validator = validatorByUpdateType[validatorUpdateType]; const validatorUpdateTypeName = _findKey( e => e === Number(validatorUpdateType), )(updateTypes); for (const update of updates) { const updateTypeName = _findKey(e => e === update.type)(updateTypes); if (Number(validatorUpdateType) === update.type) { it(`${validatorUpdateTypeName} should validate ${updateTypeName}`, () => { expect(validator.is(update)).toBe(true); }); } else { it(`${validatorUpdateTypeName} shouldn't validate ${updateTypeName}`, () => { expect(validator.is(update)).toBe(false); }); } } } }); + +describe('socket message validation', () => { + const socketMessages = [ + { + type: serverSocketMessageTypes.STATE_SYNC, + responseTo: 0, + payload: { + type: 1, + messagesResult: { + rawMessageInfos: messages, + truncationStatuses: { '86033': 'unchanged' }, + currentAsOf: 1683296863468, + }, + updatesResult: { + newUpdates: updates, + currentAsOf: 1683296863489, + }, + deltaEntryInfos: [], + deletedEntryIDs: [], + userInfos: [], + }, + }, + { + type: serverSocketMessageTypes.REQUESTS, + payload: { + serverRequests: [ + { + type: 6, + hashesToCheck: { + threadInfos: 3311950643, + entryInfos: 3191324567, + currentUserInfo: 820850779, + userInfos: 707653884, + }, + }, + ], + }, + }, + { + type: serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE, + responseTo: 194, + payload: { unfocusedToUnread: [] }, + }, + { type: serverSocketMessageTypes.PONG, responseTo: 190 }, + { + type: 6, + payload: { + updatesResult: { + currentAsOf: 1683298141720, + newUpdates: [ + { + type: 1, + id: '94428', + time: 1683298141720, + threadInfo: thread, + }, + ], + }, + userInfos: [], + }, + }, + { + type: serverSocketMessageTypes.MESSAGES, + payload: { + messagesResult: { + rawMessageInfos: messages, + truncationStatuses: { '86033': 'unchanged' }, + currentAsOf: 1683298141707, + }, + }, + }, + { + type: serverSocketMessageTypes.API_RESPONSE, + responseTo: 209, + payload: { + rawMessageInfos: messages, + truncationStatuses: { '1': 'exhaustive' }, + userInfos: {}, + }, + }, + ]; + + const validatorByMessageType = { + [serverSocketMessageTypes.STATE_SYNC]: + serverStateSyncServerSocketMessageValidator, + [serverSocketMessageTypes.REQUESTS]: + serverRequestsServerSocketMessageValidator, + [serverSocketMessageTypes.ERROR]: errorServerSocketMessageValidator, + [serverSocketMessageTypes.AUTH_ERROR]: + authErrorServerSocketMessageValidator, + [serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE]: + activityUpdateResponseServerSocketMessageValidator, + [serverSocketMessageTypes.PONG]: pongServerSocketMessageValidator, + [serverSocketMessageTypes.UPDATES]: + serverUpdatesServerSocketMessageValidator, + [serverSocketMessageTypes.MESSAGES]: messagesServerSocketMessageValidator, + [serverSocketMessageTypes.API_RESPONSE]: + apiResponseServerSocketMessageValidator, + }; + + for (const validatorMessageType in validatorByMessageType) { + const validator = validatorByMessageType[validatorMessageType]; + const validatorMessageTypeName = _findKey( + e => e === Number(validatorMessageType), + )(serverSocketMessageTypes); + + for (const message of socketMessages) { + const messageTypeName = _findKey(e => e === message.type)( + serverSocketMessageTypes, + ); + + if (Number(validatorMessageType) === message.type) { + it(`${validatorMessageTypeName} should validate ${messageTypeName}`, () => { + expect(validator.is(message)).toBe(true); + }); + } else { + it(`${validatorMessageTypeName} shouldn't validate ${messageTypeName}`, () => { + expect(validator.is(message)).toBe(false); + }); + } + } + } +});