From f166bccae04add31f3cecd58bc86fe0e545db9e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Nov=C3=A1k?= Date: Wed, 11 Sep 2024 12:50:00 +0200 Subject: [PATCH] feat: rewrite chat service --- apps/chat-service/.env.test | 26 + apps/chat-service/.prettierignore | 1 + apps/chat-service/Dockerfile | 29 + apps/chat-service/esbuild.config.js | 3 + apps/chat-service/jest.afterenv.ts | 12 + apps/chat-service/jest.config.ts | 29 + apps/chat-service/jest.setup.ts | 3 + apps/chat-service/package.json | 77 +++ .../__tests__/routes/approveRequest.test.ts | 306 +++++++++ .../src/__tests__/routes/blockInbox.test.ts | 160 +++++ .../__tests__/routes/cancelRequest.test.ts | 237 +++++++ .../src/__tests__/routes/challenge.test.ts | 86 +++ .../src/__tests__/routes/createInbox.test.ts | 171 +++++ .../src/__tests__/routes/deleteInbox.test.ts | 164 +++++ .../__tests__/routes/deleteInboxes.test.ts | 207 ++++++ .../routes/deletePulledMessages.test.ts | 153 +++++ .../src/__tests__/routes/leaveChat.test.ts | 213 ++++++ .../__tests__/routes/requestApproval.test.ts | 477 +++++++++++++ .../__tests__/routes/retrieveMessages.test.ts | 174 +++++ .../src/__tests__/routes/sendMessage.test.ts | 271 ++++++++ .../src/__tests__/routes/sendMessages.test.ts | 643 ++++++++++++++++++ .../src/__tests__/routes/updateInbox.test.ts | 70 ++ .../src/__tests__/utils/NodeTestingApp.ts | 13 + .../src/__tests__/utils/addChallengeForKey.ts | 65 ++ .../src/__tests__/utils/createMockedUser.ts | 117 ++++ .../utils/runPromiseInMockedEnvironment.ts | 81 +++ apps/chat-service/src/configs.ts | 21 + .../src/db/ChallegeDbService/domain.ts | 18 + .../src/db/ChallegeDbService/index.ts | 68 ++ .../queries/createDeleteChallenge.ts | 31 + ...createDeleteInvalidAndExpiredChallenges.ts | 28 + ...ateFindChallengeByChallengeAndPublicKey.ts | 50 ++ .../queries/createInsertChallenge.ts | 45 ++ .../createUpdateChallengeInvalidate.ts | 33 + .../src/db/InboxDbService/domain.ts | 14 + .../src/db/InboxDbService/index.ts | 54 ++ .../queries/createDeleteInboxByPublicKey.ts | 29 + .../queries/createFindInboxByPublicKey.ts | 33 + .../queries/createInsertInbox.ts | 46 ++ .../queries/createUpdateInboxMetadata.ts | 44 ++ .../src/db/MessagesDbService/domain.ts | 21 + .../src/db/MessagesDbService/index.ts | 65 ++ .../query/createDeleteAllMessagesByInboxId.ts | 29 + .../createDeletePulledMessagesByInboxId.ts | 32 + .../query/createFindMessagesByInboxId.ts | 33 + .../query/createInsertMessageForInbox.ts | 48 ++ ...ateUpdateMessageAsPulledByMessageRecord.ts | 34 + .../src/db/WhiteListDbService/domain.ts | 27 + .../src/db/WhiteListDbService/index.ts | 85 +++ .../queries/createDeleteWhitelistRecord.ts | 29 + ...eleteWhitelistRecordBySenderAndReceiver.ts | 45 ++ ...listRecordsWhereInboxIsReceiverOrSender.ts | 47 ++ ...eFindWhitelistRecordBySenderAndReceiver.ts | 47 ++ .../queries/createInsertWhitelistRecord.ts | 46 ++ .../createUpdateWhitelistRecordState.ts | 41 ++ apps/chat-service/src/db/domain.ts | 88 +++ apps/chat-service/src/db/layer.ts | 29 + .../src/db/migrations/0001_initial.ts | 83 +++ apps/chat-service/src/httpServer.ts | 85 +++ apps/chat-service/src/index.ts | 4 + apps/chat-service/src/internalServer/index.ts | 25 + apps/chat-service/src/metrics.ts | 1 + .../src/routes/challenges/createChalenge.ts | 26 + .../src/routes/challenges/createChallenges.ts | 44 ++ .../src/routes/inbox/approveReqest.ts | 112 +++ .../src/routes/inbox/blockInbox.ts | 44 ++ .../src/routes/inbox/cancelRequest.ts | 78 +++ .../src/routes/inbox/createInbox.ts | 46 ++ .../src/routes/inbox/deleteInbox.ts | 49 ++ .../src/routes/inbox/deleteInboxes.ts | 55 ++ .../src/routes/inbox/deletePulledMessages.ts | 40 ++ .../src/routes/inbox/leaveChat.ts | 87 +++ .../src/routes/inbox/requestApproval.ts | 120 ++++ .../src/routes/inbox/updateInbox.ts | 8 + .../src/routes/messages/retrieveMessages.ts | 76 +++ .../src/routes/messages/sendMessage.ts | 69 ++ .../src/routes/messages/sendMessages.ts | 126 ++++ .../src/utils/ChatChallengeService.ts | 111 +++ .../src/utils/ensureInboxExists.ts | 29 + .../findAndEnsureReceiverAndSenderInbox.ts | 51 ++ .../src/utils/findAndEnsureReceiverInbox.ts | 28 + .../src/utils/forbiddenMessageTypes.ts | 6 + .../src/utils/isSenderInReceiverWhitelist.ts | 48 ++ .../src/utils/validateChallengeInBody.ts | 28 + .../src/utils/withInboxActionRedisLock.ts | 11 + apps/chat-service/tsconfig.json | 20 + .../components/OfferInfo.tsx | 2 +- .../state/chat/atoms/blockChatActionAtom.ts | 1 - .../state/chat/atoms/sendRequestActionAtom.ts | 2 +- package.json | 1 + .../src/effect-helpers/BooleanFromString.ts | 2 +- .../src/effect-helpers/crypto.ts | 12 + .../resources-utils/src/chat/sendLeaveChat.ts | 1 + .../resources-utils/src/chat/sendMessage.ts | 1 + .../utils/generateSignedChallengesBatch.ts | 10 +- .../callWithNotificationService.ts | 2 +- packages/resources-utils/src/utils/crypto.ts | 23 +- .../rest-api/src/services/chat/contracts.ts | 221 ++++-- packages/rest-api/src/services/chat/index.ts | 131 ++-- .../src/services/chat/specification.ts | 275 ++++++++ packages/rest-api/src/services/chat/utils.ts | 22 +- .../src/services/contact/contracts.ts | 22 +- packages/server-utils/src/RedisService.ts | 18 +- packages/server-utils/src/commonConfigs.ts | 4 + packages/server-utils/src/devToolsLayer.ts | 13 +- .../src/tests/expectErrorResponse.ts | 14 + yarn.lock | 56 ++ 107 files changed, 7239 insertions(+), 152 deletions(-) create mode 100644 apps/chat-service/.env.test create mode 100644 apps/chat-service/.prettierignore create mode 100644 apps/chat-service/Dockerfile create mode 100644 apps/chat-service/esbuild.config.js create mode 100644 apps/chat-service/jest.afterenv.ts create mode 100644 apps/chat-service/jest.config.ts create mode 100644 apps/chat-service/jest.setup.ts create mode 100644 apps/chat-service/package.json create mode 100644 apps/chat-service/src/__tests__/routes/approveRequest.test.ts create mode 100644 apps/chat-service/src/__tests__/routes/blockInbox.test.ts create mode 100644 apps/chat-service/src/__tests__/routes/cancelRequest.test.ts create mode 100644 apps/chat-service/src/__tests__/routes/challenge.test.ts create mode 100644 apps/chat-service/src/__tests__/routes/createInbox.test.ts create mode 100644 apps/chat-service/src/__tests__/routes/deleteInbox.test.ts create mode 100644 apps/chat-service/src/__tests__/routes/deleteInboxes.test.ts create mode 100644 apps/chat-service/src/__tests__/routes/deletePulledMessages.test.ts create mode 100644 apps/chat-service/src/__tests__/routes/leaveChat.test.ts create mode 100644 apps/chat-service/src/__tests__/routes/requestApproval.test.ts create mode 100644 apps/chat-service/src/__tests__/routes/retrieveMessages.test.ts create mode 100644 apps/chat-service/src/__tests__/routes/sendMessage.test.ts create mode 100644 apps/chat-service/src/__tests__/routes/sendMessages.test.ts create mode 100644 apps/chat-service/src/__tests__/routes/updateInbox.test.ts create mode 100644 apps/chat-service/src/__tests__/utils/NodeTestingApp.ts create mode 100644 apps/chat-service/src/__tests__/utils/addChallengeForKey.ts create mode 100644 apps/chat-service/src/__tests__/utils/createMockedUser.ts create mode 100644 apps/chat-service/src/__tests__/utils/runPromiseInMockedEnvironment.ts create mode 100644 apps/chat-service/src/configs.ts create mode 100644 apps/chat-service/src/db/ChallegeDbService/domain.ts create mode 100644 apps/chat-service/src/db/ChallegeDbService/index.ts create mode 100644 apps/chat-service/src/db/ChallegeDbService/queries/createDeleteChallenge.ts create mode 100644 apps/chat-service/src/db/ChallegeDbService/queries/createDeleteInvalidAndExpiredChallenges.ts create mode 100644 apps/chat-service/src/db/ChallegeDbService/queries/createFindChallengeByChallengeAndPublicKey.ts create mode 100644 apps/chat-service/src/db/ChallegeDbService/queries/createInsertChallenge.ts create mode 100644 apps/chat-service/src/db/ChallegeDbService/queries/createUpdateChallengeInvalidate.ts create mode 100644 apps/chat-service/src/db/InboxDbService/domain.ts create mode 100644 apps/chat-service/src/db/InboxDbService/index.ts create mode 100644 apps/chat-service/src/db/InboxDbService/queries/createDeleteInboxByPublicKey.ts create mode 100644 apps/chat-service/src/db/InboxDbService/queries/createFindInboxByPublicKey.ts create mode 100644 apps/chat-service/src/db/InboxDbService/queries/createInsertInbox.ts create mode 100644 apps/chat-service/src/db/InboxDbService/queries/createUpdateInboxMetadata.ts create mode 100644 apps/chat-service/src/db/MessagesDbService/domain.ts create mode 100644 apps/chat-service/src/db/MessagesDbService/index.ts create mode 100644 apps/chat-service/src/db/MessagesDbService/query/createDeleteAllMessagesByInboxId.ts create mode 100644 apps/chat-service/src/db/MessagesDbService/query/createDeletePulledMessagesByInboxId.ts create mode 100644 apps/chat-service/src/db/MessagesDbService/query/createFindMessagesByInboxId.ts create mode 100644 apps/chat-service/src/db/MessagesDbService/query/createInsertMessageForInbox.ts create mode 100644 apps/chat-service/src/db/MessagesDbService/query/createUpdateMessageAsPulledByMessageRecord.ts create mode 100644 apps/chat-service/src/db/WhiteListDbService/domain.ts create mode 100644 apps/chat-service/src/db/WhiteListDbService/index.ts create mode 100644 apps/chat-service/src/db/WhiteListDbService/queries/createDeleteWhitelistRecord.ts create mode 100644 apps/chat-service/src/db/WhiteListDbService/queries/createDeleteWhitelistRecordBySenderAndReceiver.ts create mode 100644 apps/chat-service/src/db/WhiteListDbService/queries/createDeleteWhitelistRecordsWhereInboxIsReceiverOrSender.ts create mode 100644 apps/chat-service/src/db/WhiteListDbService/queries/createFindWhitelistRecordBySenderAndReceiver.ts create mode 100644 apps/chat-service/src/db/WhiteListDbService/queries/createInsertWhitelistRecord.ts create mode 100644 apps/chat-service/src/db/WhiteListDbService/queries/createUpdateWhitelistRecordState.ts create mode 100644 apps/chat-service/src/db/domain.ts create mode 100644 apps/chat-service/src/db/layer.ts create mode 100644 apps/chat-service/src/db/migrations/0001_initial.ts create mode 100644 apps/chat-service/src/httpServer.ts create mode 100644 apps/chat-service/src/index.ts create mode 100644 apps/chat-service/src/internalServer/index.ts create mode 100644 apps/chat-service/src/metrics.ts create mode 100644 apps/chat-service/src/routes/challenges/createChalenge.ts create mode 100644 apps/chat-service/src/routes/challenges/createChallenges.ts create mode 100644 apps/chat-service/src/routes/inbox/approveReqest.ts create mode 100644 apps/chat-service/src/routes/inbox/blockInbox.ts create mode 100644 apps/chat-service/src/routes/inbox/cancelRequest.ts create mode 100644 apps/chat-service/src/routes/inbox/createInbox.ts create mode 100644 apps/chat-service/src/routes/inbox/deleteInbox.ts create mode 100644 apps/chat-service/src/routes/inbox/deleteInboxes.ts create mode 100644 apps/chat-service/src/routes/inbox/deletePulledMessages.ts create mode 100644 apps/chat-service/src/routes/inbox/leaveChat.ts create mode 100644 apps/chat-service/src/routes/inbox/requestApproval.ts create mode 100644 apps/chat-service/src/routes/inbox/updateInbox.ts create mode 100644 apps/chat-service/src/routes/messages/retrieveMessages.ts create mode 100644 apps/chat-service/src/routes/messages/sendMessage.ts create mode 100644 apps/chat-service/src/routes/messages/sendMessages.ts create mode 100644 apps/chat-service/src/utils/ChatChallengeService.ts create mode 100644 apps/chat-service/src/utils/ensureInboxExists.ts create mode 100644 apps/chat-service/src/utils/findAndEnsureReceiverAndSenderInbox.ts create mode 100644 apps/chat-service/src/utils/findAndEnsureReceiverInbox.ts create mode 100644 apps/chat-service/src/utils/forbiddenMessageTypes.ts create mode 100644 apps/chat-service/src/utils/isSenderInReceiverWhitelist.ts create mode 100644 apps/chat-service/src/utils/validateChallengeInBody.ts create mode 100644 apps/chat-service/src/utils/withInboxActionRedisLock.ts create mode 100644 apps/chat-service/tsconfig.json create mode 100644 packages/rest-api/src/services/chat/specification.ts create mode 100644 packages/server-utils/src/tests/expectErrorResponse.ts diff --git a/apps/chat-service/.env.test b/apps/chat-service/.env.test new file mode 100644 index 000000000..5ffc980fc --- /dev/null +++ b/apps/chat-service/.env.test @@ -0,0 +1,26 @@ + +NODE_ENV=test +LOGIN_CODE_DUMMY_NUMBERS=+420733333331 +LOGIN_CODE_DUMMY_CODE=111111 + +SECRET_PUBLIC_KEY="LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZZd0VBWUhLb1pJemowQ0FRWUZLNEVFQUFvRFFnQUVidE9rYzJ0RmZ5VHhkVmxqSzlPQUZGYXFMMVRwU3FUaQpLbGpNenVPbjh5WjVUM3I4c04vdmUvbWdlUzg4ckNBZ29tVnJpK2pMdFU1WXQvVzlVbVozS1E9PQotLS0tLUVORCBQVUJMSUMgS0VZLS0tLS0K" +SECRET_PRIVATE_KEY="LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JR0VBZ0VBTUJBR0J5cUdTTTQ5QWdFR0JTdUJCQUFLQkcwd2F3SUJBUVFnK0Raa0VIMHN0K1VjcmhHTkhGRVgKL0RKVkFaZmtwMjlMSTZ3eERwbHVubDJoUkFOQ0FBUnUwNlJ6YTBWL0pQRjFXV01yMDRBVVZxb3ZWT2xLcE9JcQpXTXpPNDZmekpubFBldnl3Mys5NythQjVMenlzSUNDaVpXdUw2TXUxVGxpMzliMVNabmNwCi0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K" +SECRET_HMAC_KEY=test +SECRET_EAS_KEY=VexlVexl +DB_DEBUG=true + +# Local db +# DB_URL=postgresql://localhost:5432 +# DB_USER=postgres +# DB_PASSWORD=root + +TEST_DB_PREFIX=chat_service_test_ +TEST_DB_PORT=5432 +TEST_DB_USER=postgres +TEST_DB_PASSWORD=root +# TEST_KEEP_DB=false +# TEST_DB_FORCE_NAME=offer_service_test_ef4fba48e4174743864e39ac439dd7a9 + + +CHALLENGE_EXPIRATION_MINUTES=10 +REQUEST_TIMEOUT_DAYS=1 diff --git a/apps/chat-service/.prettierignore b/apps/chat-service/.prettierignore new file mode 100644 index 000000000..1521c8b76 --- /dev/null +++ b/apps/chat-service/.prettierignore @@ -0,0 +1 @@ +dist diff --git a/apps/chat-service/Dockerfile b/apps/chat-service/Dockerfile new file mode 100644 index 000000000..9414d4386 --- /dev/null +++ b/apps/chat-service/Dockerfile @@ -0,0 +1,29 @@ +FROM node:19 as builder + +WORKDIR /app + +COPY . . + +WORKDIR /app/apps/chat-service + +RUN yarn workspaces focus @vexl-next/chat-service +RUN yarn build +# RUN npx @sentry/wizard@latest -i sourcemaps + + +FROM node:19 as runner + + +ARG SERVICE_VERSION +ARG ENVIRONMENT + +ENV SERVICE_VERSION=${SERVICE_VERSION} +ENV SERVICE_NAME="Chat service - ${ENVIRONMENT}" + +COPY --from=builder /app/apps/chat-service/ /app/apps/chat-service +COPY --from=builder /app/node_modules /app/node_modules +COPY --from=builder /app/package.json /app/package.json + +WORKDIR /app/apps/chat-service + +CMD ["node", "dist/index.cjs"] diff --git a/apps/chat-service/esbuild.config.js b/apps/chat-service/esbuild.config.js new file mode 100644 index 000000000..33afccd17 --- /dev/null +++ b/apps/chat-service/esbuild.config.js @@ -0,0 +1,3 @@ +import esbuild from '@vexl-next/esbuild' + +void esbuild() diff --git a/apps/chat-service/jest.afterenv.ts b/apps/chat-service/jest.afterenv.ts new file mode 100644 index 000000000..34f9fd3fc --- /dev/null +++ b/apps/chat-service/jest.afterenv.ts @@ -0,0 +1,12 @@ +import { + disposeRuntime, + startRuntime, +} from './src/__tests__/utils/runPromiseInMockedEnvironment' + +beforeAll(async () => { + await startRuntime() +}) + +afterAll(async () => { + await disposeRuntime() +}) diff --git a/apps/chat-service/jest.config.ts b/apps/chat-service/jest.config.ts new file mode 100644 index 000000000..f32467dc8 --- /dev/null +++ b/apps/chat-service/jest.config.ts @@ -0,0 +1,29 @@ +import type {JestConfigWithTsJest} from 'ts-jest' + +const config: JestConfigWithTsJest = { + preset: 'ts-jest', + verbose: true, + + testEnvironment: 'node', + extensionsToTreatAsEsm: ['.ts'], + setupFiles: ['/jest.setup.ts'], + setupFilesAfterEnv: ['/jest.afterenv.ts'], + + testTimeout: 20000, + testMatch: ['**/*.test.ts'], // This line ensures only files with .test.ts suffix are run + transform: { + '^.+\\.(ts|js)$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + module: 'ESNext', + rootDir: '../..', + allowJs: true, + }, + }, + ], + }, +} + +export default config diff --git a/apps/chat-service/jest.setup.ts b/apps/chat-service/jest.setup.ts new file mode 100644 index 000000000..5a40da334 --- /dev/null +++ b/apps/chat-service/jest.setup.ts @@ -0,0 +1,3 @@ +import dotenv from 'dotenv' + +dotenv.config({path: '.env.test'}) diff --git a/apps/chat-service/package.json b/apps/chat-service/package.json new file mode 100644 index 000000000..452163b76 --- /dev/null +++ b/apps/chat-service/package.json @@ -0,0 +1,77 @@ +{ + "name": "@vexl-next/chat-service", + "main": "dist/index.js", + "type": "module", + "scripts": { + "test": "TEST_DB_HOST=localhost yarn jest", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules yarn jest --watch", + "build": "yarn typecheck && yarn build:esbuild", + "build:esbuild": "node esbuild.config.js", + "typecheck": "tsc --noEmit", + "clean": "rimraf ./dist", + "format": "prettier --check \"**/*.{js,mjs,cjs,jsx,ts,tsx,md,json}\"", + "lint": "eslint src --ext .js,.jsx,.ts,.tsx", + "dev": "npx tsx -r dotenv/config src/index.ts", + "start": "node dist" + }, + "eslintConfig": { + "root": true, + "extends": [ + "@vexl-next/eslint-config/index" + ] + }, + "dependencies": { + "@effect/experimental": "^0.22.6", + "@effect/opentelemetry": "^0.35.3", + "@effect/platform": "^0.62.3", + "@effect/platform-node": "^0.56.9", + "@effect/schema": "^0.70.4", + "@effect/sql": "^0.8.7", + "@effect/sql-pg": "^0.8.7", + "@opentelemetry/exporter-trace-otlp-http": "^0.52.1", + "@opentelemetry/sdk-trace-node": "^1.23.0", + "@opentelemetry/sdk-trace-web": "^1.23.0", + "@sentry/esbuild-plugin": "^2.10.2", + "@sentry/node": "^7.93.0", + "@sentry/profiling-node": "^1.3.5", + "@vexl-next/cryptography": "0.0.0", + "@vexl-next/domain": "0.0.0", + "@vexl-next/generic-utils": "0.0.0", + "@vexl-next/rest-api": "0.0.0", + "@vexl-next/server-utils": "0.0.0", + "dayjs": "^1.11.11", + "dotenv": "^16.4.5", + "effect": "^3.6.3", + "effect-http": "^0.77.4", + "effect-http-node": "^0.17.7", + "fast-check": "^3.20.0", + "firebase-admin": "^12.3.1", + "ioredis": "^5.4.1", + "node-fetch": "^3.3.2", + "pg": "^8.12.0", + "twilio": "^5.2.1" + }, + "devDependencies": { + "@types/jest": "^29.4.0", + "@types/pg": "^8", + "@types/source-map-support": "^0.5.10", + "@vexl-next/esbuild": "0.0.0", + "@vexl-next/eslint-config": "0.0.0", + "@vexl-next/prettier-config": "0.0.0", + "@vexl-next/tsconfig": "0.0.0", + "esbuild": "^0.17.16", + "eslint": "^8.50.0", + "glob": "^11.0.0", + "jest": "^29.7.0", + "nodemon": "^2.0.21", + "npm-run-all": "^4.1.5", + "prettier": "^3.3.2", + "rimraf": "^4.4.0", + "ts-jest": "^29.2.0", + "ts-node": "^10.9.1", + "tsc-alias": "^1.8.10", + "tsx": "^4.16.0", + "typescript": "^5.5.2" + }, + "prettier": "@vexl-next/prettier-config" +} diff --git a/apps/chat-service/src/__tests__/routes/approveRequest.test.ts b/apps/chat-service/src/__tests__/routes/approveRequest.test.ts new file mode 100644 index 000000000..9f8f834cc --- /dev/null +++ b/apps/chat-service/src/__tests__/routes/approveRequest.test.ts @@ -0,0 +1,306 @@ +import {HttpClientRequest} from '@effect/platform' +import {Schema} from '@effect/schema' +import {SqlClient} from '@effect/sql' +import {generatePrivateKey} from '@vexl-next/cryptography/src/KeyHolder' +import {CommonHeaders} from '@vexl-next/rest-api/src/commonHeaders' +import { + ReceiverInboxDoesNotExistError, + RequestCancelledError, + RequestNotFoundError, + SenderInboxDoesNotExistError, +} from '@vexl-next/rest-api/src/services/chat/contracts' +import {expectErrorResponse} from '@vexl-next/server-utils/src/tests/expectErrorResponse' +import {Effect} from 'effect' +import {addChallengeForKey} from '../utils/addChallengeForKey' +import {createMockedUser, type MockedUser} from '../utils/createMockedUser' +import {NodeTestingApp} from '../utils/NodeTestingApp' +import {runPromiseInMockedEnvironment} from '../utils/runPromiseInMockedEnvironment' + +let user1: MockedUser +let user2: MockedUser + +beforeEach(async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const sql = yield* _(SqlClient.SqlClient) + yield* _(sql`DELETE FROM inbox`) + yield* _(sql`DELETE FROM message`) + yield* _(sql`DELETE FROM white_list`) + + user1 = yield* _(createMockedUser('+420733333330')) + user2 = yield* _(createMockedUser('+420733333331')) + const client = yield* _(NodeTestingApp) + + yield* _( + client.requestApproval( + { + body: { + message: 'someMessage', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + }) + ) +}) + +// Expecting user1 mainKey to be approved by user2.inbox1 + +describe('Approve request', () => { + it('Request can be approved when it is pending - the message is sent to requester after', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + yield* _( + client.approveRequest( + { + body: yield* _( + user2.inbox1.addChallenge({ + message: 'acceptMessage', + publicKeyToConfirm: user1.mainKeyPair.publicKeyPemBase64, + approve: true, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + + const messages = yield* _( + client.retrieveMessages( + { + body: yield* _(user1.addChallengeForMainInbox({})), + headers: Schema.decodeSync(CommonHeaders)({ + 'user-agent': 'Vexl/2 (1.0.0) IOS', + }), + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + expect(messages.messages[0].message).toBe('acceptMessage') + + const sendingMessagesToEachOtherRequests = yield* _( + Effect.all([ + client.sendMessage( + { + body: yield* _( + user2.inbox1.addChallenge({ + message: 'someOtherMessage', + receiverPublicKey: user1.mainKeyPair.publicKeyPemBase64, + messageType: 'MESSAGE' as const, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ), + + client.sendMessage( + { + body: yield* _( + user1.addChallengeForMainInbox({ + message: 'someOtherMessage2', + receiverPublicKey: user2.inbox1.keyPair.publicKeyPemBase64, + messageType: 'MESSAGE' as const, + }) + ), + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ), + ]), + Effect.either + ) + expect(sendingMessagesToEachOtherRequests._tag).toBe('Right') + }) + ) + }) + + it('Request can be disaproved when it is pending - the message is sent to requester after', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + yield* _( + client.approveRequest( + { + body: yield* _( + user2.inbox1.addChallenge({ + message: 'disapproveMessage', + publicKeyToConfirm: user1.mainKeyPair.publicKeyPemBase64, + approve: false, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + + const messages = yield* _( + client.retrieveMessages( + { + body: yield* _(user1.addChallengeForMainInbox({})), + headers: Schema.decodeSync(CommonHeaders)({ + 'user-agent': 'Vexl/2 (1.0.0) IOS', + }), + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + expect(messages.messages[0].message).toBe('disapproveMessage') + + const sendingMessagesToEachOtherRequests = yield* _( + Effect.all([ + client.sendMessage( + { + body: yield* _( + user2.inbox1.addChallenge({ + message: 'someOtherMessage', + receiverPublicKey: user1.mainKeyPair.publicKeyPemBase64, + messageType: 'MESSAGE' as const, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ), + + client.sendMessage( + { + body: yield* _( + user1.addChallengeForMainInbox({ + message: 'someOtherMessage2', + receiverPublicKey: user2.inbox1.keyPair.publicKeyPemBase64, + messageType: 'MESSAGE' as const, + }) + ), + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ), + ]), + Effect.either + ) + expect(sendingMessagesToEachOtherRequests._tag).toBe('Left') + }) + ) + }) + + describe('Request can not be approved when it is', () => { + it('canceled', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + yield* _( + client.cancelRequestApproval( + { + body: { + message: 'someMessage2', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + const errorResponse = yield* _( + client.approveRequest( + { + body: yield* _( + user2.inbox1.addChallenge({ + message: 'acceptMessage', + publicKeyToConfirm: user1.mainKeyPair.publicKeyPemBase64, + approve: true, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ), + Effect.either + ) + expectErrorResponse(RequestCancelledError)(errorResponse) + }) + ) + }) + + it('not found', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const errorResponse = yield* _( + client.approveRequest( + { + body: yield* _( + user2.inbox1.addChallenge({ + message: 'acceptMessage', + publicKeyToConfirm: user1.inbox1.keyPair.publicKeyPemBase64, + approve: true, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ), + Effect.either + ) + expectErrorResponse(RequestNotFoundError)(errorResponse) + }) + ) + }) + + it('Sender inbox is not found', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const errorResponse = yield* _( + client.approveRequest( + { + body: yield* _( + user2.inbox1.addChallenge({ + message: 'acceptMessage', + publicKeyToConfirm: generatePrivateKey().publicKeyPemBase64, + approve: true, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ), + Effect.either + ) + expectErrorResponse(SenderInboxDoesNotExistError)(errorResponse) + }) + ) + }) + + it('receiver inbox is not found', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const errorResponse = yield* _( + client.approveRequest( + { + body: yield* _( + addChallengeForKey( + generatePrivateKey(), + user1.authHeaders + )({ + message: 'acceptMessage', + publicKeyToConfirm: user2.inbox1.keyPair.publicKeyPemBase64, + approve: true, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ), + Effect.either + ) + expectErrorResponse(ReceiverInboxDoesNotExistError)(errorResponse) + }) + ) + }) + }) +}) diff --git a/apps/chat-service/src/__tests__/routes/blockInbox.test.ts b/apps/chat-service/src/__tests__/routes/blockInbox.test.ts new file mode 100644 index 000000000..fb9ff7e39 --- /dev/null +++ b/apps/chat-service/src/__tests__/routes/blockInbox.test.ts @@ -0,0 +1,160 @@ +import {HttpClientRequest} from '@effect/platform' +import {SqlClient} from '@effect/sql' +import {generatePrivateKey} from '@vexl-next/cryptography/src/KeyHolder' +import { + ReceiverInboxDoesNotExistError, + SenderInboxDoesNotExistError, + type SendMessageRequest, +} from '@vexl-next/rest-api/src/services/chat/contracts' +import {NotPermittedToSendMessageToTargetInboxError} from '@vexl-next/rest-api/src/services/contact/contracts' +import {expectErrorResponse} from '@vexl-next/server-utils/src/tests/expectErrorResponse' +import {Effect} from 'effect' +import {addChallengeForKey} from '../utils/addChallengeForKey' +import {createMockedUser, type MockedUser} from '../utils/createMockedUser' +import {NodeTestingApp} from '../utils/NodeTestingApp' +import {runPromiseInMockedEnvironment} from '../utils/runPromiseInMockedEnvironment' + +let user1: MockedUser +let user2: MockedUser + +beforeEach(async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const sql = yield* _(SqlClient.SqlClient) + yield* _(sql`DELETE FROM inbox`) + yield* _(sql`DELETE FROM message`) + yield* _(sql`DELETE FROM white_list`) + + user1 = yield* _(createMockedUser('+420733333330')) + user2 = yield* _(createMockedUser('+420733333331')) + const client = yield* _(NodeTestingApp) + + yield* _( + client.requestApproval( + { + body: { + message: 'someMessage', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + yield* _( + client.approveRequest( + { + body: yield* _( + user2.inbox1.addChallenge({ + message: 'someMessage2', + publicKeyToConfirm: user1.mainKeyPair.publicKeyPemBase64, + approve: true, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + + yield* _(sql`DELETE FROM message`) + }) + ) +}) + +describe('Block inbox', () => { + it('Block inbox of user1 as user2, messages should not be possible to sent after that', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + yield* _( + client.blockInboxEndpoint( + { + body: yield* _( + user2.inbox1.addChallenge({ + publicKeyToBlock: user1.mainKeyPair.publicKeyPemBase64, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + + const shouldBeRejectedResponse = yield* _( + client.sendMessage( + { + body: (yield* _( + user1.addChallengeForMainInbox({ + message: 'someMessage', + messageType: 'MESSAGE' as const, + receiverPublicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }) + )) satisfies SendMessageRequest, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ), + Effect.either + ) + + expectErrorResponse(NotPermittedToSendMessageToTargetInboxError)( + shouldBeRejectedResponse + ) + }) + ) + }) + + it('throws an error when receiver inbox does not exist', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const shouldBeRejectedResponse = yield* _( + client.blockInboxEndpoint( + { + body: yield* _( + addChallengeForKey( + generatePrivateKey(), + user2.authHeaders + )({ + publicKeyToBlock: user1.mainKeyPair.publicKeyPemBase64, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ), + Effect.either + ) + + expectErrorResponse(ReceiverInboxDoesNotExistError)( + shouldBeRejectedResponse + ) + }) + ) + }) + + it('throws an error when sender inbox does not exist', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const shouldBeRejectedResponse = yield* _( + client.blockInboxEndpoint( + { + body: yield* _( + user2.inbox1.addChallenge({ + publicKeyToBlock: generatePrivateKey().publicKeyPemBase64, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ), + Effect.either + ) + + expectErrorResponse(SenderInboxDoesNotExistError)( + shouldBeRejectedResponse + ) + }) + ) + }) +}) diff --git a/apps/chat-service/src/__tests__/routes/cancelRequest.test.ts b/apps/chat-service/src/__tests__/routes/cancelRequest.test.ts new file mode 100644 index 000000000..e14e17bc9 --- /dev/null +++ b/apps/chat-service/src/__tests__/routes/cancelRequest.test.ts @@ -0,0 +1,237 @@ +import {HttpClientRequest} from '@effect/platform' +import {Schema} from '@effect/schema' +import {SqlClient} from '@effect/sql' +import {generatePrivateKey} from '@vexl-next/cryptography/src/KeyHolder' +import {E164PhoneNumberE} from '@vexl-next/domain/src/general/E164PhoneNumber.brand' +import {CommonHeaders} from '@vexl-next/rest-api/src/commonHeaders' +import { + ReceiverInboxDoesNotExistError, + RequestNotPendingError, + SenderInboxDoesNotExistError, +} from '@vexl-next/rest-api/src/services/chat/contracts' +import {createDummyAuthHeadersForUser} from '@vexl-next/server-utils/src/tests/createDummyAuthHeaders' +import {expectErrorResponse} from '@vexl-next/server-utils/src/tests/expectErrorResponse' +import {Effect} from 'effect' +import {createMockedUser, type MockedUser} from '../utils/createMockedUser' +import {NodeTestingApp} from '../utils/NodeTestingApp' +import {runPromiseInMockedEnvironment} from '../utils/runPromiseInMockedEnvironment' + +let user1: MockedUser +let user2: MockedUser + +beforeEach(async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const sql = yield* _(SqlClient.SqlClient) + yield* _(sql`DELETE FROM inbox`) + yield* _(sql`DELETE FROM message`) + yield* _(sql`DELETE FROM white_list`) + + user1 = yield* _(createMockedUser('+420733333330')) + user2 = yield* _(createMockedUser('+420733333331')) + const client = yield* _(NodeTestingApp) + + yield* _( + client.requestApproval( + { + body: { + message: 'someMessage', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + yield* _(sql`DELETE FROM message`) + }) + ) +}) + +describe('Cancel request', () => { + it('Cancel request and send request message to the one who received the request', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + yield* _( + client.cancelRequestApproval( + { + body: { + message: 'cancelMessage', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + const messages = yield* _( + client.retrieveMessages( + { + body: yield* _(user2.inbox1.addChallenge({})), + headers: Schema.decodeSync(CommonHeaders)({ + 'user-agent': 'Vexl/2 (1.0.0) IOS', + }), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + + expect(messages.messages[0]?.message).toBe('cancelMessage') + }) + ) + }) + + describe('fail when', () => { + it('Request not fonud', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const failedReqResponse = yield* _( + client.cancelRequestApproval( + { + body: { + message: 'cancelMessage', + publicKey: user2.inbox2.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ), + Effect.either + ) + + expectErrorResponse(RequestNotPendingError)(failedReqResponse) + }) + ) + }) + + it('Request is approved', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + yield* _( + client.approveRequest( + { + body: yield* _( + user2.inbox1.addChallenge({ + publicKeyToConfirm: user1.mainKeyPair.publicKeyPemBase64, + approve: true, + message: 'approve', + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + + const failedReqResponse = yield* _( + client.cancelRequestApproval( + { + body: { + message: 'cancelMessage', + publicKey: user2.inbox2.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ), + Effect.either + ) + + expectErrorResponse(RequestNotPendingError)(failedReqResponse) + }) + ) + }) + + it('Request is disaproved', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + yield* _( + client.approveRequest( + { + body: yield* _( + user2.inbox1.addChallenge({ + publicKeyToConfirm: user1.mainKeyPair.publicKeyPemBase64, + approve: false, + message: 'approve', + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + + const failedReqResponse = yield* _( + client.cancelRequestApproval( + { + body: { + message: 'cancelMessage', + publicKey: user2.inbox2.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ), + Effect.either + ) + + expectErrorResponse(RequestNotPendingError)(failedReqResponse) + }) + ) + }) + + it('sender inbox does not exist', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const dummyAuthHeaders = yield* _( + createDummyAuthHeadersForUser({ + publicKey: generatePrivateKey().publicKeyPemBase64, + phoneNumber: Schema.decodeSync(E164PhoneNumberE)('+420733333337'), + }) + ) + + const failedReqResponse = yield* _( + client.cancelRequestApproval( + { + body: { + message: 'cancelMessage', + publicKey: user2.inbox2.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(dummyAuthHeaders) + ), + Effect.either + ) + expectErrorResponse(SenderInboxDoesNotExistError)(failedReqResponse) + }) + ) + }) + + it('receiver inbox does not exist', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const failedReqResponse = yield* _( + client.cancelRequestApproval( + { + body: { + message: 'cancelMessage', + publicKey: generatePrivateKey().publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ), + Effect.either + ) + expectErrorResponse(ReceiverInboxDoesNotExistError)(failedReqResponse) + }) + ) + }) + }) +}) diff --git a/apps/chat-service/src/__tests__/routes/challenge.test.ts b/apps/chat-service/src/__tests__/routes/challenge.test.ts new file mode 100644 index 000000000..ad868f941 --- /dev/null +++ b/apps/chat-service/src/__tests__/routes/challenge.test.ts @@ -0,0 +1,86 @@ +import {HttpClientRequest} from '@effect/platform' +import {generatePrivateKey} from '@vexl-next/cryptography/src/KeyHolder' +import {Effect} from 'effect' +import {createMockedUser, type MockedUser} from '../utils/createMockedUser' +import {NodeTestingApp} from '../utils/NodeTestingApp' +import {runPromiseInMockedEnvironment} from '../utils/runPromiseInMockedEnvironment' + +let user1: MockedUser +let user2: MockedUser + +beforeAll(async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + user1 = yield* _(createMockedUser('+420733333330')) + user2 = yield* _(createMockedUser('+420733333331')) + const client = yield* _(NodeTestingApp) + + yield* _( + client.requestApproval( + { + body: { + message: 'someMessage', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + yield* _( + client.approveRequest( + { + body: yield* _( + user2.inbox1.addChallenge({ + message: 'someMessage2', + publicKeyToConfirm: user1.mainKeyPair.publicKeyPemBase64, + approve: true, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + }) + ) +}) + +it('Create challenge works', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + yield* _( + client.createChallenge( + { + body: { + publicKey: generatePrivateKey().publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + const keysForBatch = [generatePrivateKey(), generatePrivateKey()] as const + + const batch = yield* _( + client.createChallengeBatch( + { + body: { + publicKeys: [ + keysForBatch[0].publicKeyPemBase64, + keysForBatch[1].publicKeyPemBase64, + ], + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + expect(batch.challenges.map((c) => c.publicKey)).toEqual([ + keysForBatch[0].publicKeyPemBase64, + keysForBatch[1].publicKeyPemBase64, + ]) + }) + ) +}) diff --git a/apps/chat-service/src/__tests__/routes/createInbox.test.ts b/apps/chat-service/src/__tests__/routes/createInbox.test.ts new file mode 100644 index 000000000..5a25966a4 --- /dev/null +++ b/apps/chat-service/src/__tests__/routes/createInbox.test.ts @@ -0,0 +1,171 @@ +import {HttpClientRequest} from '@effect/platform' +import {Schema} from '@effect/schema' +import {SqlClient} from '@effect/sql' +import { + generatePrivateKey, + type PublicKeyPemBase64, +} from '@vexl-next/cryptography/src/KeyHolder' +import {E164PhoneNumberE} from '@vexl-next/domain/src/general/E164PhoneNumber.brand' +import {type HashedPhoneNumber} from '@vexl-next/domain/src/general/HashedPhoneNumber.brand' +import {type EcdsaSignature} from '@vexl-next/generic-utils/src/effect-helpers/crypto' +import {CommonHeaders} from '@vexl-next/rest-api/src/commonHeaders' +import {InvalidChallengeError} from '@vexl-next/rest-api/src/services/chat/contracts' +import {createDummyAuthHeadersForUser} from '@vexl-next/server-utils/src/tests/createDummyAuthHeaders' +import {expectErrorResponse} from '@vexl-next/server-utils/src/tests/expectErrorResponse' +import {Effect} from 'effect' +import {hashPublicKey} from '../../db/domain' +import {addChallengeForKey} from '../utils/addChallengeForKey' +import {NodeTestingApp} from '../utils/NodeTestingApp' +import {runPromiseInMockedEnvironment} from '../utils/runPromiseInMockedEnvironment' + +const user1Credentials = generatePrivateKey() +const user1Number = Schema.decodeSync(E164PhoneNumberE)('+420733333330') +let user1authHeaders: { + 'public-key': PublicKeyPemBase64 + signature: EcdsaSignature + hash: HashedPhoneNumber +} +let addChallengeForUser1: ReturnType + +beforeAll(async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + user1authHeaders = yield* _( + createDummyAuthHeadersForUser({ + phoneNumber: user1Number, + publicKey: user1Credentials.publicKeyPemBase64, + }) + ) + + addChallengeForUser1 = addChallengeForKey( + user1Credentials, + user1authHeaders + ) + }) + ) +}) + +describe('Create inbox', () => { + afterEach(async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const sql = yield* _(SqlClient.SqlClient) + yield* _(sql`DELETE FROM inbox`) + }) + ) + }) + + it('Does not create inbox with invalid challenge', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const createResponse = yield* _( + client.createInbox( + { + body: yield* _(addChallengeForUser1({}, true)), + headers: Schema.decodeSync(CommonHeaders)({ + 'user-agent': 'Vexl/1 (1.0.0) ANDROID', + }), + }, + HttpClientRequest.setHeaders(user1authHeaders) + ), + Effect.either + ) + expectErrorResponse(InvalidChallengeError)(createResponse) + }) + ) + }) + + it('Creates inbox', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const createResponse = yield* _( + client.createInbox( + { + body: yield* _(addChallengeForUser1({})), + headers: Schema.decodeSync(CommonHeaders)({ + 'user-agent': 'Vexl/1 (1.0.0) ANDROID', + }), + }, + HttpClientRequest.setHeaders(user1authHeaders) + ), + Effect.either + ) + + expect(createResponse._tag).toBe('Right') + + const hashedPublicKey = yield* _( + hashPublicKey(user1Credentials.publicKeyPemBase64) + ) + + const sql = yield* _(SqlClient.SqlClient) + const data = yield* _(sql` + SELECT + * + FROM + inbox + WHERE + public_key = ${hashedPublicKey} + AND platform = 'ANDROID' + AND client_version = 1 + `) + + expect(data).toHaveLength(1) + }) + ) + }) + + it('Does not fail when inbox already exists & updates metadata', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const createResponse = yield* _( + client.createInbox( + { + body: yield* _(addChallengeForUser1({})), + headers: Schema.decodeSync(CommonHeaders)({ + 'user-agent': 'Vexl/1 (1.0.0) ANDROID', + }), + }, + HttpClientRequest.setHeaders(user1authHeaders) + ), + Effect.either + ) + expect(createResponse._tag).toBe('Right') + + const createResponse2 = yield* _( + client.createInbox( + { + body: yield* _(addChallengeForUser1({})), + headers: Schema.decodeSync(CommonHeaders)({ + 'user-agent': 'Vexl/2 (1.0.0) IOS', + }), + }, + HttpClientRequest.setHeaders(user1authHeaders) + ), + Effect.either + ) + expect(createResponse2._tag).toBe('Right') + + const inboxHash = yield* _( + hashPublicKey(user1Credentials.publicKeyPemBase64) + ) + const sql = yield* _(SqlClient.SqlClient) + const data = yield* _(sql` + SELECT + * + FROM + inbox + WHERE + public_key = ${inboxHash} + `) + expect(data[0].platform).toBe('IOS') + expect(data[0].clientVersion).toBe(2) + }) + ) + }) +}) diff --git a/apps/chat-service/src/__tests__/routes/deleteInbox.test.ts b/apps/chat-service/src/__tests__/routes/deleteInbox.test.ts new file mode 100644 index 000000000..acea2860a --- /dev/null +++ b/apps/chat-service/src/__tests__/routes/deleteInbox.test.ts @@ -0,0 +1,164 @@ +import {HttpClientRequest} from '@effect/platform' +import {SqlClient} from '@effect/sql' +import {type SendMessageRequest} from '@vexl-next/rest-api/src/services/chat/contracts' +import {InboxDoesNotExistError} from '@vexl-next/rest-api/src/services/contact/contracts' +import {expectErrorResponse} from '@vexl-next/server-utils/src/tests/expectErrorResponse' +import {Effect} from 'effect' +import {hashPublicKey} from '../../db/domain' +import {createMockedUser, type MockedUser} from '../utils/createMockedUser' +import {NodeTestingApp} from '../utils/NodeTestingApp' +import {runPromiseInMockedEnvironment} from '../utils/runPromiseInMockedEnvironment' + +let user1: MockedUser +let user2: MockedUser + +beforeEach(async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + // Clear database before each to start fresh + const sql = yield* _(SqlClient.SqlClient) + yield* _(sql`DELETE FROM inbox`) + yield* _(sql`DELETE FROM message`) + yield* _(sql`DELETE FROM white_list`) + + user1 = yield* _(createMockedUser('+420733333330')) + user2 = yield* _(createMockedUser('+420733333331')) + const client = yield* _(NodeTestingApp) + + yield* _( + client.requestApproval( + { + body: { + message: 'someMessage', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + // will send message user1 -> user2 + yield* _( + client.approveRequest( + { + body: yield* _( + user2.inbox1.addChallenge({ + message: 'someMessage2', + publicKeyToConfirm: user1.mainKeyPair.publicKeyPemBase64, + approve: true, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + + // Will send message user2 -> user1 + const messageToSend = (yield* _( + user2.inbox1.addChallenge({ + message: 'someMessage', + messageType: 'MESSAGE' as const, + receiverPublicKey: user1.mainKeyPair.publicKeyPemBase64, + }) + )) satisfies SendMessageRequest + + yield* _( + client.sendMessage( + { + body: messageToSend, + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + }) + ) +}) +describe('deleteInbox', () => { + it('deletes existing inbox and removes all messages and connections receiving by thtat inbox', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + const sql = yield* _(SqlClient.SqlClient) + + const [{id}] = yield* _(sql` + SELECT + id + FROM + inbox + WHERE + public_key = ${yield* _( + hashPublicKey(user2.inbox1.keyPair.publicKeyPemBase64) + )} + `) + + yield* _( + client.deleteInbox( + {body: yield* _(user2.inbox1.addChallenge({}))}, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + + expect(id).not.toBeUndefined() + + const deletedInbox = yield* _(sql` + SELECT + * + FROM + inbox + WHERE + id = ${id} + `) + expect(deletedInbox).toHaveLength(0) + + const messagesForInbox = yield* _(sql` + SELECT + * + FROM + message + WHERE + inbox_id = ${id} + `) + const allMessages = yield* _(sql` + SELECT + * + FROM + message + `) + expect(messagesForInbox).toHaveLength(0) + expect(allMessages).not.toHaveLength(0) + + const whitelists = yield* _(sql` + SELECT + * + FROM + white_list + `) + expect(whitelists).toHaveLength(0) + }) + ) + }) + + it('Throws an error when inbox is already removed', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + yield* _( + client.deleteInbox( + {body: yield* _(user2.inbox1.addChallenge({}))}, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + const failResponse = yield* _( + client.deleteInbox( + {body: yield* _(user2.inbox1.addChallenge({}))}, + HttpClientRequest.setHeaders(user2.authHeaders) + ), + Effect.either + ) + + expectErrorResponse(InboxDoesNotExistError)(failResponse) + }) + ) + }) +}) diff --git a/apps/chat-service/src/__tests__/routes/deleteInboxes.test.ts b/apps/chat-service/src/__tests__/routes/deleteInboxes.test.ts new file mode 100644 index 000000000..f950d44d9 --- /dev/null +++ b/apps/chat-service/src/__tests__/routes/deleteInboxes.test.ts @@ -0,0 +1,207 @@ +import {HttpClientRequest} from '@effect/platform' +import {SqlClient, type SqlError} from '@effect/sql' +import {generatePrivateKey} from '@vexl-next/cryptography/src/KeyHolder' +import {InboxDoesNotExistError} from '@vexl-next/rest-api/src/services/contact/contracts' +import {expectErrorResponse} from '@vexl-next/server-utils/src/tests/expectErrorResponse' +import {Effect} from 'effect' +import {hashPublicKey} from '../../db/domain' +import {addChallengeForKey} from '../utils/addChallengeForKey' +import {createMockedUser, type MockedUser} from '../utils/createMockedUser' +import {NodeTestingApp} from '../utils/NodeTestingApp' +import {runPromiseInMockedEnvironment} from '../utils/runPromiseInMockedEnvironment' + +let user1: MockedUser +let user2: MockedUser +let user3: MockedUser + +beforeEach(async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + // Clear database before each to start fresh + const sql = yield* _(SqlClient.SqlClient) + yield* _(sql`DELETE FROM inbox`) + yield* _(sql`DELETE FROM message`) + yield* _(sql`DELETE FROM white_list`) + + user1 = yield* _(createMockedUser('+420733333330')) + user2 = yield* _(createMockedUser('+420733333331')) + user3 = yield* _(createMockedUser('+420733333332')) + const client = yield* _(NodeTestingApp) + + // user1 -> user2.inbox1 + yield* _( + client.requestApproval( + { + body: { + message: 'someMessage', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + yield* _( + client.approveRequest( + { + body: yield* _( + user2.inbox1.addChallenge({ + message: 'someMessage2', + publicKeyToConfirm: user1.mainKeyPair.publicKeyPemBase64, + approve: true, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + + // user3 -> user2.inbox2 + yield* _( + client.requestApproval( + { + body: { + message: 'someMessage', + publicKey: user2.inbox2.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user3.authHeaders) + ) + ) + + yield* _( + client.approveRequest( + { + body: yield* _( + user2.inbox2.addChallenge({ + message: 'someMessage2', + publicKeyToConfirm: user3.mainKeyPair.publicKeyPemBase64, + approve: true, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + }) + ) +}) + +const expectInboxDeletedFully = ( + id: string +): Effect.Effect => + Effect.gen(function* (_) { + const sql = yield* _(SqlClient.SqlClient) + const deletedInbox = yield* _(sql` + SELECT + * + FROM + inbox + WHERE + id = ${id} + `) + expect(deletedInbox).toHaveLength(0) + + const messagesForInbox = yield* _(sql` + SELECT + * + FROM + message + WHERE + inbox_id = ${id} + `) + const allMessages = yield* _(sql` + SELECT + * + FROM + message + `) + expect(messagesForInbox).toHaveLength(0) + expect(allMessages).not.toHaveLength(0) + }) + +describe('Delete inboxes', () => { + it('deletes existing inbox and removes all messages and connections receiving by thtat inbox', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + const sql = yield* _(SqlClient.SqlClient) + + const [{id: id1}, {id: id2}] = yield* _(sql` + SELECT + id + FROM + inbox + WHERE + ${sql.in('public_key', [ + yield* _(hashPublicKey(user2.inbox1.keyPair.publicKeyPemBase64)), + yield* _(hashPublicKey(user2.inbox2.keyPair.publicKeyPemBase64)), + ])} + `) + + yield* _( + client.deleteInboxes( + { + body: { + dataForRemoval: [ + yield* _(user2.inbox1.addChallenge({})), + yield* _(user2.inbox2.addChallenge({})), + ], + }, + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + yield* _(expectInboxDeletedFully(String(id1))) + yield* _(expectInboxDeletedFully(String(id2))) + }) + ) + }) + + it('Throws an error when inbox does not exist and does not delete other inboxes in the request', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + const sql = yield* _(SqlClient.SqlClient) + + const error = yield* _( + client.deleteInboxes( + { + body: { + dataForRemoval: [ + yield* _(user2.inbox1.addChallenge({})), + yield* _(user2.inbox2.addChallenge({})), + yield* _( + addChallengeForKey( + generatePrivateKey(), + user2.authHeaders + )({}) + ), + ], + }, + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ), + Effect.either + ) + + expectErrorResponse(InboxDoesNotExistError)(error) + + const [{id: id1}, {id: id2}] = yield* _(sql` + SELECT + id + FROM + inbox + WHERE + ${sql.in('public_key', [ + yield* _(hashPublicKey(user2.inbox1.keyPair.publicKeyPemBase64)), + yield* _(hashPublicKey(user2.inbox2.keyPair.publicKeyPemBase64)), + ])} + `) + + expect(id1).not.toBeUndefined() + expect(id2).not.toBeUndefined() + }) + ) + }) +}) diff --git a/apps/chat-service/src/__tests__/routes/deletePulledMessages.test.ts b/apps/chat-service/src/__tests__/routes/deletePulledMessages.test.ts new file mode 100644 index 000000000..d786d8a55 --- /dev/null +++ b/apps/chat-service/src/__tests__/routes/deletePulledMessages.test.ts @@ -0,0 +1,153 @@ +import {Effect} from 'effect' +import {runPromiseInMockedEnvironment} from '../utils/runPromiseInMockedEnvironment' + +import {HttpClientRequest} from '@effect/platform' +import {Schema} from '@effect/schema' +import {generatePrivateKey} from '@vexl-next/cryptography/src/KeyHolder' +import {CommonHeaders} from '@vexl-next/rest-api/src/commonHeaders' +import {type SendMessageRequest} from '@vexl-next/rest-api/src/services/chat/contracts' +import {InboxDoesNotExistError} from '@vexl-next/rest-api/src/services/contact/contracts' +import {expectErrorResponse} from '@vexl-next/server-utils/src/tests/expectErrorResponse' +import {addChallengeForKey} from '../utils/addChallengeForKey' +import {createMockedUser, type MockedUser} from '../utils/createMockedUser' +import {NodeTestingApp} from '../utils/NodeTestingApp' + +let user1: MockedUser +let user2: MockedUser + +beforeAll(async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + user1 = yield* _(createMockedUser('+420733333330')) + user2 = yield* _(createMockedUser('+420733333331')) + const client = yield* _(NodeTestingApp) + + yield* _( + client.requestApproval( + { + body: { + message: 'someMessage', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + yield* _( + client.approveRequest( + { + body: yield* _( + user2.inbox1.addChallenge({ + message: 'someMessage2', + publicKeyToConfirm: user1.mainKeyPair.publicKeyPemBase64, + approve: true, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + }) + ) +}) + +describe('Delete pulled messages', () => { + it('Delete pulled messages so the other user can not retreive them anymore', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const messagesForUser1Before = yield* _( + client.retrieveMessages( + { + body: yield* _(user1.addChallengeForMainInbox({})), + headers: Schema.decodeSync(CommonHeaders)({ + 'user-agent': 'Vexl/2 (1.0.0) IOS', + }), + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + expect(messagesForUser1Before.messages).not.toHaveLength(0) + + const messageToSend = (yield* _( + user2.inbox1.addChallenge({ + message: 'Message sent after pull', + messageType: 'MESSAGE' as const, + receiverPublicKey: user1.mainKeyPair.publicKeyPemBase64, + }) + )) satisfies SendMessageRequest + + yield* _( + client.sendMessage( + { + body: messageToSend, + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + + yield* _( + client.deletePulledMessages( + { + body: yield* _(user1.addChallengeForMainInbox({})), + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + const messagesForUser1 = yield* _( + client.retrieveMessages( + { + body: yield* _(user1.addChallengeForMainInbox({})), + headers: Schema.decodeSync(CommonHeaders)({ + 'user-agent': 'Vexl/2 (1.0.0) IOS', + }), + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + expect(messagesForUser1.messages).toHaveLength(1) + expect(messagesForUser1.messages[0].message).toBe( + 'Message sent after pull' + ) + + const messagesForUser2 = yield* _( + client.retrieveMessages( + { + body: yield* _(user2.inbox1.addChallenge({})), + headers: Schema.decodeSync(CommonHeaders)({ + 'user-agent': 'Vexl/2 (1.0.0) IOS', + }), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + // only request message + expect(messagesForUser2.messages).toHaveLength(1) + }) + ) + }) + + it('Retruns an error when inbox does not exist', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + const errorResponse = yield* _( + client.deletePulledMessages( + { + body: yield* _( + addChallengeForKey(generatePrivateKey(), user1.authHeaders)({}) + ), + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ), + Effect.either + ) + + expectErrorResponse(InboxDoesNotExistError)(errorResponse) + }) + ) + }) +}) diff --git a/apps/chat-service/src/__tests__/routes/leaveChat.test.ts b/apps/chat-service/src/__tests__/routes/leaveChat.test.ts new file mode 100644 index 000000000..bcb75ff43 --- /dev/null +++ b/apps/chat-service/src/__tests__/routes/leaveChat.test.ts @@ -0,0 +1,213 @@ +import {HttpClientRequest} from '@effect/platform' +import {Schema} from '@effect/schema' +import {SqlClient} from '@effect/sql' +import {generatePrivateKey} from '@vexl-next/cryptography/src/KeyHolder' +import {CommonHeaders} from '@vexl-next/rest-api/src/commonHeaders' +import { + ReceiverInboxDoesNotExistError, + SenderInboxDoesNotExistError, + type SendMessageRequest, +} from '@vexl-next/rest-api/src/services/chat/contracts' +import {NotPermittedToSendMessageToTargetInboxError} from '@vexl-next/rest-api/src/services/contact/contracts' +import {expectErrorResponse} from '@vexl-next/server-utils/src/tests/expectErrorResponse' +import {Effect} from 'effect' +import {addChallengeForKey} from '../utils/addChallengeForKey' +import {createMockedUser, type MockedUser} from '../utils/createMockedUser' +import {NodeTestingApp} from '../utils/NodeTestingApp' +import {runPromiseInMockedEnvironment} from '../utils/runPromiseInMockedEnvironment' + +let user1: MockedUser +let user2: MockedUser + +beforeEach(async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + // Clear database before each to start fresh + const sql = yield* _(SqlClient.SqlClient) + yield* _(sql`DELETE FROM inbox`) + yield* _(sql`DELETE FROM message`) + yield* _(sql`DELETE FROM white_list`) + + user1 = yield* _(createMockedUser('+420733333330')) + user2 = yield* _(createMockedUser('+420733333331')) + const client = yield* _(NodeTestingApp) + + yield* _( + client.requestApproval( + { + body: { + message: 'someMessage', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + // will send message user1 -> user2 + yield* _( + client.approveRequest( + { + body: yield* _( + user2.inbox1.addChallenge({ + message: 'someMessage2', + publicKeyToConfirm: user1.mainKeyPair.publicKeyPemBase64, + approve: true, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + + // Will send message user2 -> user1 + const messageToSend = (yield* _( + user2.inbox1.addChallenge({ + message: 'someMessage', + messageType: 'MESSAGE' as const, + receiverPublicKey: user1.mainKeyPair.publicKeyPemBase64, + }) + )) satisfies SendMessageRequest + + yield* _( + client.sendMessage( + { + body: messageToSend, + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + + yield* _(sql`DELETE FROM message`) + }) + ) +}) + +describe('Leave chat', () => { + it('Leaves chat, removes whitelist records, sends message to other party about leaving', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + yield* _( + client.leaveChat( + { + body: yield* _( + user2.inbox1.addChallenge({ + message: 'leaveMessage', + receiverPublicKey: user1.mainKeyPair.publicKeyPemBase64, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + + const sql = yield* _(SqlClient.SqlClient) + const whitelistRecords = yield* _(sql` + SELECT + * + FROM + white_list + `) + expect(whitelistRecords).toHaveLength(0) + + const messagesForUser1 = yield* _( + client.retrieveMessages( + { + body: yield* _(user1.addChallengeForMainInbox({})), + headers: Schema.decodeSync(CommonHeaders)({ + 'user-agent': 'Vexl/2 (1.0.0) IOS', + }), + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + expect(messagesForUser1.messages[0].message).toEqual('leaveMessage') + }) + ) + }) + + describe('it fails when', () => { + it('Reciever inbox does not exist', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const failedResponse = yield* _( + client.leaveChat( + { + body: yield* _( + user2.inbox1.addChallenge({ + message: 'leaveMessage', + receiverPublicKey: generatePrivateKey().publicKeyPemBase64, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ), + Effect.either + ) + + expectErrorResponse(ReceiverInboxDoesNotExistError)(failedResponse) + }) + ) + }) + + it('Sender inbox does not exist', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const failedResponse = yield* _( + client.leaveChat( + { + body: yield* _( + addChallengeForKey( + generatePrivateKey(), + user2.authHeaders + )({ + message: 'leaveMessage', + receiverPublicKey: user1.mainKeyPair.publicKeyPemBase64, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ), + Effect.either + ) + + expectErrorResponse(SenderInboxDoesNotExistError)(failedResponse) + }) + ) + }) + + it('Not permitted to send messages to each other', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const failedResponse = yield* _( + client.leaveChat( + { + body: yield* _( + user2.inbox1.addChallenge({ + message: 'leaveMessage', + receiverPublicKey: user1.inbox1.keyPair.publicKeyPemBase64, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ), + Effect.either + ) + + expectErrorResponse(NotPermittedToSendMessageToTargetInboxError)( + failedResponse + ) + }) + ) + }) + }) +}) diff --git a/apps/chat-service/src/__tests__/routes/requestApproval.test.ts b/apps/chat-service/src/__tests__/routes/requestApproval.test.ts new file mode 100644 index 000000000..e30fd7e4a --- /dev/null +++ b/apps/chat-service/src/__tests__/routes/requestApproval.test.ts @@ -0,0 +1,477 @@ +import {HttpClientRequest} from '@effect/platform' +import {Schema} from '@effect/schema' +import {SqlClient} from '@effect/sql' +import {generatePrivateKey} from '@vexl-next/cryptography/src/KeyHolder' +import {E164PhoneNumberE} from '@vexl-next/domain/src/general/E164PhoneNumber.brand' +import {CommonHeaders} from '@vexl-next/rest-api/src/commonHeaders' +import { + ReceiverInboxDoesNotExistError, + RequestMessagingNotAllowedError, + SenderInboxDoesNotExistError, +} from '@vexl-next/rest-api/src/services/chat/contracts' +import {createDummyAuthHeadersForUser} from '@vexl-next/server-utils/src/tests/createDummyAuthHeaders' +import {expectErrorResponse} from '@vexl-next/server-utils/src/tests/expectErrorResponse' +import {Effect} from 'effect' +import {createMockedUser, type MockedUser} from '../utils/createMockedUser' +import {NodeTestingApp} from '../utils/NodeTestingApp' +import {runPromiseInMockedEnvironment} from '../utils/runPromiseInMockedEnvironment' + +let user1: MockedUser +let user2: MockedUser + +beforeEach(async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + // Clear database before each to start fresh + const sql = yield* _(SqlClient.SqlClient) + yield* _(sql`DELETE FROM inbox`) + yield* _(sql`DELETE FROM message`) + yield* _(sql`DELETE FROM white_list`) + + user1 = yield* _(createMockedUser('+420733333330')) + user2 = yield* _(createMockedUser('+420733333331')) + }) + ) +}) + +describe('Request approval', () => { + it('Request approval sends an message to the other side', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + yield* _( + client.requestApproval( + { + body: { + message: 'request message', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + const messagesForUser2 = yield* _( + client.retrieveMessages( + { + body: yield* _(user2.inbox1.addChallenge({})), + headers: Schema.decodeSync(CommonHeaders)({ + 'user-agent': 'Vexl/2 (1.0.0) IOS', + }), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + + expect(messagesForUser2.messages).toHaveLength(1) + expect(messagesForUser2.messages[0]).toHaveProperty( + 'message', + 'request message' + ) + }) + ) + }) + + it('Can not request approval twice in a row', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + yield* _( + client.requestApproval( + { + body: { + message: 'request message', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + const toFail = yield* _( + client.requestApproval( + { + body: { + message: 'request', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ), + Effect.either + ) + expectErrorResponse(RequestMessagingNotAllowedError)(toFail) + }) + ) + }) + + it('Can request approval twice within timeout interval', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + yield* _( + client.requestApproval( + { + body: { + message: 'request message', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + const sql = yield* _(SqlClient.SqlClient) + yield* _(sql` + UPDATE white_list + SET + date = NOW() - INTERVAL '1 day' + `) + + const toNotFail = yield* _( + client.requestApproval( + { + body: { + message: 'request', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ), + Effect.either + ) + expect(toNotFail._tag).toBe('Right') + + const toFail = yield* _( + client.requestApproval( + { + body: { + message: 'request', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ), + Effect.either + ) + expectErrorResponse(RequestMessagingNotAllowedError)(toFail) + }) + ) + }) + + it('Cannot send request when already approved', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + yield* _( + client.requestApproval( + { + body: { + message: 'request message', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + yield* _( + client.approveRequest( + { + body: yield* _( + user2.inbox1.addChallenge({ + message: 'approval message', + approve: true, + publicKeyToConfirm: user1.mainKeyPair.publicKeyPemBase64, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + + const toFail = yield* _( + client.requestApproval( + { + body: { + message: 'request', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ), + Effect.either + ) + expectErrorResponse(RequestMessagingNotAllowedError)(toFail) + }) + ) + }) + + it('Cannot send request when already disaproved', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + yield* _( + client.requestApproval( + { + body: { + message: 'request message', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + yield* _( + client.approveRequest( + { + body: yield* _( + user2.inbox1.addChallenge({ + message: 'approval message', + approve: false, + publicKeyToConfirm: user1.mainKeyPair.publicKeyPemBase64, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + + const toFail = yield* _( + client.requestApproval( + { + body: { + message: 'request', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ), + Effect.either + ) + expectErrorResponse(RequestMessagingNotAllowedError)(toFail) + }) + ) + }) + + it('Cannot send request when the other side blocked', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + yield* _( + client.requestApproval( + { + body: { + message: 'request message', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + yield* _( + client.blockInboxEndpoint( + { + body: yield* _( + user2.inbox1.addChallenge({ + publicKeyToBlock: user1.mainKeyPair.publicKeyPemBase64, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + + const toFail = yield* _( + client.requestApproval( + { + body: { + message: 'request', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ), + Effect.either + ) + expectErrorResponse(RequestMessagingNotAllowedError)(toFail) + }) + ) + }) + + it('Cannot send request right after cancelation', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + yield* _( + client.requestApproval( + { + body: { + message: 'request message', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + yield* _( + client.cancelRequestApproval( + { + body: { + message: 'cancel message', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + const toFail = yield* _( + client.requestApproval( + { + body: { + message: 'request', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ), + Effect.either + ) + expectErrorResponse(RequestMessagingNotAllowedError)(toFail) + }) + ) + }) + + it('can send request after cancelation when request timeout period has passed', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + yield* _( + client.requestApproval( + { + body: { + message: 'request message', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + yield* _( + client.cancelRequestApproval( + { + body: { + message: 'cancel message', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + const sql = yield* _(SqlClient.SqlClient) + yield* _(sql` + UPDATE white_list + SET + date = NOW() - INTERVAL '1 day' + `) + + const toNotFail = yield* _( + client.requestApproval( + { + body: { + message: 'request', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ), + Effect.either + ) + + expect(toNotFail._tag).toBe('Right') + + const toFail = yield* _( + client.requestApproval( + { + body: { + message: 'request', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ), + Effect.either + ) + expectErrorResponse(RequestMessagingNotAllowedError)(toFail) + }) + ) + }) + + it('Returns error when sending request to non existing inbox', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const failedResponse = yield* _( + client.requestApproval( + { + body: { + message: 'request message', + publicKey: generatePrivateKey().publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ), + Effect.either + ) + + expectErrorResponse(ReceiverInboxDoesNotExistError)(failedResponse) + }) + ) + }) + + it('Returns error when sending request from non existing inbox', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const failedResponse = yield* _( + client.requestApproval( + { + body: { + message: 'request message', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders( + yield* _( + createDummyAuthHeadersForUser({ + phoneNumber: + Schema.decodeSync(E164PhoneNumberE)('+420733333332'), + publicKey: generatePrivateKey().publicKeyPemBase64, + }) + ) + ) + ), + Effect.either + ) + + expectErrorResponse(SenderInboxDoesNotExistError)(failedResponse) + }) + ) + }) +}) diff --git a/apps/chat-service/src/__tests__/routes/retrieveMessages.test.ts b/apps/chat-service/src/__tests__/routes/retrieveMessages.test.ts new file mode 100644 index 000000000..6609a2a24 --- /dev/null +++ b/apps/chat-service/src/__tests__/routes/retrieveMessages.test.ts @@ -0,0 +1,174 @@ +import {Effect} from 'effect' +import {runPromiseInMockedEnvironment} from '../utils/runPromiseInMockedEnvironment' + +import {HttpClientRequest} from '@effect/platform' +import {Schema} from '@effect/schema' +import {SqlClient} from '@effect/sql' +import {generatePrivateKey} from '@vexl-next/cryptography/src/KeyHolder' +import {CommonHeaders} from '@vexl-next/rest-api/src/commonHeaders' +import {type SendMessageRequest} from '@vexl-next/rest-api/src/services/chat/contracts' +import {InboxDoesNotExistError} from '@vexl-next/rest-api/src/services/contact/contracts' +import {expectErrorResponse} from '@vexl-next/server-utils/src/tests/expectErrorResponse' +import {hashPublicKey} from '../../db/domain' +import {addChallengeForKey} from '../utils/addChallengeForKey' +import {createMockedUser, type MockedUser} from '../utils/createMockedUser' +import {NodeTestingApp} from '../utils/NodeTestingApp' + +let user1: MockedUser +let user2: MockedUser + +beforeAll(async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + user1 = yield* _(createMockedUser('+420733333330')) + user2 = yield* _(createMockedUser('+420733333331')) + const client = yield* _(NodeTestingApp) + + yield* _( + client.requestApproval( + { + body: { + message: 'someMessage', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + yield* _( + client.approveRequest( + { + body: yield* _( + user2.inbox1.addChallenge({ + message: 'someMessage2', + publicKeyToConfirm: user1.mainKeyPair.publicKeyPemBase64, + approve: true, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + }) + ) +}) + +describe('Retrieve messages', () => { + it('Correctly receives messages in inbox', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const messageToSend = (yield* _( + user2.inbox1.addChallenge({ + message: 'someMessage3', + messageType: 'MESSAGE' as const, + receiverPublicKey: user1.mainKeyPair.publicKeyPemBase64, + }) + )) satisfies SendMessageRequest + + yield* _( + client.sendMessage( + { + body: messageToSend, + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + + const messagesForUser1 = yield* _( + client.retrieveMessages( + { + body: yield* _(user1.addChallengeForMainInbox({})), + headers: Schema.decodeSync(CommonHeaders)({ + 'user-agent': 'Vexl/1 (1.0.0) ANDROID', + }), + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + expect(messagesForUser1.messages.map((one) => one.message)).toEqual([ + 'someMessage2', + 'someMessage3', + ]) + }) + ) + }) + + it('Correctly updates inbox metadata', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const messageToSend = (yield* _( + user2.inbox1.addChallenge({ + message: 'someMessage3', + messageType: 'MESSAGE' as const, + receiverPublicKey: user1.mainKeyPair.publicKeyPemBase64, + }) + )) satisfies SendMessageRequest + + yield* _( + client.sendMessage( + { + body: messageToSend, + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + + yield* _( + client.retrieveMessages( + { + body: yield* _(user1.addChallengeForMainInbox({})), + headers: Schema.decodeSync(CommonHeaders)({ + 'user-agent': 'Vexl/2 (1.0.0) IOS', + }), + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + const inboxHash = yield* _( + hashPublicKey(user1.mainKeyPair.publicKeyPemBase64) + ) + const sql = yield* _(SqlClient.SqlClient) + const data = yield* _(sql` + SELECT + * + FROM + inbox + WHERE + public_key = ${inboxHash} + `) + expect(data[0].platform).toBe('IOS') + expect(data[0].clientVersion).toBe(2) + }) + ) + }) + + it('Retruns an error when inbox does not exist', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + const errorResponse = yield* _( + client.retrieveMessages( + { + body: yield* _( + addChallengeForKey(generatePrivateKey(), user1.authHeaders)({}) + ), + headers: Schema.decodeSync(CommonHeaders)({ + 'user-agent': 'Vexl/1 (1.0.0) ANDROID', + }), + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ), + Effect.either + ) + + expectErrorResponse(InboxDoesNotExistError)(errorResponse) + }) + ) + }) +}) diff --git a/apps/chat-service/src/__tests__/routes/sendMessage.test.ts b/apps/chat-service/src/__tests__/routes/sendMessage.test.ts new file mode 100644 index 000000000..f07493673 --- /dev/null +++ b/apps/chat-service/src/__tests__/routes/sendMessage.test.ts @@ -0,0 +1,271 @@ +import {HttpClientRequest} from '@effect/platform' +import {Schema} from '@effect/schema' +import {SqlClient} from '@effect/sql' +import {generatePrivateKey} from '@vexl-next/cryptography/src/KeyHolder' +import {CommonHeaders} from '@vexl-next/rest-api/src/commonHeaders' +import { + ReceiverInboxDoesNotExistError, + SenderInboxDoesNotExistError, + type SendMessageRequest, +} from '@vexl-next/rest-api/src/services/chat/contracts' +import { + ForbiddenMessageTypeError, + NotPermittedToSendMessageToTargetInboxError, +} from '@vexl-next/rest-api/src/services/contact/contracts' +import {expectErrorResponse} from '@vexl-next/server-utils/src/tests/expectErrorResponse' +import {Effect} from 'effect' +import {addChallengeForKey} from '../utils/addChallengeForKey' +import {createMockedUser, type MockedUser} from '../utils/createMockedUser' +import {NodeTestingApp} from '../utils/NodeTestingApp' +import {runPromiseInMockedEnvironment} from '../utils/runPromiseInMockedEnvironment' + +let user1: MockedUser +let user2: MockedUser + +beforeAll(async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + user1 = yield* _(createMockedUser('+420733333330')) + user2 = yield* _(createMockedUser('+420733333331')) + const client = yield* _(NodeTestingApp) + + yield* _( + client.requestApproval( + { + body: { + message: 'someMessage', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + yield* _( + client.approveRequest( + { + body: yield* _( + user2.inbox1.addChallenge({ + message: 'someMessage2', + publicKeyToConfirm: user1.mainKeyPair.publicKeyPemBase64, + approve: true, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + + const sql = yield* _(SqlClient.SqlClient) + yield* _(sql`DELETE FROM message`) + }) + ) +}) + +describe('Send message', () => { + it('Sends message from user1 to user2', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const messageToSend = (yield* _( + user1.addChallengeForMainInbox({ + message: 'someMessage', + messageType: 'MESSAGE' as const, + receiverPublicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }) + )) satisfies SendMessageRequest + + yield* _( + client.sendMessage( + { + body: messageToSend, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + const messagesReceived = yield* _( + client.retrieveMessages( + { + body: yield* _(user2.inbox1.addChallenge({})), + headers: Schema.decodeSync(CommonHeaders)({ + 'user-agent': 'Vexl/2 (1.0.0) IOS', + }), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + + expect(messagesReceived.messages.length).toBe(1) + expect(messagesReceived.messages[0].message).toBe('someMessage') + + const messagesReceived2 = yield* _( + client.retrieveMessages( + { + body: yield* _(user2.inbox2.addChallenge({})), + headers: Schema.decodeSync(CommonHeaders)({ + 'user-agent': 'Vexl/2 (1.0.0) IOS', + }), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + + expect(messagesReceived2.messages.length).toBe(0) + }) + ) + }) + + it('Throws correct error when Receiver inbox does not exist', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const messageToSend = (yield* _( + user1.addChallengeForMainInbox({ + message: 'someMessage', + messageType: 'MESSAGE' as const, + receiverPublicKey: generatePrivateKey().publicKeyPemBase64, + }) + )) satisfies SendMessageRequest + + const response = yield* _( + client.sendMessage( + { + body: messageToSend, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ), + Effect.either + ) + + expectErrorResponse(ReceiverInboxDoesNotExistError)(response) + }) + ) + }) + + it('Throws correct error when sender inbox does not exist', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const messageToSend = (yield* _( + addChallengeForKey( + generatePrivateKey(), + user1.authHeaders + )({ + message: 'someMessage', + messageType: 'MESSAGE' as const, + receiverPublicKey: user2.inbox3.keyPair.publicKeyPemBase64, + }) + )) satisfies SendMessageRequest + + const response = yield* _( + client.sendMessage( + { + body: messageToSend, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ), + Effect.either + ) + + expectErrorResponse(SenderInboxDoesNotExistError)(response) + }) + ) + }) + + it('Throws correct error when not permitted to send message to target inbox', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const messageToSend = (yield* _( + user1.addChallengeForMainInbox({ + message: 'someMessage', + messageType: 'MESSAGE' as const, + receiverPublicKey: user2.inbox2.keyPair.publicKeyPemBase64, + }) + )) satisfies SendMessageRequest + + const response = yield* _( + client.sendMessage( + { + body: messageToSend, + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ), + Effect.either + ) + + expectErrorResponse(NotPermittedToSendMessageToTargetInboxError)( + response + ) + }) + ) + }) + + it('Can not send message of type that is generated by BE', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const messageToSend = yield* _( + user1.addChallengeForMainInbox({ + message: 'someMessage', + receiverPublicKey: user2.inbox2.keyPair.publicKeyPemBase64, + }) + ) + + const response = yield* _( + client.sendMessage( + { + body: {...messageToSend, messageType: 'REQUEST_MESSAGING'}, + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ), + Effect.either + ) + + expectErrorResponse(ForbiddenMessageTypeError)(response) + + const response2 = yield* _( + client.sendMessage( + { + body: {...messageToSend, messageType: 'APPROVE_MESSAGING'}, + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ), + Effect.either + ) + + expectErrorResponse(ForbiddenMessageTypeError)(response2) + + const response3 = yield* _( + client.sendMessage( + { + body: {...messageToSend, messageType: 'DISAPPROVE_MESSAGING'}, + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ), + Effect.either + ) + + expectErrorResponse(ForbiddenMessageTypeError)(response3) + + const response4 = yield* _( + client.sendMessage( + { + body: {...messageToSend, messageType: 'CANCEL_REQUEST_MESSAGING'}, + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ), + Effect.either + ) + + expectErrorResponse(ForbiddenMessageTypeError)(response4) + }) + ) + }) +}) diff --git a/apps/chat-service/src/__tests__/routes/sendMessages.test.ts b/apps/chat-service/src/__tests__/routes/sendMessages.test.ts new file mode 100644 index 000000000..f5291f88d --- /dev/null +++ b/apps/chat-service/src/__tests__/routes/sendMessages.test.ts @@ -0,0 +1,643 @@ +import {HttpClientRequest} from '@effect/platform' +import {Schema} from '@effect/schema' +import {SqlClient} from '@effect/sql' +import {generatePrivateKey} from '@vexl-next/cryptography/src/KeyHolder' +import {CommonHeaders} from '@vexl-next/rest-api/src/commonHeaders' +import { + ReceiverInboxDoesNotExistError, + SenderInboxDoesNotExistError, +} from '@vexl-next/rest-api/src/services/chat/contracts' +import { + ForbiddenMessageTypeError, + NotPermittedToSendMessageToTargetInboxError, +} from '@vexl-next/rest-api/src/services/contact/contracts' +import {expectErrorResponse} from '@vexl-next/server-utils/src/tests/expectErrorResponse' +import {Effect} from 'effect' +import {addChallengeForKey} from '../utils/addChallengeForKey' +import {createMockedUser, type MockedUser} from '../utils/createMockedUser' +import {NodeTestingApp} from '../utils/NodeTestingApp' +import {runPromiseInMockedEnvironment} from '../utils/runPromiseInMockedEnvironment' + +let user1: MockedUser +let user2: MockedUser +let user3: MockedUser + +beforeEach(async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + // Clear database before each to start fresh + const sql = yield* _(SqlClient.SqlClient) + yield* _(sql`DELETE FROM inbox`) + yield* _(sql`DELETE FROM message`) + yield* _(sql`DELETE FROM white_list`) + + user1 = yield* _(createMockedUser('+420733333330')) + user2 = yield* _(createMockedUser('+420733333331')) + user3 = yield* _(createMockedUser('+420733333332')) + const client = yield* _(NodeTestingApp) + + // user1 -> user2.inbox1 + yield* _( + client.requestApproval( + { + body: { + message: 'someMessage', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + yield* _( + client.approveRequest( + { + body: yield* _( + user2.inbox1.addChallenge({ + message: 'someMessage2', + publicKeyToConfirm: user1.mainKeyPair.publicKeyPemBase64, + approve: true, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + + // user1 -> user2.inbox2 + yield* _( + client.requestApproval( + { + body: { + message: 'someMessage', + publicKey: user2.inbox2.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + yield* _( + client.approveRequest( + { + body: yield* _( + user2.inbox2.addChallenge({ + message: 'someMessage2', + publicKeyToConfirm: user1.mainKeyPair.publicKeyPemBase64, + approve: true, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + + // user3 -> user2.inbox1 + yield* _( + client.requestApproval( + { + body: { + message: 'someMessage', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user3.authHeaders) + ) + ) + + yield* _( + client.approveRequest( + { + body: yield* _( + user2.inbox1.addChallenge({ + message: 'someMessage2', + publicKeyToConfirm: user3.mainKeyPair.publicKeyPemBase64, + approve: true, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + + // user3 -> user2.inbox2 + yield* _( + client.requestApproval( + { + body: { + message: 'someMessage', + publicKey: user2.inbox2.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user3.authHeaders) + ) + ) + + yield* _( + client.approveRequest( + { + body: yield* _( + user2.inbox2.addChallenge({ + message: 'someMessage2', + publicKeyToConfirm: user3.mainKeyPair.publicKeyPemBase64, + approve: true, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + yield* _(sql`DELETE FROM message`) + }) + ) +}) + +describe('Send messages', () => { + it('Sends messages to multiple inboxes', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const messagesToSend = [ + yield* _( + user2.inbox1.addChallenge({ + senderPublicKey: user2.inbox1.keyPair.publicKeyPemBase64, + messages: [ + { + receiverPublicKey: user1.mainKeyPair.publicKeyPemBase64, + message: '1fromUser2inbox1', + messageType: 'MESSAGE' as const, + }, + { + receiverPublicKey: user3.mainKeyPair.publicKeyPemBase64, + message: '2fromUser2inbox1', + messageType: 'MESSAGE' as const, + }, + ], + }) + ), + yield* _( + user2.inbox2.addChallenge({ + senderPublicKey: user2.inbox2.keyPair.publicKeyPemBase64, + messages: [ + { + receiverPublicKey: user1.mainKeyPair.publicKeyPemBase64, + message: '3fromUser2inbox2', + messageType: 'MESSAGE' as const, + }, + { + receiverPublicKey: user3.mainKeyPair.publicKeyPemBase64, + message: '4fromUser2inbox2', + messageType: 'MESSAGE' as const, + }, + ], + }) + ), + ] + + yield* _( + client.sendMessages( + { + body: {data: messagesToSend}, + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + + const messagesReceivedByUser1 = yield* _( + client.retrieveMessages( + { + body: yield* _(user1.addChallengeForMainInbox({})), + headers: Schema.decodeSync(CommonHeaders)({ + 'user-agent': 'Vexl/2 (1.0.0) IOS', + }), + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + expect( + messagesReceivedByUser1.messages.map((one) => one.message) + ).toEqual(['1fromUser2inbox1', '3fromUser2inbox2']) + + const messagesReceivedByUser3 = yield* _( + client.retrieveMessages( + { + body: yield* _(user3.addChallengeForMainInbox({})), + headers: Schema.decodeSync(CommonHeaders)({ + 'user-agent': 'Vexl/2 (1.0.0) IOS', + }), + }, + HttpClientRequest.setHeaders(user3.authHeaders) + ) + ) + + expect( + messagesReceivedByUser3.messages.map((one) => one.message) + ).toEqual(['2fromUser2inbox1', '4fromUser2inbox2']) + }) + ) + }) + + it('Throws correct error when Receiver inbox does not exist', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const messagesToSend = [ + yield* _( + user2.inbox1.addChallenge({ + senderPublicKey: user2.inbox1.keyPair.publicKeyPemBase64, + messages: [ + { + receiverPublicKey: user1.mainKeyPair.publicKeyPemBase64, + message: '1fromUser2inbox1', + messageType: 'MESSAGE' as const, + }, + { + receiverPublicKey: user3.mainKeyPair.publicKeyPemBase64, + message: '2fromUser2inbox1', + messageType: 'MESSAGE' as const, + }, + ], + }) + ), + yield* _( + user2.inbox2.addChallenge({ + senderPublicKey: user2.inbox2.keyPair.publicKeyPemBase64, + messages: [ + { + receiverPublicKey: generatePrivateKey().publicKeyPemBase64, + message: '3fromUser2inbox2', + messageType: 'MESSAGE' as const, + }, + { + receiverPublicKey: user3.mainKeyPair.publicKeyPemBase64, + message: '4fromUser2inbox2', + messageType: 'MESSAGE' as const, + }, + ], + }) + ), + ] + + const errorResponse = yield* _( + client.sendMessages( + { + body: {data: messagesToSend}, + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ), + Effect.either + ) + + expectErrorResponse(ReceiverInboxDoesNotExistError)(errorResponse) + + const messagesReceivedByUser1 = yield* _( + client.retrieveMessages( + { + body: yield* _(user1.addChallengeForMainInbox({})), + headers: Schema.decodeSync(CommonHeaders)({ + 'user-agent': 'Vexl/2 (1.0.0) IOS', + }), + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + expect( + messagesReceivedByUser1.messages.map((one) => one.message) + ).toEqual([]) + + const messagesReceivedByUser3 = yield* _( + client.retrieveMessages( + { + body: yield* _(user3.addChallengeForMainInbox({})), + headers: Schema.decodeSync(CommonHeaders)({ + 'user-agent': 'Vexl/2 (1.0.0) IOS', + }), + }, + HttpClientRequest.setHeaders(user3.authHeaders) + ) + ) + + expect( + messagesReceivedByUser3.messages.map((one) => one.message) + ).toEqual([]) + }) + ) + }) + + it('Throws correct error when sender inbox does not exist', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const nonExistingPrivKey = generatePrivateKey() + const messagesToSend = [ + yield* _( + user2.inbox1.addChallenge({ + senderPublicKey: user2.inbox1.keyPair.publicKeyPemBase64, + messages: [ + { + receiverPublicKey: user1.mainKeyPair.publicKeyPemBase64, + message: '1fromUser2inbox1', + messageType: 'MESSAGE' as const, + }, + { + receiverPublicKey: user3.mainKeyPair.publicKeyPemBase64, + message: '2fromUser2inbox1', + messageType: 'MESSAGE' as const, + }, + ], + }) + ), + yield* _( + user2.inbox2.addChallenge({ + senderPublicKey: user2.inbox2.keyPair.publicKeyPemBase64, + messages: [ + { + receiverPublicKey: user1.mainKeyPair.publicKeyPemBase64, + message: '3fromUser2inbox2', + messageType: 'MESSAGE' as const, + }, + { + receiverPublicKey: user3.mainKeyPair.publicKeyPemBase64, + message: '4fromUser2inbox2', + messageType: 'MESSAGE' as const, + }, + ], + }) + ), + yield* _( + addChallengeForKey( + nonExistingPrivKey, + user2.authHeaders + )({ + senderPublicKey: nonExistingPrivKey.publicKeyPemBase64, + messages: [ + { + receiverPublicKey: user1.mainKeyPair.publicKeyPemBase64, + message: '3fromUser2inbox2', + messageType: 'MESSAGE' as const, + }, + { + receiverPublicKey: user3.mainKeyPair.publicKeyPemBase64, + message: '4fromUser2inbox2', + messageType: 'MESSAGE' as const, + }, + ], + }) + ), + ] + + const errorResponse = yield* _( + client.sendMessages( + { + body: {data: messagesToSend}, + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ), + Effect.either + ) + + expectErrorResponse(SenderInboxDoesNotExistError)(errorResponse) + + const messagesReceivedByUser1 = yield* _( + client.retrieveMessages( + { + body: yield* _(user1.addChallengeForMainInbox({})), + headers: Schema.decodeSync(CommonHeaders)({ + 'user-agent': 'Vexl/2 (1.0.0) IOS', + }), + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + expect( + messagesReceivedByUser1.messages.map((one) => one.message) + ).toEqual([]) + + const messagesReceivedByUser3 = yield* _( + client.retrieveMessages( + { + body: yield* _(user3.addChallengeForMainInbox({})), + headers: Schema.decodeSync(CommonHeaders)({ + 'user-agent': 'Vexl/2 (1.0.0) IOS', + }), + }, + HttpClientRequest.setHeaders(user3.authHeaders) + ) + ) + + expect( + messagesReceivedByUser3.messages.map((one) => one.message) + ).toEqual([]) + }) + ) + }) + + it('Throws correct error when not permitted to send message to target inbox', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const messagesToSend = [ + yield* _( + user2.inbox1.addChallenge({ + senderPublicKey: user2.inbox1.keyPair.publicKeyPemBase64, + messages: [ + { + receiverPublicKey: user1.mainKeyPair.publicKeyPemBase64, + message: '1fromUser2inbox1', + messageType: 'MESSAGE' as const, + }, + { + receiverPublicKey: user3.mainKeyPair.publicKeyPemBase64, + message: '2fromUser2inbox1', + messageType: 'MESSAGE' as const, + }, + ], + }) + ), + yield* _( + user2.inbox2.addChallenge({ + senderPublicKey: user2.inbox2.keyPair.publicKeyPemBase64, + messages: [ + { + receiverPublicKey: user3.inbox1.keyPair.publicKeyPemBase64, + message: '3fromUser2inbox2', + messageType: 'MESSAGE' as const, + }, + { + receiverPublicKey: user3.mainKeyPair.publicKeyPemBase64, + message: '4fromUser2inbox2', + messageType: 'MESSAGE' as const, + }, + ], + }) + ), + ] + + const errorResponse = yield* _( + client.sendMessages( + { + body: {data: messagesToSend}, + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ), + Effect.either + ) + + expectErrorResponse(NotPermittedToSendMessageToTargetInboxError)( + errorResponse + ) + + const messagesReceivedByUser1 = yield* _( + client.retrieveMessages( + { + body: yield* _(user1.addChallengeForMainInbox({})), + headers: Schema.decodeSync(CommonHeaders)({ + 'user-agent': 'Vexl/2 (1.0.0) IOS', + }), + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + expect( + messagesReceivedByUser1.messages.map((one) => one.message) + ).toEqual([]) + + const messagesReceivedByUser3 = yield* _( + client.retrieveMessages( + { + body: yield* _(user3.addChallengeForMainInbox({})), + headers: Schema.decodeSync(CommonHeaders)({ + 'user-agent': 'Vexl/2 (1.0.0) IOS', + }), + }, + HttpClientRequest.setHeaders(user3.authHeaders) + ) + ) + + expect( + messagesReceivedByUser3.messages.map((one) => one.message) + ).toEqual([]) + }) + ) + }) + + it('Can not send message of type that is generated by BE', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const errorResponse1 = yield* _( + client.sendMessages( + { + body: { + data: [ + yield* _( + user2.inbox1.addChallenge({ + senderPublicKey: user2.inbox1.keyPair.publicKeyPemBase64, + messages: [ + { + receiverPublicKey: + user1.mainKeyPair.publicKeyPemBase64, + message: '1fromUser2inbox1', + messageType: 'REQUEST_MESSAGING' as const, + }, + ], + }) + ), + ], + }, + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ), + Effect.either + ) + expectErrorResponse(ForbiddenMessageTypeError)(errorResponse1) + + const errorResponse2 = yield* _( + client.sendMessages( + { + body: { + data: [ + yield* _( + user2.inbox1.addChallenge({ + senderPublicKey: user2.inbox1.keyPair.publicKeyPemBase64, + messages: [ + { + receiverPublicKey: + user1.mainKeyPair.publicKeyPemBase64, + message: '1fromUser2inbox1', + messageType: 'APPROVE_MESSAGING' as const, + }, + ], + }) + ), + ], + }, + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ), + Effect.either + ) + expectErrorResponse(ForbiddenMessageTypeError)(errorResponse2) + + const errorResponse3 = yield* _( + client.sendMessages( + { + body: { + data: [ + yield* _( + user2.inbox1.addChallenge({ + senderPublicKey: user2.inbox1.keyPair.publicKeyPemBase64, + messages: [ + { + receiverPublicKey: + user1.mainKeyPair.publicKeyPemBase64, + message: '1fromUser2inbox1', + messageType: 'DISAPPROVE_MESSAGING' as const, + }, + ], + }) + ), + ], + }, + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ), + Effect.either + ) + expectErrorResponse(ForbiddenMessageTypeError)(errorResponse3) + + const errorResponse4 = yield* _( + client.sendMessages( + { + body: { + data: [ + yield* _( + user2.inbox1.addChallenge({ + senderPublicKey: user2.inbox1.keyPair.publicKeyPemBase64, + messages: [ + { + receiverPublicKey: + user1.mainKeyPair.publicKeyPemBase64, + message: '1fromUser2inbox1', + messageType: 'CANCEL_REQUEST_MESSAGING' as const, + }, + ], + }) + ), + ], + }, + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ), + Effect.either + ) + expectErrorResponse(ForbiddenMessageTypeError)(errorResponse4) + }) + ) + }) +}) diff --git a/apps/chat-service/src/__tests__/routes/updateInbox.test.ts b/apps/chat-service/src/__tests__/routes/updateInbox.test.ts new file mode 100644 index 000000000..f98ba3ced --- /dev/null +++ b/apps/chat-service/src/__tests__/routes/updateInbox.test.ts @@ -0,0 +1,70 @@ +import {HttpClientRequest} from '@effect/platform' +import {SqlClient} from '@effect/sql' +import {Effect} from 'effect' +import {createMockedUser, type MockedUser} from '../utils/createMockedUser' +import {NodeTestingApp} from '../utils/NodeTestingApp' +import {runPromiseInMockedEnvironment} from '../utils/runPromiseInMockedEnvironment' + +let user1: MockedUser +let user2: MockedUser + +beforeAll(async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + user1 = yield* _(createMockedUser('+420733333330')) + user2 = yield* _(createMockedUser('+420733333331')) + const client = yield* _(NodeTestingApp) + + yield* _( + client.requestApproval( + { + body: { + message: 'someMessage', + publicKey: user2.inbox1.keyPair.publicKeyPemBase64, + }, + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ) + ) + + yield* _( + client.approveRequest( + { + body: yield* _( + user2.inbox1.addChallenge({ + message: 'someMessage2', + publicKeyToConfirm: user1.mainKeyPair.publicKeyPemBase64, + approve: true, + }) + ), + }, + HttpClientRequest.setHeaders(user2.authHeaders) + ) + ) + + const sql = yield* _(SqlClient.SqlClient) + yield* _(sql`DELETE FROM message`) + }) + ) +}) + +describe('Update inbox', () => { + it('Can be called sucessfully', async () => { + await runPromiseInMockedEnvironment( + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + const reseponse = yield* _( + client.updateInbox( + { + body: yield* _(user1.addChallengeForMainInbox({})), + }, + HttpClientRequest.setHeaders(user1.authHeaders) + ), + Effect.either + ) + + expect(reseponse).toMatchObject({_tag: 'Right'}) + }) + ) + }) +}) diff --git a/apps/chat-service/src/__tests__/utils/NodeTestingApp.ts b/apps/chat-service/src/__tests__/utils/NodeTestingApp.ts new file mode 100644 index 000000000..07cfe4bb0 --- /dev/null +++ b/apps/chat-service/src/__tests__/utils/NodeTestingApp.ts @@ -0,0 +1,13 @@ +import {ChatApiSpecification} from '@vexl-next/rest-api/src/services/chat/specification' +import {Context, Layer, type Effect} from 'effect' +import {NodeTesting} from 'effect-http-node' +import {app} from '../../httpServer' + +const nodeTestingAppEffect = NodeTesting.make(app, ChatApiSpecification) + +export class NodeTestingApp extends Context.Tag('NodeTestingApp')< + NodeTestingApp, + Effect.Effect.Success +>() { + static readonly Live = Layer.scoped(NodeTestingApp, nodeTestingAppEffect) +} diff --git a/apps/chat-service/src/__tests__/utils/addChallengeForKey.ts b/apps/chat-service/src/__tests__/utils/addChallengeForKey.ts new file mode 100644 index 000000000..f6d76fe5c --- /dev/null +++ b/apps/chat-service/src/__tests__/utils/addChallengeForKey.ts @@ -0,0 +1,65 @@ +import {HttpClientRequest} from '@effect/platform' +import { + type PrivateKeyHolder, + type PublicKeyPemBase64, +} from '@vexl-next/cryptography/src/KeyHolder' +import {type HashedPhoneNumber} from '@vexl-next/domain/src/general/HashedPhoneNumber.brand' +import { + type CryptoError, + type EcdsaSignature, + ecdsaSignE, +} from '@vexl-next/generic-utils/src/effect-helpers/crypto' +import {type SignedChallenge} from '@vexl-next/rest-api/src/services/chat/contracts' +import {Effect} from 'effect' +import {type ClientError} from 'effect-http' +import {NodeTestingApp} from './NodeTestingApp' + +export const addChallengeForKey = + ( + key: PrivateKeyHolder, + authHeaders: { + 'public-key': PublicKeyPemBase64 + signature: EcdsaSignature + hash: HashedPhoneNumber + } + ) => + ( + request: T, + simulateInvalidChallenge?: boolean + ): Effect.Effect< + T & { + readonly publicKey: PublicKeyPemBase64 + readonly senderPublicKey: PublicKeyPemBase64 // Make this compatible with all requests is ignored when ot used + readonly signedChallenge: SignedChallenge + }, + CryptoError | ClientError.ClientError, + NodeTestingApp + > => + Effect.gen(function* (_) { + const client = yield* _(NodeTestingApp) + + const challenge = yield* _( + client.createChallenge( + { + body: {publicKey: key.publicKeyPemBase64}, + }, + HttpClientRequest.setHeaders(authHeaders) + ) + ) + + const signedChallenge = yield* _( + ecdsaSignE(key.privateKeyPemBase64)( + simulateInvalidChallenge ? 'bad' : challenge.challenge + ) + ) + + return { + ...request, + publicKey: key.publicKeyPemBase64, + senderPublicKey: key.publicKeyPemBase64, + signedChallenge: { + challenge: challenge.challenge, + signature: signedChallenge, + }, + } + }) diff --git a/apps/chat-service/src/__tests__/utils/createMockedUser.ts b/apps/chat-service/src/__tests__/utils/createMockedUser.ts new file mode 100644 index 000000000..5b3d3b0a8 --- /dev/null +++ b/apps/chat-service/src/__tests__/utils/createMockedUser.ts @@ -0,0 +1,117 @@ +import {HttpClientRequest} from '@effect/platform' +import {Schema} from '@effect/schema' +import { + generatePrivateKey, + type PrivateKeyHolder, + type PublicKeyPemBase64, +} from '@vexl-next/cryptography/src/KeyHolder' +import {E164PhoneNumberE} from '@vexl-next/domain/src/general/E164PhoneNumber.brand' +import {type HashedPhoneNumber} from '@vexl-next/domain/src/general/HashedPhoneNumber.brand' +import { + type CryptoError, + type EcdsaSignature, +} from '@vexl-next/generic-utils/src/effect-helpers/crypto' +import {CommonHeaders} from '@vexl-next/rest-api/src/commonHeaders' +import {type ServerCrypto} from '@vexl-next/server-utils/src/ServerCrypto' +import {createDummyAuthHeadersForUser} from '@vexl-next/server-utils/src/tests/createDummyAuthHeaders' +import {Effect} from 'effect' +import {type ClientError} from 'effect-http' +import {addChallengeForKey} from './addChallengeForKey' +import {NodeTestingApp} from './NodeTestingApp' + +interface MockedInbox { + keyPair: PrivateKeyHolder + addChallenge: ReturnType +} + +export interface MockedUser { + mainKeyPair: PrivateKeyHolder + authHeaders: { + 'public-key': PublicKeyPemBase64 + signature: EcdsaSignature + hash: HashedPhoneNumber + } + addChallengeForMainInbox: ReturnType + inbox1: MockedInbox + inbox2: MockedInbox + inbox3: MockedInbox +} + +export const createMockedInbox = (authHeaders: { + 'public-key': PublicKeyPemBase64 + signature: EcdsaSignature + hash: HashedPhoneNumber +}): Effect.Effect< + MockedInbox, + CryptoError | ClientError.ClientError, + NodeTestingApp +> => + Effect.gen(function* (_) { + const keyPair = generatePrivateKey() + const addChallenge = addChallengeForKey(keyPair, authHeaders) + + const client = yield* _(NodeTestingApp) + yield* _( + client.createInbox( + { + body: yield* _(addChallenge({})), + headers: Schema.decodeSync(CommonHeaders)({ + 'user-agent': 'Vexl/1 (1.0.0) ANDROID', + }), + }, + HttpClientRequest.setHeaders(authHeaders) + ) + ) + + return {keyPair, addChallenge} + }) + +export const createMockedUser = ( + numberRaw: string +): Effect.Effect< + MockedUser, + CryptoError | ClientError.ClientError, + NodeTestingApp | ServerCrypto +> => + Effect.gen(function* (_) { + const mainKeyPair = generatePrivateKey() + const number = Schema.decodeSync(E164PhoneNumberE)(numberRaw) + + const authHeaders = yield* _( + createDummyAuthHeadersForUser({ + phoneNumber: number, + publicKey: mainKeyPair.publicKeyPemBase64, + }) + ) + + const addChallengeForMainInbox = addChallengeForKey( + mainKeyPair, + authHeaders + ) + + const client = yield* _(NodeTestingApp) + yield* _( + client.createInbox( + { + body: yield* _(addChallengeForMainInbox({})), + headers: Schema.decodeSync(CommonHeaders)({ + 'user-agent': 'Vexl/1 (1.0.0) ANDROID', + }), + }, + HttpClientRequest.setHeaders(authHeaders) + ) + ) + + const inbox1 = yield* _(createMockedInbox(authHeaders)) + const inbox2 = yield* _(createMockedInbox(authHeaders)) + const inbox3 = yield* _(createMockedInbox(authHeaders)) + + return { + mainKeyPair, + authHeaders, + addChallengeForMainInbox, + inbox1, + inbox2, + inbox3, + } + }) diff --git a/apps/chat-service/src/__tests__/utils/runPromiseInMockedEnvironment.ts b/apps/chat-service/src/__tests__/utils/runPromiseInMockedEnvironment.ts new file mode 100644 index 000000000..7c33d51d4 --- /dev/null +++ b/apps/chat-service/src/__tests__/utils/runPromiseInMockedEnvironment.ts @@ -0,0 +1,81 @@ +import {NodeContext} from '@effect/platform-node' +import {type SqlClient} from '@effect/sql/SqlClient' +import {type RedisService} from '@vexl-next/server-utils/src/RedisService' +import {ServerCrypto} from '@vexl-next/server-utils/src/ServerCrypto' +import {mockedDashboardReportsService} from '@vexl-next/server-utils/src/tests/mockedDashboardReportsService' +import {mockedRedisLayer} from '@vexl-next/server-utils/src/tests/mockedRedisLayer' +import { + disposeTestDatabase, + setupTestDatabase, +} from '@vexl-next/server-utils/src/tests/testDb' +import {Console, Effect, Layer, ManagedRuntime, type Scope} from 'effect' +import {cryptoConfig} from '../../configs' +import {ChallengeDbService} from '../../db/ChallegeDbService' +import {InboxDbService} from '../../db/InboxDbService' +import DbLayer from '../../db/layer' +import {MessagesDbService} from '../../db/MessagesDbService' +import {WhitelistDbService} from '../../db/WhiteListDbService' +import {ChatChallengeService} from '../../utils/ChatChallengeService' +import {NodeTestingApp} from './NodeTestingApp' + +export type MockedContexts = + | RedisService + | NodeTestingApp + | ServerCrypto + | SqlClient + | ChallengeDbService + | InboxDbService + | MessagesDbService + | WhitelistDbService + +const universalContext = Layer.mergeAll( + mockedRedisLayer, + ServerCrypto.layer(cryptoConfig) +) + +const context = NodeTestingApp.Live.pipe( + Layer.provideMerge(ChatChallengeService.Live), + Layer.provideMerge( + Layer.mergeAll( + ChallengeDbService.Live, + InboxDbService.Live, + MessagesDbService.Live, + WhitelistDbService.Live + ) + ), + Layer.provideMerge(universalContext), + Layer.provideMerge(mockedDashboardReportsService), + Layer.provideMerge(DbLayer), + Layer.provideMerge(NodeContext.layer) +) + +const runtime = ManagedRuntime.make(context) + +export const startRuntime = async (): Promise => { + await Effect.runPromise(setupTestDatabase) + await runtime.runPromise(Console.log('Initialized the test environment')) +} + +export const disposeRuntime = async (): Promise => { + await Effect.runPromise( + Effect.andThen(runtime.disposeEffect, () => + Console.log('Disposed test environment') + ) + ) + await Effect.runPromise(disposeTestDatabase) +} + +export const runPromiseInMockedEnvironment = async ( + effectToRun: Effect.Effect +): Promise => { + await runtime.runPromise( + effectToRun.pipe( + Effect.scoped, + Effect.catchAll((e) => { + console.warn(e) + expect(e).toBe('Error in test') + return Console.error('Error in test', e) + }) + ) + ) +} diff --git a/apps/chat-service/src/configs.ts b/apps/chat-service/src/configs.ts new file mode 100644 index 000000000..0cd571c44 --- /dev/null +++ b/apps/chat-service/src/configs.ts @@ -0,0 +1,21 @@ +import {Config} from 'effect' + +export { + cryptoConfig, + databaseConfig, + easKey, + healthServerPortConfig, + hmacKey, + isRunningInDevelopmentConfig, + isRunningInTestConfig, + nodeEnvConfig, + portConfig, + redisUrl, + secretPrivateKey, + secretPublicKey, +} from '@vexl-next/server-utils/src/commonConfigs' + +export const requestTimeoutDaysConfig = Config.number('REQUEST_TIMEOUT_DAYS') +export const challengeExpirationMinutesConfig = Config.number( + 'CHALLENGE_EXPIRATION_MINUTES' +) diff --git a/apps/chat-service/src/db/ChallegeDbService/domain.ts b/apps/chat-service/src/db/ChallegeDbService/domain.ts new file mode 100644 index 000000000..76139dfb6 --- /dev/null +++ b/apps/chat-service/src/db/ChallegeDbService/domain.ts @@ -0,0 +1,18 @@ +import {Schema} from '@effect/schema' +import {PublicKeyPemBase64E} from '@vexl-next/cryptography/src/KeyHolder/brands' +import {ChatChallengeE} from '@vexl-next/rest-api/src/services/chat/contracts' + +export const ChallengeRecordId = Schema.BigInt.pipe( + Schema.brand('ChallengeRecordId') +) +export type ChallengeRecordId = Schema.Schema.Type + +export class ChallengeRecord extends Schema.Class( + 'ChallengeRecord' +)({ + id: ChallengeRecordId, + challenge: ChatChallengeE, + publicKey: PublicKeyPemBase64E, + createdAt: Schema.DateFromSelf, + valid: Schema.Boolean, +}) {} diff --git a/apps/chat-service/src/db/ChallegeDbService/index.ts b/apps/chat-service/src/db/ChallegeDbService/index.ts new file mode 100644 index 000000000..85fe3a496 --- /dev/null +++ b/apps/chat-service/src/db/ChallegeDbService/index.ts @@ -0,0 +1,68 @@ +import {type UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import {type ChatChallenge} from '@vexl-next/rest-api/src/services/chat/contracts' +import {Context, Effect, Layer, type Option} from 'effect' +import {type ChallengeRecord} from './domain' +import {createDeleteChallenge} from './queries/createDeleteChallenge' +import {createDeleteInvalidAndExpiredChallenges} from './queries/createDeleteInvalidAndExpiredChallenges' +import { + createFindChallengeByChallengeAndPublicKey, + type FindChallengeByChallengeAndPublicKey, +} from './queries/createFindChallengeByChallengeAndPublicKey' +import { + createInsertChallenge, + type InsertChallengeParams, +} from './queries/createInsertChallenge' +import {createUpdateChallengeInvalidate} from './queries/createUpdateChallengeInvalidate' + +export interface ChallengeDbOperations { + deleteInvalidAndExpiredChallenges: () => Effect.Effect< + void, + UnexpectedServerError + > + + findChallengeByChallengeAndPublicKey: ( + args: FindChallengeByChallengeAndPublicKey + ) => Effect.Effect, UnexpectedServerError> + + insertChallenge: ( + args: InsertChallengeParams + ) => Effect.Effect + + updateChallengeInvalidate: ( + args: ChatChallenge + ) => Effect.Effect + + deleteChallenge: ( + args: ChatChallenge + ) => Effect.Effect +} + +export class ChallengeDbService extends Context.Tag('ChallengeDbService')< + ChallengeDbService, + ChallengeDbOperations +>() { + static readonly Live = Layer.effect( + ChallengeDbService, + Effect.gen(function* (_) { + const deleteInvalidAndExpiredChallenges = yield* _( + createDeleteInvalidAndExpiredChallenges + ) + const findChallengeByChallengeAndPublicKey = yield* _( + createFindChallengeByChallengeAndPublicKey + ) + const insertChallenge = yield* _(createInsertChallenge) + const updateChallengeInvalidate = yield* _( + createUpdateChallengeInvalidate + ) + const deleteChallenge = yield* _(createDeleteChallenge) + + return { + deleteInvalidAndExpiredChallenges, + findChallengeByChallengeAndPublicKey, + insertChallenge, + updateChallengeInvalidate, + deleteChallenge, + } + }) + ) +} diff --git a/apps/chat-service/src/db/ChallegeDbService/queries/createDeleteChallenge.ts b/apps/chat-service/src/db/ChallegeDbService/queries/createDeleteChallenge.ts new file mode 100644 index 000000000..878bd28ce --- /dev/null +++ b/apps/chat-service/src/db/ChallegeDbService/queries/createDeleteChallenge.ts @@ -0,0 +1,31 @@ +import {SqlResolver} from '@effect/sql' +import {PgClient} from '@effect/sql-pg' +import {UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import {ChatChallengeE} from '@vexl-next/rest-api/src/services/chat/contracts' +import {Effect, flow} from 'effect' + +export const createDeleteChallenge = Effect.gen(function* (_) { + const sql = yield* _(PgClient.PgClient) + + const resolver = yield* _( + SqlResolver.void('deleteChallenge', { + Request: ChatChallengeE, + execute: (params) => sql` + DELETE FROM challenge + WHERE + ${sql.in('challenge', params)} + `, + }) + ) + + return flow( + resolver.execute, + Effect.catchAll((e) => + Effect.zipRight( + Effect.logError('Error in deleteChallenge', e), + Effect.fail(new UnexpectedServerError({status: 500})) + ) + ), + Effect.withSpan('deleteChallenge query') + ) +}) diff --git a/apps/chat-service/src/db/ChallegeDbService/queries/createDeleteInvalidAndExpiredChallenges.ts b/apps/chat-service/src/db/ChallegeDbService/queries/createDeleteInvalidAndExpiredChallenges.ts new file mode 100644 index 000000000..c65b25883 --- /dev/null +++ b/apps/chat-service/src/db/ChallegeDbService/queries/createDeleteInvalidAndExpiredChallenges.ts @@ -0,0 +1,28 @@ +import {PgClient} from '@effect/sql-pg' +import {UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import {Effect} from 'effect' +import {challengeExpirationMinutesConfig} from '../../../configs' + +export const createDeleteInvalidAndExpiredChallenges = Effect.gen( + function* (_) { + const sql = yield* _(PgClient.PgClient) + + const expirationMinutes = yield* _(challengeExpirationMinutesConfig) + + return () => + sql` + DELETE FROM challenge + WHERE + valid = FALSE + OR created_at < now() - interval '1 MINUTE' * ${expirationMinutes} + `.pipe( + Effect.catchAll((e) => + Effect.zipRight( + Effect.logError('Error in deleteInvalidAndExpiredChallenges', e), + Effect.fail(new UnexpectedServerError({status: 500})) + ) + ), + Effect.withSpan('deleteInvalidAndExpiredChallenges query') + ) + } +) diff --git a/apps/chat-service/src/db/ChallegeDbService/queries/createFindChallengeByChallengeAndPublicKey.ts b/apps/chat-service/src/db/ChallegeDbService/queries/createFindChallengeByChallengeAndPublicKey.ts new file mode 100644 index 000000000..047ac536e --- /dev/null +++ b/apps/chat-service/src/db/ChallegeDbService/queries/createFindChallengeByChallengeAndPublicKey.ts @@ -0,0 +1,50 @@ +import {Schema} from '@effect/schema' +import {SqlClient, SqlSchema} from '@effect/sql' +import {PublicKeyPemBase64E} from '@vexl-next/cryptography/src/KeyHolder/brands' +import {UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import {ChatChallengeE} from '@vexl-next/rest-api/src/services/chat/contracts' +import {Effect, flow} from 'effect' +import {challengeExpirationMinutesConfig} from '../../../configs' +import {ChallengeRecord} from '../domain' + +export const FindChallengeByChallengeAndPublicKey = Schema.Struct({ + challenge: ChatChallengeE, + publicKey: PublicKeyPemBase64E, +}) +export type FindChallengeByChallengeAndPublicKey = Schema.Schema.Type< + typeof FindChallengeByChallengeAndPublicKey +> + +export const createFindChallengeByChallengeAndPublicKey = Effect.gen( + function* (_) { + const sql = yield* _(SqlClient.SqlClient) + const expirationMinutes = yield* _(challengeExpirationMinutesConfig) + + const query = SqlSchema.findOne({ + Request: FindChallengeByChallengeAndPublicKey, + Result: ChallengeRecord, + execute: (params) => sql` + SELECT + * + FROM + challenge + WHERE + challenge = ${params.challenge} + AND public_key = ${params.publicKey} + AND created_at > now() - interval '1 MINUTE' * ${expirationMinutes} + AND valid = TRUE + `, + }) + + return flow( + query, + Effect.catchAll((e) => + Effect.zipRight( + Effect.logError('Error in findChallengeByChallengeAndPublicKey', e), + Effect.fail(new UnexpectedServerError({status: 500})) + ) + ), + Effect.withSpan('findChallengeByChallengeAndPublicKey query') + ) + } +) diff --git a/apps/chat-service/src/db/ChallegeDbService/queries/createInsertChallenge.ts b/apps/chat-service/src/db/ChallegeDbService/queries/createInsertChallenge.ts new file mode 100644 index 000000000..c4f6ea7b5 --- /dev/null +++ b/apps/chat-service/src/db/ChallegeDbService/queries/createInsertChallenge.ts @@ -0,0 +1,45 @@ +import {Schema} from '@effect/schema' +import {SqlResolver} from '@effect/sql' +import {PgClient} from '@effect/sql-pg' +import {PublicKeyPemBase64E} from '@vexl-next/cryptography/src/KeyHolder/brands' +import {UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import {ChatChallengeE} from '@vexl-next/rest-api/src/services/chat/contracts' +import {Effect, flow} from 'effect' + +const InsertChallengeParams = Schema.Struct({ + challenge: ChatChallengeE, + publicKey: PublicKeyPemBase64E, + createdAt: Schema.optionalWith(Schema.DateFromSelf, { + default: () => new Date(), + }), + valid: Schema.optionalWith(Schema.Boolean, {default: () => true}), +}) + +export type InsertChallengeParams = Schema.Schema.Type< + typeof InsertChallengeParams +> + +export const createInsertChallenge = Effect.gen(function* (_) { + const sql = yield* _(PgClient.PgClient) + + const resolver = yield* _( + SqlResolver.void('insertChallenge', { + Request: InsertChallengeParams, + execute: (params) => sql` + INSERT INTO + challenge ${sql.insert(params)} + `, + }) + ) + + return flow( + resolver.execute, + Effect.catchAll((e) => + Effect.zipRight( + Effect.logError('Error in insertChallenge', e), + Effect.fail(new UnexpectedServerError({status: 500})) + ) + ), + Effect.withSpan('insertChallenge query') + ) +}) diff --git a/apps/chat-service/src/db/ChallegeDbService/queries/createUpdateChallengeInvalidate.ts b/apps/chat-service/src/db/ChallegeDbService/queries/createUpdateChallengeInvalidate.ts new file mode 100644 index 000000000..08db377e2 --- /dev/null +++ b/apps/chat-service/src/db/ChallegeDbService/queries/createUpdateChallengeInvalidate.ts @@ -0,0 +1,33 @@ +import {SqlResolver} from '@effect/sql' +import {PgClient} from '@effect/sql-pg' +import {UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import {ChatChallengeE} from '@vexl-next/rest-api/src/services/chat/contracts' +import {Effect, flow} from 'effect' + +export const createUpdateChallengeInvalidate = Effect.gen(function* (_) { + const sql = yield* _(PgClient.PgClient) + + const resolver = yield* _( + SqlResolver.void('updateChallengeInvalidate', { + Request: ChatChallengeE, + execute: (params) => sql` + UPDATE challenge + SET + valid = FALSE + WHERE + ${sql.in('challenge', params)} + `, + }) + ) + + return flow( + resolver.execute, + Effect.catchAll((e) => + Effect.zipRight( + Effect.logError('Error in updateChallengeInvalidate', e), + Effect.fail(new UnexpectedServerError({status: 500})) + ) + ), + Effect.withSpan('updateChallengeInvalidate query') + ) +}) diff --git a/apps/chat-service/src/db/InboxDbService/domain.ts b/apps/chat-service/src/db/InboxDbService/domain.ts new file mode 100644 index 000000000..63be5fdba --- /dev/null +++ b/apps/chat-service/src/db/InboxDbService/domain.ts @@ -0,0 +1,14 @@ +import {Schema} from '@effect/schema' +import {VersionCode} from '@vexl-next/domain/src/utility/VersionCode.brand' +import {PlatformNameE} from '@vexl-next/rest-api/src/PlatformName' +import {PublicKeyHashed} from '../domain' + +export const InboxRecordId = Schema.BigInt.pipe(Schema.brand('InboxRecordId')) +export type InboxRecordId = Schema.Schema.Type + +export class InboxRecord extends Schema.Class('InboxRecord')({ + id: InboxRecordId, + publicKey: PublicKeyHashed, + platform: Schema.optionalWith(PlatformNameE, {as: 'Option'}), + clientVersion: Schema.optionalWith(VersionCode, {as: 'Option'}), +}) {} diff --git a/apps/chat-service/src/db/InboxDbService/index.ts b/apps/chat-service/src/db/InboxDbService/index.ts new file mode 100644 index 000000000..b5fbe9e35 --- /dev/null +++ b/apps/chat-service/src/db/InboxDbService/index.ts @@ -0,0 +1,54 @@ +import {type UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import {Context, Effect, Layer, type Option} from 'effect' +import {type PublicKeyHashed} from '../domain' +import {type InboxRecord} from './domain' +import {createDeleteInboxByPublicKey} from './queries/createDeleteInboxByPublicKey' +import {createFindInboxByPublicKey} from './queries/createFindInboxByPublicKey' +import { + createInsertInbox, + type InsertInboxParams, +} from './queries/createInsertInbox' +import { + createUpdateInboxMetadata, + type UpdateInboxMetadataParams, +} from './queries/createUpdateInboxMetadata' + +export interface InboxDbOperations { + deleteInboxByPublicKey: ( + args: PublicKeyHashed + ) => Effect.Effect + + findInboxByPublicKey: ( + args: PublicKeyHashed + ) => Effect.Effect, UnexpectedServerError> + + insertInbox: ( + args: InsertInboxParams + ) => Effect.Effect + + updateInboxMetadata: ( + args: UpdateInboxMetadataParams + ) => Effect.Effect +} + +export class InboxDbService extends Context.Tag('InboxDbService')< + InboxDbService, + InboxDbOperations +>() { + static readonly Live = Layer.effect( + InboxDbService, + Effect.gen(function* (_) { + const deleteInboxByPublicKey = yield* _(createDeleteInboxByPublicKey) + const findInboxByPublicKey = yield* _(createFindInboxByPublicKey) + const insertInbox = yield* _(createInsertInbox) + const updateInboxMetadata = yield* _(createUpdateInboxMetadata) + + return { + deleteInboxByPublicKey, + findInboxByPublicKey, + insertInbox, + updateInboxMetadata, + } + }) + ) +} diff --git a/apps/chat-service/src/db/InboxDbService/queries/createDeleteInboxByPublicKey.ts b/apps/chat-service/src/db/InboxDbService/queries/createDeleteInboxByPublicKey.ts new file mode 100644 index 000000000..b4138237b --- /dev/null +++ b/apps/chat-service/src/db/InboxDbService/queries/createDeleteInboxByPublicKey.ts @@ -0,0 +1,29 @@ +import {SqlSchema} from '@effect/sql' +import {PgClient} from '@effect/sql-pg' +import {UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import {Effect, flow} from 'effect' +import {PublicKeyHashed} from '../../domain' + +export const createDeleteInboxByPublicKey = Effect.gen(function* (_) { + const sql = yield* _(PgClient.PgClient) + + const query = SqlSchema.void({ + Request: PublicKeyHashed, + execute: (params) => sql` + DELETE FROM inbox + WHERE + public_key = ${params} + `, + }) + + return flow( + query, + Effect.catchAll((e) => + Effect.zipRight( + Effect.logError('Error in deleteInboxByPublicKey', e), + Effect.fail(new UnexpectedServerError({status: 500})) + ) + ), + Effect.withSpan('deleteInboxByPublicKey query') + ) +}) diff --git a/apps/chat-service/src/db/InboxDbService/queries/createFindInboxByPublicKey.ts b/apps/chat-service/src/db/InboxDbService/queries/createFindInboxByPublicKey.ts new file mode 100644 index 000000000..6ab73380a --- /dev/null +++ b/apps/chat-service/src/db/InboxDbService/queries/createFindInboxByPublicKey.ts @@ -0,0 +1,33 @@ +import {SqlClient, SqlSchema} from '@effect/sql' +import {UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import {Effect, flow} from 'effect' +import {PublicKeyHashed} from '../../domain' +import {InboxRecord} from '../domain' + +export const createFindInboxByPublicKey = Effect.gen(function* (_) { + const sql = yield* _(SqlClient.SqlClient) + + const query = SqlSchema.findOne({ + Request: PublicKeyHashed, + Result: InboxRecord, + execute: (params) => sql` + SELECT + * + FROM + inbox + WHERE + public_key = ${params} + `, + }) + + return flow( + query, + Effect.catchAll((e) => + Effect.zipRight( + Effect.logError('Error in findInboxByPublicKey', e), + Effect.fail(new UnexpectedServerError({status: 500})) + ) + ), + Effect.withSpan('findInboxByPublicKey query') + ) +}) diff --git a/apps/chat-service/src/db/InboxDbService/queries/createInsertInbox.ts b/apps/chat-service/src/db/InboxDbService/queries/createInsertInbox.ts new file mode 100644 index 000000000..3267f631f --- /dev/null +++ b/apps/chat-service/src/db/InboxDbService/queries/createInsertInbox.ts @@ -0,0 +1,46 @@ +import {Schema} from '@effect/schema' +import {SqlResolver} from '@effect/sql' +import {PgClient} from '@effect/sql-pg' +import {UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import {VersionCode} from '@vexl-next/domain/src/utility/VersionCode.brand' +import {PlatformNameE} from '@vexl-next/rest-api/src/PlatformName' +import {Effect, flow} from 'effect' +import {PublicKeyHashed} from '../../domain' + +export const InsertInboxParams = Schema.Struct({ + publicKey: PublicKeyHashed, + platform: Schema.optionalWith(PlatformNameE, {as: 'Option'}), + clientVersion: Schema.optionalWith(VersionCode, {as: 'Option'}), +}) +export type InsertInboxParams = Schema.Schema.Type + +export const createInsertInbox = Effect.gen(function* (_) { + const sql = yield* _(PgClient.PgClient) + + const resolver = yield* _( + SqlResolver.void('insertInbox', { + Request: InsertInboxParams, + execute: (params) => sql` + INSERT INTO + inbox ${sql.insert( + params.map((one) => ({ + ...one, + platform: one.platform ?? null, + clientVersion: one.clientVersion ?? null, + })) + )} + `, + }) + ) + + return flow( + resolver.execute, + Effect.catchAll((e) => + Effect.zipRight( + Effect.logError('Error in insertInbox', e), + Effect.fail(new UnexpectedServerError({status: 500})) + ) + ), + Effect.withSpan('insertInbox query') + ) +}) diff --git a/apps/chat-service/src/db/InboxDbService/queries/createUpdateInboxMetadata.ts b/apps/chat-service/src/db/InboxDbService/queries/createUpdateInboxMetadata.ts new file mode 100644 index 000000000..51d9c4c6a --- /dev/null +++ b/apps/chat-service/src/db/InboxDbService/queries/createUpdateInboxMetadata.ts @@ -0,0 +1,44 @@ +import {Schema} from '@effect/schema' +import {SqlSchema} from '@effect/sql' +import {PgClient} from '@effect/sql-pg' +import {UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import {VersionCode} from '@vexl-next/domain/src/utility/VersionCode.brand' +import {PlatformNameE} from '@vexl-next/rest-api/src/PlatformName' +import {Effect, flow} from 'effect' +import {InboxRecordId} from '../domain' + +const UpdateInboxMetadataParams = Schema.Struct({ + id: InboxRecordId, + platform: Schema.optionalWith(PlatformNameE, {as: 'Option'}), + clientVersion: Schema.optionalWith(VersionCode, {as: 'Option'}), +}) +export type UpdateInboxMetadataParams = Schema.Schema.Type< + typeof UpdateInboxMetadataParams +> + +export const createUpdateInboxMetadata = Effect.gen(function* (_) { + const sql = yield* _(PgClient.PgClient) + + const query = SqlSchema.void({ + Request: UpdateInboxMetadataParams, + execute: (params) => sql` + UPDATE inbox + SET + platform = ${params.platform ?? null}, + client_version = ${params.clientVersion ?? null} + WHERE + id = ${params.id} + `, + }) + + return flow( + query, + Effect.catchAll((e) => + Effect.zipRight( + Effect.logError('Error in updateInboxMetadata', e), + Effect.fail(new UnexpectedServerError({status: 500})) + ) + ), + Effect.withSpan('updateInboxMetadata query') + ) +}) diff --git a/apps/chat-service/src/db/MessagesDbService/domain.ts b/apps/chat-service/src/db/MessagesDbService/domain.ts new file mode 100644 index 000000000..5ed348ec2 --- /dev/null +++ b/apps/chat-service/src/db/MessagesDbService/domain.ts @@ -0,0 +1,21 @@ +import {Schema} from '@effect/schema' +import {MessageTypeE} from '@vexl-next/domain/src/general/messaging' +import {PublicKeyEncrypted} from '../domain' +import {InboxRecordId} from '../InboxDbService/domain' + +export const MessageRecordId = Schema.BigInt.pipe( + Schema.brand('MessageRecordId') +) +export type MessageRecordId = Schema.Schema.Type + +export class MessageRecord extends Schema.Class('MessageRecord')( + { + id: MessageRecordId, + message: Schema.String, + senderPublicKey: PublicKeyEncrypted, // TODO is this needed? + pulled: Schema.Boolean, + type: MessageTypeE, // TODO brand + inboxId: InboxRecordId, + // TODO deleteAfter - date that is randomly generated 6-15 days into the future + } +) {} diff --git a/apps/chat-service/src/db/MessagesDbService/index.ts b/apps/chat-service/src/db/MessagesDbService/index.ts new file mode 100644 index 000000000..6d07e8cfe --- /dev/null +++ b/apps/chat-service/src/db/MessagesDbService/index.ts @@ -0,0 +1,65 @@ +import {type UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import {Context, Effect, Layer} from 'effect' +import {type InboxRecordId} from '../InboxDbService/domain' +import {type MessageRecord, type MessageRecordId} from './domain' +import {createDeleteAllMessagesByInboxId} from './query/createDeleteAllMessagesByInboxId' +import {createDeletePulledMessagesMessagesByInboxId} from './query/createDeletePulledMessagesByInboxId' +import {createFindMessagesByInboxId} from './query/createFindMessagesByInboxId' +import { + createInsertMessageForInbox, + type InsertMessageForInboxParams, +} from './query/createInsertMessageForInbox' +import {createUpdateMessageAsPulledByMessageRecord} from './query/createUpdateMessageAsPulledByMessageRecord' + +export interface MessagesDbOperations { + deleteAllMessagesByInboxId: ( + args: InboxRecordId + ) => Effect.Effect + + deletePulledMessagesByInboxId: ( + args: InboxRecordId + ) => Effect.Effect + + findMessagesByInboxId: ( + args: InboxRecordId + ) => Effect.Effect + + insertMessageForInbox: ( + args: InsertMessageForInboxParams + ) => Effect.Effect + + updateMessageAsPulledByMessageRecord: ( + args: MessageRecordId + ) => Effect.Effect +} + +export class MessagesDbService extends Context.Tag('MessagesDbService')< + MessagesDbService, + MessagesDbOperations +>() { + static readonly Live = Layer.effect( + MessagesDbService, + Effect.gen(function* (_) { + const deleteAllMessagesByInboxId = yield* _( + createDeleteAllMessagesByInboxId + ) + const deletePulledMessagesByInboxId = yield* _( + createDeletePulledMessagesMessagesByInboxId + ) + const findMessagesByInboxId = yield* _(createFindMessagesByInboxId) + const updateMessageAsPulledByMessageRecord = yield* _( + createUpdateMessageAsPulledByMessageRecord + ) + + const insertMessageForInbox = yield* _(createInsertMessageForInbox) + + return { + deleteAllMessagesByInboxId, + deletePulledMessagesByInboxId, + findMessagesByInboxId, + updateMessageAsPulledByMessageRecord, + insertMessageForInbox, + } + }) + ) +} diff --git a/apps/chat-service/src/db/MessagesDbService/query/createDeleteAllMessagesByInboxId.ts b/apps/chat-service/src/db/MessagesDbService/query/createDeleteAllMessagesByInboxId.ts new file mode 100644 index 000000000..e30bbf93e --- /dev/null +++ b/apps/chat-service/src/db/MessagesDbService/query/createDeleteAllMessagesByInboxId.ts @@ -0,0 +1,29 @@ +import {SqlSchema} from '@effect/sql' +import {PgClient} from '@effect/sql-pg' +import {UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import {Effect, flow} from 'effect' +import {InboxRecordId} from '../../InboxDbService/domain' + +export const createDeleteAllMessagesByInboxId = Effect.gen(function* (_) { + const sql = yield* _(PgClient.PgClient) + + const query = SqlSchema.void({ + Request: InboxRecordId, + execute: (params) => sql` + DELETE FROM message + WHERE + inbox_id = ${params} + `, + }) + + return flow( + query, + Effect.catchAll((e) => + Effect.zipRight( + Effect.logError('Error in deleteAllMessagesByInboxId', e), + Effect.fail(new UnexpectedServerError({status: 500})) + ) + ), + Effect.withSpan('deleteAllMessagesByInboxId find') + ) +}) diff --git a/apps/chat-service/src/db/MessagesDbService/query/createDeletePulledMessagesByInboxId.ts b/apps/chat-service/src/db/MessagesDbService/query/createDeletePulledMessagesByInboxId.ts new file mode 100644 index 000000000..143d7b979 --- /dev/null +++ b/apps/chat-service/src/db/MessagesDbService/query/createDeletePulledMessagesByInboxId.ts @@ -0,0 +1,32 @@ +import {SqlSchema} from '@effect/sql' +import {PgClient} from '@effect/sql-pg' +import {UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import {Effect, flow} from 'effect' +import {InboxRecordId} from '../../InboxDbService/domain' + +export const createDeletePulledMessagesMessagesByInboxId = Effect.gen( + function* (_) { + const sql = yield* _(PgClient.PgClient) + + const query = SqlSchema.void({ + Request: InboxRecordId, + execute: (params) => sql` + DELETE FROM message + WHERE + inbox_id = ${params} + AND pulled = TRUE + `, + }) + + return flow( + query, + Effect.catchAll((e) => + Effect.zipRight( + Effect.logError('Error in deletePulledMessagesByInboxId', e), + Effect.fail(new UnexpectedServerError({status: 500})) + ) + ), + Effect.withSpan('deletePulledMessagesByInboxId find') + ) + } +) diff --git a/apps/chat-service/src/db/MessagesDbService/query/createFindMessagesByInboxId.ts b/apps/chat-service/src/db/MessagesDbService/query/createFindMessagesByInboxId.ts new file mode 100644 index 000000000..572bb92cf --- /dev/null +++ b/apps/chat-service/src/db/MessagesDbService/query/createFindMessagesByInboxId.ts @@ -0,0 +1,33 @@ +import {SqlClient, SqlSchema} from '@effect/sql' +import {UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import {Effect, flow} from 'effect' +import {InboxRecordId} from '../../InboxDbService/domain' +import {MessageRecord} from '../domain' + +export const createFindMessagesByInboxId = Effect.gen(function* (_) { + const sql = yield* _(SqlClient.SqlClient) + + const query = SqlSchema.findAll({ + Request: InboxRecordId, + Result: MessageRecord, + execute: (params) => sql` + SELECT + * + FROM + message + WHERE + inbox_id = ${params} + `, + }) + + return flow( + query, + Effect.catchAll((e) => + Effect.zipRight( + Effect.logError('Error in findMessagesByInboxId', e), + Effect.fail(new UnexpectedServerError({status: 500})) + ) + ), + Effect.withSpan('findMessagesByInboxId find') + ) +}) diff --git a/apps/chat-service/src/db/MessagesDbService/query/createInsertMessageForInbox.ts b/apps/chat-service/src/db/MessagesDbService/query/createInsertMessageForInbox.ts new file mode 100644 index 000000000..572dcdc4d --- /dev/null +++ b/apps/chat-service/src/db/MessagesDbService/query/createInsertMessageForInbox.ts @@ -0,0 +1,48 @@ +import {Schema} from '@effect/schema' +import {SqlClient, SqlSchema} from '@effect/sql' +import {UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import {MessageTypeE} from '@vexl-next/domain/src/general/messaging' +import {Effect, flow} from 'effect' +import {PublicKeyEncrypted} from '../../domain' +import {InboxRecordId} from '../../InboxDbService/domain' +import {MessageRecord} from '../domain' + +export const InsertMessageForInboxParams = Schema.Struct({ + message: Schema.String, + senderPublicKey: PublicKeyEncrypted, + type: MessageTypeE, + inboxId: InboxRecordId, +}) +export type InsertMessageForInboxParams = Schema.Schema.Type< + typeof InsertMessageForInboxParams +> + +export const createInsertMessageForInbox = Effect.gen(function* (_) { + const sql = yield* _(SqlClient.SqlClient) + + const query = SqlSchema.findOne({ + Request: InsertMessageForInboxParams, + Result: MessageRecord, + execute: (params) => sql` + INSERT INTO + message ${sql.insert({ + ...params, + pulled: false, + })} + RETURNING + * + `, + }) + + return flow( + query, + Effect.flatten, + Effect.catchAll((e) => + Effect.zipRight( + Effect.logError('Error in insertMessage', e), + Effect.fail(new UnexpectedServerError({status: 500})) + ) + ), + Effect.withSpan('insertMessage query') + ) +}) diff --git a/apps/chat-service/src/db/MessagesDbService/query/createUpdateMessageAsPulledByMessageRecord.ts b/apps/chat-service/src/db/MessagesDbService/query/createUpdateMessageAsPulledByMessageRecord.ts new file mode 100644 index 000000000..45e19c6a0 --- /dev/null +++ b/apps/chat-service/src/db/MessagesDbService/query/createUpdateMessageAsPulledByMessageRecord.ts @@ -0,0 +1,34 @@ +import {SqlClient, SqlResolver} from '@effect/sql' +import {UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import {Effect, flow} from 'effect' +import {MessageRecordId} from '../domain' + +export const createUpdateMessageAsPulledByMessageRecord = Effect.gen( + function* (_) { + const sql = yield* _(SqlClient.SqlClient) + + const resolver = yield* _( + SqlResolver.void('updateMessageAsPulledByInboxId', { + Request: MessageRecordId, + execute: (params) => sql` + UPDATE message + SET + pulled = TRUE + WHERE + ${sql.in('id', params)} + `, + }) + ) + + return flow( + resolver.execute, + Effect.catchAll((e) => + Effect.zipRight( + Effect.logError('Error in updateMessageAsPulledByInboxId', e), + Effect.fail(new UnexpectedServerError({status: 500})) + ) + ), + Effect.withSpan('updateMessageAsPulledByInboxId find') + ) + } +) diff --git a/apps/chat-service/src/db/WhiteListDbService/domain.ts b/apps/chat-service/src/db/WhiteListDbService/domain.ts new file mode 100644 index 000000000..566040e6d --- /dev/null +++ b/apps/chat-service/src/db/WhiteListDbService/domain.ts @@ -0,0 +1,27 @@ +import {Schema} from '@effect/schema' +import {PublicKeyHashed} from '../domain' +import {InboxRecordId} from '../InboxDbService/domain' + +export const WhitelistRecordId = Schema.BigInt.pipe( + Schema.brand('WhitelistRecordId') +) +export type WhitelistRecordId = Schema.Schema.Type + +export const WhiteListState = Schema.Literal( + 'APROVED', + 'DISAPROVED', + 'BLOCKED', + 'WAITING', + 'CANCELED' +) +export type WhiteListState = Schema.Schema.Type + +export class WhitelistRecord extends Schema.Class( + 'WhitelistRecord' +)({ + id: WhitelistRecordId, + inboxId: InboxRecordId, + publicKey: PublicKeyHashed, + state: WhiteListState, + date: Schema.DateFromSelf, +}) {} diff --git a/apps/chat-service/src/db/WhiteListDbService/index.ts b/apps/chat-service/src/db/WhiteListDbService/index.ts new file mode 100644 index 000000000..a934cd19a --- /dev/null +++ b/apps/chat-service/src/db/WhiteListDbService/index.ts @@ -0,0 +1,85 @@ +import {type UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import {Context, Effect, Layer, type Option} from 'effect' +import {type WhitelistRecord, type WhitelistRecordId} from './domain' +import {createDeleteWhitelistRecord} from './queries/createDeleteWhitelistRecord' +import { + createDeleteWhitelistRecordBySenderAndReceiver, + type DeleteWhitelistRecordBySenderAndReceiverParams, +} from './queries/createDeleteWhitelistRecordBySenderAndReceiver' +import { + createDeleteWhitelistRecordsWhereInboxIsReceiverOrSender, + type DeleteWhitelistRecordsWhereInboxIsReceiverOrSenderParams, +} from './queries/createDeleteWhitelistRecordsWhereInboxIsReceiverOrSender' +import { + createFindWhitelistRecordBySenderAndReceiver, + type FindWhitelistRecordBySenderAndReceiverParams, +} from './queries/createFindWhitelistRecordBySenderAndReceiver' +import { + createInsertWhitelistRecord, + type InsertWhitelistRecordParams, +} from './queries/createInsertWhitelistRecord' +import { + createUpdateWhitelistRecordState, + type UpdateWhitelistRecordParams, +} from './queries/createUpdateWhitelistRecordState' + +export interface WhitelistDbOperations { + deleteWhitelistRecord: ( + params: WhitelistRecordId + ) => Effect.Effect + + deleteWhitelistRecordBySenderAndReceiver: ( + params: DeleteWhitelistRecordBySenderAndReceiverParams + ) => Effect.Effect + + findWhitelistRecordBySenderAndReceiver: ( + params: FindWhitelistRecordBySenderAndReceiverParams + ) => Effect.Effect, UnexpectedServerError> + + deleteWhitelistRecordsWhereInboxIsReceiverOrSender: ( + params: DeleteWhitelistRecordsWhereInboxIsReceiverOrSenderParams + ) => Effect.Effect + + insertWhitelistRecord: ( + params: InsertWhitelistRecordParams + ) => Effect.Effect + + updateWhitelistRecordState: ( + params: UpdateWhitelistRecordParams + ) => Effect.Effect +} + +export class WhitelistDbService extends Context.Tag('WhitelistDbService')< + WhitelistDbService, + WhitelistDbOperations +>() { + static readonly Live = Layer.effect( + WhitelistDbService, + Effect.gen(function* (_) { + const deleteWhitelistRecord = yield* _(createDeleteWhitelistRecord) + const deleteWhitelistRecordBySenderAndReceiver = yield* _( + createDeleteWhitelistRecordBySenderAndReceiver + ) + const findWhitelistRecordBySenderAndReceiver = yield* _( + createFindWhitelistRecordBySenderAndReceiver + ) + const insertWhitelistRecord = yield* _(createInsertWhitelistRecord) + const updateWhitelistRecordState = yield* _( + createUpdateWhitelistRecordState + ) + + const deleteWhitelistRecordsWhereInboxIsReceiverOrSender = yield* _( + createDeleteWhitelistRecordsWhereInboxIsReceiverOrSender + ) + + return { + deleteWhitelistRecord, + findWhitelistRecordBySenderAndReceiver, + insertWhitelistRecord, + updateWhitelistRecordState, + deleteWhitelistRecordBySenderAndReceiver, + deleteWhitelistRecordsWhereInboxIsReceiverOrSender, + } + }) + ) +} diff --git a/apps/chat-service/src/db/WhiteListDbService/queries/createDeleteWhitelistRecord.ts b/apps/chat-service/src/db/WhiteListDbService/queries/createDeleteWhitelistRecord.ts new file mode 100644 index 000000000..d9294f0e9 --- /dev/null +++ b/apps/chat-service/src/db/WhiteListDbService/queries/createDeleteWhitelistRecord.ts @@ -0,0 +1,29 @@ +import {SqlSchema} from '@effect/sql' +import {PgClient} from '@effect/sql-pg' +import {UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import {Effect, flow} from 'effect' +import {WhitelistRecordId} from '../domain' + +export const createDeleteWhitelistRecord = Effect.gen(function* (_) { + const sql = yield* _(PgClient.PgClient) + + const query = SqlSchema.void({ + Request: WhitelistRecordId, + execute: (params) => sql` + DELETE FROM white_list + WHERE + id = ${params} + `, + }) + + return flow( + query, + Effect.catchAll((e) => + Effect.zipRight( + Effect.logError('Error in deleteWhitelistRecord', e), + Effect.fail(new UnexpectedServerError({status: 500})) + ) + ), + Effect.withSpan('deleteWhitelistRecord query') + ) +}) diff --git a/apps/chat-service/src/db/WhiteListDbService/queries/createDeleteWhitelistRecordBySenderAndReceiver.ts b/apps/chat-service/src/db/WhiteListDbService/queries/createDeleteWhitelistRecordBySenderAndReceiver.ts new file mode 100644 index 000000000..db2b40ed7 --- /dev/null +++ b/apps/chat-service/src/db/WhiteListDbService/queries/createDeleteWhitelistRecordBySenderAndReceiver.ts @@ -0,0 +1,45 @@ +import {Schema} from '@effect/schema' +import {SqlSchema} from '@effect/sql' +import {PgClient} from '@effect/sql-pg' +import {UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import {Effect, flow} from 'effect' +import {PublicKeyHashed} from '../../domain' +import {InboxRecordId} from '../../InboxDbService/domain' + +export const DeleteWhitelistRecordBySenderAndReceiverParams = Schema.Struct({ + sender: PublicKeyHashed, + receiver: InboxRecordId, +}) +export type DeleteWhitelistRecordBySenderAndReceiverParams = Schema.Schema.Type< + typeof DeleteWhitelistRecordBySenderAndReceiverParams +> + +export const createDeleteWhitelistRecordBySenderAndReceiver = Effect.gen( + function* (_) { + const sql = yield* _(PgClient.PgClient) + + const query = SqlSchema.void({ + Request: DeleteWhitelistRecordBySenderAndReceiverParams, + execute: (params) => sql` + DELETE FROM white_list + WHERE + inbox_id = ${params.receiver} + AND public_key = ${params.sender} + `, + }) + + return flow( + query, + Effect.catchAll((e) => + Effect.zipRight( + Effect.logError( + 'Error in deleteWhitelistRecordBySenderAndReceiver', + e + ), + Effect.fail(new UnexpectedServerError({status: 500})) + ) + ), + Effect.withSpan('deleteWhitelistRecordBySenderAndReceiver query') + ) + } +) diff --git a/apps/chat-service/src/db/WhiteListDbService/queries/createDeleteWhitelistRecordsWhereInboxIsReceiverOrSender.ts b/apps/chat-service/src/db/WhiteListDbService/queries/createDeleteWhitelistRecordsWhereInboxIsReceiverOrSender.ts new file mode 100644 index 000000000..f40a5de86 --- /dev/null +++ b/apps/chat-service/src/db/WhiteListDbService/queries/createDeleteWhitelistRecordsWhereInboxIsReceiverOrSender.ts @@ -0,0 +1,47 @@ +import {Schema} from '@effect/schema' +import {SqlSchema} from '@effect/sql' +import {PgClient} from '@effect/sql-pg' +import {UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import {Effect, flow} from 'effect' +import {PublicKeyHashed} from '../../domain' +import {InboxRecordId} from '../../InboxDbService/domain' + +const DeleteWhitelistRecordsWhereInboxIsReceiverOrSenderParams = Schema.Struct({ + inboxId: InboxRecordId, + publicKey: PublicKeyHashed, +}) +export type DeleteWhitelistRecordsWhereInboxIsReceiverOrSenderParams = + Schema.Schema.Type< + typeof DeleteWhitelistRecordsWhereInboxIsReceiverOrSenderParams + > + +export const createDeleteWhitelistRecordsWhereInboxIsReceiverOrSender = + Effect.gen(function* (_) { + const sql = yield* _(PgClient.PgClient) + + const query = SqlSchema.void({ + Request: DeleteWhitelistRecordsWhereInboxIsReceiverOrSenderParams, + execute: (params) => sql` + DELETE FROM white_list + WHERE + inbox_id = ${params.inboxId} + OR public_key = ${params.publicKey} + `, + }) + + return flow( + query, + Effect.catchAll((e) => + Effect.zipRight( + Effect.logError( + 'Error in DeleteWhitelistRecordsWhereInboxIsReceiverOrSender', + e + ), + Effect.fail(new UnexpectedServerError({status: 500})) + ) + ), + Effect.withSpan( + 'DeleteWhitelistRecordsWhereInboxIsReceiverOrSender query' + ) + ) + }) diff --git a/apps/chat-service/src/db/WhiteListDbService/queries/createFindWhitelistRecordBySenderAndReceiver.ts b/apps/chat-service/src/db/WhiteListDbService/queries/createFindWhitelistRecordBySenderAndReceiver.ts new file mode 100644 index 000000000..cede6501b --- /dev/null +++ b/apps/chat-service/src/db/WhiteListDbService/queries/createFindWhitelistRecordBySenderAndReceiver.ts @@ -0,0 +1,47 @@ +import {Schema} from '@effect/schema' +import {SqlSchema} from '@effect/sql' +import {PgClient} from '@effect/sql-pg' +import {UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import {Effect, flow} from 'effect' +import {PublicKeyHashed} from '../../domain' +import {InboxRecordId} from '../../InboxDbService/domain' +import {WhitelistRecord} from '../domain' + +export const FindWhitelistRecordBySenderAndReceiverParams = Schema.Struct({ + sender: PublicKeyHashed, + receiver: InboxRecordId, +}) +export type FindWhitelistRecordBySenderAndReceiverParams = Schema.Schema.Type< + typeof FindWhitelistRecordBySenderAndReceiverParams +> + +export const createFindWhitelistRecordBySenderAndReceiver = Effect.gen( + function* (_) { + const sql = yield* _(PgClient.PgClient) + + const query = SqlSchema.findOne({ + Request: FindWhitelistRecordBySenderAndReceiverParams, + Result: WhitelistRecord, + execute: (params) => sql` + SELECT + * + FROM + white_list w + WHERE + w.inbox_id = ${params.receiver} + AND w.public_key = ${params.sender} + `, + }) + + return flow( + query, + Effect.catchAll((e) => + Effect.zipRight( + Effect.logError('Error in findWhitelistRecordBySenderAndReceiver', e), + Effect.fail(new UnexpectedServerError({status: 500})) + ) + ), + Effect.withSpan('findWhitelistRecordBySenderAndReceiver find') + ) + } +) diff --git a/apps/chat-service/src/db/WhiteListDbService/queries/createInsertWhitelistRecord.ts b/apps/chat-service/src/db/WhiteListDbService/queries/createInsertWhitelistRecord.ts new file mode 100644 index 000000000..7ce2176ef --- /dev/null +++ b/apps/chat-service/src/db/WhiteListDbService/queries/createInsertWhitelistRecord.ts @@ -0,0 +1,46 @@ +import {Schema} from '@effect/schema' +import {SqlSchema} from '@effect/sql' +import {PgClient} from '@effect/sql-pg' +import {UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import {Effect, flow} from 'effect' +import {PublicKeyHashed} from '../../domain' +import {InboxRecordId} from '../../InboxDbService/domain' +import {WhiteListState} from '../domain' + +export const InsertWhitelistRecordParams = Schema.Struct({ + sender: PublicKeyHashed, + receiver: InboxRecordId, + state: WhiteListState, +}) + +export type InsertWhitelistRecordParams = Schema.Schema.Type< + typeof InsertWhitelistRecordParams +> + +export const createInsertWhitelistRecord = Effect.gen(function* (_) { + const sql = yield* _(PgClient.PgClient) + + const query = SqlSchema.void({ + Request: InsertWhitelistRecordParams, + execute: (params) => sql` + INSERT INTO + white_list ${sql.insert({ + inboxId: params.receiver, + publicKey: params.sender, + state: params.state, + date: new Date(), + })} + `, + }) + + return flow( + query, + Effect.catchAll((e) => + Effect.zipRight( + Effect.logError('Error in insertWhitelistRecord', e), + Effect.fail(new UnexpectedServerError({status: 500})) + ) + ), + Effect.withSpan('insertWhitelistRecord query') + ) +}) diff --git a/apps/chat-service/src/db/WhiteListDbService/queries/createUpdateWhitelistRecordState.ts b/apps/chat-service/src/db/WhiteListDbService/queries/createUpdateWhitelistRecordState.ts new file mode 100644 index 000000000..09d288525 --- /dev/null +++ b/apps/chat-service/src/db/WhiteListDbService/queries/createUpdateWhitelistRecordState.ts @@ -0,0 +1,41 @@ +import {Schema} from '@effect/schema' +import {SqlSchema} from '@effect/sql' +import {PgClient} from '@effect/sql-pg' +import {UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import {Effect, flow} from 'effect' +import {WhitelistRecordId, WhiteListState} from '../domain' + +export const UpdateWhitelistRecordParams = Schema.Struct({ + id: WhitelistRecordId, + state: WhiteListState, +}) + +export type UpdateWhitelistRecordParams = Schema.Schema.Type< + typeof UpdateWhitelistRecordParams +> + +export const createUpdateWhitelistRecordState = Effect.gen(function* (_) { + const sql = yield* _(PgClient.PgClient) + + const query = SqlSchema.void({ + Request: UpdateWhitelistRecordParams, + execute: (params) => sql` + UPDATE white_list + SET + state = ${params.state} + WHERE + id = ${params.id} + `, + }) + + return flow( + query, + Effect.catchAll((e) => + Effect.zipRight( + Effect.logError('Error in updateWhitelistRecord', e), + Effect.fail(new UnexpectedServerError({status: 500})) + ) + ), + Effect.withSpan('updateWhitelistRecord query') + ) +}) diff --git a/apps/chat-service/src/db/domain.ts b/apps/chat-service/src/db/domain.ts new file mode 100644 index 000000000..32ba1aa9e --- /dev/null +++ b/apps/chat-service/src/db/domain.ts @@ -0,0 +1,88 @@ +import {Schema} from '@effect/schema' +import {type PublicKeyPemBase64} from '@vexl-next/cryptography/src/KeyHolder' +import {PublicKeyPemBase64E} from '@vexl-next/cryptography/src/KeyHolder/brands' +import {UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import { + aesDecrpytE, + aesEncrpytE, + AesGtmCypher, + hashSha256, +} from '@vexl-next/generic-utils/src/effect-helpers/crypto' +import {type ServerCrypto} from '@vexl-next/server-utils/src/ServerCrypto' +import {type ConfigError, Effect} from 'effect' +import {easKey} from '../configs' + +export const PublicKeyHashed = Schema.String.pipe( + Schema.brand('PublicKeyHashed') +) +export type PublicKeyHashed = Schema.Schema.Type + +export const hashPublicKey = ( + publicKey: PublicKeyPemBase64 +): Effect.Effect => + hashSha256(publicKey).pipe( + Effect.flatMap(Schema.decode(PublicKeyHashed)), + Effect.catchAll( + (e) => + new UnexpectedServerError({ + status: 500, + cause: e, + detail: 'Error while hashing public key', + }) + ) + ) + +export const PublicKeyEncrypted = Schema.String.pipe( + Schema.brand('PublicKeyEncrypted') +) + +export type PublicKeyEncrypted = Schema.Schema.Type + +const brandPublicKeyEncrypted = Schema.decodeSync(PublicKeyEncrypted) +export const encryptPublicKey = ( + publicKey: PublicKeyPemBase64 +): Effect.Effect< + PublicKeyEncrypted, + UnexpectedServerError | ConfigError.ConfigError, + ServerCrypto +> => + Effect.gen(function* (_) { + const key = yield* _(easKey) + const encrypt = aesEncrpytE(key, true) + + return yield* _( + encrypt(publicKey), + Effect.catchAll((e) => + Effect.zipRight( + Effect.logError('Error while encrypting public key', e), + Effect.fail(new UnexpectedServerError({status: 500})) + ) + ), + Effect.map(brandPublicKeyEncrypted) + ) + }) + +export const decryptPublicKey = ( + publicKey: PublicKeyEncrypted +): Effect.Effect< + PublicKeyPemBase64, + UnexpectedServerError | ConfigError.ConfigError, + ServerCrypto +> => + Effect.gen(function* (_) { + const key = yield* _(easKey) + const decrypt = aesDecrpytE(key) + + return yield* _( + publicKey, + Schema.decode(AesGtmCypher), + Effect.flatMap(decrypt), + Effect.flatMap(Schema.decode(PublicKeyPemBase64E)), + Effect.catchAll((e) => + Effect.zipRight( + Effect.logError('Error while decrypting publicKey', e), + Effect.fail(new UnexpectedServerError({status: 500})) + ) + ) + ) + }) diff --git a/apps/chat-service/src/db/layer.ts b/apps/chat-service/src/db/layer.ts new file mode 100644 index 000000000..34e9e4027 --- /dev/null +++ b/apps/chat-service/src/db/layer.ts @@ -0,0 +1,29 @@ +import {PgClient, PgMigrator} from '@effect/sql-pg' +import {loadMigrationsFromEffect} from '@vexl-next/server-utils/src/loadMigrationsFromEffect' +import {Config, Layer, String} from 'effect' +import {databaseConfig} from '../configs' +import initialMigraiton from './migrations/0001_initial' + +const migrations = [ + { + id: 1, + name: 'initial', + migrationEffect: initialMigraiton, + }, +] as const + +const SqlLive = PgClient.layer( + databaseConfig.pipe( + Config.map((config) => ({ + ...config, + transformQueryNames: String.camelToSnake, + transformResultNames: String.snakeToCamel, + })) + ) +) +const MigratorLive = PgMigrator.layer({ + loader: loadMigrationsFromEffect(migrations), +}).pipe(Layer.provide(SqlLive)) + +const DbLayer = Layer.mergeAll(SqlLive, MigratorLive) +export default DbLayer diff --git a/apps/chat-service/src/db/migrations/0001_initial.ts b/apps/chat-service/src/db/migrations/0001_initial.ts new file mode 100644 index 000000000..dba2784a6 --- /dev/null +++ b/apps/chat-service/src/db/migrations/0001_initial.ts @@ -0,0 +1,83 @@ +import {SqlClient} from '@effect/sql' +import {Effect} from 'effect' + +export default Effect.flatMap( + SqlClient.SqlClient, + (sql) => sql` + CREATE TABLE databasechangeloglock ( + id integer NOT NULL PRIMARY KEY, + locked boolean NOT NULL, + lockgranted timestamp, + lockedby varchar(255) + ); + + CREATE TABLE databasechangelog ( + id varchar(255) NOT NULL, + author varchar(255) NOT NULL, + filename varchar(255) NOT NULL, + dateexecuted timestamp NOT NULL, + orderexecuted integer NOT NULL, + exectype varchar(10) NOT NULL, + md5sum varchar(35), + description varchar(255), + comments varchar(255), + tag varchar(255), + liquibase varchar(20), + contexts varchar(255), + labels varchar(255), + deployment_id varchar(10) + ); + + CREATE TABLE challenge ( + id bigint GENERATED BY DEFAULT AS IDENTITY CONSTRAINT "PK_Challenge" PRIMARY KEY, + challenge varchar NOT NULL UNIQUE, + public_key varchar NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + valid boolean DEFAULT TRUE NOT NULL + ); + + CREATE INDEX "086cc4a8540b4d579438_ix" ON challenge (public_key); + + CREATE INDEX "88c6403cdcd7415d9c8c_ix" ON challenge (created_at); + + CREATE TABLE inbox ( + id bigint GENERATED BY DEFAULT AS IDENTITY CONSTRAINT "PK_Inbox" PRIMARY KEY, + public_key varchar NOT NULL UNIQUE, + token varchar, + platform varchar, + client_version integer DEFAULT 0 NOT NULL + ); + + CREATE INDEX a757cbd855c64b2f9934_ix ON inbox (public_key); + + CREATE TABLE message ( + id bigint GENERATED BY DEFAULT AS IDENTITY CONSTRAINT "PK_Message" PRIMARY KEY, + inbox_id bigint NOT NULL, + message varchar NOT NULL, + sender_public_key varchar NOT NULL, + pulled boolean DEFAULT FALSE NOT NULL, + type varchar NOT NULL + ); + + CREATE INDEX "5834a3584c634d0fab53_ix" ON message (sender_public_key); + + CREATE TABLE stats ( + id bigint GENERATED BY DEFAULT AS IDENTITY CONSTRAINT "PK_Stats" PRIMARY KEY, + key varchar NOT NULL, + value integer NOT NULL, + date timestamp NOT NULL + ); + + CREATE INDEX de11d18f200d4bc687f1_ix ON stats (key); + + CREATE TABLE white_list ( + id bigint GENERATED BY DEFAULT AS IDENTITY CONSTRAINT "PK_WhiteList" PRIMARY KEY, + inbox_id bigint NOT NULL, + public_key varchar NOT NULL, + state varchar NOT NULL, + date date DEFAULT CURRENT_DATE NOT NULL + ); + + CREATE INDEX fa8fa5318f0b4b298baa_ix ON white_list (public_key); + ` +) diff --git a/apps/chat-service/src/httpServer.ts b/apps/chat-service/src/httpServer.ts new file mode 100644 index 000000000..950ba06f4 --- /dev/null +++ b/apps/chat-service/src/httpServer.ts @@ -0,0 +1,85 @@ +import {NodeContext} from '@effect/platform-node' +import {ChatApiSpecification} from '@vexl-next/rest-api/src/services/chat/specification' +import {healthServerLayer} from '@vexl-next/server-utils/src/HealthServer' +import {setupLoggingMiddlewares} from '@vexl-next/server-utils/src/loggingMiddlewares' +import {RedisService} from '@vexl-next/server-utils/src/RedisService' +import {ServerCrypto} from '@vexl-next/server-utils/src/ServerCrypto' +import {Effect, Layer} from 'effect' +import {RouterBuilder} from 'effect-http' +import {NodeServer} from 'effect-http-node' +import { + cryptoConfig, + healthServerPortConfig, + portConfig, + redisUrl, +} from './configs' +import {ChallengeDbService} from './db/ChallegeDbService' +import {InboxDbService} from './db/InboxDbService' +import DbLayer from './db/layer' +import {MessagesDbService} from './db/MessagesDbService' +import {WhitelistDbService} from './db/WhiteListDbService' +import {internalServerLive} from './internalServer' +import {createChallenge} from './routes/challenges/createChalenge' +import {createChallenges} from './routes/challenges/createChallenges' +import {approveRequest} from './routes/inbox/approveReqest' +import {blockInbox} from './routes/inbox/blockInbox' +import {cancelRequest} from './routes/inbox/cancelRequest' +import {createInbox} from './routes/inbox/createInbox' +import {deleteInbox} from './routes/inbox/deleteInbox' +import {deleteInboxes} from './routes/inbox/deleteInboxes' +import {deletePulledMessages} from './routes/inbox/deletePulledMessages' +import {leaveChat} from './routes/inbox/leaveChat' +import {requestApproval} from './routes/inbox/requestApproval' +import {updateInbox} from './routes/inbox/updateInbox' +import {retrieveMessages} from './routes/messages/retrieveMessages' +import {sendMessage} from './routes/messages/sendMessage' +import {sendMessages} from './routes/messages/sendMessages' +import {ChatChallengeService} from './utils/ChatChallengeService' + +export const app = RouterBuilder.make(ChatApiSpecification).pipe( + // challenges + RouterBuilder.handle(createChallenge), + RouterBuilder.handle(createChallenges), + // inbox + RouterBuilder.handle(approveRequest), + RouterBuilder.handle(blockInbox), + RouterBuilder.handle(cancelRequest), + RouterBuilder.handle(createInbox), + RouterBuilder.handle(deleteInbox), + RouterBuilder.handle(deleteInboxes), + RouterBuilder.handle(updateInbox), + RouterBuilder.handle(requestApproval), + RouterBuilder.handle(leaveChat), + RouterBuilder.handle(deletePulledMessages), + // messages + RouterBuilder.handle(retrieveMessages), + RouterBuilder.handle(sendMessage), + RouterBuilder.handle(sendMessages), + + RouterBuilder.build, + setupLoggingMiddlewares +) + +const MainLive = Layer.mergeAll( + internalServerLive, + ServerCrypto.layer(cryptoConfig), + healthServerLayer({port: healthServerPortConfig}), + ChatChallengeService.Live +).pipe( + Layer.provideMerge( + Layer.mergeAll( + ChallengeDbService.Live, + InboxDbService.Live, + MessagesDbService.Live, + WhitelistDbService.Live + ) + ), + Layer.provideMerge(DbLayer), + Layer.provideMerge(RedisService.layer(redisUrl)), + Layer.provideMerge(NodeContext.layer) +) + +export const httpServer = portConfig.pipe( + Effect.flatMap((port) => NodeServer.listen({port})(app)), + Effect.provide(MainLive) +) diff --git a/apps/chat-service/src/index.ts b/apps/chat-service/src/index.ts new file mode 100644 index 000000000..fb170e714 --- /dev/null +++ b/apps/chat-service/src/index.ts @@ -0,0 +1,4 @@ +import {runMainInNode} from '@vexl-next/server-utils/src/runMainInNode' +import {httpServer} from './httpServer' + +runMainInNode(httpServer) diff --git a/apps/chat-service/src/internalServer/index.ts b/apps/chat-service/src/internalServer/index.ts new file mode 100644 index 000000000..0ab9f4975 --- /dev/null +++ b/apps/chat-service/src/internalServer/index.ts @@ -0,0 +1,25 @@ +import {HttpRouter, HttpServerResponse} from '@effect/platform' +import {internalServerPortConfig} from '@vexl-next/server-utils/src/commonConfigs' +import {makeInternalServer} from '@vexl-next/server-utils/src/InternalServer' +import {Effect} from 'effect' +import {ChallengeDbService} from '../db/ChallegeDbService' + +export const internalServerLive = makeInternalServer( + HttpRouter.empty.pipe( + HttpRouter.post( + '/clean-invalid-challenges', + Effect.gen(function* (_) { + const db = yield* _(ChallengeDbService) + yield* _(db.deleteInvalidAndExpiredChallenges()) + + return HttpServerResponse.text('ok', {status: 200}) + }).pipe( + // No redis lock. What if it gets called twice? No biggie + Effect.withSpan('Clean invalid challenges') + ) + ) + ), + { + port: internalServerPortConfig, + } +) diff --git a/apps/chat-service/src/metrics.ts b/apps/chat-service/src/metrics.ts new file mode 100644 index 000000000..70b786d12 --- /dev/null +++ b/apps/chat-service/src/metrics.ts @@ -0,0 +1 @@ +// TODO diff --git a/apps/chat-service/src/routes/challenges/createChalenge.ts b/apps/chat-service/src/routes/challenges/createChalenge.ts new file mode 100644 index 000000000..501e88961 --- /dev/null +++ b/apps/chat-service/src/routes/challenges/createChalenge.ts @@ -0,0 +1,26 @@ +import {Schema} from '@effect/schema' +import {unixMillisecondsFromNow} from '@vexl-next/domain/src/utility/UnixMilliseconds.brand' +import {CreateChallengeEndpoint} from '@vexl-next/rest-api/src/services/chat/specification' +import makeEndpointEffect from '@vexl-next/server-utils/src/makeEndpointEffect' +import {Effect} from 'effect' +import {Handler} from 'effect-http' +import {challengeExpirationMinutesConfig} from '../../configs' +import {ChatChallengeService} from '../../utils/ChatChallengeService' + +export const createChallenge = Handler.make(CreateChallengeEndpoint, (req) => + makeEndpointEffect( + Effect.gen(function* (_) { + const challengeService = yield* _(ChatChallengeService) + const publicKey = req.body.publicKey + const expirationMinutes = yield* _(challengeExpirationMinutesConfig) + + const challenge = yield* _(challengeService.createChallenge(publicKey)) + + return { + challenge, + expiration: unixMillisecondsFromNow(expirationMinutes * 60 * 1000), + } + }), + Schema.Void + ) +) diff --git a/apps/chat-service/src/routes/challenges/createChallenges.ts b/apps/chat-service/src/routes/challenges/createChallenges.ts new file mode 100644 index 000000000..458acbee8 --- /dev/null +++ b/apps/chat-service/src/routes/challenges/createChallenges.ts @@ -0,0 +1,44 @@ +import {Schema} from '@effect/schema' +import {unixMillisecondsFromNow} from '@vexl-next/domain/src/utility/UnixMilliseconds.brand' +import {CreateChallengeBatchEndpoint} from '@vexl-next/rest-api/src/services/chat/specification' +import makeEndpointEffect from '@vexl-next/server-utils/src/makeEndpointEffect' +import {Array, Effect, pipe} from 'effect' +import {Handler} from 'effect-http' +import {challengeExpirationMinutesConfig} from '../../configs' +import {ChatChallengeService} from '../../utils/ChatChallengeService' + +export const createChallenges = Handler.make( + CreateChallengeBatchEndpoint, + (req) => + makeEndpointEffect( + pipe( + req.body.publicKeys, + Array.map((publicKey) => + Effect.gen(function* (_) { + const challengeService = yield* _(ChatChallengeService) + + const challenge = yield* _( + challengeService.createChallenge(publicKey) + ) + + return { + publicKey, + challenge, + } + }) + ), + Effect.all, + Effect.flatMap((v) => + challengeExpirationMinutesConfig.pipe( + Effect.map((expirationMinutes) => ({ + challenges: v, + expiration: unixMillisecondsFromNow( + expirationMinutes * 60 * 1000 + ), + })) + ) + ) + ), + Schema.Void + ) +) diff --git a/apps/chat-service/src/routes/inbox/approveReqest.ts b/apps/chat-service/src/routes/inbox/approveReqest.ts new file mode 100644 index 000000000..2468a4d8d --- /dev/null +++ b/apps/chat-service/src/routes/inbox/approveReqest.ts @@ -0,0 +1,112 @@ +import { + ApproveRequestErrors, + type ApproveRequestResponse, + RequestCancelledError, + RequestNotFoundError, + RequestNotPendingError, +} from '@vexl-next/rest-api/src/services/chat/contracts' +import {ApproveRequestEndpoint} from '@vexl-next/rest-api/src/services/chat/specification' +import makeEndpointEffect from '@vexl-next/server-utils/src/makeEndpointEffect' +import {withDbTransaction} from '@vexl-next/server-utils/src/withDbTransaction' +import {Effect} from 'effect' +import {Handler} from 'effect-http' +import {encryptPublicKey} from '../../db/domain' +import {MessagesDbService} from '../../db/MessagesDbService' +import {WhitelistDbService} from '../../db/WhiteListDbService' +import {findAndEnsureReceiverAndSenderInbox} from '../../utils/findAndEnsureReceiverAndSenderInbox' +import {validateChallengeInBody} from '../../utils/validateChallengeInBody' +import {withInboxActionRedisLock} from '../../utils/withInboxActionRedisLock' + +export const approveRequest = Handler.make(ApproveRequestEndpoint, (req) => + makeEndpointEffect( + Effect.gen(function* (_) { + yield* _(validateChallengeInBody(req.body)) + + // from the point of view of the one that sent the request + const {receiverInbox, senderInbox} = yield* _( + findAndEnsureReceiverAndSenderInbox({ + receiver: req.body.publicKey, + sender: req.body.publicKeyToConfirm, + }) + ) + + const whitelistDb = yield* _(WhitelistDbService) + const whitelistRecord = yield* _( + whitelistDb.findWhitelistRecordBySenderAndReceiver({ + sender: senderInbox.publicKey, + receiver: receiverInbox.id, + }), + Effect.flatten, + Effect.catchTag( + 'NoSuchElementException', + () => new RequestNotFoundError() + ), + Effect.filterOrFail( + (v): v is {state: 'WAITING'} & typeof v => v.state === 'WAITING', + (v) => + v.state === 'CANCELED' + ? new RequestCancelledError() + : new RequestNotPendingError() + ) + ) + + const messagesDb = yield* _(MessagesDbService) + if (req.body.approve) { + yield* _( + whitelistDb.updateWhitelistRecordState({ + id: whitelistRecord.id, + state: 'APROVED', + }) + ) + // make sure to insert the other way around! + yield* _( + whitelistDb.insertWhitelistRecord({ + receiver: senderInbox.id, + sender: receiverInbox.publicKey, + state: 'APROVED', + }) + ) + } else { + // Not approved + yield* _( + whitelistDb.updateWhitelistRecordState({ + id: whitelistRecord.id, + state: 'DISAPROVED', + }) + ) + + // make sure the other way around is deleted + yield* _( + whitelistDb.deleteWhitelistRecordBySenderAndReceiver({ + receiver: senderInbox.id, + sender: receiverInbox.publicKey, + }) + ) + } + + const encryptedSenderPublicKey = yield* _( + encryptPublicKey(req.body.publicKey) + ) + + const sentMessage = yield* _( + messagesDb.insertMessageForInbox({ + inboxId: senderInbox.id, + senderPublicKey: encryptedSenderPublicKey, + message: req.body.message, + type: req.body.approve ? 'APPROVE_MESSAGING' : 'DISAPPROVE_MESSAGING', + }) + ) + + return { + id: Number(sentMessage.id), + message: req.body.message, + notificationHandled: false, + senderPublicKey: req.body.publicKey, + } satisfies ApproveRequestResponse + }).pipe( + withInboxActionRedisLock(req.body.publicKey, req.body.publicKeyToConfirm), + withDbTransaction + ), + ApproveRequestErrors + ) +) diff --git a/apps/chat-service/src/routes/inbox/blockInbox.ts b/apps/chat-service/src/routes/inbox/blockInbox.ts new file mode 100644 index 000000000..aec517421 --- /dev/null +++ b/apps/chat-service/src/routes/inbox/blockInbox.ts @@ -0,0 +1,44 @@ +import {BlockInboxErrors} from '@vexl-next/rest-api/src/services/chat/contracts' +import {BlockInboxEndpoint} from '@vexl-next/rest-api/src/services/chat/specification' +import makeEndpointEffect from '@vexl-next/server-utils/src/makeEndpointEffect' +import {withDbTransaction} from '@vexl-next/server-utils/src/withDbTransaction' +import {Effect} from 'effect' +import {Handler} from 'effect-http' +import {WhitelistDbService} from '../../db/WhiteListDbService' +import {findAndEnsureReceiverAndSenderInbox} from '../../utils/findAndEnsureReceiverAndSenderInbox' +import {validateChallengeInBody} from '../../utils/validateChallengeInBody' +import {withInboxActionRedisLock} from '../../utils/withInboxActionRedisLock' + +export const blockInbox = Handler.make(BlockInboxEndpoint, (req) => + makeEndpointEffect( + Effect.gen(function* (_) { + yield* _(validateChallengeInBody(req.body)) + + const {receiverInbox: blockerInbox, senderInbox: toBlockInbox} = yield* _( + findAndEnsureReceiverAndSenderInbox({ + receiver: req.body.publicKey, + sender: req.body.publicKeyToBlock, + }) + ) + + const whitelistDb = yield* _(WhitelistDbService) + // we want to block so toBlockInbox can't send messages to blockerInbox + yield* _( + whitelistDb.deleteWhitelistRecordBySenderAndReceiver({ + sender: toBlockInbox.publicKey, + receiver: blockerInbox.id, + }) + ) + yield* _( + whitelistDb.insertWhitelistRecord({ + sender: toBlockInbox.publicKey, + receiver: blockerInbox.id, + state: 'BLOCKED', + }) + ) + + return null + }).pipe(withInboxActionRedisLock(req.body.publicKey), withDbTransaction), + BlockInboxErrors + ) +) diff --git a/apps/chat-service/src/routes/inbox/cancelRequest.ts b/apps/chat-service/src/routes/inbox/cancelRequest.ts new file mode 100644 index 000000000..808c31037 --- /dev/null +++ b/apps/chat-service/src/routes/inbox/cancelRequest.ts @@ -0,0 +1,78 @@ +import { + type CancelApprovalResponse, + CancelRequestApprovalErrors, + RequestNotPendingError, +} from '@vexl-next/rest-api/src/services/chat/contracts' +import {CancelRequestApprovalEndpoint} from '@vexl-next/rest-api/src/services/chat/specification' +import makeEndpointEffect from '@vexl-next/server-utils/src/makeEndpointEffect' +import {withDbTransaction} from '@vexl-next/server-utils/src/withDbTransaction' +import {Effect} from 'effect' +import {Handler} from 'effect-http' +import {encryptPublicKey} from '../../db/domain' +import {MessagesDbService} from '../../db/MessagesDbService' +import {WhitelistDbService} from '../../db/WhiteListDbService' +import {findAndEnsureReceiverAndSenderInbox} from '../../utils/findAndEnsureReceiverAndSenderInbox' +import {withInboxActionRedisLock} from '../../utils/withInboxActionRedisLock' + +export const cancelRequest = Handler.make( + CancelRequestApprovalEndpoint, + (req, sec) => + makeEndpointEffect( + Effect.gen(function* (_) { + const {receiverInbox, senderInbox} = yield* _( + findAndEnsureReceiverAndSenderInbox({ + receiver: req.body.publicKey, + sender: sec['public-key'], + }) + ) + + const whitelistDb = yield* _(WhitelistDbService) + + const whitelistRecord = yield* _( + whitelistDb.findWhitelistRecordBySenderAndReceiver({ + receiver: receiverInbox.id, + sender: senderInbox.publicKey, + }), + Effect.flatten, + Effect.catchTag( + 'NoSuchElementException', + () => new RequestNotPendingError() + ) + ) + + if (whitelistRecord.state !== 'WAITING') { + return yield* _(Effect.fail(new RequestNotPendingError())) + } + + yield* _( + whitelistDb.updateWhitelistRecordState({ + id: whitelistRecord.id, + state: 'CANCELED', + }) + ) + + const senderPublicKey = yield* _(encryptPublicKey(sec['public-key'])) + + const messagesDb = yield* _(MessagesDbService) + const sentMessage = yield* _( + messagesDb.insertMessageForInbox({ + message: req.body.message, + inboxId: receiverInbox.id, + senderPublicKey, + type: 'CANCEL_REQUEST_MESSAGING', + }) + ) + + return { + id: Number(sentMessage.id), + message: sentMessage.message, + senderPublicKey: sec['public-key'], + notificationHandled: false, + } satisfies CancelApprovalResponse + }).pipe( + withInboxActionRedisLock(sec['public-key'], req.body.publicKey), + withDbTransaction + ), + CancelRequestApprovalErrors + ) +) diff --git a/apps/chat-service/src/routes/inbox/createInbox.ts b/apps/chat-service/src/routes/inbox/createInbox.ts new file mode 100644 index 000000000..8889f402b --- /dev/null +++ b/apps/chat-service/src/routes/inbox/createInbox.ts @@ -0,0 +1,46 @@ +import {InvalidChallengeError} from '@vexl-next/rest-api/src/services/chat/contracts' +import {CreateInboxEndpoint} from '@vexl-next/rest-api/src/services/chat/specification' +import makeEndpointEffect from '@vexl-next/server-utils/src/makeEndpointEffect' +import {withDbTransaction} from '@vexl-next/server-utils/src/withDbTransaction' +import {Effect, Option} from 'effect' +import {Handler} from 'effect-http' +import {hashPublicKey} from '../../db/domain' +import {InboxDbService} from '../../db/InboxDbService' +import {validateChallengeInBody} from '../../utils/validateChallengeInBody' +import {withInboxActionRedisLock} from '../../utils/withInboxActionRedisLock' + +export const createInbox = Handler.make(CreateInboxEndpoint, (req) => + makeEndpointEffect( + Effect.gen(function* (_) { + yield* _(validateChallengeInBody(req.body)) + + const inboxService = yield* _(InboxDbService) + const hashedPublicKey = yield* _(hashPublicKey(req.body.publicKey)) + + const existingInbox = yield* _( + inboxService.findInboxByPublicKey(hashedPublicKey) + ) + if (Option.isSome(existingInbox)) { + yield* _( + inboxService.updateInboxMetadata({ + id: existingInbox.value.id, + clientVersion: req.headers.clientVersionOrNone, + platform: req.headers.clientPlatformOrNone, + }) + ) + return null + } + + yield* _( + inboxService.insertInbox({ + publicKey: hashedPublicKey, + clientVersion: req.headers.clientVersionOrNone, + platform: req.headers.clientPlatformOrNone, + }) + ) + + return null + }).pipe(withInboxActionRedisLock(req.body.publicKey), withDbTransaction), + InvalidChallengeError + ) +) diff --git a/apps/chat-service/src/routes/inbox/deleteInbox.ts b/apps/chat-service/src/routes/inbox/deleteInbox.ts new file mode 100644 index 000000000..757a1bc59 --- /dev/null +++ b/apps/chat-service/src/routes/inbox/deleteInbox.ts @@ -0,0 +1,49 @@ +import {DeleteInboxErrors} from '@vexl-next/rest-api/src/services/chat/contracts' +import {DeleteInboxEndpoint} from '@vexl-next/rest-api/src/services/chat/specification' +import {InboxDoesNotExistError} from '@vexl-next/rest-api/src/services/contact/contracts' +import makeEndpointEffect from '@vexl-next/server-utils/src/makeEndpointEffect' +import {withDbTransaction} from '@vexl-next/server-utils/src/withDbTransaction' +import {Effect} from 'effect' +import {Handler} from 'effect-http' +import {hashPublicKey} from '../../db/domain' +import {InboxDbService} from '../../db/InboxDbService' +import {MessagesDbService} from '../../db/MessagesDbService' +import {WhitelistDbService} from '../../db/WhiteListDbService' +import {validateChallengeInBody} from '../../utils/validateChallengeInBody' +import {withInboxActionRedisLock} from '../../utils/withInboxActionRedisLock' + +export const deleteInbox = Handler.make(DeleteInboxEndpoint, (req) => + makeEndpointEffect( + Effect.gen(function* (_) { + yield* _(validateChallengeInBody(req.body)) + + const hashedPublicKey = yield* _(hashPublicKey(req.body.publicKey)) + + const inboxService = yield* _(InboxDbService) + const inbox = yield* _( + inboxService.findInboxByPublicKey(hashedPublicKey), + Effect.flatten, + Effect.catchTag( + 'NoSuchElementException', + () => new InboxDoesNotExistError() + ) + ) + + const whitelistDb = yield* _(WhitelistDbService) + yield* _( + whitelistDb.deleteWhitelistRecordsWhereInboxIsReceiverOrSender({ + inboxId: inbox.id, + publicKey: hashedPublicKey, + }) + ) + + const messagesDb = yield* _(MessagesDbService) + yield* _(messagesDb.deleteAllMessagesByInboxId(inbox.id)) + + yield* _(inboxService.deleteInboxByPublicKey(hashedPublicKey)) + + return null + }).pipe(withInboxActionRedisLock(req.body.publicKey), withDbTransaction), + DeleteInboxErrors + ) +) diff --git a/apps/chat-service/src/routes/inbox/deleteInboxes.ts b/apps/chat-service/src/routes/inbox/deleteInboxes.ts new file mode 100644 index 000000000..e858b62fd --- /dev/null +++ b/apps/chat-service/src/routes/inbox/deleteInboxes.ts @@ -0,0 +1,55 @@ +import {DeleteInboxErrors} from '@vexl-next/rest-api/src/services/chat/contracts' +import {DeleteInboxesEndpoint} from '@vexl-next/rest-api/src/services/chat/specification' +import {InboxDoesNotExistError} from '@vexl-next/rest-api/src/services/contact/contracts' +import makeEndpointEffect from '@vexl-next/server-utils/src/makeEndpointEffect' +import {withDbTransaction} from '@vexl-next/server-utils/src/withDbTransaction' +import {Array, Effect} from 'effect' +import {Handler} from 'effect-http' +import {hashPublicKey} from '../../db/domain' +import {InboxDbService} from '../../db/InboxDbService' +import {MessagesDbService} from '../../db/MessagesDbService' +import {WhitelistDbService} from '../../db/WhiteListDbService' +import {validateChallengeInBody} from '../../utils/validateChallengeInBody' +import {withInboxActionRedisLock} from '../../utils/withInboxActionRedisLock' + +export const deleteInboxes = Handler.make(DeleteInboxesEndpoint, (req) => + makeEndpointEffect( + Effect.all( + Array.map(req.body.dataForRemoval, (inboxToDelete) => + Effect.gen(function* (_) { + yield* _(validateChallengeInBody(inboxToDelete)) + const hashedPublicKey = yield* _( + hashPublicKey(inboxToDelete.publicKey) + ) + + const inboxService = yield* _(InboxDbService) + const inbox = yield* _( + inboxService.findInboxByPublicKey(hashedPublicKey), + Effect.flatten, + Effect.catchTag( + 'NoSuchElementException', + () => new InboxDoesNotExistError() + ) + ) + + const whitelistDb = yield* _(WhitelistDbService) + yield* _( + whitelistDb.deleteWhitelistRecordsWhereInboxIsReceiverOrSender({ + inboxId: inbox.id, + publicKey: hashedPublicKey, + }) + ) + + const messagesDb = yield* _(MessagesDbService) + yield* _(messagesDb.deleteAllMessagesByInboxId(inbox.id)) + + yield* _(inboxService.deleteInboxByPublicKey(hashedPublicKey)) + }).pipe( + withInboxActionRedisLock(inboxToDelete.publicKey), + Effect.flatMap(() => Effect.succeed({})) + ) + ) + ), + DeleteInboxErrors + ).pipe(withDbTransaction) +) diff --git a/apps/chat-service/src/routes/inbox/deletePulledMessages.ts b/apps/chat-service/src/routes/inbox/deletePulledMessages.ts new file mode 100644 index 000000000..9f7eaa7f2 --- /dev/null +++ b/apps/chat-service/src/routes/inbox/deletePulledMessages.ts @@ -0,0 +1,40 @@ +import {DeletePulledMessagesErrors} from '@vexl-next/rest-api/src/services/chat/contracts' +import {DeletePulledMessagesEndpoint} from '@vexl-next/rest-api/src/services/chat/specification' +import {InboxDoesNotExistError} from '@vexl-next/rest-api/src/services/contact/contracts' +import makeEndpointEffect from '@vexl-next/server-utils/src/makeEndpointEffect' +import {withDbTransaction} from '@vexl-next/server-utils/src/withDbTransaction' +import {Effect} from 'effect' +import {Handler} from 'effect-http' +import {hashPublicKey} from '../../db/domain' +import {InboxDbService} from '../../db/InboxDbService' +import {MessagesDbService} from '../../db/MessagesDbService' +import {validateChallengeInBody} from '../../utils/validateChallengeInBody' +import {withInboxActionRedisLock} from '../../utils/withInboxActionRedisLock' + +export const deletePulledMessages = Handler.make( + DeletePulledMessagesEndpoint, + (req) => + makeEndpointEffect( + Effect.gen(function* (_) { + yield* _(validateChallengeInBody(req.body)) + + const hashedPublicKey = yield* _(hashPublicKey(req.body.publicKey)) + const inboxDb = yield* _(InboxDbService) + + const inboxRecord = yield* _( + inboxDb.findInboxByPublicKey(hashedPublicKey), + Effect.flatten, + Effect.catchTag( + 'NoSuchElementException', + () => new InboxDoesNotExistError() + ) + ) + + const messagesService = yield* _(MessagesDbService) + yield* _(messagesService.deletePulledMessagesByInboxId(inboxRecord.id)) + + return null + }).pipe(withInboxActionRedisLock(req.body.publicKey), withDbTransaction), + DeletePulledMessagesErrors + ) +) diff --git a/apps/chat-service/src/routes/inbox/leaveChat.ts b/apps/chat-service/src/routes/inbox/leaveChat.ts new file mode 100644 index 000000000..995b5a9e5 --- /dev/null +++ b/apps/chat-service/src/routes/inbox/leaveChat.ts @@ -0,0 +1,87 @@ +import { + LeaveChatErrors, + type CancelApprovalResponse, +} from '@vexl-next/rest-api/src/services/chat/contracts' +import {LeaveChatEndpoint} from '@vexl-next/rest-api/src/services/chat/specification' +import makeEndpointEffect from '@vexl-next/server-utils/src/makeEndpointEffect' +import {withDbTransaction} from '@vexl-next/server-utils/src/withDbTransaction' +import {Effect} from 'effect' +import {Handler} from 'effect-http' +import {encryptPublicKey} from '../../db/domain' +import {MessagesDbService} from '../../db/MessagesDbService' +import {WhitelistDbService} from '../../db/WhiteListDbService' +import {findAndEnsureReceiverAndSenderInbox} from '../../utils/findAndEnsureReceiverAndSenderInbox' +import {ensureSenderInReceiverWhitelist} from '../../utils/isSenderInReceiverWhitelist' +import {validateChallengeInBody} from '../../utils/validateChallengeInBody' +import {withInboxActionRedisLock} from '../../utils/withInboxActionRedisLock' + +export const leaveChat = Handler.make(LeaveChatEndpoint, (req) => + makeEndpointEffect( + Effect.gen(function* (_) { + yield* _( + validateChallengeInBody({ + signedChallenge: req.body.signedChallenge, + publicKey: req.body.senderPublicKey, + }) + ) + + const {receiverInbox, senderInbox} = yield* _( + findAndEnsureReceiverAndSenderInbox({ + sender: req.body.senderPublicKey, + receiver: req.body.receiverPublicKey, + }) + ) + + yield* _( + ensureSenderInReceiverWhitelist({ + sender: req.body.senderPublicKey, + receiver: receiverInbox.id, + }) + ) + + const whitelistDb = yield* _(WhitelistDbService) + + // Remove both whitelist records + yield* _( + whitelistDb.deleteWhitelistRecordBySenderAndReceiver({ + sender: senderInbox.publicKey, + receiver: receiverInbox.id, + }) + ) + yield* _( + whitelistDb.deleteWhitelistRecordBySenderAndReceiver({ + sender: receiverInbox.publicKey, + receiver: senderInbox.id, + }) + ) + + // send message about leaving + const senderKeyEncrypted = yield* _( + encryptPublicKey(req.body.senderPublicKey) + ) + const messagesDb = yield* _(MessagesDbService) + const sentMessage = yield* _( + messagesDb.insertMessageForInbox({ + message: req.body.message, + inboxId: receiverInbox.id, + senderPublicKey: senderKeyEncrypted, + type: 'DELETE_CHAT', + }) + ) + + return { + id: Number(sentMessage.id), + message: sentMessage.message, + senderPublicKey: req.body.senderPublicKey, + notificationHandled: false, + } satisfies CancelApprovalResponse + }).pipe( + withInboxActionRedisLock( + req.body.senderPublicKey, + req.body.receiverPublicKey + ), + withDbTransaction + ), + LeaveChatErrors + ) +) diff --git a/apps/chat-service/src/routes/inbox/requestApproval.ts b/apps/chat-service/src/routes/inbox/requestApproval.ts new file mode 100644 index 000000000..9fefe9c3e --- /dev/null +++ b/apps/chat-service/src/routes/inbox/requestApproval.ts @@ -0,0 +1,120 @@ +import {type UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import { + RequestApprovalErrors, + type RequestApprovalResponse, + RequestMessagingNotAllowedError, +} from '@vexl-next/rest-api/src/services/chat/contracts' +import {RequestApprovalEndpoint} from '@vexl-next/rest-api/src/services/chat/specification' +import makeEndpointEffect from '@vexl-next/server-utils/src/makeEndpointEffect' +import {withDbTransaction} from '@vexl-next/server-utils/src/withDbTransaction' +import dayjs from 'dayjs' +import {type ConfigError, Effect, Option} from 'effect' +import {Handler} from 'effect-http' +import {requestTimeoutDaysConfig} from '../../configs' +import {type InboxRecord} from '../../db/InboxDbService/domain' +import {MessagesDbService} from '../../db/MessagesDbService' +import {WhitelistDbService} from '../../db/WhiteListDbService' +import {encryptPublicKey} from '../../db/domain' +import {findAndEnsureReceiverAndSenderInbox} from '../../utils/findAndEnsureReceiverAndSenderInbox' +import {withInboxActionRedisLock} from '../../utils/withInboxActionRedisLock' + +const canSendRequest = ({ + receiverInbox, + senderInbox, +}: { + receiverInbox: InboxRecord + senderInbox: InboxRecord +}): Effect.Effect< + boolean, + UnexpectedServerError | ConfigError.ConfigError, + WhitelistDbService +> => + Effect.gen(function* (_) { + const whitelistDb = yield* _(WhitelistDbService) + const whitelistRecordOption = yield* _( + whitelistDb.findWhitelistRecordBySenderAndReceiver({ + sender: senderInbox.publicKey, + receiver: receiverInbox.id, + }) + ) + + if (Option.isNone(whitelistRecordOption)) { + return true + } + + const whitelistRecord = whitelistRecordOption.value + + if ( + whitelistRecord.state === 'CANCELED' || + whitelistRecord.state === 'WAITING' || + whitelistRecord.state === 'DISAPROVED' + ) { + const requestTimeoutDays = yield* _(requestTimeoutDaysConfig) + const canBeRequestedAgainFrom = dayjs(whitelistRecord.date).add( + requestTimeoutDays, + 'days' + ) + + return dayjs().isAfter(canBeRequestedAgainFrom) + } + + return false + }) + +export const requestApproval = Handler.make( + RequestApprovalEndpoint, + (req, sec) => + makeEndpointEffect( + Effect.gen(function* (_) { + const {receiverInbox, senderInbox} = yield* _( + findAndEnsureReceiverAndSenderInbox({ + receiver: req.body.publicKey, + sender: sec['public-key'], + }) + ) + + if (!(yield* _(canSendRequest({receiverInbox, senderInbox})))) { + return yield* _(Effect.fail(new RequestMessagingNotAllowedError())) + } + + const whitelistDb = yield* _(WhitelistDbService) + // first delete the existing record if it exists + yield* _( + whitelistDb.deleteWhitelistRecordBySenderAndReceiver({ + receiver: receiverInbox.id, + sender: senderInbox.publicKey, + }) + ) + + yield* _( + whitelistDb.insertWhitelistRecord({ + sender: senderInbox.publicKey, + receiver: receiverInbox.id, + state: 'WAITING', + }) + ) + + const encryptedSenderKey = yield* _(encryptPublicKey(sec['public-key'])) + const messagesDb = yield* _(MessagesDbService) + const insertedMessage = yield* _( + messagesDb.insertMessageForInbox({ + inboxId: receiverInbox.id, + message: req.body.message, + senderPublicKey: encryptedSenderKey, + type: 'REQUEST_MESSAGING', + }) + ) + + return { + id: Number(insertedMessage.id), + message: insertedMessage.message, + senderPublicKey: sec['public-key'], + notificationHandled: false, + } satisfies RequestApprovalResponse + }).pipe( + withInboxActionRedisLock(sec['public-key'], req.body.publicKey), + withDbTransaction + ), + RequestApprovalErrors + ) +) diff --git a/apps/chat-service/src/routes/inbox/updateInbox.ts b/apps/chat-service/src/routes/inbox/updateInbox.ts new file mode 100644 index 000000000..a673ae135 --- /dev/null +++ b/apps/chat-service/src/routes/inbox/updateInbox.ts @@ -0,0 +1,8 @@ +import {UpdateInboxEndpoint} from '@vexl-next/rest-api/src/services/chat/specification' +import {Effect} from 'effect' +import {Handler} from 'effect-http' + +// Depreciated - left here for backwards compatibility +export const updateInbox = Handler.make(UpdateInboxEndpoint, () => + Effect.succeed({}) +) diff --git a/apps/chat-service/src/routes/messages/retrieveMessages.ts b/apps/chat-service/src/routes/messages/retrieveMessages.ts new file mode 100644 index 000000000..1562e7106 --- /dev/null +++ b/apps/chat-service/src/routes/messages/retrieveMessages.ts @@ -0,0 +1,76 @@ +import {RetrieveMessagesErrors} from '@vexl-next/rest-api/src/services/chat/contracts' +import {RetrieveMessagesEndpoint} from '@vexl-next/rest-api/src/services/chat/specification' +import makeEndpointEffect from '@vexl-next/server-utils/src/makeEndpointEffect' +import {withDbTransaction} from '@vexl-next/server-utils/src/withDbTransaction' +import {Array, Effect, Option, pipe} from 'effect' +import {Handler} from 'effect-http' +import {decryptPublicKey} from '../../db/domain' +import {InboxDbService} from '../../db/InboxDbService' +import {MessagesDbService} from '../../db/MessagesDbService' +import {ensureInboxExists} from '../../utils/ensureInboxExists' +import {validateChallengeInBody} from '../../utils/validateChallengeInBody' + +export const retrieveMessages = Handler.make(RetrieveMessagesEndpoint, (req) => + makeEndpointEffect( + Effect.gen(function* (_) { + yield* _(validateChallengeInBody(req.body)) + + const inbox = yield* _(ensureInboxExists(req.body.publicKey)) + const inboxDb = yield* _(InboxDbService) + yield* _( + inboxDb.updateInboxMetadata({ + clientVersion: req.headers.clientVersionOrNone, + platform: req.headers.clientPlatformOrNone, + id: inbox.id, + }) + ) + + const messagesDb = yield* _(MessagesDbService) + const messages = yield* _(messagesDb.findMessagesByInboxId(inbox.id)) + + const messagesToReturn = yield* _( + pipe( + messages, + Array.map((oneMessage) => + decryptPublicKey(oneMessage.senderPublicKey).pipe( + Effect.map((senderPublicKey) => + Option.some({ + id: oneMessage.id, + message: oneMessage.message, + senderPublicKey, + }) + ), + // if one message fails, make sure to return the rest to not make the inbox unusable + Effect.catchAll(() => + Effect.zipRight( + Effect.logError( + 'Failed to decrypt message sender public key' + ), + Effect.succeed(Option.none()) + ) + ) + ) + ), + Effect.all, + Effect.map((array) => Array.filterMap(array, (v) => v)) + ) + ) + + yield* _( + messagesToReturn, + Array.map((message) => + messagesDb.updateMessageAsPulledByMessageRecord(message.id) + ), + (effects) => Effect.all(effects, {batching: true}) + ) + + return { + messages: Array.map(messagesToReturn, (one) => ({ + ...one, + id: Number(one.id), + })), + } + }), + RetrieveMessagesErrors + ).pipe(withDbTransaction) +) diff --git a/apps/chat-service/src/routes/messages/sendMessage.ts b/apps/chat-service/src/routes/messages/sendMessage.ts new file mode 100644 index 000000000..6ef705f2a --- /dev/null +++ b/apps/chat-service/src/routes/messages/sendMessage.ts @@ -0,0 +1,69 @@ +import { + SendMessageErrors, + type SendMessageResponse, +} from '@vexl-next/rest-api/src/services/chat/contracts' +import {SendMessageEndpoint} from '@vexl-next/rest-api/src/services/chat/specification' +import {ForbiddenMessageTypeError} from '@vexl-next/rest-api/src/services/contact/contracts' +import makeEndpointEffect from '@vexl-next/server-utils/src/makeEndpointEffect' +import {withDbTransaction} from '@vexl-next/server-utils/src/withDbTransaction' +import {Effect} from 'effect' +import {Handler} from 'effect-http' +import {MessagesDbService} from '../../db/MessagesDbService' +import {encryptPublicKey} from '../../db/domain' +import {findAndEnsureReceiverAndSenderInbox} from '../../utils/findAndEnsureReceiverAndSenderInbox' +import {forbiddenMessageTypes} from '../../utils/forbiddenMessageTypes' +import {ensureSenderInReceiverWhitelist} from '../../utils/isSenderInReceiverWhitelist' +import {validateChallengeInBody} from '../../utils/validateChallengeInBody' +import {withInboxActionRedisLock} from '../../utils/withInboxActionRedisLock' + +export const sendMessage = Handler.make(SendMessageEndpoint, (req) => + makeEndpointEffect( + Effect.gen(function* (_) { + yield* _( + validateChallengeInBody({ + publicKey: req.body.senderPublicKey, + signedChallenge: req.body.signedChallenge, + }) + ) + + if (forbiddenMessageTypes.includes(req.body.messageType)) { + return yield* _(Effect.fail(new ForbiddenMessageTypeError())) + } + + const {receiverInbox} = yield* _( + findAndEnsureReceiverAndSenderInbox({ + sender: req.body.senderPublicKey, + receiver: req.body.receiverPublicKey, + }) + ) + + yield* _( + ensureSenderInReceiverWhitelist({ + receiver: receiverInbox.id, + sender: req.body.senderPublicKey, + }) + ) + + const messagesDb = yield* _(MessagesDbService) + const messageRecord = yield* _( + messagesDb.insertMessageForInbox({ + message: req.body.message, + senderPublicKey: yield* _(encryptPublicKey(req.body.senderPublicKey)), + inboxId: receiverInbox.id, + type: req.body.messageType, + }) + ) + + return { + id: Number(messageRecord.id), + message: req.body.message, + senderPublicKey: req.body.senderPublicKey, + notificationHandled: false, + } satisfies SendMessageResponse + }), + SendMessageErrors + ).pipe( + withInboxActionRedisLock(req.body.receiverPublicKey), + withDbTransaction + ) +) diff --git a/apps/chat-service/src/routes/messages/sendMessages.ts b/apps/chat-service/src/routes/messages/sendMessages.ts new file mode 100644 index 000000000..6f99c8204 --- /dev/null +++ b/apps/chat-service/src/routes/messages/sendMessages.ts @@ -0,0 +1,126 @@ +import {type PublicKeyPemBase64} from '@vexl-next/cryptography/src/KeyHolder' +import {type UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import { + SenderInboxDoesNotExistError, + SendMessageErrors, + type MessageInBatch, + type ReceiverInboxDoesNotExistError, + type SendMessageResponse, +} from '@vexl-next/rest-api/src/services/chat/contracts' +import {SendMessagesEndpoint} from '@vexl-next/rest-api/src/services/chat/specification' +import { + ForbiddenMessageTypeError, + type NotPermittedToSendMessageToTargetInboxError, +} from '@vexl-next/rest-api/src/services/contact/contracts' +import { + type RedisLockError, + type RedisService, +} from '@vexl-next/server-utils/src/RedisService' +import {type ServerCrypto} from '@vexl-next/server-utils/src/ServerCrypto' +import makeEndpointEffect from '@vexl-next/server-utils/src/makeEndpointEffect' +import {withDbTransaction} from '@vexl-next/server-utils/src/withDbTransaction' +import {Array, Effect, pipe, type ConfigError} from 'effect' +import {Handler} from 'effect-http' +import {InboxDbService} from '../../db/InboxDbService' +import {MessagesDbService} from '../../db/MessagesDbService' +import {type WhitelistDbService} from '../../db/WhiteListDbService' +import {encryptPublicKey, hashPublicKey} from '../../db/domain' +import {findAndEnsureReceiverInbox} from '../../utils/findAndEnsureReceiverInbox' +import {forbiddenMessageTypes} from '../../utils/forbiddenMessageTypes' +import {ensureSenderInReceiverWhitelist} from '../../utils/isSenderInReceiverWhitelist' +import {validateChallengeInBody} from '../../utils/validateChallengeInBody' +import {withInboxActionRedisLock} from '../../utils/withInboxActionRedisLock' + +const sendMessage = ( + senderPublicKey: PublicKeyPemBase64, + message: MessageInBatch +): Effect.Effect< + SendMessageResponse, + | ReceiverInboxDoesNotExistError + | UnexpectedServerError + | NotPermittedToSendMessageToTargetInboxError + | ConfigError.ConfigError + | RedisLockError + | ForbiddenMessageTypeError, + | WhitelistDbService + | MessagesDbService + | InboxDbService + | ServerCrypto + | RedisService +> => + Effect.gen(function* (_) { + const receiverInbox = yield* _( + findAndEnsureReceiverInbox(message.receiverPublicKey) + ) + + yield* _( + ensureSenderInReceiverWhitelist({ + receiver: receiverInbox.id, + sender: senderPublicKey, + }) + ) + + if (forbiddenMessageTypes.includes(message.messageType)) { + return yield* _(Effect.fail(new ForbiddenMessageTypeError())) + } + + const messagesDb = yield* _(MessagesDbService) + const messageRecord = yield* _( + messagesDb.insertMessageForInbox({ + message: message.message, + senderPublicKey: yield* _(encryptPublicKey(senderPublicKey)), + inboxId: receiverInbox.id, + type: message.messageType, + }) + ) + + return { + id: Number(messageRecord.id), + message: messageRecord.message, + senderPublicKey, + notificationHandled: false, + } satisfies SendMessageResponse + }).pipe(withInboxActionRedisLock(message.receiverPublicKey)) // TODO lock two inboxes + +export const sendMessages = Handler.make(SendMessagesEndpoint, (req) => + makeEndpointEffect( + pipe( + req.body.data, + Array.map((oneMessage) => + Effect.gen(function* (_) { + yield* _( + validateChallengeInBody({ + publicKey: oneMessage.senderPublicKey, + ...oneMessage, + }) + ) + + const inboxDb = yield* _(InboxDbService) + const hashedSenderKey = yield* _( + hashPublicKey(oneMessage.senderPublicKey) + ) + yield* _( + inboxDb.findInboxByPublicKey(hashedSenderKey), + Effect.flatten, + Effect.catchTag( + 'NoSuchElementException', + () => new SenderInboxDoesNotExistError() + ) + ) + + const result = yield* _( + oneMessage.messages, + Array.map((message) => + sendMessage(oneMessage.senderPublicKey, message) + ), + Effect.all + ) + return result + }) + ), + Effect.all, + Effect.map(Array.flatten) + ), + SendMessageErrors + ).pipe(withDbTransaction) +) diff --git a/apps/chat-service/src/utils/ChatChallengeService.ts b/apps/chat-service/src/utils/ChatChallengeService.ts new file mode 100644 index 000000000..f28d1fe22 --- /dev/null +++ b/apps/chat-service/src/utils/ChatChallengeService.ts @@ -0,0 +1,111 @@ +import {Schema} from '@effect/schema' +import {type PublicKeyPemBase64} from '@vexl-next/cryptography/src/KeyHolder' +import {UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import { + ecdsaVerifyE, + generateChallenge, +} from '@vexl-next/generic-utils/src/effect-helpers/crypto' +import { + ChatChallengeE, + type ChatChallenge, + type SignedChallenge, +} from '@vexl-next/rest-api/src/services/chat/contracts' +import { + withRedisLock, + type RedisLockError, + type RedisService, +} from '@vexl-next/server-utils/src/RedisService' +import {Context, Effect, Layer, Option} from 'effect' +import {ChallengeDbService} from '../db/ChallegeDbService' + +export const generateRandomChatChallenge = generateChallenge().pipe( + Effect.flatMap(Schema.decode(ChatChallengeE)), + Effect.catchAll((e) => + Effect.zipRight( + Effect.log('Error while normalizing challenge', e), + new UnexpectedServerError({status: 500, cause: e}) + ) + ) +) + +export interface ChatChallengeOperations { + createChallenge: ( + args: PublicKeyPemBase64 + ) => Effect.Effect + verifyChallenge: (args: { + signedChallenge: SignedChallenge + publicKey: PublicKeyPemBase64 + }) => Effect.Effect< + boolean, + UnexpectedServerError | RedisLockError, + RedisService + > +} + +export class ChatChallengeService extends Context.Tag('ChatChallengeService')< + ChatChallengeService, + ChatChallengeOperations +>() { + static readonly Live = Layer.effect( + ChatChallengeService, + Effect.gen(function* (_) { + const challengeDb = yield* _(ChallengeDbService) + + const createChatChallenge: ChatChallengeOperations['createChallenge'] = ( + publicKey: PublicKeyPemBase64 + ) => + Effect.gen(function* (_) { + const challenge = yield* _(generateRandomChatChallenge) + yield* _( + challengeDb.insertChallenge({ + challenge, + publicKey, + createdAt: new Date(), + valid: true, + }) + ) + + return challenge + }).pipe(Effect.withSpan('CreateChallenge')) + + const verifyChallenge: ChatChallengeOperations['verifyChallenge'] = ({ + signedChallenge: {challenge, signature}, + publicKey, + }) => + Effect.gen(function* (_) { + const challengeRecord = yield* _( + challengeDb.findChallengeByChallengeAndPublicKey({ + challenge, + publicKey, + }) + ) + + if (Option.isNone(challengeRecord)) return false + + const isValid = yield* _( + ecdsaVerifyE(publicKey)({data: challenge, signature}), + Effect.catchAll((e) => + Effect.zipRight( + Effect.log('Error while verifying challenge', e), + Effect.succeed(false) + ) + ) + ) + + if (isValid) { + yield* _(challengeDb.deleteChallenge(challenge)) + } + + return isValid + }).pipe( + withRedisLock(`verifyChallenge:${challenge}`), + Effect.withSpan('VerifyChallenge') + ) + + return { + createChallenge: createChatChallenge, + verifyChallenge, + } + }) + ) +} diff --git a/apps/chat-service/src/utils/ensureInboxExists.ts b/apps/chat-service/src/utils/ensureInboxExists.ts new file mode 100644 index 000000000..0ac14c047 --- /dev/null +++ b/apps/chat-service/src/utils/ensureInboxExists.ts @@ -0,0 +1,29 @@ +import {type PublicKeyPemBase64} from '@vexl-next/cryptography/src/KeyHolder' +import {type UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import {InboxDoesNotExistError} from '@vexl-next/rest-api/src/services/contact/contracts' +import {Effect} from 'effect' +import {InboxDbService} from '../db/InboxDbService' +import {type InboxRecord} from '../db/InboxDbService/domain' +import {hashPublicKey} from '../db/domain' + +export const ensureInboxExists = ( + publicKey: PublicKeyPemBase64 +): Effect.Effect< + InboxRecord, + InboxDoesNotExistError | UnexpectedServerError, + InboxDbService +> => + Effect.gen(function* (_) { + const inboxDb = yield* _(InboxDbService) + + const publicKeyEncrypted = yield* _(hashPublicKey(publicKey)) + + return yield* _( + inboxDb.findInboxByPublicKey(publicKeyEncrypted), + Effect.flatten, + Effect.catchTag( + 'NoSuchElementException', + () => new InboxDoesNotExistError() + ) + ) + }) diff --git a/apps/chat-service/src/utils/findAndEnsureReceiverAndSenderInbox.ts b/apps/chat-service/src/utils/findAndEnsureReceiverAndSenderInbox.ts new file mode 100644 index 000000000..b9c491920 --- /dev/null +++ b/apps/chat-service/src/utils/findAndEnsureReceiverAndSenderInbox.ts @@ -0,0 +1,51 @@ +import {type PublicKeyPemBase64} from '@vexl-next/cryptography/src/KeyHolder' +import {type UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import { + ReceiverInboxDoesNotExistError, + SenderInboxDoesNotExistError, +} from '@vexl-next/rest-api/src/services/chat/contracts' +import {Effect} from 'effect' +import {hashPublicKey} from '../db/domain' +import {InboxDbService} from '../db/InboxDbService' +import {type InboxRecord} from '../db/InboxDbService/domain' + +export const findAndEnsureReceiverAndSenderInbox = ({ + receiver, + sender, +}: { + receiver: PublicKeyPemBase64 + sender: PublicKeyPemBase64 +}): Effect.Effect< + {senderInbox: InboxRecord; receiverInbox: InboxRecord}, + | SenderInboxDoesNotExistError + | ReceiverInboxDoesNotExistError + | UnexpectedServerError, + InboxDbService +> => + Effect.gen(function* (_) { + const receiverPubKeyHash = yield* _(hashPublicKey(receiver)) + const senderPubKeyHash = yield* _(hashPublicKey(sender)) + + const inboxService = yield* _(InboxDbService) + const senderInbox = yield* _( + inboxService.findInboxByPublicKey(senderPubKeyHash), + Effect.flatten, + Effect.catchTag( + 'NoSuchElementException', + () => new SenderInboxDoesNotExistError() + ) + ) + const receiverInbox = yield* _( + inboxService.findInboxByPublicKey(receiverPubKeyHash), + Effect.flatten, + Effect.catchTag( + 'NoSuchElementException', + () => new ReceiverInboxDoesNotExistError() + ) + ) + + return { + senderInbox, + receiverInbox, + } + }) diff --git a/apps/chat-service/src/utils/findAndEnsureReceiverInbox.ts b/apps/chat-service/src/utils/findAndEnsureReceiverInbox.ts new file mode 100644 index 000000000..198964636 --- /dev/null +++ b/apps/chat-service/src/utils/findAndEnsureReceiverInbox.ts @@ -0,0 +1,28 @@ +import {type PublicKeyPemBase64} from '@vexl-next/cryptography/src/KeyHolder' +import {type UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import {ReceiverInboxDoesNotExistError} from '@vexl-next/rest-api/src/services/chat/contracts' +import {Effect} from 'effect' +import {hashPublicKey} from '../db/domain' +import {InboxDbService} from '../db/InboxDbService' +import {type InboxRecord} from '../db/InboxDbService/domain' + +export const findAndEnsureReceiverInbox = ( + receiverPubKey: PublicKeyPemBase64 +): Effect.Effect< + InboxRecord, + ReceiverInboxDoesNotExistError | UnexpectedServerError, + InboxDbService +> => + Effect.gen(function* (_) { + const receiverPubKeyHash = yield* _(hashPublicKey(receiverPubKey)) + + const inboxService = yield* _(InboxDbService) + return yield* _( + inboxService.findInboxByPublicKey(receiverPubKeyHash), + Effect.flatten, + Effect.catchTag( + 'NoSuchElementException', + () => new ReceiverInboxDoesNotExistError() + ) + ) + }) diff --git a/apps/chat-service/src/utils/forbiddenMessageTypes.ts b/apps/chat-service/src/utils/forbiddenMessageTypes.ts new file mode 100644 index 000000000..82cb5cf32 --- /dev/null +++ b/apps/chat-service/src/utils/forbiddenMessageTypes.ts @@ -0,0 +1,6 @@ +export const forbiddenMessageTypes = [ + 'REQUEST_MESSAGING', + 'APPROVE_MESSAGING', + 'DISAPPROVE_MESSAGING', + 'CANCEL_REQUEST_MESSAGING', +] diff --git a/apps/chat-service/src/utils/isSenderInReceiverWhitelist.ts b/apps/chat-service/src/utils/isSenderInReceiverWhitelist.ts new file mode 100644 index 000000000..457f60f25 --- /dev/null +++ b/apps/chat-service/src/utils/isSenderInReceiverWhitelist.ts @@ -0,0 +1,48 @@ +import {type PublicKeyPemBase64} from '@vexl-next/cryptography/src/KeyHolder' +import {type UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import {NotPermittedToSendMessageToTargetInboxError} from '@vexl-next/rest-api/src/services/contact/contracts' +import {Effect, Option} from 'effect' +import {hashPublicKey} from '../db/domain' +import {type InboxRecordId} from '../db/InboxDbService/domain' +import {WhitelistDbService} from '../db/WhiteListDbService' + +export const isSenderInReceiverWhitelist = ({ + sender, + receiver, +}: { + sender: PublicKeyPemBase64 + receiver: InboxRecordId +}): Effect.Effect => + Effect.gen(function* (_) { + const hashedSenderKey = yield* _(hashPublicKey(sender)) + + const whitelistDb = yield* _(WhitelistDbService) + const whitelistRecord = yield* _( + whitelistDb.findWhitelistRecordBySenderAndReceiver({ + sender: hashedSenderKey, + receiver, + }) + ) + + if (Option.isNone(whitelistRecord)) return false + if (whitelistRecord.value.state === 'APROVED') return true + return false + }) + +export const ensureSenderInReceiverWhitelist = ({ + receiver, + sender, +}: { + sender: PublicKeyPemBase64 + receiver: InboxRecordId +}): Effect.Effect< + void, + NotPermittedToSendMessageToTargetInboxError | UnexpectedServerError, + WhitelistDbService +> => + isSenderInReceiverWhitelist({sender, receiver}).pipe( + Effect.filterOrFail( + (one) => one, + () => new NotPermittedToSendMessageToTargetInboxError() + ) + ) diff --git a/apps/chat-service/src/utils/validateChallengeInBody.ts b/apps/chat-service/src/utils/validateChallengeInBody.ts new file mode 100644 index 000000000..8eb7184d1 --- /dev/null +++ b/apps/chat-service/src/utils/validateChallengeInBody.ts @@ -0,0 +1,28 @@ +import {type UnexpectedServerError} from '@vexl-next/domain/src/general/commonErrors' +import { + InvalidChallengeError, + type RequestBaseWithChallenge, +} from '@vexl-next/rest-api/src/services/chat/contracts' +import { + type RedisLockError, + type RedisService, +} from '@vexl-next/server-utils/src/RedisService' +import {Effect} from 'effect' +import {ChatChallengeService} from './ChatChallengeService' + +export const validateChallengeInBody = ( + body: RequestBaseWithChallenge +): Effect.Effect< + boolean, + UnexpectedServerError | RedisLockError | InvalidChallengeError, + ChatChallengeService | RedisService +> => + ChatChallengeService.pipe( + Effect.flatMap((chatChallengeService) => + chatChallengeService.verifyChallenge(body) + ), + Effect.filterOrFail( + (one) => one, + () => new InvalidChallengeError() + ) + ) diff --git a/apps/chat-service/src/utils/withInboxActionRedisLock.ts b/apps/chat-service/src/utils/withInboxActionRedisLock.ts new file mode 100644 index 000000000..038da0677 --- /dev/null +++ b/apps/chat-service/src/utils/withInboxActionRedisLock.ts @@ -0,0 +1,11 @@ +import {type PublicKeyPemBase64} from '@vexl-next/cryptography/src/KeyHolder' +import {withRedisLock} from '@vexl-next/server-utils/src/RedisService' +import {Array} from 'effect' + +export const withInboxActionRedisLock = ( + ...publicKeys: PublicKeyPemBase64[] +): ReturnType> => + withRedisLock( + Array.map(publicKeys, (key) => `inboxAction:${key}`), + '10 seconds' + ) diff --git a/apps/chat-service/tsconfig.json b/apps/chat-service/tsconfig.json new file mode 100644 index 000000000..664bcdc6b --- /dev/null +++ b/apps/chat-service/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "node", + "rootDir": ".", + "outDir": "./dist", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitAny": true, + "skipLibCheck": true, + "checkJs": false, + "allowJs": true + }, + "ts-node": { + "esm": true, + "experimentalSpecifierResolution": "node" + } +} diff --git a/apps/mobile/src/components/OfferDetailScreen/components/OfferInfo.tsx b/apps/mobile/src/components/OfferDetailScreen/components/OfferInfo.tsx index 548ca9045..73624becc 100644 --- a/apps/mobile/src/components/OfferDetailScreen/components/OfferInfo.tsx +++ b/apps/mobile/src/components/OfferDetailScreen/components/OfferInfo.tsx @@ -110,7 +110,7 @@ function OfferInfo({ submitRequest({text, originOffer: offer}), TE.match( (e) => { - if (e._tag === 'ReceiverOfferInboxDoesNotExistError') { + if (e._tag === 'ReceiverInboxDoesNotExistError') { Alert.alert(t('common.error'), t('offer.offerNotFound'), [ { text: t('common.close'), diff --git a/apps/mobile/src/state/chat/atoms/blockChatActionAtom.ts b/apps/mobile/src/state/chat/atoms/blockChatActionAtom.ts index 11b06da73..cf5d1a11e 100644 --- a/apps/mobile/src/state/chat/atoms/blockChatActionAtom.ts +++ b/apps/mobile/src/state/chat/atoms/blockChatActionAtom.ts @@ -67,7 +67,6 @@ export default function blockChatActionAtom( ), TE.chainW(() => api.chat.blockInbox({ - block: true, keyPair: chat.inbox.privateKey, publicKeyToBlock: chat.otherSide.publicKey, }) diff --git a/apps/mobile/src/state/chat/atoms/sendRequestActionAtom.ts b/apps/mobile/src/state/chat/atoms/sendRequestActionAtom.ts index 8bc47278c..9b1fe269f 100644 --- a/apps/mobile/src/state/chat/atoms/sendRequestActionAtom.ts +++ b/apps/mobile/src/state/chat/atoms/sendRequestActionAtom.ts @@ -75,7 +75,7 @@ export const sendRequestHandleUIActionAtom = atom( set(sendRequestActionAtom, {text, originOffer}), TE.matchE( (e) => { - if (e._tag === 'SenderUserInboxDoesNotExistError') { + if (e._tag === 'SenderInboxDoesNotExistError') { reportError('warn', new Error('Sender user inbox does not exist'), { e, }) diff --git a/package.json b/package.json index 958851264..65aa782c7 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "location-service:build-docker-image": "./scripts/build-app-docker-image.sh location-service", "btc-exchange-rate-service:build-docker-image": "./scripts/build-app-docker-image.sh btc-exchange-rate-service", "user-service:build-docker-image": "./scripts/build-app-docker-image.sh user-service", + "chat-service:build-docker-image": "./scripts/build-app-docker-image.sh chat-service", "dashboard-app:build-docker-image": "./scripts/build-app-docker-image.sh dashboard-app", "offer-service:build-docker-image": "./scripts/build-app-docker-image.sh offer-service", "metrics-service:build-docker-image": "./scripts/build-app-docker-image.sh metrics-service", diff --git a/packages/generic-utils/src/effect-helpers/BooleanFromString.ts b/packages/generic-utils/src/effect-helpers/BooleanFromString.ts index 3be73ed80..7e610d2ae 100644 --- a/packages/generic-utils/src/effect-helpers/BooleanFromString.ts +++ b/packages/generic-utils/src/effect-helpers/BooleanFromString.ts @@ -1,7 +1,7 @@ import {ParseResult, Schema} from '@effect/schema' import {Effect} from 'effect' -export const BooleanfromString = Schema.transformOrFail( +export const BooleanFromString = Schema.transformOrFail( Schema.String, Schema.Boolean, { diff --git a/packages/generic-utils/src/effect-helpers/crypto.ts b/packages/generic-utils/src/effect-helpers/crypto.ts index 08923b847..3081e81d7 100644 --- a/packages/generic-utils/src/effect-helpers/crypto.ts +++ b/packages/generic-utils/src/effect-helpers/crypto.ts @@ -18,6 +18,7 @@ import { eciesGTMEncrypt, } from '@vexl-next/cryptography/src/operations/ecies' import * as hmac from '@vexl-next/cryptography/src/operations/hmac' +import {sha256} from '@vexl-next/cryptography/src/operations/sha' import {randomBytes} from 'crypto' import {Effect} from 'effect' @@ -229,3 +230,14 @@ export const aesDecrpytE = }) ) ) + +export const hashSha256 = (data: string): Effect.Effect => + Effect.sync(() => sha256(data)).pipe( + Effect.catchAllDefect( + (e) => + new CryptoError({ + message: `Unable to hash data with sha256, data: ${data}`, + error: e, + }) + ) + ) diff --git a/packages/resources-utils/src/chat/sendLeaveChat.ts b/packages/resources-utils/src/chat/sendLeaveChat.ts index 63b493c22..d6b6a3a05 100644 --- a/packages/resources-utils/src/chat/sendLeaveChat.ts +++ b/packages/resources-utils/src/chat/sendLeaveChat.ts @@ -53,6 +53,7 @@ export default function sendLeaveChat({ callWithNotificationService(api.leaveChat, { message: encrypted, receiverPublicKey, + senderPublicKey: senderKeypair.publicKeyPemBase64, keyPair: senderKeypair, })({ notificationApi, diff --git a/packages/resources-utils/src/chat/sendMessage.ts b/packages/resources-utils/src/chat/sendMessage.ts index 4f7f234a8..e1d1dcc63 100644 --- a/packages/resources-utils/src/chat/sendMessage.ts +++ b/packages/resources-utils/src/chat/sendMessage.ts @@ -60,6 +60,7 @@ export default function sendMessage({ messagePreview: encryptedPreview, messageType: message.messageType, receiverPublicKey, + senderPublicKey: senderKeypair.publicKeyPemBase64, keyPair: senderKeypair, })({ fcmCypher: theirFcmCypher, diff --git a/packages/resources-utils/src/chat/utils/generateSignedChallengesBatch.ts b/packages/resources-utils/src/chat/utils/generateSignedChallengesBatch.ts index b179dea1f..67ec266a1 100644 --- a/packages/resources-utils/src/chat/utils/generateSignedChallengesBatch.ts +++ b/packages/resources-utils/src/chat/utils/generateSignedChallengesBatch.ts @@ -5,8 +5,8 @@ import { import {toError, type BasicError} from '@vexl-next/domain/src/utility/errors' import {type ChatPrivateApi} from '@vexl-next/rest-api/src/services/chat' import {type SignedChallenge} from '@vexl-next/rest-api/src/services/chat/contracts' -import * as A from 'fp-ts/Array' import * as O from 'fp-ts/Option' +import * as RA from 'fp-ts/ReadonlyArray' import * as TE from 'fp-ts/TaskEither' import {flow, pipe} from 'fp-ts/function' import {type ExtractLeftTE} from '../../utils/ExtractLeft' @@ -46,7 +46,7 @@ export function generateSignedChallengeBatch( TE.map((one) => one.challenges), TE.chainW( flow( - A.map((challenge) => + RA.map((challenge) => pipe( selectKeyPair(keyPairs)(challenge.publicKey), TE.fromOption( @@ -68,10 +68,12 @@ export function generateSignedChallengeBatch( })) ) ), - A.sequence(TE.ApplicativePar), + RA.sequence(TE.ApplicativePar), TE.mapLeft(toError('ErrorGeneratingSignedChallengeBatch')) ) - ) + ), + // Readonly workaround + TE.map((one) => [...one]) ) } } diff --git a/packages/resources-utils/src/notifications/callWithNotificationService.ts b/packages/resources-utils/src/notifications/callWithNotificationService.ts index df4afcfc3..c77c62315 100644 --- a/packages/resources-utils/src/notifications/callWithNotificationService.ts +++ b/packages/resources-utils/src/notifications/callWithNotificationService.ts @@ -17,7 +17,7 @@ interface NotificationArgs { } export function callWithNotificationService< - T extends {notificationServiceReady: boolean} & object, + T extends object, L, R extends {notificationHandled: boolean}, >( diff --git a/packages/resources-utils/src/utils/crypto.ts b/packages/resources-utils/src/utils/crypto.ts index af9fdf020..8072768fe 100644 --- a/packages/resources-utils/src/utils/crypto.ts +++ b/packages/resources-utils/src/utils/crypto.ts @@ -1,3 +1,4 @@ +import {Schema} from '@effect/schema' import * as crypto from '@vexl-next/cryptography' import {type KeyHolder} from '@vexl-next/cryptography' import { @@ -5,23 +6,27 @@ import { type PrivateKeyPemBase64, } from '@vexl-next/cryptography/src/KeyHolder' import {toError, type BasicError} from '@vexl-next/domain/src/utility/errors' +import {EcdsaSignature} from '@vexl-next/generic-utils/src/effect-helpers/crypto' import {createHash} from 'crypto' import * as E from 'fp-ts/Either' +import {pipe} from 'fp-ts/lib/function' import * as TE from 'fp-ts/TaskEither' - export type CryptoError = BasicError<'CryptoError'> export function ecdsaSign( privateKey: PrivateKeyHolder -): (challenge: string) => E.Either { +): (challenge: string) => E.Either { return (challenge: string) => - E.tryCatch( - () => - crypto.ecdsa.ecdsaSign({ - privateKey: privateKey.privateKeyPemBase64, - challenge, - }), - toError('CryptoError', 'Error while signing challenge') + pipe( + E.tryCatch( + () => + crypto.ecdsa.ecdsaSign({ + privateKey: privateKey.privateKeyPemBase64, + challenge, + }), + toError('CryptoError', 'Error while signing challenge') + ), + E.map(Schema.decodeSync(EcdsaSignature)) ) } diff --git a/packages/rest-api/src/services/chat/contracts.ts b/packages/rest-api/src/services/chat/contracts.ts index 55dde775a..b7ac7f36e 100644 --- a/packages/rest-api/src/services/chat/contracts.ts +++ b/packages/rest-api/src/services/chat/contracts.ts @@ -1,12 +1,6 @@ import {Schema} from '@effect/schema' -import { - PrivateKeyHolder, - PublicKeyPemBase64, -} from '@vexl-next/cryptography/src/KeyHolder' -import { - PrivateKeyHolderE, - PublicKeyPemBase64E, -} from '@vexl-next/cryptography/src/KeyHolder/brands' +import {PublicKeyPemBase64} from '@vexl-next/cryptography/src/KeyHolder' +import {PublicKeyPemBase64E} from '@vexl-next/cryptography/src/KeyHolder/brands' import { MessageType, MessageTypeE, @@ -18,58 +12,107 @@ import { UnixMilliseconds, UnixMillisecondsE, } from '@vexl-next/domain/src/utility/UnixMilliseconds.brand' +import {BooleanFromString} from '@vexl-next/generic-utils/src/effect-helpers/BooleanFromString' +import {EcdsaSignature} from '@vexl-next/generic-utils/src/effect-helpers/crypto' +import {Brand} from 'effect' import {z} from 'zod' import { NoContentResponse, NoContentResponseE, } from '../../NoContentResponse.brand' +import { + ForbiddenMessageTypeError, + InboxDoesNotExistError, + NotPermittedToSendMessageToTargetInboxError, +} from '../contact/contracts' + +export const NotificationServiceReadyQueryParams = Schema.Struct({ + notificationServiceReady: BooleanFromString, +}) export class RequestCancelledError extends Schema.TaggedError( 'RequestCancelledError' )('RequestCancelledError', { status: Schema.optionalWith(Schema.Literal(400), {default: () => 400}), + code: Schema.optionalWith(Schema.Literal('100106'), { + default: () => '100106', + }), }) {} export class RequestNotFoundError extends Schema.TaggedError( 'RequestNotFoundError' )('RequestNotFoundError', { status: Schema.optionalWith(Schema.Literal(400), {default: () => 400}), + code: Schema.optionalWith(Schema.Literal('100104'), { + default: () => '100104', + }), }) {} -export class RequestAlreadyApprovedError extends Schema.TaggedError( - 'RequestAlreadyApprovedError' -)('RequestAlreadyApprovedError', { +export class RequestNotPendingError extends Schema.TaggedError( + 'RequestNotPendingError' +)('RequestNotPendingError', { status: Schema.optionalWith(Schema.Literal(400), {default: () => 400}), + code: Schema.optionalWith(Schema.Literal('100153'), { + default: () => '100153', + }), }) {} export class OtherSideAccountDeleted extends Schema.TaggedError( 'OtherSideAccountDeleted' )('OtherSideAccountDeleted', { + code: Schema.optionalWith(Schema.Literal('100101'), { + default: () => '100101', + }), + status: Schema.optionalWith(Schema.Literal(400), {default: () => 400}), +}) {} + +export class ReceiverInboxDoesNotExistError extends Schema.TaggedError( + 'ReceiverInboxDoesNotExistError' +)('ReceiverInboxDoesNotExistError', { status: Schema.optionalWith(Schema.Literal(400), {default: () => 400}), + code: Schema.optionalWith(Schema.Literal('100101'), { + default: () => '100101', + }), }) {} -export class ReceiverOfferInboxDoesNotExistError extends Schema.TaggedError( - 'ReceiverOfferInboxDoesNotExistError' -)('ReceiverOfferInboxDoesNotExistError', { +export class SenderInboxDoesNotExistError extends Schema.TaggedError( + 'SenderInboxDoesNotExistError' +)('SenderInboxDoesNotExistError', { status: Schema.optionalWith(Schema.Literal(400), {default: () => 400}), + code: Schema.optionalWith(Schema.Literal('100107'), { + default: () => '100107', + }), }) {} -export class SenderUserInboxDoesNotExistError extends Schema.TaggedError( - 'SenderUserInboxDoesNotExistError' -)('SenderUserInboxDoesNotExistError', { +export class RequestMessagingNotAllowedError extends Schema.TaggedError( + 'RequestMessagingNotAllowedError' +)('RequestMessagingNotAllowedError', { status: Schema.optionalWith(Schema.Literal(400), {default: () => 400}), }) {} +export class InvalidChallengeError extends Schema.TaggedError( + 'InvalidChallengeError' +)('InvalidChallengeError', { + status: Schema.optionalWith(Schema.Literal(400), {default: () => 400}), +}) {} + +export const ChatChallenge = z + .string() + .transform((v) => Brand.nominal>()(v)) + +export const ChatChallengeE = Schema.String.pipe(Schema.brand('ChatChallenge')) +export type ChatChallenge = Schema.Schema.Type + export const SignedChallenge = z .object({ - challenge: z.string(), + challenge: ChatChallenge, signature: z.string(), }) .readonly() export const SignedChallengeE = Schema.Struct({ - challenge: Schema.String, - signature: Schema.String, + challenge: ChatChallengeE, + signature: EcdsaSignature, }) export type SignedChallenge = Schema.Schema.Type @@ -84,12 +127,17 @@ export type ServerMessageWithId = Schema.Schema.Type< typeof ServerMessageWithIdE > -const RequestBaseWithChallenge = z.object({ - keyPair: PrivateKeyHolder, +export const RequestBaseWithChallenge = z.object({ + publicKey: PublicKeyPemBase64, + signedChallenge: SignedChallenge, }) -const RequestBaseWithChallengeE = Schema.Struct({ - keyPair: PrivateKeyHolderE, +export const RequestBaseWithChallengeE = Schema.Struct({ + publicKey: PublicKeyPemBase64E, + signedChallenge: SignedChallengeE, }) +export type RequestBaseWithChallenge = Schema.Schema.Type< + typeof RequestBaseWithChallengeE +> export const UpdateInboxRequest = RequestBaseWithChallenge.extend({ token: z.string().optional(), @@ -128,6 +176,12 @@ export type CreateInboxResponse = Schema.Schema.Type< typeof CreateInboxResponseE > +export const DeleteInboxErrors = Schema.Union( + InvalidChallengeError, + InboxDoesNotExistError +) +export type DeleteInboxErrors = Schema.Schema.Type + export const DeleteInboxRequest = RequestBaseWithChallenge.extend({}) export const DeleteInboxRequestE = Schema.Struct({ ...RequestBaseWithChallengeE.fields, @@ -140,6 +194,14 @@ export type DeleteInboxResponse = Schema.Schema.Type< typeof DeleteInboxResponseE > +export const DeletePulledMessagesErrors = Schema.Union( + InboxDoesNotExistError, + InvalidChallengeError +) +export type DeletePulledMessagesErrors = Schema.Schema.Type< + typeof DeletePulledMessagesErrors +> + export const DeletePulledMessagesRequest = RequestBaseWithChallenge.extend({}) export const DeletePulledMessagesRequestE = Schema.Struct({ ...RequestBaseWithChallengeE.fields, @@ -154,6 +216,13 @@ export type DeletePulledMessagesResponse = Schema.Schema.Type< typeof DeletePulledMessagesResponseE > +export const BlockInboxErrors = Schema.Union( + ReceiverInboxDoesNotExistError, + SenderInboxDoesNotExistError, + InvalidChallengeError +) +export type BlockInboxErrors = Schema.Schema.Type + export const BlockInboxRequest = RequestBaseWithChallenge.extend({ publicKeyToBlock: PublicKeyPemBase64, block: z.boolean(), @@ -161,7 +230,7 @@ export const BlockInboxRequest = RequestBaseWithChallenge.extend({ export const BlockInboxRequestE = Schema.Struct({ ...RequestBaseWithChallengeE.fields, publicKeyToBlock: PublicKeyPemBase64E, - block: Schema.Boolean, + // block: Schema.Boolean, // not used inside the app }) export type BlockInboxRequest = Schema.Schema.Type @@ -173,13 +242,18 @@ export const RequestApprovalRequest = z .object({ publicKey: PublicKeyPemBase64, message: z.string(), - notificationServiceReady: z.boolean(), }) .readonly() + +export const RequestApprovalErrors = Schema.Union( + ReceiverInboxDoesNotExistError, + SenderInboxDoesNotExistError, + RequestMessagingNotAllowedError +) + export const RequestApprovalRequestE = Schema.Struct({ publicKey: PublicKeyPemBase64E, message: Schema.String, - notificationServiceReady: Schema.Boolean, }) export type RequestApprovalRequest = Schema.Schema.Type< typeof RequestApprovalRequestE @@ -196,15 +270,23 @@ export type RequestApprovalResponse = Schema.Schema.Type< typeof RequestApprovalResponseE > +export const CancelRequestApprovalErrors = Schema.Union( + RequestNotPendingError, + ReceiverInboxDoesNotExistError, + SenderInboxDoesNotExistError, + InvalidChallengeError +) +export type CancelRequestApprovalErrors = Schema.Schema.Type< + typeof CancelRequestApprovalErrors +> + export const CancelApprovalRequest = z.object({ publicKey: PublicKeyPemBase64, message: z.string(), - notificationServiceReady: z.boolean(), }) export const CancelApprovalRequestE = Schema.Struct({ publicKey: PublicKeyPemBase64E, message: Schema.String, - notificationServiceReady: Schema.Boolean, }) export type CancelApprovalRequest = Schema.Schema.Type< typeof CancelApprovalRequestE @@ -221,18 +303,28 @@ export type CancelApprovalResponse = Schema.Schema.Type< typeof CancelApprovalResponseE > +export const ApproveRequestErrors = Schema.Union( + InvalidChallengeError, + RequestCancelledError, + RequestNotFoundError, + RequestNotPendingError, + ReceiverInboxDoesNotExistError, + SenderInboxDoesNotExistError +) +export type ApproveRequestErrors = Schema.Schema.Type< + typeof ApproveRequestErrors +> + export const ApproveRequestRequest = RequestBaseWithChallenge.extend({ publicKeyToConfirm: PublicKeyPemBase64, message: z.string(), approve: z.boolean(), - notificationServiceReady: z.boolean(), }) export const ApproveRequestRequestE = Schema.Struct({ ...RequestBaseWithChallengeE.fields, publicKeyToConfirm: PublicKeyPemBase64E, message: Schema.String, approve: Schema.Boolean, - notificationServiceReady: Schema.Boolean, }) export type ApproveRequestRequest = Schema.Schema.Type< typeof ApproveRequestRequestE @@ -275,6 +367,11 @@ export type DeleteInboxesResponse = Schema.Schema.Type< typeof DeleteInboxesResponseE > +export const RetrieveMessagesErrors = Schema.Union( + InboxDoesNotExistError, + InvalidChallengeError +) + export const RetrieveMessagesRequest = RequestBaseWithChallenge.extend({}) export const RetrieveMessagesRequestE = Schema.Struct({ ...RequestBaseWithChallengeE.fields, @@ -294,20 +391,20 @@ export type RetrieveMessagesResponse = Schema.Schema.Type< > export const SendMessageRequest = z.object({ - keyPair: PrivateKeyHolder, + senderPublicKey: PublicKeyPemBase64, + signedChallenge: SignedChallenge, receiverPublicKey: PublicKeyPemBase64, message: z.string(), messageType: MessageType, messagePreview: z.string().optional(), - notificationServiceReady: z.boolean(), }) export const SendMessageRequestE = Schema.Struct({ - keyPair: PrivateKeyHolderE, + signedChallenge: SignedChallengeE, + senderPublicKey: PublicKeyPemBase64E, receiverPublicKey: PublicKeyPemBase64E, message: Schema.String, messageType: MessageTypeE, messagePreview: Schema.optional(Schema.String), - notificationServiceReady: Schema.Boolean, }) export type SendMessageRequest = Schema.Schema.Type @@ -322,17 +419,24 @@ export type SendMessageResponse = Schema.Schema.Type< typeof SendMessageResponseE > +export const LeaveChatErrors = Schema.Union( + InvalidChallengeError, + ReceiverInboxDoesNotExistError, + SenderInboxDoesNotExistError, + NotPermittedToSendMessageToTargetInboxError +) + export const LeaveChatRequest = z.object({ - keyPair: PrivateKeyHolder, + senderPublicKey: PublicKeyPemBase64, + signedChallenge: SignedChallenge, receiverPublicKey: PublicKeyPemBase64, message: z.string(), - notificationServiceReady: z.boolean(), }) export const LeaveChatRequestE = Schema.Struct({ - keyPair: PrivateKeyHolderE, + senderPublicKey: PublicKeyPemBase64E, + signedChallenge: SignedChallengeE, receiverPublicKey: PublicKeyPemBase64E, message: Schema.String, - notificationServiceReady: Schema.Boolean, }) export type LeaveChatRequest = Schema.Schema.Type @@ -371,6 +475,14 @@ export const InboxInBatchE = Schema.Struct({ }) export type InboxInBatch = Schema.Schema.Type +export const SendMessageErrors = Schema.Union( + ReceiverInboxDoesNotExistError, + SenderInboxDoesNotExistError, + NotPermittedToSendMessageToTargetInboxError, + ForbiddenMessageTypeError, + InvalidChallengeError +) + export const SendMessagesRequest = z.object({ data: z.array(InboxInBatch), }) @@ -405,12 +517,13 @@ export const CreateChallengeRequestE = Schema.Struct({ export type CreateChallengeRequest = Schema.Schema.Type< typeof CreateChallengeRequestE > + export const CreateChallengeResponse = z.object({ - challenge: z.string(), + challenge: ChatChallenge, expiration: UnixMilliseconds, }) export const CreateChallengeResponseE = Schema.Struct({ - challenge: Schema.String, + challenge: ChatChallengeE, expiration: UnixMillisecondsE, }) export type CreateChallengeResponse = Schema.Schema.Type< @@ -427,20 +540,26 @@ export type CreateChallengesRequest = Schema.Schema.Type< typeof CreateChallengesRequestE > -export const CreateChallengesResponse = z.object({ - challenges: z.array( - z.object({ - publicKey: PublicKeyPemBase64, - challenge: z.string(), - }) - ), - expiration: UnixMilliseconds, -}) +export const CreateChallengesResponse = z + .object({ + challenges: z + .array( + z + .object({ + publicKey: PublicKeyPemBase64, + challenge: ChatChallenge, + }) + .readonly() + ) + .readonly(), + expiration: UnixMilliseconds, + }) + .readonly() export const CreateChallengesResponseE = Schema.Struct({ challenges: Schema.Array( Schema.Struct({ publicKey: PublicKeyPemBase64E, - challenge: Schema.String, + challenge: ChatChallengeE, }) ), expiration: UnixMillisecondsE, diff --git a/packages/rest-api/src/services/chat/index.ts b/packages/rest-api/src/services/chat/index.ts index d02d94287..1d7a0b052 100644 --- a/packages/rest-api/src/services/chat/index.ts +++ b/packages/rest-api/src/services/chat/index.ts @@ -1,3 +1,5 @@ +import {Schema} from '@effect/schema' +import {type PrivateKeyHolder} from '@vexl-next/cryptography/src/KeyHolder' import {type SemverString} from '@vexl-next/domain/src/utility/SmeverString.brand' import {type VersionCode} from '@vexl-next/domain/src/utility/VersionCode.brand' import {type CreateAxiosDefaults} from 'axios' @@ -21,16 +23,24 @@ import { BlockInboxResponse, CancelApprovalResponse, CreateChallengeResponse, + CreateChallengeResponseE, CreateChallengesResponse, + CreateChallengesResponseE, CreateInboxResponse, DeleteInboxResponse, DeleteInboxesResponse, DeletePulledMessagesResponse, LeaveChatResponse, + OtherSideAccountDeleted, + ReceiverInboxDoesNotExistError, RequestApprovalResponse, + RequestCancelledError, + RequestNotFoundError, + RequestNotPendingError, RetrieveMessagesResponse, SendMessageResponse, SendMessagesResponse, + SenderInboxDoesNotExistError, UpdateInboxResponse, type ApproveRequestRequest, type BlockInboxRequest, @@ -42,16 +52,10 @@ import { type DeleteInboxesRequest, type DeletePulledMessagesRequest, type LeaveChatRequest, - type OtherSideAccountDeleted, - type ReceiverOfferInboxDoesNotExistError, - type RequestAlreadyApprovedError, type RequestApprovalRequest, - type RequestCancelledError, - type RequestNotFoundError, type RetrieveMessagesRequest, type SendMessageRequest, type SendMessagesRequest, - type SenderUserInboxDoesNotExistError, type UpdateInboxRequest, } from './contracts' import {addChallengeToRequest} from './utils' @@ -88,11 +92,18 @@ export function privateApi({ const addChallenge = addChallengeToRequest(axiosInstance) + type RequestWithGeneratableChallenge = Omit< + T, + 'publicKey' | 'signedChallenge' + > & { + keyPair: PrivateKeyHolder + } + return { // ---------------------- // 👇 Inbox // ---------------------- - updateInbox(data: UpdateInboxRequest) { + updateInbox(data: RequestWithGeneratableChallenge) { return pipe( addChallenge(data), TE.chainW((data) => @@ -104,7 +115,7 @@ export function privateApi({ ) ) }, - createInbox(data: CreateInboxRequest) { + createInbox(data: RequestWithGeneratableChallenge) { return pipe( addChallenge(data), TE.chainW((data) => @@ -116,7 +127,7 @@ export function privateApi({ ) ) }, - deleteInbox(data: DeleteInboxRequest) { + deleteInbox(data: RequestWithGeneratableChallenge) { return pipe( addChallenge(data), TE.chainW((data) => @@ -128,7 +139,9 @@ export function privateApi({ ) ) }, - deletePulledMessages(data: DeletePulledMessagesRequest) { + deletePulledMessages( + data: RequestWithGeneratableChallenge + ) { return pipe( addChallenge(data), TE.chainW((data) => @@ -140,7 +153,7 @@ export function privateApi({ ) ) }, - blockInbox(data: BlockInboxRequest) { + blockInbox(data: RequestWithGeneratableChallenge) { return pipe( addChallenge(data), TE.chainW((data) => @@ -160,25 +173,18 @@ export function privateApi({ method: 'post', url: '/inboxes/approval/request', data, - params: { - notificationServiceReady: data.notificationServiceReady, - }, }, RequestApprovalResponse ), TE.mapLeft((e) => { if (e._tag === 'BadStatusCodeError') { if (e.response.data.code === '100101') { - return { - _tag: 'ReceiverOfferInboxDoesNotExistError', - } as ReceiverOfferInboxDoesNotExistError + return new ReceiverInboxDoesNotExistError() } } if (e._tag === 'BadStatusCodeError') { if (e.response.data.code === '100107') { - return { - _tag: 'SenderUserInboxDoesNotExistError', - } as SenderUserInboxDoesNotExistError + return new SenderInboxDoesNotExistError() } } return e @@ -193,35 +199,28 @@ export function privateApi({ method: 'post', url: '/inboxes/approval/cancel', data, - params: { - notificationServiceReady: data.notificationServiceReady, - }, }, CancelApprovalResponse ), TE.mapLeft((e) => { if (e._tag === 'BadStatusCodeError') { if (e.response.data.code === '100104') { - return { - _tag: 'RequestNotFoundError', - } as RequestNotFoundError + return new RequestNotFoundError() } if (e.response.data.code === '100153') { - return { - _tag: 'RequestAlreadyApprovedError', - } as RequestAlreadyApprovedError + return new RequestNotPendingError() } if (e.response.data.code === '100101') { - return { - _tag: 'OtherSideAccountDeleted', - } as OtherSideAccountDeleted + return new OtherSideAccountDeleted() } } return e }) ) }, - approveRequest(originalData: ApproveRequestRequest) { + approveRequest( + originalData: RequestWithGeneratableChallenge + ) { return pipe( addChallenge(originalData), TE.chainW((data) => @@ -231,9 +230,6 @@ export function privateApi({ method: 'post', url: '/inboxes/approval/confirm', data, - params: { - notificationServiceReady: originalData.notificationServiceReady, - }, }, ApproveRequestResponse ) @@ -241,22 +237,16 @@ export function privateApi({ TE.mapLeft((e) => { if (e._tag === 'BadStatusCodeError') { if (e.response.data.code === '100106') { - return {_tag: 'RequestCancelledError'} as RequestCancelledError + return new RequestCancelledError() } if (e.response.data.code === '100104') { - return { - _tag: 'RequestNotFoundError', - } as RequestNotFoundError + return new RequestNotFoundError() } if (e.response.data.code === '100153') { - return { - _tag: 'RequestAlreadyApprovedError', - } as RequestAlreadyApprovedError + return new RequestNotPendingError() } if (e.response.data.code === '100101') { - return { - _tag: 'OtherSideAccountDeleted', - } as OtherSideAccountDeleted + return new OtherSideAccountDeleted() } } return e @@ -270,7 +260,7 @@ export function privateApi({ DeleteInboxesResponse ) }, - leaveChat(originalData: LeaveChatRequest) { + leaveChat(originalData: RequestWithGeneratableChallenge) { return pipe( addChallenge(originalData), TE.map(({publicKey, ...data}) => ({ @@ -284,9 +274,6 @@ export function privateApi({ method: 'post', url: '/inboxes/leave-chat', data, - params: { - notificationServiceReady: originalData.notificationServiceReady, - }, }, LeaveChatResponse ) @@ -307,7 +294,9 @@ export function privateApi({ // ---------------------- // 👇 Message // ---------------------- - retrieveMessages(data: RetrieveMessagesRequest) { + retrieveMessages( + data: RequestWithGeneratableChallenge + ) { return pipe( addChallenge(data), TE.chainW((data) => @@ -327,13 +316,18 @@ export function privateApi({ }) ) }, - sendMessage(originalData: SendMessageRequest) { + sendMessage( + originalData: RequestWithGeneratableChallenge + ) { return pipe( addChallenge(originalData), - TE.map(({publicKey, ...data}) => ({ - ...data, - senderPublicKey: publicKey, - })), + TE.map( + ({publicKey, ...data}) => + ({ + ...data, + senderPublicKey: publicKey, + }) satisfies SendMessageRequest + ), TE.chainW((data) => axiosCallWithValidation( axiosInstance, @@ -341,9 +335,6 @@ export function privateApi({ method: 'post', url: '/inboxes/messages', data, - params: { - notificationServiceReady: originalData.notificationServiceReady, - }, }, SendMessageResponse ) @@ -372,17 +363,23 @@ export function privateApi({ // 👇 Challenge // ---------------------- createChallenge(data: CreateChallengeRequest) { - return axiosCallWithValidation( - axiosInstance, - {method: 'POST', url: '/challenges', data}, - CreateChallengeResponse + return pipe( + axiosCallWithValidation( + axiosInstance, + {method: 'POST', url: '/challenges', data}, + CreateChallengeResponse + ), + TE.map((one) => Schema.decodeSync(CreateChallengeResponseE)(one)) ) }, createChallengeBatch(data: CreateChallengesRequest) { - return axiosCallWithValidation( - axiosInstance, - {method: 'POST', url: '/challenges/batch', data}, - CreateChallengesResponse + return pipe( + axiosCallWithValidation( + axiosInstance, + {method: 'POST', url: '/challenges/batch', data}, + CreateChallengesResponse + ), + TE.map((one) => Schema.decodeSync(CreateChallengesResponseE)(one)) ) }, } diff --git a/packages/rest-api/src/services/chat/specification.ts b/packages/rest-api/src/services/chat/specification.ts new file mode 100644 index 000000000..2364f8800 --- /dev/null +++ b/packages/rest-api/src/services/chat/specification.ts @@ -0,0 +1,275 @@ +import {Api, ApiGroup} from 'effect-http' +import {ServerSecurity} from '../../apiSecurity' +import {CommonHeaders} from '../../commonHeaders' +import { + ApproveRequestErrors, + ApproveRequestRequestE, + ApproveRequestResponseE, + BlockInboxErrors, + BlockInboxRequestE, + BlockInboxResponseE, + CancelApprovalRequestE, + CancelApprovalResponseE, + CancelRequestApprovalErrors, + CreateChallengeRequestE, + CreateChallengeResponseE, + CreateChallengesRequestE, + CreateChallengesResponseE, + CreateInboxRequestE, + CreateInboxResponseE, + DeleteInboxErrors, + DeleteInboxesRequestE, + DeleteInboxesResponseE, + DeleteInboxRequestE, + DeleteInboxResponseE, + DeletePulledMessagesErrors, + DeletePulledMessagesRequestE, + DeletePulledMessagesResponseE, + LeaveChatErrors, + LeaveChatRequestE, + LeaveChatResponseE, + RequestApprovalErrors, + RequestApprovalRequestE, + RequestApprovalResponseE, + RetrieveMessagesErrors, + RetrieveMessagesRequestE, + RetrieveMessagesResponseE, + SendMessageErrors, + SendMessageRequestE, + SendMessageResponseE, + SendMessagesRequestE, + SendMessagesResponseE, + UpdateInboxRequestE, + UpdateInboxResponseE, +} from './contracts' + +export const UpdateInboxEndpoint = Api.put('updateInbox', '/api/v1/inboxes', { + deprecated: true, + description: + 'Not needed anymore since chat service does not sent fcm messages and does not collect fcm tokens anymore', +}).pipe( + Api.setSecurity(ServerSecurity), + Api.setResponseStatus(200 as const), + Api.setRequestBody(UpdateInboxRequestE), + Api.setResponseBody(UpdateInboxResponseE) +) + +export const CreateInboxEndpoint = Api.post( + 'createInbox', + '/api/v1/inboxes' +).pipe( + Api.setSecurity(ServerSecurity), + Api.setResponseStatus(200 as const), + Api.setRequestBody(CreateInboxRequestE), + Api.setRequestHeaders(CommonHeaders), + Api.setResponseBody(CreateInboxResponseE) +) + +export const DeleteInboxEndpoint = Api.delete( + 'deleteInbox', + '/api/v1/inboxes' +).pipe( + Api.setSecurity(ServerSecurity), + Api.setResponseStatus(200 as const), + Api.setRequestBody(DeleteInboxRequestE), + Api.setResponseBody(DeleteInboxResponseE), + Api.addResponse({ + status: 400, + body: DeleteInboxErrors, + }) +) + +export const DeletePulledMessagesEndpoint = Api.delete( + 'deletePulledMessages', + '/api/v1/inboxes/messages' +).pipe( + Api.setSecurity(ServerSecurity), + Api.setResponseStatus(200 as const), + Api.setRequestBody(DeletePulledMessagesRequestE), + Api.setResponseBody(DeletePulledMessagesResponseE), + Api.addResponse({ + status: 400, + body: DeletePulledMessagesErrors, + }) +) + +export const BlockInboxEndpoint = Api.put( + 'blockInboxEndpoint', + '/api/v1/inboxes/block' +).pipe( + Api.setSecurity(ServerSecurity), + Api.setResponseStatus(200 as const), + Api.setRequestBody(BlockInboxRequestE), + Api.setResponseBody(BlockInboxResponseE), + Api.addResponse({ + status: 400, + body: BlockInboxErrors, + }) +) + +export const RequestApprovalEndpoint = Api.post( + 'requestApproval', + '/api/v1/inboxes/approval/request' +).pipe( + Api.setSecurity(ServerSecurity), + Api.setResponseStatus(200 as const), + Api.setRequestBody(RequestApprovalRequestE), + Api.setResponseBody(RequestApprovalResponseE), + Api.addResponse({ + status: 400, + body: RequestApprovalErrors, + }) +) + +export const CancelRequestApprovalEndpoint = Api.post( + 'cancelRequestApproval', + '/api/v1/inboxes/approval/cancel' +).pipe( + Api.setSecurity(ServerSecurity), + Api.setResponseStatus(200 as const), + Api.setRequestBody(CancelApprovalRequestE), + Api.setResponseBody(CancelApprovalResponseE), + Api.addResponse({ + status: 400, + body: CancelRequestApprovalErrors, + }) +) + +export const ApproveRequestEndpoint = Api.post( + 'approveRequest', + '/api/v1/inboxes/approval/confirm' +).pipe( + Api.setSecurity(ServerSecurity), + Api.setResponseStatus(200 as const), + Api.setRequestBody(ApproveRequestRequestE), + Api.setResponseBody(ApproveRequestResponseE), + Api.addResponse({ + status: 400, + body: ApproveRequestErrors, + }) +) + +export const DeleteInboxesEndpoint = Api.delete( + 'deleteInboxes', + '/api/v1/inboxes/batch', + {deprecated: true} +).pipe( + Api.setSecurity(ServerSecurity), + Api.setResponseStatus(200 as const), + Api.setRequestBody(DeleteInboxesRequestE), + Api.setResponseBody(DeleteInboxesResponseE) +) + +export const LeaveChatEndpoint = Api.post( + 'leaveChat', + '/api/v1/inboxes/leave-chat' +).pipe( + Api.setSecurity(ServerSecurity), + Api.setResponseStatus(200 as const), + + Api.setRequestBody(LeaveChatRequestE), + Api.setResponseBody(LeaveChatResponseE), + Api.addResponse({ + status: 400, + body: LeaveChatErrors, + }) +) + +export const RetrieveMessagesEndpoint = Api.put( + 'retrieveMessages', + '/api/v1/inboxes/messages' +).pipe( + Api.setSecurity(ServerSecurity), + Api.setResponseStatus(200 as const), + Api.setRequestHeaders(CommonHeaders), + Api.setRequestBody(RetrieveMessagesRequestE), + Api.setResponseBody(RetrieveMessagesResponseE), + Api.addResponse({ + status: 400, + body: RetrieveMessagesErrors, + }) +) + +export const SendMessageEndpoint = Api.post( + 'sendMessage', + '/api/v1/inboxes/messages' +).pipe( + Api.setSecurity(ServerSecurity), + Api.setResponseStatus(200 as const), + Api.setRequestBody(SendMessageRequestE), + Api.setResponseBody(SendMessageResponseE), + Api.addResponse({ + status: 400, + body: SendMessageErrors, + }) +) + +export const SendMessagesEndpoint = Api.post( + 'sendMessages', + '/api/v1/inboxes/messages/batch', + { + deprecated: true, + } +).pipe( + Api.setSecurity(ServerSecurity), + Api.setResponseStatus(200 as const), + Api.setRequestBody(SendMessagesRequestE), + Api.setResponseBody(SendMessagesResponseE), + Api.addResponse({ + status: 400, + body: SendMessageErrors, + }) +) + +export const CreateChallengeEndpoint = Api.post( + 'createChallenge', + '/api/v1/challenges' +).pipe( + Api.setSecurity(ServerSecurity), + Api.setResponseStatus(200 as const), + Api.setRequestBody(CreateChallengeRequestE), + Api.setResponseBody(CreateChallengeResponseE) +) + +export const CreateChallengeBatchEndpoint = Api.post( + 'createChallengeBatch', + '/api/v1/challenges/batch' +).pipe( + Api.setSecurity(ServerSecurity), + Api.setResponseStatus(200 as const), + Api.setRequestBody(CreateChallengesRequestE), + Api.setResponseBody(CreateChallengesResponseE) +) + +const InboxesApiGroup = ApiGroup.make('Inboxes').pipe( + ApiGroup.addEndpoint(UpdateInboxEndpoint), + ApiGroup.addEndpoint(CreateInboxEndpoint), + ApiGroup.addEndpoint(DeleteInboxEndpoint), + ApiGroup.addEndpoint(BlockInboxEndpoint), + ApiGroup.addEndpoint(RequestApprovalEndpoint), + ApiGroup.addEndpoint(CancelRequestApprovalEndpoint), + ApiGroup.addEndpoint(ApproveRequestEndpoint), + ApiGroup.addEndpoint(DeleteInboxesEndpoint), + ApiGroup.addEndpoint(LeaveChatEndpoint), + ApiGroup.addEndpoint(DeletePulledMessagesEndpoint) +) + +const MessagesApiGroup = ApiGroup.make('Messages').pipe( + ApiGroup.addEndpoint(RetrieveMessagesEndpoint), + ApiGroup.addEndpoint(SendMessageEndpoint), + ApiGroup.addEndpoint(SendMessagesEndpoint) +) + +const ChallengeApiGroup = ApiGroup.make('Challenges').pipe( + ApiGroup.addEndpoint(CreateChallengeEndpoint), + ApiGroup.addEndpoint(CreateChallengeBatchEndpoint) +) + +export const ChatApiSpecification = Api.make({ + title: 'Chat service', + version: '1.0.0', +}).pipe( + Api.addGroup(InboxesApiGroup), + Api.addGroup(MessagesApiGroup), + Api.addGroup(ChallengeApiGroup) +) diff --git a/packages/rest-api/src/services/chat/utils.ts b/packages/rest-api/src/services/chat/utils.ts index ffc1ab112..ac8be0193 100644 --- a/packages/rest-api/src/services/chat/utils.ts +++ b/packages/rest-api/src/services/chat/utils.ts @@ -1,15 +1,21 @@ +import {Schema} from '@effect/schema' import * as crypto from '@vexl-next/cryptography' import { type PrivateKeyHolder, type PublicKeyPemBase64, } from '@vexl-next/cryptography/src/KeyHolder' +import {EcdsaSignature} from '@vexl-next/generic-utils/src/effect-helpers/crypto' import {type ExtractLeftTE} from '@vexl-next/resources-utils/src/utils/ExtractLeft' import {type AxiosInstance} from 'axios' import * as E from 'fp-ts/Either' import * as TE from 'fp-ts/TaskEither' import {pipe} from 'fp-ts/function' import {axiosCallWithValidation} from '../../utils' -import {CreateChallengeResponse, type SignedChallenge} from './contracts' +import { + ChatChallenge, + CreateChallengeResponse, + type SignedChallenge, +} from './contracts' export type ErrorGeneratingChallenge = ExtractLeftTE< ReturnType> @@ -46,9 +52,11 @@ function generateChallenge({axiosInstance}: {axiosInstance: AxiosInstance}) { ) } -export function addChallengeToRequest( - axiosInstance: AxiosInstance -): (data: T) => TE.TaskEither< +export function addChallengeToRequest(axiosInstance: AxiosInstance): < + T extends {keyPair: PrivateKeyHolder}, +>( + data: T +) => TE.TaskEither< ErrorGeneratingChallenge | ErrorSigningChallenge, Omit & { publicKey: PublicKeyPemBase64 @@ -59,9 +67,13 @@ export function addChallengeToRequest( pipe( keyPair.publicKeyPemBase64, generateChallenge({axiosInstance}), + TE.map((one) => ChatChallenge.parse(one)), TE.bindTo('challenge'), TE.bindW('signature', ({challenge}) => - TE.fromEither(ecdsaSign(keyPair)(challenge)) + pipe( + TE.fromEither(ecdsaSign(keyPair)(challenge)), + TE.map((one) => Schema.decodeSync(EcdsaSignature)(one)) + ) ), TE.map((signedChallenge) => ({ ...data, diff --git a/packages/rest-api/src/services/contact/contracts.ts b/packages/rest-api/src/services/contact/contracts.ts index 63a12aa9e..3ddcaa249 100644 --- a/packages/rest-api/src/services/contact/contracts.ts +++ b/packages/rest-api/src/services/contact/contracts.ts @@ -9,7 +9,7 @@ import { } from '@vexl-next/domain/src/general/HashedPhoneNumber.brand' import {ConnectionLevelE} from '@vexl-next/domain/src/general/offers' import {FcmTokenE} from '@vexl-next/domain/src/utility/FcmToken.brand' -import {BooleanfromString} from '@vexl-next/generic-utils/src/effect-helpers/BooleanFromString' +import {BooleanFromString} from '@vexl-next/generic-utils/src/effect-helpers/BooleanFromString' import {EcdsaSignature} from '@vexl-next/generic-utils/src/effect-helpers/crypto' import {z} from 'zod' import {PageRequestE, PageResponseE} from '../../Pagination.brand' @@ -18,14 +18,24 @@ export class InboxDoesNotExistError extends Schema.TaggedError 404}), - code: Schema.optionalWith(Schema.Literal(100101), {default: () => 100101}), + code: Schema.optionalWith(Schema.Literal('100101'), { + default: () => '100101', + }), }) {} export class NotPermittedToSendMessageToTargetInboxError extends Schema.TaggedError( 'notPermittedToSendMessageToTargetInbox' )('notPermittedToSendMessageToTargetInbox', { status: Schema.optionalWith(Schema.Literal(400), {default: () => 400}), - code: Schema.optionalWith(Schema.Literal(100104), {default: () => 100104}), + code: Schema.optionalWith(Schema.Literal('100104'), { + default: () => '100104', + }), +}) {} + +export class ForbiddenMessageTypeError extends Schema.TaggedError( + 'ForbiddenMessageTypeError' +)('ForbiddenMessageTypeError', { + status: Schema.optionalWith(Schema.Literal(400), {default: () => 400}), }) {} export class InitialImportContactsQuotaReachedError extends Schema.TaggedError( @@ -44,7 +54,9 @@ export class UserNotFoundError extends Schema.TaggedError( 'UserNotFoundError' )('UserNotFoundError', { status: Schema.optionalWith(Schema.Literal(404), {default: () => 404}), - code: Schema.optionalWith(Schema.Literal(100101), {default: () => 100101}), + code: Schema.optionalWith(Schema.Literal('100101'), { + default: () => '100101', + }), }) {} export const CreateUserRequest = Schema.Struct({ @@ -139,7 +151,7 @@ export type FetchCommonConnectionsResponse = Schema.Schema.Type< > export const CheckUserExistsRequest = Schema.Struct({ - notifyExistingUserAboutLogin: BooleanfromString, + notifyExistingUserAboutLogin: BooleanFromString, }) export const UserExistsResponse = Schema.Struct({ diff --git a/packages/server-utils/src/RedisService.ts b/packages/server-utils/src/RedisService.ts index 4098b654a..e70c7f648 100644 --- a/packages/server-utils/src/RedisService.ts +++ b/packages/server-utils/src/RedisService.ts @@ -106,18 +106,30 @@ export class RedisService extends Context.Tag('RedisService')< ) ) - const releaseLockEffect = (lock: Lock): Effect.Effect => - Effect.promise(async () => await redlock.release(lock)).pipe( + const releaseLockEffect = (lock: Lock): Effect.Effect => { + const now = Date.now() + if (lock.expiration < now) { + return Effect.logWarning( + `Attempted to release an expired lock. Lock time of ${lock.resources.join()} should be increased`, + { + resources: lock.expiration, + overExpirationMillis: now - lock.expiration, + } + ) + } + + return Effect.promise(async () => await redlock.release(lock)).pipe( Effect.zipLeft( Effect.logInfo('Released Redis lock', lock.resources) ), catchAllDefect((e) => Effect.zipRight( - Effect.logError('Error while releasing lock', e, lock.value), + Effect.logError('Error while releasing lock', e, lock), Effect.void ) ) ) + } const withLock: RedisOperations['withLock'] = (program) => diff --git a/packages/server-utils/src/commonConfigs.ts b/packages/server-utils/src/commonConfigs.ts index 1c09fb9be..ce4eb757f 100644 --- a/packages/server-utils/src/commonConfigs.ts +++ b/packages/server-utils/src/commonConfigs.ts @@ -86,3 +86,7 @@ export const internalServerPortConfig = Config.option( export const memoryDebugIntervalMsConfig = Config.option( Config.number('MEMORY_DEBUG_INTERVAL_MS') ) + +export const disableDevToolsInDevelopmentConfig = Config.option( + Config.boolean('DISABLE_DEV_TOOLS') +) diff --git a/packages/server-utils/src/devToolsLayer.ts b/packages/server-utils/src/devToolsLayer.ts index 731844d95..8ed670628 100644 --- a/packages/server-utils/src/devToolsLayer.ts +++ b/packages/server-utils/src/devToolsLayer.ts @@ -1,13 +1,20 @@ import {DevTools} from '@effect/experimental' import {NodeSocket} from '@effect/platform-node' -import {type Config, type ConfigError, Effect, Layer} from 'effect' +import {type Config, type ConfigError, Effect, Layer, Option} from 'effect' +import {disableDevToolsInDevelopmentConfig} from './commonConfigs' export const devToolsLayer = ( envConfig: Config.Config<'production' | 'development' | 'test'> ): Layer.Layer => Layer.unwrapEffect( - envConfig.pipe( - Effect.flatMap((env) => { + Effect.all([envConfig, disableDevToolsInDevelopmentConfig]).pipe( + Effect.flatMap(([env, disableDevTools]) => { + if (Option.getOrElse(disableDevTools, () => false)) { + return Effect.zipRight( + Effect.log('Dev tools disabled by config DISABLE_DEV_TOOLS = true'), + Effect.succeed(Layer.empty) + ) + } if (env === 'development') { return Effect.zipRight( Effect.log( diff --git a/packages/server-utils/src/tests/expectErrorResponse.ts b/packages/server-utils/src/tests/expectErrorResponse.ts new file mode 100644 index 000000000..abc6fecaf --- /dev/null +++ b/packages/server-utils/src/tests/expectErrorResponse.ts @@ -0,0 +1,14 @@ +import {Schema} from '@effect/schema' +import {Either} from 'effect' + +export const expectErrorResponse = + (ResponseErrorSchema: Schema.Schema) => + (failedResponse: Either.Either) => { + expect(failedResponse._tag).toEqual('Left') + if (!Either.isLeft(failedResponse)) return + + expect( + Schema.decodeUnknownEither(ResponseErrorSchema)(failedResponse.left.error) + ._tag + ).toEqual('Right') + } diff --git a/yarn.lock b/yarn.lock index f7f16aa2e..8c600afc0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12260,6 +12260,62 @@ __metadata: languageName: unknown linkType: soft +"@vexl-next/chat-service@workspace:apps/chat-service": + version: 0.0.0-use.local + resolution: "@vexl-next/chat-service@workspace:apps/chat-service" + dependencies: + "@effect/experimental": ^0.22.6 + "@effect/opentelemetry": ^0.35.3 + "@effect/platform": ^0.62.3 + "@effect/platform-node": ^0.56.9 + "@effect/schema": ^0.70.4 + "@effect/sql": ^0.8.7 + "@effect/sql-pg": ^0.8.7 + "@opentelemetry/exporter-trace-otlp-http": ^0.52.1 + "@opentelemetry/sdk-trace-node": ^1.23.0 + "@opentelemetry/sdk-trace-web": ^1.23.0 + "@sentry/esbuild-plugin": ^2.10.2 + "@sentry/node": ^7.93.0 + "@sentry/profiling-node": ^1.3.5 + "@types/jest": ^29.4.0 + "@types/pg": ^8 + "@types/source-map-support": ^0.5.10 + "@vexl-next/cryptography": 0.0.0 + "@vexl-next/domain": 0.0.0 + "@vexl-next/esbuild": 0.0.0 + "@vexl-next/eslint-config": 0.0.0 + "@vexl-next/generic-utils": 0.0.0 + "@vexl-next/prettier-config": 0.0.0 + "@vexl-next/rest-api": 0.0.0 + "@vexl-next/server-utils": 0.0.0 + "@vexl-next/tsconfig": 0.0.0 + dayjs: ^1.11.11 + dotenv: ^16.4.5 + effect: ^3.6.3 + effect-http: ^0.77.4 + effect-http-node: ^0.17.7 + esbuild: ^0.17.16 + eslint: ^8.50.0 + fast-check: ^3.20.0 + firebase-admin: ^12.3.1 + glob: ^11.0.0 + ioredis: ^5.4.1 + jest: ^29.7.0 + node-fetch: ^3.3.2 + nodemon: ^2.0.21 + npm-run-all: ^4.1.5 + pg: ^8.12.0 + prettier: ^3.3.2 + rimraf: ^4.4.0 + ts-jest: ^29.2.0 + ts-node: ^10.9.1 + tsc-alias: ^1.8.10 + tsx: ^4.16.0 + twilio: ^5.2.1 + typescript: ^5.5.2 + languageName: unknown + linkType: soft + "@vexl-next/contact-service@workspace:apps/contact-service": version: 0.0.0-use.local resolution: "@vexl-next/contact-service@workspace:apps/contact-service"