From 91a28dbc7348d9d282527f4c4c1aa8caa934f2a7 Mon Sep 17 00:00:00 2001 From: codebestia Date: Fri, 21 Aug 2026 01:33:09 +0100 Subject: [PATCH 1/4] feat: add protection workflow --- .github/workflows/close-linked-issues.yml | 75 +++++++++++++++++++++++ .github/workflows/guard-main-branch.yml | 75 +++++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 .github/workflows/close-linked-issues.yml create mode 100644 .github/workflows/guard-main-branch.yml diff --git a/.github/workflows/close-linked-issues.yml b/.github/workflows/close-linked-issues.yml new file mode 100644 index 0000000..e77b9b4 --- /dev/null +++ b/.github/workflows/close-linked-issues.yml @@ -0,0 +1,75 @@ +name: Close Linked Issues on Dev Merge + +# GitHub only auto-closes "Closes #N" issues when a PR merges into the default +# branch (main). Since contributor PRs merge into `dev`, this workflow closes +# the linked issues at dev-merge time instead. + +on: + pull_request_target: + types: [closed] + branches: [dev] + +permissions: + issues: write + +jobs: + close-linked-issues: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - name: Close issues linked with closing keywords + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const text = `${pr.title}\n${pr.body || ''}`; + + // Match closing keywords: close(s|d), fix(es|ed), resolve(s|d) + #N + const pattern = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s+#(\d+)/gi; + const issueNumbers = [...new Set( + [...text.matchAll(pattern)].map((m) => Number(m[1])) + )]; + + if (issueNumbers.length === 0) { + core.info(`PR #${pr.number} has no linked issues with closing keywords.`); + return; + } + + for (const issue_number of issueNumbers) { + try { + const { data: issue } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number, + }); + + // Skip PRs referenced by number, and issues already closed + if (issue.pull_request) { + core.info(`#${issue_number} is a PR, skipping.`); + continue; + } + if (issue.state === 'closed') { + core.info(`#${issue_number} is already closed, skipping.`); + continue; + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number, + body: `Closed by #${pr.number}, merged into \`dev\`.`, + }); + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number, + state: 'closed', + state_reason: 'completed', + }); + + core.info(`Closed issue #${issue_number} (linked to PR #${pr.number}).`); + } catch (e) { + core.warning(`Could not close #${issue_number}: ${e.message}`); + } + } diff --git a/.github/workflows/guard-main-branch.yml b/.github/workflows/guard-main-branch.yml new file mode 100644 index 0000000..2b6c07c --- /dev/null +++ b/.github/workflows/guard-main-branch.yml @@ -0,0 +1,75 @@ +name: Guard Main Branch + +# Closes any PR targeting `main` that was not opened by the repo maintainer. +# Contributors must target the `dev` branch instead. +# Uses pull_request_target so the token has write access for PRs from forks. + +on: + pull_request_target: + types: [opened, reopened, edited, ready_for_review] + branches: [main] + +permissions: + pull-requests: write + +jobs: + enforce-dev-target: + runs-on: ubuntu-latest + steps: + - name: Close non-maintainer PRs to main + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + + // Skip if the PR no longer targets main (relevant for `edited` events) + if (pr.base.ref !== 'main') { + core.info(`PR #${pr.number} targets ${pr.base.ref}, nothing to do.`); + return; + } + + const author = pr.user.login; + + // The repo owner is always allowed + let allowed = author === context.repo.owner; + + // Also allow collaborators with admin or maintain permission + if (!allowed) { + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: author, + }); + allowed = ['admin', 'maintain'].includes(data.permission); + } catch (e) { + core.info(`${author} is not a collaborator: treating as external contributor.`); + } + } + + if (allowed) { + core.info(`PR #${pr.number} by ${author} is allowed to target main.`); + return; + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: [ + `👋 Hi @${author}, thanks for your contribution!`, + '', + 'Pull requests from contributors must target the **`dev`** branch — only the repo maintainer merges into `main`.', + '', + 'This PR is being closed automatically. Please open a new PR (or retarget this one by reopening it after editing the base branch) against `dev`.', + ].join('\n'), + }); + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + state: 'closed', + }); + + core.info(`Closed PR #${pr.number} by ${author} (targeted main).`); From 33d5bd841298a6dd3a5b0bb0fc1c9cf75fe8b44f Mon Sep 17 00:00:00 2001 From: codebestia Date: Fri, 21 Aug 2026 03:15:24 +0100 Subject: [PATCH 2/4] fix(major): full codebase CI fix --- .github/workflows/backend-ci.yml | 17 + .gitignore | 4 +- apps/backend/eslint.config.js | 24 + .../src/__tests__/askAssistant.test.ts | 31 +- .../src/__tests__/auth.integration.test.ts | 20 + .../src/__tests__/ciphertextInvariant.test.ts | 6 +- .../conversations.messages-history.test.ts | 65 +- .../__tests__/conversations.routes.test.ts | 28 + .../src/__tests__/deviceRevocation.test.ts | 20 +- .../src/__tests__/devices.link.test.ts | 12 +- apps/backend/src/__tests__/dispatcher.test.ts | 115 ++-- .../src/__tests__/e2ee.integration.test.ts | 77 +-- .../src/__tests__/file.messages.test.ts | 569 ++++++----------- .../src/__tests__/messages.list.test.ts | 197 ++++-- .../backend/src/__tests__/mls.history.test.ts | 33 +- .../otpAtomicity.concurrency.test.ts | 6 + .../src/__tests__/privacy.messaging.test.ts | 20 +- apps/backend/src/__tests__/push.test.ts | 100 ++- apps/backend/src/__tests__/pushFilter.test.ts | 15 +- apps/backend/src/__tests__/rateLimit.test.ts | 92 ++- .../src/__tests__/readReceipts-new.test.ts | 63 +- .../src/__tests__/readReceipts.test.ts | 44 +- .../backend/src/__tests__/roomManager.test.ts | 4 +- apps/backend/src/__tests__/selfSync.test.ts | 6 + .../signalInvariants.devices.test.ts | 30 +- .../__tests__/signalInvariants.socket.test.ts | 26 +- apps/backend/src/__tests__/uploads.test.ts | 27 +- .../src/__tests__/users.bundle.test.ts | 26 +- apps/backend/src/__tests__/users.test.ts | 41 +- apps/backend/src/app.ts | 12 + apps/backend/src/config/rateLimits.ts | 12 + apps/backend/src/db/schema.ts | 40 +- apps/backend/src/index.ts | 8 +- apps/backend/src/lib/ciphertextInvariant.ts | 3 +- apps/backend/src/lib/fileIntegrity.ts | 15 +- apps/backend/src/lib/localObjectStore.ts | 9 + apps/backend/src/lib/messages.ts | 3 + apps/backend/src/lib/objectStore.ts | 2 + apps/backend/src/routes/auth.ts | 8 +- apps/backend/src/routes/conversations.ts | 17 +- apps/backend/src/routes/devices.ts | 29 +- apps/backend/src/routes/push.ts | 2 +- apps/backend/src/routes/uploads.ts | 38 ++ apps/backend/src/routes/users.ts | 86 +-- apps/backend/src/schemas/auth.schemas.ts | 6 +- apps/backend/src/services/deviceGc.ts | 6 +- apps/backend/src/services/envelopeGc.ts | 5 +- apps/backend/src/services/fileCleanup.ts | 3 +- apps/backend/src/services/presence.ts | 5 +- apps/backend/src/services/pushFilter.ts | 2 +- apps/backend/src/services/pushNotification.ts | 2 +- apps/backend/src/services/rateLimit.ts | 1 + .../replay-protection.service.spec.ts | 6 +- .../src/services/replay-protection.service.ts | 10 +- apps/backend/src/services/roomManager.ts | 4 +- apps/backend/src/services/stellarListener.ts | 15 +- apps/backend/src/socket/dispatcher.spec.ts | 66 +- apps/backend/src/socket/dispatcher.ts | 24 +- apps/backend/src/socket/messaging.ts | 65 +- apps/backend/tsconfig.json | 15 +- apps/backend/vitest.config.ts | 5 + apps/web/package.json | 21 +- .../src/app/app/conversations/[id]/page.tsx | 2 +- apps/web/src/lib/crypto.identityGuard.test.ts | 4 +- apps/web/src/lib/crypto.test.ts | 3 + apps/web/src/lib/crypto.ts | 2 +- apps/web/src/lib/crypto/doubleRatchet.ts | 600 ++++++------------ apps/web/src/lib/crypto/e2ee.test.ts | 98 ++- apps/web/src/lib/crypto/ratchetSession.ts | 465 ++++++++++++++ apps/web/src/lib/cryptoStore.ts | 26 + apps/web/src/lib/fileEncryption.ts | 2 +- apps/web/src/lib/identityTrust.test.ts | 2 +- apps/web/src/lib/mls.ts | 14 +- apps/web/src/lib/session.ts | 4 +- apps/web/src/lib/signalClient.test.ts | 8 +- apps/web/src/lib/signalClient.ts | 29 +- apps/web/src/lib/signalSession.test.ts | 6 +- apps/web/src/lib/signalSession.ts | 4 +- apps/web/src/lib/thumbnail.ts | 2 +- pnpm-lock.yaml | 177 ++++++ 80 files changed, 2266 insertions(+), 1445 deletions(-) create mode 100644 apps/web/src/lib/crypto/ratchetSession.ts diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 6c1483a..c30d595 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -26,6 +26,23 @@ jobs: --health-timeout 3s --health-retries 5 + # The `Run migrations` step below targets DATABASE_URL, so the database + # it points at has to actually exist. Credentials/db name must match the + # DATABASE_URL declared under `env`. + postgres: + image: postgres:16-alpine + ports: + - 5432:5432 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: clicked + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + defaults: run: working-directory: apps/backend diff --git a/.gitignore b/.gitignore index 8bd188d..37b92cc 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,9 @@ apps/backend/.local-storage/ # Agent-tool isolated worktrees — internal tooling state, not project source. .claude/worktrees/ -# TypeScript build artifacts +# TypeScript build artifacts. `apps/backend/tsconfig.json` emits to dist/ +# (already ignored above); these patterns remain only to keep a tree that +# predates that fix from re-staging stale emitted files. apps/backend/src/**/*.js apps/backend/src/**/*.js.map apps/backend/src/**/*.d.ts diff --git a/apps/backend/eslint.config.js b/apps/backend/eslint.config.js index 8665cda..030f096 100644 --- a/apps/backend/eslint.config.js +++ b/apps/backend/eslint.config.js @@ -6,6 +6,30 @@ export default tseslint.config( js.configs.recommended, ...tseslint.configs.recommended, { + // This is a Node service. `no-undef` (from js.configs.recommended) has no + // view of @types/node, so without declaring the ambient globals it flags + // every `process`/`console`/`Buffer` use in the codebase. + languageOptions: { + globals: { + process: 'readonly', + console: 'readonly', + Buffer: 'readonly', + URL: 'readonly', + URLSearchParams: 'readonly', + TextEncoder: 'readonly', + TextDecoder: 'readonly', + AbortController: 'readonly', + fetch: 'readonly', + crypto: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + setInterval: 'readonly', + clearInterval: 'readonly', + setImmediate: 'readonly', + queueMicrotask: 'readonly', + NodeJS: 'readonly', + }, + }, rules: { '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], '@typescript-eslint/no-explicit-any': 'warn', diff --git a/apps/backend/src/__tests__/askAssistant.test.ts b/apps/backend/src/__tests__/askAssistant.test.ts index dc7e7be..2a22485 100644 --- a/apps/backend/src/__tests__/askAssistant.test.ts +++ b/apps/backend/src/__tests__/askAssistant.test.ts @@ -132,10 +132,26 @@ function makeIo() { }; } +// Handlers now run exclusively through the enveloped 'dispatch' path (#342) +// — there's no more raw socket.on(type, ...) listener to grab directly. +let envelopeSeq = 0; +function dispatchEvent(socket: EventEmitter, type: string) { + return async (payload: unknown) => { + envelopeSeq += 1; + EventEmitter.prototype.emit.call(socket, 'dispatch', { + eventId: `test-evt-${envelopeSeq}`, + type, + timestamp: Date.now(), + payload, + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + }; +} + async function getHandler(socket: EventEmitter, io: unknown) { const { registerMessagingHandlers } = await import('../socket/messaging.js'); registerMessagingHandlers(io as never, socket as never); - return socket.listeners('ask_assistant')[0] as (p: unknown) => Promise; + return dispatchEvent(socket, 'ask_assistant'); } function envelopeRows(): Array> { @@ -167,10 +183,16 @@ const REPLY_MESSAGE = { deletedAt: null, }; -beforeEach(() => { +beforeEach(async () => { vi.clearAllMocks(); insertCalls.length = 0; + // ask_assistant is charged against a per-user budget (#375) that, with no + // Redis in tests, lives in a process-wide counter — without this reset the + // later cases in this file trip the limit instead of reaching the handler. + const { clearLocalRateLimitCounters } = await import('../services/rateLimiter.js'); + clearLocalRateLimitCounters(); + mockMemberFindFirst.mockReset().mockResolvedValue({ id: 'm1', userId: ALICE, @@ -223,24 +245,29 @@ describe('ask_assistant — per-device envelope fan-out (#337)', () => { // Every active device of every member gets its own row — including both of // the asking user's own devices, so the reply reaches all of them. + // `protocol` is stamped per envelope (#364) so pre-cutover history stays + // interpretable after a device's capabilities change. expect(envelopeRows()).toEqual([ { messageId: REPLY_MESSAGE.id, recipientDeviceId: ALICE_DEVICE_1, recipientUserId: ALICE, ciphertext: REPLY, + protocol: 'sealed_box', }, { messageId: REPLY_MESSAGE.id, recipientDeviceId: ALICE_DEVICE_2, recipientUserId: ALICE, ciphertext: REPLY, + protocol: 'sealed_box', }, { messageId: REPLY_MESSAGE.id, recipientDeviceId: BOB_DEVICE, recipientUserId: BOB, ciphertext: REPLY, + protocol: 'sealed_box', }, ]); }); diff --git a/apps/backend/src/__tests__/auth.integration.test.ts b/apps/backend/src/__tests__/auth.integration.test.ts index 778d604..964326c 100644 --- a/apps/backend/src/__tests__/auth.integration.test.ts +++ b/apps/backend/src/__tests__/auth.integration.test.ts @@ -71,6 +71,15 @@ const SIGNATURE = 'aabbccdd'; const NONCE = 'test-nonce-abc123'; const IDENTITY_KEY = Buffer.alloc(44, 1).toString('base64'); // 44-byte SPKI placeholder +// `device` is a required part of the verify payload (#232) — a session must +// always be bound to a registered device, so there is no deviceless variant. +const DEVICE_PAYLOAD = { + deviceName: 'Test Device', + platform: 'web' as const, + identityPublicKey: IDENTITY_KEY, + registrationId: 1, +}; + function setupInsert(userId = 'new-user-id', deviceId = 'new-device-id') { // New-user flow inserts: users → wallets → devices (3 calls total). const userReturning = vi.fn().mockResolvedValue([{ id: userId }]); @@ -149,6 +158,7 @@ describe('POST /auth/verify', () => { signature: SIGNATURE, nonce: NONCE, identityPublicKey: IDENTITY_KEY, + device: DEVICE_PAYLOAD, }); expect(res.status).toBe(200); @@ -168,6 +178,7 @@ describe('POST /auth/verify', () => { signature: SIGNATURE, nonce: NONCE, identityPublicKey: IDENTITY_KEY, + device: DEVICE_PAYLOAD, }); expect(res.status).toBe(200); @@ -186,6 +197,7 @@ describe('POST /auth/verify', () => { signature: SIGNATURE, nonce: NONCE, identityPublicKey: IDENTITY_KEY, + device: DEVICE_PAYLOAD, }); expect(res.status).toBe(200); @@ -200,6 +212,7 @@ describe('POST /auth/verify', () => { signature: SIGNATURE, nonce: 'expired-nonce', identityPublicKey: IDENTITY_KEY, + device: DEVICE_PAYLOAD, }); expect(res.status).toBe(401); @@ -215,6 +228,7 @@ describe('POST /auth/verify', () => { signature: 'badsig', nonce: NONCE, identityPublicKey: IDENTITY_KEY, + device: DEVICE_PAYLOAD, }); expect(res.status).toBe(401); @@ -232,6 +246,7 @@ describe('POST /auth/verify', () => { signature: SIGNATURE, nonce: NONCE, identityPublicKey: IDENTITY_KEY, + device: DEVICE_PAYLOAD, }); expect(res.status).toBe(401); @@ -272,6 +287,7 @@ describe('POST /auth/verify', () => { signature: SIGNATURE, nonce: NONCE, identityPublicKey: IDENTITY_KEY, + device: DEVICE_PAYLOAD, }); expect(res.status).toBe(401); @@ -307,6 +323,7 @@ describe('Auth rate limiting', () => { signature: SIGNATURE, nonce: NONCE, identityPublicKey: IDENTITY_KEY, + device: DEVICE_PAYLOAD, }); expect(res.status).toBe(200); } @@ -316,6 +333,7 @@ describe('Auth rate limiting', () => { signature: SIGNATURE, nonce: NONCE, identityPublicKey: IDENTITY_KEY, + device: DEVICE_PAYLOAD, }); expect(blocked.status).toBe(429); expect(blocked.headers['retry-after']).toBeDefined(); @@ -329,6 +347,7 @@ describe('Auth rate limiting', () => { signature: SIGNATURE, nonce: NONCE, identityPublicKey: IDENTITY_KEY, + device: DEVICE_PAYLOAD, }); } const verifyBlocked = await request(app).post('/auth/verify').send({ @@ -336,6 +355,7 @@ describe('Auth rate limiting', () => { signature: SIGNATURE, nonce: NONCE, identityPublicKey: IDENTITY_KEY, + device: DEVICE_PAYLOAD, }); expect(verifyBlocked.status).toBe(429); diff --git a/apps/backend/src/__tests__/ciphertextInvariant.test.ts b/apps/backend/src/__tests__/ciphertextInvariant.test.ts index 4f86331..76d2e41 100644 --- a/apps/backend/src/__tests__/ciphertextInvariant.test.ts +++ b/apps/backend/src/__tests__/ciphertextInvariant.test.ts @@ -34,8 +34,8 @@ describe('ciphertext-only invariant', () => { ); it('detects forbidden fields in a payload', () => { - expect(findForbiddenCiphertextFields({ ciphertext: 'encrypted', plaintext: 'secret' })).toEqual([ - 'plaintext', - ]); + expect(findForbiddenCiphertextFields({ ciphertext: 'encrypted', plaintext: 'secret' })).toEqual( + ['plaintext'], + ); }); }); diff --git a/apps/backend/src/__tests__/conversations.messages-history.test.ts b/apps/backend/src/__tests__/conversations.messages-history.test.ts index 5d8718b..25bcb6e 100644 --- a/apps/backend/src/__tests__/conversations.messages-history.test.ts +++ b/apps/backend/src/__tests__/conversations.messages-history.test.ts @@ -20,7 +20,13 @@ let fixtureMessages: Array> = []; const mockFindMember = vi.fn(); vi.mock('../lib/socket.js', () => ({ getSocketServer: () => undefined })); -vi.mock('../lib/redis.js', () => ({ get redis() { return null; }, CONV_CACHE_TTL: 30, convCacheKey: () => '' })); +vi.mock('../lib/redis.js', () => ({ + get redis() { + return null; + }, + CONV_CACHE_TTL: 30, + convCacheKey: () => '', +})); vi.mock('../lib/conversationCache.js', () => ({ invalidateConversationCaches: vi.fn() })); vi.mock('../lib/messages.js', () => ({ serializeMessage: (m: unknown) => m })); @@ -88,17 +94,19 @@ vi.mock('../db/index.js', () => ({ findFirst: vi.fn(async ({ where }: { where: Cond }) => fixtureMessages.find((row) => evalCond(row, where)), ), - findMany: vi.fn(async ({ where, orderBy, limit }: { where: Cond; orderBy: unknown; limit: number }) => { - let rows = fixtureMessages.filter((row) => evalCond(row, where)); - // orderBy is always [desc(createdAt), desc(id)] in this route. - rows = [...rows].sort((a, b) => { - const byCreatedAt = String(b['createdAt']).localeCompare(String(a['createdAt'])); - if (byCreatedAt !== 0) return byCreatedAt; - return String(b['id']).localeCompare(String(a['id'])); - }); - void orderBy; - return rows.slice(0, limit); - }), + findMany: vi.fn( + async ({ where, orderBy, limit }: { where: Cond; orderBy: unknown; limit: number }) => { + let rows = fixtureMessages.filter((row) => evalCond(row, where)); + // orderBy is always [desc(createdAt), desc(id)] in this route. + rows = [...rows].sort((a, b) => { + const byCreatedAt = String(b['createdAt']).localeCompare(String(a['createdAt'])); + if (byCreatedAt !== 0) return byCreatedAt; + return String(b['id']).localeCompare(String(a['id'])); + }); + void orderBy; + return rows.slice(0, limit); + }, + ), }, }, select: (projection: { id: string }) => ({ @@ -114,6 +122,33 @@ vi.mock('../db/index.js', () => ({ }, })); +// Non-MLS conversation: the epoch-window lookup finds no group, so rows stay +// visible and the MLS placeholder path is not exercised here. +vi.mock('../services/mlsGroups.js', () => ({ + getConversationEpochWindow: vi.fn().mockResolvedValue({ hasGroup: false, window: null }), +})); + +vi.mock('../services/groupControl.js', () => ({ + appendGroupControlEvent: vi.fn(), + broadcastGroupControlEvent: vi.fn(), + getGroupState: vi.fn(), + readGroupControlEvents: vi.fn(), + serializeGroupControlEvent: (e: unknown) => e, + DEFAULT_GROUP_CONTROL_PAGE_SIZE: 100, + MAX_GROUP_CONTROL_PAGE_SIZE: 500, + MAX_GROUP_CONTROL_PAYLOAD_BYTES: 65536, +})); + +vi.mock('../services/rateLimit.js', () => ({ + checkFirstContactLimit: vi.fn().mockResolvedValue({ allowed: true, count: 0 }), + checkGroupInviteLimit: vi.fn().mockResolvedValue({ allowed: true, count: 0 }), +})); + +vi.mock('../services/auditLog.js', () => ({ + actorFromRequest: vi.fn(() => ({})), + recordAuditEvent: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('../middleware/auth.js', () => ({ requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { (req as express.Request & { auth: { userId: string; deviceId: string } }).auth = { @@ -219,7 +254,11 @@ describe('GET /conversations/:id/messages — edit-chain resolution (#340)', () it('leaves tombstoned (deleted) messages visible exactly as before, independent of edit resolution', async () => { fixtureMessages = [ - msg({ id: 'm1', createdAt: '2026-01-01T00:00:00.000Z', deletedAt: '2026-01-02T00:00:00.000Z' }), + msg({ + id: 'm1', + createdAt: '2026-01-01T00:00:00.000Z', + deletedAt: '2026-01-02T00:00:00.000Z', + }), ]; const res = await request(makeApp()).get('/conversations/conv-1/messages'); diff --git a/apps/backend/src/__tests__/conversations.routes.test.ts b/apps/backend/src/__tests__/conversations.routes.test.ts index 5413090..a977803 100644 --- a/apps/backend/src/__tests__/conversations.routes.test.ts +++ b/apps/backend/src/__tests__/conversations.routes.test.ts @@ -42,6 +42,33 @@ const mockFrom = vi.fn(() => ({ where: mockWhere })); const mockSelect = vi.fn(() => ({ from: mockFrom })); const mockExecute = vi.fn().mockResolvedValue([]); +// Membership changes now run inside a transaction together with their +// group-control event (#369), so the mock db hands out a transaction handle +// that behaves like the top-level one. Sequencing itself is covered by +// groupControl.test.ts; here it is stubbed so these tests stay about routing. +const mockTransaction = vi.fn((fn: (tx: unknown) => unknown) => + fn({ delete: mockDelete, insert: mockInsert, update: mockUpdate }), +); + +const mockAppendGroupControlEvent = vi.fn(async () => ({ + event: { conversationId: 'conv-1', epoch: 1, sequence: 1, eventType: 'member_added' }, + systemMessage: null, +})); +const mockBroadcastGroupControlEvent = vi.fn(); +const mockGetGroupState = vi.fn(); +const mockReadGroupControlEvents = vi.fn(); + +vi.mock('../services/groupControl.js', () => ({ + appendGroupControlEvent: mockAppendGroupControlEvent, + broadcastGroupControlEvent: mockBroadcastGroupControlEvent, + getGroupState: mockGetGroupState, + readGroupControlEvents: mockReadGroupControlEvents, + serializeGroupControlEvent: (event: unknown) => event, + DEFAULT_GROUP_CONTROL_PAGE_SIZE: 100, + MAX_GROUP_CONTROL_PAGE_SIZE: 500, + MAX_GROUP_CONTROL_PAYLOAD_BYTES: 65536, +})); + vi.mock('../db/index.js', () => ({ db: { query: { @@ -54,6 +81,7 @@ vi.mock('../db/index.js', () => ({ update: mockUpdate, select: mockSelect, execute: mockExecute, + transaction: mockTransaction, }, })); diff --git a/apps/backend/src/__tests__/deviceRevocation.test.ts b/apps/backend/src/__tests__/deviceRevocation.test.ts index 3fd06aa..4d101be 100644 --- a/apps/backend/src/__tests__/deviceRevocation.test.ts +++ b/apps/backend/src/__tests__/deviceRevocation.test.ts @@ -133,9 +133,8 @@ describe('isDeviceConnected — Redis-backed registry (#341)', () => { it('returns false again after the only socket unregisters', async () => { const { isDeviceConnected } = await import('../services/deviceRevocation.js'); - const { registerPresenceSocket, unregisterPresenceSocket } = await import( - '../services/presence.js' - ); + const { registerPresenceSocket, unregisterPresenceSocket } = + await import('../services/presence.js'); const redis = new FakeRedis(); await registerPresenceSocket(redis as never, 'user-1', 'device-1', 'socket-1'); @@ -147,9 +146,8 @@ describe('isDeviceConnected — Redis-backed registry (#341)', () => { it('stays true when one of two sockets for the same device disconnects', async () => { const { isDeviceConnected } = await import('../services/deviceRevocation.js'); - const { registerPresenceSocket, unregisterPresenceSocket } = await import( - '../services/presence.js' - ); + const { registerPresenceSocket, unregisterPresenceSocket } = + await import('../services/presence.js'); const redis = new FakeRedis(); await registerPresenceSocket(redis as never, 'user-1', 'device-1', 'socket-1'); @@ -167,9 +165,8 @@ describe('revocation disconnect flow — no regression from the registry swap (# }); it('disconnects a locally-connected socket when its device is revoked', async () => { - const { startDeviceRevocationListener, isDeviceRevoked } = await import( - '../services/deviceRevocation.js' - ); + const { startDeviceRevocationListener, isDeviceRevoked } = + await import('../services/deviceRevocation.js'); const { registerPresenceSocket } = await import('../services/presence.js'); const redis = new FakeRedis(); @@ -188,9 +185,8 @@ describe('revocation disconnect flow — no regression from the registry swap (# }); it('marks the device revoked even when no socket is registered for it', async () => { - const { startDeviceRevocationListener, isDeviceRevoked } = await import( - '../services/deviceRevocation.js' - ); + const { startDeviceRevocationListener, isDeviceRevoked } = + await import('../services/deviceRevocation.js'); const redis = new FakeRedis(); mockIo = { sockets: { sockets: new Map() } }; diff --git a/apps/backend/src/__tests__/devices.link.test.ts b/apps/backend/src/__tests__/devices.link.test.ts index 78e2a77..5211236 100644 --- a/apps/backend/src/__tests__/devices.link.test.ts +++ b/apps/backend/src/__tests__/devices.link.test.ts @@ -77,8 +77,8 @@ vi.mock('../middleware/auth.js', () => ({ }, })); -const { devicesRouter, deviceLinkChallengeLimiter, deviceLinkVerifyLimiter } = - await import('../routes/devices.js'); +const { devicesRouter } = await import('../routes/devices.js'); +const { clearLocalRateLimitCounters } = await import('../services/rateLimiter.js'); // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -114,11 +114,11 @@ function signFreighter(message: string) { return walletKeypair.sign(digest).toString('base64'); } +// The link limiters are buckets in the shared limiter (#375) rather than +// standalone express-rate-limit instances, so a test resets the counters the +// buckets are charged against instead of individual keys. function resetLimiters() { - for (const key of ['127.0.0.1', '::ffff:127.0.0.1', '::1']) { - deviceLinkChallengeLimiter.resetKey(key); - deviceLinkVerifyLimiter.resetKey(key); - } + clearLocalRateLimitCounters(); } function setupInsertChain(id = 'new-device-id', createdAt = new Date('2026-07-01T00:00:00.000Z')) { diff --git a/apps/backend/src/__tests__/dispatcher.test.ts b/apps/backend/src/__tests__/dispatcher.test.ts index b9e627d..87be09b 100644 --- a/apps/backend/src/__tests__/dispatcher.test.ts +++ b/apps/backend/src/__tests__/dispatcher.test.ts @@ -279,110 +279,81 @@ describe('EventDispatcher.listen — envelope routing', () => { }); }); -describe('EventDispatcher — configurable idempotency TTL (#344)', () => { - const ORIGINAL_TTL = process.env.IDEMPOTENCY_TTL_SECONDS; +describe('EventDispatcher — configurable replay-protection TTL (#344)', () => { + // Dedup is delegated to services/replay-protection.service.ts, which keys on + // (deviceId, eventId) rather than eventId alone — two devices legitimately + // generating the same eventId must not block each other — and reads its + // window from REPLAY_PROTECTION_TTL_SECONDS. + const ORIGINAL_TTL = process.env['REPLAY_PROTECTION_TTL_SECONDS']; afterEach(() => { - if (ORIGINAL_TTL === undefined) delete process.env.IDEMPOTENCY_TTL_SECONDS; - else process.env.IDEMPOTENCY_TTL_SECONDS = ORIGINAL_TTL; + if (ORIGINAL_TTL === undefined) delete process.env['REPLAY_PROTECTION_TTL_SECONDS']; + else process.env['REPLAY_PROTECTION_TTL_SECONDS'] = ORIGINAL_TTL; }); - it('defaults the Redis SET EX TTL to 86400 seconds when unset', async () => { - delete process.env.IDEMPOTENCY_TTL_SECONDS; + async function dispatchOnce(redis: ReturnType, eventId: string) { const { socket, trigger } = makeSocket(); - const redis = makeRedis('OK'); const dispatcher = new EventDispatcher(makeIo(), socket, redis as never); - dispatcher.register('join_room', vi.fn()); + const handler = vi.fn().mockResolvedValue(undefined); + dispatcher.register('join_room', handler); dispatcher.listen(); - trigger('dispatch', { - eventId: 'evt-default-ttl', - type: 'join_room', - timestamp: Date.now(), - payload: {}, - }); - + trigger('dispatch', { eventId, type: 'join_room', timestamp: Date.now(), payload: {} }); await new Promise((r) => setTimeout(r, 10)); - expect(redis.set).toHaveBeenCalledWith( - 'event:idempotency:evt-default-ttl', - '1', - 'EX', - 86_400, - 'NX', - ); + return handler; + } + + it('defaults the TTL to 300 seconds when unset', async () => { + delete process.env['REPLAY_PROTECTION_TTL_SECONDS']; + const redis = makeRedis('OK'); + + await dispatchOnce(redis, 'evt-default-ttl'); + + expect(redis.set).toHaveBeenCalledWith('replay:d1:evt-default-ttl', '1', 'EX', 300, 'NX'); }); - it('reads the Redis SET EX TTL from IDEMPOTENCY_TTL_SECONDS when configured', async () => { - process.env.IDEMPOTENCY_TTL_SECONDS = '120'; - const { socket, trigger } = makeSocket(); + it('reads the TTL from REPLAY_PROTECTION_TTL_SECONDS when configured', async () => { + process.env['REPLAY_PROTECTION_TTL_SECONDS'] = '120'; const redis = makeRedis('OK'); - const dispatcher = new EventDispatcher(makeIo(), socket, redis as never); - dispatcher.register('join_room', vi.fn()); - dispatcher.listen(); - trigger('dispatch', { - eventId: 'evt-custom-ttl', - type: 'join_room', - timestamp: Date.now(), - payload: {}, - }); + await dispatchOnce(redis, 'evt-custom-ttl'); - await new Promise((r) => setTimeout(r, 10)); - expect(redis.set).toHaveBeenCalledWith( - 'event:idempotency:evt-custom-ttl', - '1', - 'EX', - 120, - 'NX', - ); + expect(redis.set).toHaveBeenCalledWith('replay:d1:evt-custom-ttl', '1', 'EX', 120, 'NX'); }); it('falls back to the default TTL for an invalid (non-numeric) override', async () => { - process.env.IDEMPOTENCY_TTL_SECONDS = 'not-a-number'; - const { socket, trigger } = makeSocket(); + process.env['REPLAY_PROTECTION_TTL_SECONDS'] = 'not-a-number'; const redis = makeRedis('OK'); - const dispatcher = new EventDispatcher(makeIo(), socket, redis as never); - dispatcher.register('join_room', vi.fn()); - dispatcher.listen(); - trigger('dispatch', { - eventId: 'evt-invalid-ttl', - type: 'join_room', - timestamp: Date.now(), - payload: {}, - }); + await dispatchOnce(redis, 'evt-invalid-ttl'); + + expect(redis.set).toHaveBeenCalledWith('replay:d1:evt-invalid-ttl', '1', 'EX', 300, 'NX'); + }); + + it('keys the dedup entry on the device, so one device cannot block another', async () => { + const redis = makeRedis('OK'); + + await dispatchOnce(redis, 'shared-event-id'); - await new Promise((r) => setTimeout(r, 10)); expect(redis.set).toHaveBeenCalledWith( - 'event:idempotency:evt-invalid-ttl', + expect.stringContaining('replay:d1:'), '1', 'EX', - 86_400, + expect.any(Number), 'NX', ); }); - it('rejects a duplicate eventId within the TTL window regardless of configured TTL', async () => { - process.env.IDEMPOTENCY_TTL_SECONDS = '300'; - const { socket, trigger } = makeSocket(); - // null == Redis SET NX found the key already present (still within TTL) + it('rejects a duplicate eventId within the TTL window', async () => { + process.env['REPLAY_PROTECTION_TTL_SECONDS'] = '300'; + // null == SET NX found the key already present (still within the window) const redis = makeRedis(null); - const dispatcher = new EventDispatcher(makeIo(), socket, redis as never); - const handler = vi.fn(); - dispatcher.register('join_room', handler); - dispatcher.listen(); - trigger('dispatch', { - eventId: 'evt-duplicate-within-ttl', - type: 'join_room', - timestamp: Date.now(), - payload: {}, - }); + const handler = await dispatchOnce(redis, 'evt-duplicate-within-ttl'); - await new Promise((r) => setTimeout(r, 10)); expect(handler).not.toHaveBeenCalled(); expect(redis.set).toHaveBeenCalledWith( - 'event:idempotency:evt-duplicate-within-ttl', + 'replay:d1:evt-duplicate-within-ttl', '1', 'EX', 300, diff --git a/apps/backend/src/__tests__/e2ee.integration.test.ts b/apps/backend/src/__tests__/e2ee.integration.test.ts index ad65322..234df5d 100644 --- a/apps/backend/src/__tests__/e2ee.integration.test.ts +++ b/apps/backend/src/__tests__/e2ee.integration.test.ts @@ -22,23 +22,29 @@ const mockFindMembership = vi.fn(); const mockInsertMessages = vi.fn(); const mockInsertEnvelopes = vi.fn(); -const mockTransaction = vi.fn(async (cb: (tx: unknown) => Promise) => { - const tx = { - insert: (table: string) => ({ - values: (vals: unknown) => ({ - returning: async () => { - if (table === 'messages_table') { - mockInsertMessages(vals); - const row = { ...(vals as object), id: 'msg-001', createdAt: new Date() }; - return [row]; - } - mockInsertEnvelopes(vals); - return [{}]; - }, - }), +// The message insert uses `.values(...).returning()` while the envelope insert +// just awaits `.values(...)`, so values() has to be both thenable and expose +// returning() or one of the two silently records nothing. +function recordInsert(table: string, vals: unknown) { + if (table === 'messages_table') { + mockInsertMessages(vals); + return [{ ...(vals as object), id: 'msg-001', createdAt: new Date() }]; + } + mockInsertEnvelopes(vals); + return [{}]; +} + +function insertStub(table: string) { + return { + values: (vals: unknown) => ({ + returning: async () => recordInsert(table, vals), + then: (resolve: (value: unknown) => void) => resolve(recordInsert(table, vals)), }), }; - return cb(tx); +} + +const mockTransaction = vi.fn(async (cb: (tx: unknown) => Promise) => { + return cb({ insert: insertStub }); }); vi.mock('../db/index.js', () => ({ @@ -47,18 +53,7 @@ vi.mock('../db/index.js', () => ({ conversationMembers: { findMany: mockFindMembers, findFirst: mockFindMembership }, devices: { findMany: mockFindDevices }, }, - insert: (table: string) => ({ - values: (vals: unknown) => ({ - returning: async () => { - if (table === 'messages_table') { - mockInsertMessages(vals); - return [{ ...(vals as object), id: 'msg-001', createdAt: new Date() }]; - } - mockInsertEnvelopes(vals); - return [{}]; - }, - }), - }), + insert: insertStub, transaction: mockTransaction, }, })); @@ -77,7 +72,9 @@ vi.mock('drizzle-orm', () => ({ isNull: vi.fn((col: unknown) => ({ isNull: col })), })); -import { fanoutMessage, fanoutGroupMlsMessage } from '../services/fanout.js'; +// Imported dynamically: a static import is hoisted above the mock-state +// consts above, so the vi.mock factories would run before they initialise. +const { fanoutMessage, fanoutGroupMlsMessage } = await import('../services/fanout.js'); // ── Fixtures ─────────────────────────────────────────────────────────────────── @@ -89,7 +86,6 @@ const DEVICE_A2 = 'device-a2'; // Alice's second device const DEVICE_B1 = 'device-b1'; const PLAINTEXT = 'Hello, world!'; // must never appear in stored ciphertext -const DM_CIPHERTEXT_A1 = 'AEAD:encrypted-for-a1'; const DM_CIPHERTEXT_A2 = 'AEAD:encrypted-for-a2'; const DM_CIPHERTEXT_B1 = 'AEAD:encrypted-for-b1'; const MLS_GROUP_CIPHERTEXT = 'MLS:single-group-ciphertext'; @@ -105,26 +101,9 @@ beforeEach(() => { { id: DEVICE_B1, userId: USER_B }, ]); mockFindMembership.mockResolvedValue({ id: 'mem-001' }); - mockTransaction.mockImplementation(async (cb) => { - const insertedMsg = { id: 'msg-001', conversationId: CONV_ID, createdAt: new Date() }; - const envelopes: unknown[] = []; - const tx = { - insert: (table: string) => ({ - values: (vals: unknown) => ({ - returning: async () => { - if (table === 'messages_table') { - mockInsertMessages(vals); - return [insertedMsg]; - } - mockInsertEnvelopes(vals); - envelopes.push(vals); - return [{}]; - }, - }), - }), - }; - return cb(tx); - }); + // clearAllMocks() drops the implementation, so restate it against the same + // stub the module-level mock uses rather than a second, divergent one. + mockTransaction.mockImplementation(async (cb) => cb({ insert: insertStub })); }); // ── 1. Server only stores ciphertext, never plaintext ───────────────────────── diff --git a/apps/backend/src/__tests__/file.messages.test.ts b/apps/backend/src/__tests__/file.messages.test.ts index d2f2f11..37ada98 100644 --- a/apps/backend/src/__tests__/file.messages.test.ts +++ b/apps/backend/src/__tests__/file.messages.test.ts @@ -1,22 +1,18 @@ /** - * Tests for file message construction (issues #228, #337). + * Tests for file message construction (issues #228, #337, #347, #335). * * Validates that: - * - The handler calls the shared `validateMessagePayload` (#335). + * - The handler delegates content-type rules to the shared + * `validateMessagePayload` (#335) rather than duplicating them inline. * - File messages reference a `ready` file authorized for the sender. * - The handler rejects files that are not `ready` (pending, deleted, missing). * - Access control: only the uploader may reference a file. * - File must belong to the same conversation. - * - Fan-out via io.to(conversationId).emit('new_message') is identical to - * the text-message path. - * - `fileKey` is never inspected or stored by the server — it lives only - * inside the encrypted `content` envelope ciphertext. - * - Envelopes are required, matching the text-message path. * - Non-members are rejected before any file check. - * - `fileKey` is never inspected or stored by the server — it lives only - * inside the encrypted envelope ciphertext. + * - The file key is never inspected or stored by the server — it lives only + * inside each recipient's individually-sealed envelope ciphertext. * - * Envelope migration (#337): `send_file_message` used to persist a single + * Envelope migration (#337/#347): `send_file_message` used to persist a single * shared `messages.ciphertext` with zero `message_envelopes` rows and fan out * with a raw `io.to(conversationId).emit('new_message', …)`. It now mirrors * `send_message` exactly: @@ -84,6 +80,7 @@ vi.mock('../db/schema.js', () => ({ messageEnvelopes: { __table: 'message_envelopes' }, devices: { __table: 'devices' }, files: { __table: 'files' }, + users: { __table: 'users' }, })); vi.mock('drizzle-orm', () => ({ @@ -93,13 +90,15 @@ vi.mock('drizzle-orm', () => ({ isNull: vi.fn((col: unknown) => ({ col, op: 'isNull' })), inArray: vi.fn((col: unknown, vals: unknown) => ({ col, vals })), lt: vi.fn(), + lte: vi.fn(), + or: vi.fn((...args: unknown[]) => args), desc: vi.fn(), sql: vi.fn(), })); -vi.mock('../lib/validateMessagePayload.js', () => ({ - validateMessagePayload: vi.fn().mockReturnValue({ ok: true }), -})); +// `validateMessagePayload` is deliberately NOT mocked: the point of #335 is +// that this handler enforces exactly the shared rules, so the real validator +// is what these tests exercise. vi.mock('../lib/conversationCache.js', () => ({ invalidateConversationCaches: vi.fn().mockResolvedValue(undefined), @@ -140,6 +139,26 @@ vi.mock('../services/deviceDelivery.js', () => ({ publishToDevice: vi.fn().mockResolvedValue(undefined), })); +// ── Constants ──────────────────────────────────────────────────────────────── + +const SENDER_ID = 'user-sender'; +const SENDER_DEVICE = 'device-sender'; +const SIBLING_B = 'device-sibling-b'; +const SIBLING_C = 'device-sibling-c'; +const BOB_DEVICE = 'device-bob'; +const CONVERSATION_ID = 'conv-1'; +const FILE_ID = 'file-abc'; +const MESSAGE_ID = 'msg-client-supplied'; +const DEFAULT_MESSAGE_ID = 'msg-1'; + +// The content is an E2EE envelope ciphertext for the message body. The server +// treats it as an opaque string. The file's symmetric encryption key must +// NEVER appear here — it only ever lives inside `envelopes[].ciphertext`. +const ENVELOPE_CIPHERTEXT = 'encrypted:{"fileId":"file-abc","fileName":"photo.jpg"}'; + +/** A default, well-formed envelope set covering only an unrelated recipient. */ +const ENVELOPES = [{ recipientDeviceId: BOB_DEVICE, ciphertext: 'cipher-for-bob' }]; + // ── Helpers ────────────────────────────────────────────────────────────────── function makeSocket(userId: string, deviceId = SENDER_DEVICE) { @@ -186,23 +205,21 @@ function messageRow(): Record { return (call?.values as Record) ?? {}; } -const SENDER_ID = 'user-sender'; -const SENDER_DEVICE = 'device-sender'; -const SIBLING_B = 'device-sibling-b'; -const SIBLING_C = 'device-sibling-c'; -const BOB_DEVICE = 'device-bob'; -const CONVERSATION_ID = 'conv-1'; -const FILE_ID = 'file-abc'; -const MESSAGE_ID = 'msg-client-supplied'; - -const ENVELOPES = [ - { recipientDeviceId: 'dev-recipient-1', ciphertext: 'for-recipient-1' }, - { recipientDeviceId: 'dev-sender-sibling', ciphertext: 'for-sender-sibling' }, -]; -// The content is an E2EE envelope ciphertext for the message body. The server -// treats it as an opaque string. The file's symmetric encryption key must -// NEVER appear here — it only ever lives inside `envelopes[].ciphertext`. -const ENVELOPE_CIPHERTEXT = 'encrypted:{"fileId":"file-abc","fileName":"photo.jpg"}'; +/** The row `.returning()` resolves to after the message insert. */ +function insertedMessage(overrides: Record = {}) { + return { + id: DEFAULT_MESSAGE_ID, + conversationId: CONVERSATION_ID, + senderId: SENDER_ID, + senderDeviceId: SENDER_DEVICE, + contentType: 'image', + ciphertext: ENVELOPE_CIPHERTEXT, + fileId: FILE_ID, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + deletedAt: null, + ...overrides, + }; +} function readyFile( overrides: Partial<{ @@ -227,7 +244,8 @@ function fileMessagePayload( fileId: string; messageId: string; content: string; - contentType: 'file' | 'image' | 'video' | 'audio'; + contentType: string; + envelopes: Array<{ recipientDeviceId: string; ciphertext: string }>; }> = {}, ) { return { @@ -235,7 +253,8 @@ function fileMessagePayload( fileId: FILE_ID, messageId: DEFAULT_MESSAGE_ID, content: ENVELOPE_CIPHERTEXT, - contentType: 'image' as const, + contentType: 'image', + envelopes: ENVELOPES, ...overrides, }; } @@ -279,46 +298,44 @@ describe('send_file_message — per-device envelopes (#337)', () => { const io = makeIo(); const handler = await getHandler(socket, io); - await handler({ - conversationId: CONVERSATION_ID, - senderId: SENDER_ID, - fileId: FILE_ID, - content: ENVELOPE_CIPHERTEXT, - contentType: 'image', - envelopes: [ - { recipientDeviceId: SIBLING_B, ciphertext: 'cipher-for-sibling' }, - { recipientDeviceId: BOB_DEVICE, ciphertext: 'cipher-for-bob' }, - ], - }); + await handler( + fileMessagePayload({ + envelopes: [ + { recipientDeviceId: SIBLING_B, ciphertext: 'cipher-for-sibling' }, + { recipientDeviceId: BOB_DEVICE, ciphertext: 'cipher-for-bob' }, + ], + }), + ); expect(socket.emitted.some((e) => e.event === 'error')).toBe(false); // Message row carries the sending device, so recipients can attribute it. expect(messageRow()).toMatchObject({ + id: DEFAULT_MESSAGE_ID, conversationId: CONVERSATION_ID, senderId: SENDER_ID, senderDeviceId: SENDER_DEVICE, contentType: 'image', fileId: FILE_ID, - createdAt: new Date(), - deletedAt: null, - envelopes: ENVELOPES, + ciphertext: ENVELOPE_CIPHERTEXT, }); // One envelope row per recipient device, each with its own ciphertext and // the resolved owning user id. expect(envelopeRows()).toEqual([ { - messageId: 'msg-1', + messageId: DEFAULT_MESSAGE_ID, recipientDeviceId: SIBLING_B, recipientUserId: SENDER_ID, ciphertext: 'cipher-for-sibling', + protocol: 'sealed_box', }, { - messageId: 'msg-1', + messageId: DEFAULT_MESSAGE_ID, recipientDeviceId: BOB_DEVICE, recipientUserId: 'user-bob', ciphertext: 'cipher-for-bob', + protocol: 'sealed_box', }, ]); @@ -338,16 +355,15 @@ describe('send_file_message — per-device envelopes (#337)', () => { const io = makeIo(); const handler = await getHandler(socket, io); - await handler({ - conversationId: CONVERSATION_ID, - fileId: FILE_ID, - content: ENVELOPE_CIPHERTEXT, - contentType: 'file', - envelopes: [ - { recipientDeviceId: BOB_DEVICE, ciphertext: 'cipher-for-bob' }, - { recipientDeviceId: 'device-ghost', ciphertext: 'cipher-for-ghost' }, - ], - }); + await handler( + fileMessagePayload({ + contentType: 'file', + envelopes: [ + { recipientDeviceId: BOB_DEVICE, ciphertext: 'cipher-for-bob' }, + { recipientDeviceId: 'device-ghost', ciphertext: 'cipher-for-ghost' }, + ], + }), + ); expect(envelopeRows()).toHaveLength(1); expect(envelopeRows()[0]).toMatchObject({ recipientDeviceId: BOB_DEVICE }); @@ -361,16 +377,11 @@ describe('send_file_message — per-device envelopes (#337)', () => { const handler = await getHandler(socket, io); // Only sibling B is covered; sibling C is absent. - await handler({ - conversationId: CONVERSATION_ID, - fileId: FILE_ID, - content: ENVELOPE_CIPHERTEXT, - contentType: 'image', - envelopes: [{ recipientDeviceId: SIBLING_B, ciphertext: 'cipher-for-b' }], - }); - mockFileFindFirst.mockResolvedValueOnce(readyFile()); - mockMessageFindFirst.mockResolvedValueOnce(undefined); - mockFindMany.mockResolvedValueOnce([{ userId: SENDER_ID }, { userId: 'user-2' }]); + await handler( + fileMessagePayload({ + envelopes: [{ recipientDeviceId: SIBLING_B, ciphertext: 'cipher-for-b' }], + }), + ); const errors = socket.emitted.filter((e) => e.event === 'error'); expect(errors).toHaveLength(1); @@ -383,7 +394,7 @@ describe('send_file_message — per-device envelopes (#337)', () => { expect(deliverMessage).not.toHaveBeenCalled(); }); - it('rejects with device_set_mismatch when envelopes are omitted entirely but siblings exist', async () => { + it('rejects when the envelopes cover nobody the sender owns', async () => { mockDevicesFindMany.mockResolvedValueOnce([{ id: SIBLING_B }]); const socket = makeSocket(SENDER_ID); @@ -392,13 +403,11 @@ describe('send_file_message — per-device envelopes (#337)', () => { // A non-empty envelopes array satisfies the file-key requirement, but it // doesn't cover the sender's sibling device — that's still a mismatch. - await handler({ - conversationId: CONVERSATION_ID, - fileId: FILE_ID, - contentType: 'image', - envelopes: ENVELOPES, - envelopes: [{ recipientDeviceId: 'device-unrelated', ciphertext: 'cipher-for-unrelated' }], - }); + await handler( + fileMessagePayload({ + envelopes: [{ recipientDeviceId: 'device-unrelated', ciphertext: 'cipher-for-unrelated' }], + }), + ); const errors = socket.emitted.filter((e) => e.event === 'error'); expect(errors).toHaveLength(1); @@ -409,45 +418,24 @@ describe('send_file_message — per-device envelopes (#337)', () => { it('does not require sibling coverage for revoked sibling devices', async () => { // fetchSiblingDeviceIds filters revoked devices at the DB level, so a // sender whose only other device is revoked sees no siblings at all. - mockDevicesFindMany.mockResolvedValue([]); + mockDevicesFindMany + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: BOB_DEVICE, userId: 'user-bob' }]); const socket = makeSocket(SENDER_ID); const io = makeIo(); const handler = await getHandler(socket, io); - const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( - p: unknown, - ) => Promise; - await handler(fileMessagePayload({ messageId: returnedMessage.id, contentType: 'image' })); + await handler(fileMessagePayload()); expect(socket.emitted.some((e) => e.event === 'error')).toBe(false); - expect(mockInsert).toHaveBeenCalled(); - expect(valuesFn).toHaveBeenCalledWith( - expect.objectContaining({ - id: returnedMessage.id, - conversationId: CONVERSATION_ID, - senderId: SENDER_ID, - fileId: FILE_ID, - contentType: 'image', - }), - ); - - const socket = makeSocket(SENDER_ID); - const io = makeIo(); - const handler = await getHandler(socket, io); - - await handler({ + expect(messageRow()).toMatchObject({ + id: DEFAULT_MESSAGE_ID, conversationId: CONVERSATION_ID, - messageId: MESSAGE_ID, + senderId: SENDER_ID, fileId: FILE_ID, - content: ENVELOPE_CIPHERTEXT, contentType: 'image', - envelopes: [{ recipientDeviceId: BOB_DEVICE, ciphertext: 'cipher-for-bob' }], }); - - expect(socket.emit).toHaveBeenCalledWith('message_ack', { messageId: MESSAGE_ID, createdAt }); - expect(mockInsert).not.toHaveBeenCalled(); - expect(deliverMessage).not.toHaveBeenCalled(); }); it('uses the client-supplied messageId for the row and its envelopes', async () => { @@ -460,21 +448,14 @@ describe('send_file_message — per-device envelopes (#337)', () => { const io = makeIo(); const handler = await getHandler(socket, io); - await handler({ - conversationId: CONVERSATION_ID, - messageId: MESSAGE_ID, - fileId: FILE_ID, - content: ENVELOPE_CIPHERTEXT, - contentType: 'image', - envelopes: [{ recipientDeviceId: BOB_DEVICE, ciphertext: 'cipher-for-bob' }], - }); + await handler(fileMessagePayload({ messageId: MESSAGE_ID })); expect(messageRow()).toMatchObject({ id: MESSAGE_ID }); expect(envelopeRows()[0]).toMatchObject({ messageId: MESSAGE_ID }); }); }); -describe('send_file_message — delivery pipeline (#337)', () => { +describe('send_file_message — delivery pipeline (#347)', () => { it('delivers through deliverMessage instead of a raw io.to().emit()', async () => { const message = insertedMessage(); mockReturning.mockResolvedValue([message]); @@ -486,10 +467,49 @@ describe('send_file_message — delivery pipeline (#337)', () => { const io = makeIo(); const handler = await getHandler(socket, io); - const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( - p: unknown, - ) => Promise; - await handler(fileMessagePayload({ messageId: 'msg-not-member', contentType: 'file' })); + await handler(fileMessagePayload()); + + expect(deliverMessage).toHaveBeenCalledTimes(1); + expect(deliverMessage).toHaveBeenCalledWith(io, message, CONVERSATION_ID); + + // The ack carries the persisted createdAt, matching send_message. + expect(socket.emit).toHaveBeenCalledWith('message_ack', { + messageId: DEFAULT_MESSAGE_ID, + createdAt: message.createdAt, + }); + }); + + it('dispatches offline push for the envelope recipients, not sendPushForMessage', async () => { + mockDevicesFindMany + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: BOB_DEVICE, userId: 'user-bob' }]); + + const socket = makeSocket(SENDER_ID); + const io = makeIo(); + const handler = await getHandler(socket, io); + + await handler(fileMessagePayload()); + + expect(dispatchOfflinePush).toHaveBeenCalledWith( + CONVERSATION_ID, + DEFAULT_MESSAGE_ID, + [BOB_DEVICE], + SENDER_ID, + ); + expect(sendPushForMessage).not.toHaveBeenCalled(); + }); + + it('does not deliver or push when the transaction fails', async () => { + mockDevicesFindMany + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: BOB_DEVICE, userId: 'user-bob' }]); + mockReturning.mockRejectedValueOnce(new Error('insert exploded')); + + const socket = makeSocket(SENDER_ID); + const io = makeIo(); + const handler = await getHandler(socket, io); + + await handler(fileMessagePayload()); expect(socket.emit).toHaveBeenCalledWith( 'error', @@ -509,14 +529,7 @@ describe('send_file_message — validation and access control', () => { const io = makeIo(); const handler = await getHandler(socket, io); - await handler({ - conversationId: CONVERSATION_ID, - fileId: FILE_ID, - contentType: 'file', - envelopes: ENVELOPES, - content: ENVELOPE_CIPHERTEXT, - contentType: 'image', - }); + await handler(fileMessagePayload({ contentType: 'file', envelopes: [] })); expect(socket.emit).toHaveBeenCalledWith( 'error', @@ -528,6 +541,37 @@ describe('send_file_message — validation and access control', () => { expect(mockInsert).not.toHaveBeenCalled(); }); + it('rejects an unsupported contentType via the shared validator', async () => { + const socket = makeSocket(SENDER_ID); + const io = makeIo(); + const handler = await getHandler(socket, io); + + await handler(fileMessagePayload({ contentType: 'application/zip' })); + + expect(socket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ + event: 'send_file_message', + message: expect.stringContaining('unsupported contentType'), + }), + ); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it('rejects a client-submitted system contentType with 403', async () => { + const socket = makeSocket(SENDER_ID); + const io = makeIo(); + const handler = await getHandler(socket, io); + + await handler(fileMessagePayload({ contentType: 'system' })); + + expect(socket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ event: 'send_file_message', code: 403 }), + ); + expect(mockInsert).not.toHaveBeenCalled(); + }); + it('rejects when sender is not a member of the conversation', async () => { mockMemberFindFirst.mockResolvedValueOnce(undefined); // no membership @@ -535,13 +579,7 @@ describe('send_file_message — validation and access control', () => { const io = makeIo(); const handler = await getHandler(socket, io); - await handler({ - conversationId: CONVERSATION_ID, - fileId: FILE_ID, - content: ENVELOPE_CIPHERTEXT, - contentType: 'image', - envelopes: [{ recipientDeviceId: 'device-recipient', ciphertext: 'sealed-file-key' }], - }); + await handler(fileMessagePayload()); expect(socket.emit).toHaveBeenCalledWith( 'error', @@ -560,23 +598,7 @@ describe('send_file_message — validation and access control', () => { const io = makeIo(); const handler = await getHandler(socket, io); - const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( - p: unknown, - ) => Promise; - await handler({ - conversationId: CONVERSATION_ID, - fileId: 'nonexistent-file', - contentType: 'image', - envelopes: ENVELOPES, - }); - mockMessageFindFirst.mockResolvedValueOnce(undefined); - await handler( - fileMessagePayload({ - messageId: 'msg-missing-file', - fileId: 'nonexistent-file', - contentType: 'image', - }), - ); + await handler(fileMessagePayload({ fileId: 'nonexistent-file' })); expect(socket.emit).toHaveBeenCalledWith( 'error', @@ -595,20 +617,7 @@ describe('send_file_message — validation and access control', () => { const io = makeIo(); const handler = await getHandler(socket, io); - const { registerMessagingHandlers } = await import('../socket/messaging.js'); - registerMessagingHandlers(io as never, socket as never); - - const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( - p: unknown, - ) => Promise; - await handler({ - conversationId: CONVERSATION_ID, - fileId: FILE_ID, - contentType: 'file', - envelopes: ENVELOPES, - }); - mockMessageFindFirst.mockResolvedValueOnce(undefined); - await handler(fileMessagePayload({ messageId: 'msg-pending-file', contentType: 'file' })); + await handler(fileMessagePayload({ contentType: 'file' })); expect(socket.emit).toHaveBeenCalledWith( 'error', @@ -627,20 +636,7 @@ describe('send_file_message — validation and access control', () => { const io = makeIo(); const handler = await getHandler(socket, io); - const { registerMessagingHandlers } = await import('../socket/messaging.js'); - registerMessagingHandlers(io as never, socket as never); - - const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( - p: unknown, - ) => Promise; - await handler({ - conversationId: CONVERSATION_ID, - fileId: FILE_ID, - contentType: 'file', - envelopes: ENVELOPES, - }); - mockMessageFindFirst.mockResolvedValueOnce(undefined); - await handler(fileMessagePayload({ messageId: 'msg-deleted-file', contentType: 'file' })); + await handler(fileMessagePayload({ contentType: 'file' })); expect(socket.emit).toHaveBeenCalledWith( 'error', @@ -659,20 +655,7 @@ describe('send_file_message — validation and access control', () => { const io = makeIo(); const handler = await getHandler(socket, io); - const { registerMessagingHandlers } = await import('../socket/messaging.js'); - registerMessagingHandlers(io as never, socket as never); - - const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( - p: unknown, - ) => Promise; - await handler({ - conversationId: CONVERSATION_ID, - fileId: FILE_ID, - contentType: 'image', - envelopes: ENVELOPES, - }); - mockMessageFindFirst.mockResolvedValueOnce(undefined); - await handler(fileMessagePayload({ messageId: 'msg-wrong-conv', contentType: 'image' })); + await handler(fileMessagePayload()); expect(socket.emit).toHaveBeenCalledWith( 'error', @@ -696,20 +679,7 @@ describe('send_file_message — validation and access control', () => { const io = makeIo(); const handler = await getHandler(socket, io); - const { registerMessagingHandlers } = await import('../socket/messaging.js'); - registerMessagingHandlers(io as never, socket as never); - - const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( - p: unknown, - ) => Promise; - await handler({ - conversationId: CONVERSATION_ID, - fileId: FILE_ID, - contentType: 'video', - envelopes: ENVELOPES, - }); - mockMessageFindFirst.mockResolvedValueOnce(undefined); - await handler(fileMessagePayload({ messageId: 'msg-unauthorized', contentType: 'video' })); + await handler(fileMessagePayload({ contentType: 'video' })); expect(socket.emit).toHaveBeenCalledWith( 'error', @@ -726,19 +696,7 @@ describe('send_file_message — validation and access control', () => { const io = makeIo(); const handler = await getHandler(socket, io); - const { registerMessagingHandlers } = await import('../socket/messaging.js'); - registerMessagingHandlers(io as never, socket as never); - - const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( - p: unknown, - ) => Promise; - await handler( - fileMessagePayload({ - messageId: 'msg-empty-content', - content: ' ', - contentType: 'audio', - }), - ); + await handler(fileMessagePayload({ content: ' ', contentType: 'audio' })); expect(socket.emit).toHaveBeenCalledWith( 'error', @@ -750,110 +708,20 @@ describe('send_file_message — validation and access control', () => { expect(mockInsert).not.toHaveBeenCalled(); }); - it('fan-out is identical to text message: io.to(conversationId).emit("new_message", message)', async () => { - const returnedMessage = { - id: 'msg-2', - conversationId: CONVERSATION_ID, - senderId: SENDER_ID, - contentType: 'audio', - fileId: FILE_ID, - createdAt: new Date(), - deletedAt: null, - }; - - mockMemberFindFirst.mockResolvedValueOnce({ - id: 'membership-1', - userId: SENDER_ID, - conversationId: CONVERSATION_ID, - }); - mockFileFindFirst.mockResolvedValueOnce(readyFile()); - mockMessageFindFirst.mockResolvedValueOnce(undefined); - mockFindMany.mockResolvedValueOnce([{ userId: SENDER_ID }]); - - const returningFn = vi.fn().mockResolvedValue([returnedMessage]); - const valuesFn = vi.fn().mockReturnValue({ returning: returningFn }); - mockInsert.mockReturnValue({ values: valuesFn }); - - const socket = makeSocket(SENDER_ID); - const io = makeIo(); - const handler = await getHandler(socket, io); - - const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( - p: unknown, - ) => Promise; - await handler({ - conversationId: CONVERSATION_ID, - fileId: FILE_ID, - contentType: 'audio', - envelopes: ENVELOPES, - }); - await handler(fileMessagePayload({ messageId: returnedMessage.id, contentType: 'audio' })); - - expect(socket.emit).toHaveBeenCalledWith( - 'error', - expect.objectContaining({ - event: 'send_file_message', - message: expect.stringContaining('contentType must be one of'), - }), - ); - expect(mockInsert).not.toHaveBeenCalled(); - }); - - it('fileKey inside envelope ciphertext is never extracted or stored by the server', async () => { - // The server must treat envelope `ciphertext` as an opaque blob. We verify that the - // insert values object does NOT contain a `fileKey` field — the key must - // remain only inside the encrypted envelope ciphertext. - const returnedMessage = { - id: 'msg-3', - conversationId: CONVERSATION_ID, - senderId: SENDER_ID, - contentType: 'image', - fileId: FILE_ID, - createdAt: new Date(), - deletedAt: null, - }; - - mockMemberFindFirst.mockResolvedValueOnce({ - id: 'membership-1', - userId: SENDER_ID, - conversationId: CONVERSATION_ID, - }); - mockFileFindFirst.mockResolvedValueOnce(readyFile()); - mockMessageFindFirst.mockResolvedValueOnce(undefined); - mockFindMany.mockResolvedValueOnce([{ userId: SENDER_ID }]); - - const returningFn = vi.fn().mockResolvedValue([returnedMessage]); - const valuesFn = vi.fn().mockReturnValue({ returning: returningFn }); - mockInsert.mockReturnValue({ values: valuesFn }); + it('the file key is never lifted out of the envelope into the message row', async () => { + mockDevicesFindMany + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: BOB_DEVICE, userId: 'user-bob' }]); const socket = makeSocket(SENDER_ID); const io = makeIo(); const handler = await getHandler(socket, io); - const { registerMessagingHandlers } = await import('../socket/messaging.js'); - registerMessagingHandlers(io as never, socket as never); + await handler(fileMessagePayload()); - const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( - p: unknown, - ) => Promise; - await handler({ - conversationId: CONVERSATION_ID, - fileId: FILE_ID, - contentType: 'image', - envelopes: ENVELOPES, - }); - - // The inserted values must not include a top-level `fileKey` field - const insertedValues = (valuesFn.mock.calls[0] as unknown[])[0] as Record; - expect(insertedValues).not.toHaveProperty('fileKey'); - - // The message row itself has no ciphertext; it's all in the envelopes. - expect(insertedValues.ciphertext).toBeUndefined(); - await handler(fileMessagePayload({ messageId: returnedMessage.id, contentType: 'image' })); - - // The inserted values must not include a top-level `fileKey` field. + // The server must treat envelope `ciphertext` as an opaque blob: no + // top-level key field, and the body ciphertext stored verbatim. expect(messageRow()).not.toHaveProperty('fileKey'); - // The ciphertext is stored as-is (opaque encrypted blob). expect(messageRow().ciphertext).toBe(ENVELOPE_CIPHERTEXT); }); @@ -862,47 +730,26 @@ describe('send_file_message — validation and access control', () => { for (const contentType of contentTypes) { vi.clearAllMocks(); + insertCalls.length = 0; - const returnedMessage = { - id: `msg-${contentType}`, - conversationId: CONVERSATION_ID, - senderId: SENDER_ID, - contentType, - fileId: FILE_ID, - createdAt: new Date(), - deletedAt: null, - }; - - mockMemberFindFirst.mockResolvedValueOnce({ + mockMemberFindFirst.mockResolvedValue({ id: 'membership-1', userId: SENDER_ID, conversationId: CONVERSATION_ID, }); - mockFileFindFirst.mockResolvedValueOnce(readyFile()); - mockMessageFindFirst.mockResolvedValueOnce(undefined); - mockFindMany.mockResolvedValueOnce([{ userId: SENDER_ID }]); - - const returningFn = vi.fn().mockResolvedValue([returnedMessage]); - const valuesFn = vi.fn().mockReturnValue({ returning: returningFn }); - mockInsert.mockReturnValue({ values: valuesFn }); + mockFileFindFirst.mockResolvedValue(readyFile()); + mockMessageFindFirst.mockResolvedValue(undefined); + mockMemberFindMany.mockResolvedValue([{ userId: SENDER_ID }]); + mockReturning.mockResolvedValue([insertedMessage({ contentType })]); + mockDevicesFindMany + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: BOB_DEVICE, userId: 'user-bob' }]); const socket = makeSocket(SENDER_ID); const io = makeIo(); const handler = await getHandler(socket, io); - const { registerMessagingHandlers } = await import('../socket/messaging.js'); - registerMessagingHandlers(io as never, socket as never); - - const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( - p: unknown, - ) => Promise; - await handler({ - conversationId: CONVERSATION_ID, - fileId: FILE_ID, - contentType, - envelopes: ENVELOPES, - }); - await handler(fileMessagePayload({ messageId: returnedMessage.id, contentType })); + await handler(fileMessagePayload({ contentType })); expect(messageRow()).toMatchObject({ contentType }); } @@ -911,19 +758,11 @@ describe('send_file_message — validation and access control', () => { it('requires a messageId so retries can be idempotent', async () => { const socket = makeSocket(SENDER_ID); const io = makeIo(); + const handler = await getHandler(socket, io); - const { registerMessagingHandlers } = await import('../socket/messaging.js'); - registerMessagingHandlers(io as never, socket as never); - - const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( - p: unknown, - ) => Promise; - await handler({ - conversationId: CONVERSATION_ID, - fileId: FILE_ID, - content: ENVELOPE_CIPHERTEXT, - contentType: 'image', - }); + const withoutMessageId = { ...fileMessagePayload() } as Record; + delete withoutMessageId['messageId']; + await handler(withoutMessageId); expect(socket.emit).toHaveBeenCalledWith( 'error', @@ -937,23 +776,13 @@ describe('send_file_message — validation and access control', () => { it('acks duplicate messageIds without creating a second file message', async () => { const createdAt = new Date('2026-01-01T00:00:00.000Z'); - mockMemberFindFirst.mockResolvedValueOnce({ - id: 'membership-1', - userId: SENDER_ID, - conversationId: CONVERSATION_ID, - }); mockMessageFindFirst.mockResolvedValueOnce({ createdAt }); const socket = makeSocket(SENDER_ID); const io = makeIo(); + const handler = await getHandler(socket, io); - const { registerMessagingHandlers } = await import('../socket/messaging.js'); - registerMessagingHandlers(io as never, socket as never); - - const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( - p: unknown, - ) => Promise; - await handler(fileMessagePayload({ messageId: 'msg-duplicate', contentType: 'image' })); + await handler(fileMessagePayload({ messageId: 'msg-duplicate' })); expect(socket.emit).toHaveBeenCalledWith('message_ack', { messageId: 'msg-duplicate', diff --git a/apps/backend/src/__tests__/messages.list.test.ts b/apps/backend/src/__tests__/messages.list.test.ts index 9110dbc..53be313 100644 --- a/apps/backend/src/__tests__/messages.list.test.ts +++ b/apps/backend/src/__tests__/messages.list.test.ts @@ -1,5 +1,11 @@ /** * Tests for GET /conversations/:id/messages (#336). + * + * The endpoint pages backwards over a conversation with a `(createdAt, id)` + * cursor, collapses edit chains to their newest version, and runs every row + * through `serializeMessage` so the shape matches `GET /conversations/:id` + * and a message this device holds no envelope for is explicitly marked + * `unavailable` rather than arriving as an unexplained null ciphertext. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import request from 'supertest'; @@ -8,12 +14,15 @@ import express from 'express'; // ── Mocks ──────────────────────────────────────────────────────────────────── const mockMemberFindFirst = vi.fn(); +const mockMessageFindFirst = vi.fn(); +const mockMessageFindMany = vi.fn(); const mockSelect = vi.fn(); vi.mock('../db/index.js', () => ({ db: { query: { conversationMembers: { findFirst: mockMemberFindFirst }, + messages: { findFirst: mockMessageFindFirst, findMany: mockMessageFindMany }, }, select: mockSelect, }, @@ -23,32 +32,75 @@ vi.mock('../db/schema.js', () => ({ messages: { id: 'id', conversationId: 'conversationId', - senderId: 'senderId', - senderDeviceId: 'senderDeviceId', - contentType: 'contentType', createdAt: 'createdAt', - deletedAt: 'deletedAt', editsMessageId: 'editsMessageId', - fileId: 'fileId', - }, - messageEnvelopes: { - messageId: 'messageId', - ciphertext: 'ciphertext', - recipientDeviceId: 'recipientDeviceId', - }, - conversationMembers: { - userId: 'userId', - conversationId: 'conversationId', }, + messageEnvelopes: { recipientDeviceId: 'recipientDeviceId' }, + conversationMembers: { userId: 'userId', conversationId: 'conversationId' }, + conversations: {}, + tokenTransfers: {}, + devices: {}, + users: {}, })); vi.mock('drizzle-orm', () => ({ and: vi.fn((...args: unknown[]) => ({ type: 'and', args })), asc: vi.fn((col: unknown) => ({ type: 'asc', col })), + count: vi.fn(() => ({ type: 'count' })), desc: vi.fn((col: unknown) => ({ type: 'desc', col })), eq: vi.fn((col: unknown, val: unknown) => ({ type: 'eq', col, val })), - lt: vi.fn((col: unknown, val: unknown) => ({ type: 'lt', col, val })), + inArray: vi.fn((col: unknown, vals: unknown) => ({ type: 'inArray', col, vals })), + isNotNull: vi.fn((col: unknown) => ({ type: 'isNotNull', col })), isNull: vi.fn((col: unknown) => ({ type: 'isNull', col })), + lt: vi.fn((col: unknown, val: unknown) => ({ type: 'lt', col, val })), + ne: vi.fn((col: unknown, val: unknown) => ({ type: 'ne', col, val })), + notInArray: vi.fn((col: unknown, vals: unknown) => ({ type: 'notInArray', col, vals })), + or: vi.fn((...args: unknown[]) => ({ type: 'or', args })), + sql: Object.assign( + vi.fn((strings: TemplateStringsArray, ...vals: unknown[]) => ({ strings, vals })), + { raw: vi.fn() }, + ), +})); + +vi.mock('../lib/redis.js', () => ({ + get redis() { + return null; + }, + CONV_CACHE_TTL: 30, + convCacheKey: (userId: string) => `conversations:${userId}`, +})); + +vi.mock('../lib/socket.js', () => ({ getSocketServer: () => null })); + +vi.mock('../lib/conversationCache.js', () => ({ + invalidateConversationCaches: vi.fn().mockResolvedValue(undefined), +})); + +// Non-MLS conversation: the epoch-window lookup finds no group, so every row +// stays visible and the MLS placeholder path is not exercised here. +vi.mock('../services/mlsGroups.js', () => ({ + getConversationEpochWindow: vi.fn().mockResolvedValue({ hasGroup: false, window: null }), +})); + +vi.mock('../services/groupControl.js', () => ({ + appendGroupControlEvent: vi.fn(), + broadcastGroupControlEvent: vi.fn(), + getGroupState: vi.fn(), + readGroupControlEvents: vi.fn(), + serializeGroupControlEvent: (e: unknown) => e, + DEFAULT_GROUP_CONTROL_PAGE_SIZE: 100, + MAX_GROUP_CONTROL_PAGE_SIZE: 500, + MAX_GROUP_CONTROL_PAYLOAD_BYTES: 65536, +})); + +vi.mock('../services/rateLimit.js', () => ({ + checkFirstContactLimit: vi.fn().mockResolvedValue({ allowed: true, count: 0 }), + checkGroupInviteLimit: vi.fn().mockResolvedValue({ allowed: true, count: 0 }), +})); + +vi.mock('../services/auditLog.js', () => ({ + actorFromRequest: vi.fn(() => ({})), + recordAuditEvent: vi.fn().mockResolvedValue(undefined), })); vi.mock('../middleware/auth.js', () => ({ @@ -61,12 +113,12 @@ vi.mock('../middleware/auth.js', () => ({ }, })); -const { messagesRouter } = await import('../routes/messages.js'); +const { conversationsRouter } = await import('../routes/conversations.js'); function makeApp() { const app = express(); app.use(express.json()); - app.use(messagesRouter); + app.use('/conversations', conversationsRouter); return app; } @@ -83,18 +135,16 @@ function makeMessageRow(n: number, withEnvelope = true) { deletedAt: null, editsMessageId: null, fileId: null, - ciphertext: withEnvelope ? `cipher-${n}` : null, + ciphertext: null, + envelopes: withEnvelope ? [{ ciphertext: `cipher-${n}` }] : [], }; } -function mockDbQuery(rows: ReturnType[]) { - const limitFn = vi.fn().mockResolvedValue(rows); - const orderByFn = vi.fn().mockReturnValue({ limit: limitFn }); - const whereFn = vi.fn().mockReturnValue({ orderBy: orderByFn }); - const leftJoinFn = vi.fn().mockReturnValue({ where: whereFn }); - const fromFn = vi.fn().mockReturnValue({ leftJoin: leftJoinFn }); +/** `db.select(...).from(...).where(...)` — the superseded-edit-ids lookup. */ +function mockSupersededIds(ids: Array<{ id: string | null }>) { + const whereFn = vi.fn().mockResolvedValue(ids); + const fromFn = vi.fn().mockReturnValue({ where: whereFn }); mockSelect.mockReturnValue({ from: fromFn }); - return { limitFn, orderByFn, whereFn, leftJoinFn }; } // ── Tests ───────────────────────────────────────────────────────────────────── @@ -102,56 +152,99 @@ function mockDbQuery(rows: ReturnType[]) { beforeEach(() => { vi.clearAllMocks(); mockMemberFindFirst.mockResolvedValue({ id: 'cm-1' }); + mockMessageFindMany.mockResolvedValue([]); + mockSupersededIds([]); }); describe('GET /conversations/:id/messages (#336)', () => { it('returns 403 when caller is not a member', async () => { mockMemberFindFirst.mockResolvedValue(null); - const res = await request(makeApp()).get('/conversations/conv-1'); + const res = await request(makeApp()).get('/conversations/conv-1/messages'); expect(res.status).toBe(403); }); - it('returns empty array for a new conversation', async () => { - mockDbQuery([]); - const res = await request(makeApp()).get('/conversations/conv-1'); + it('returns an empty array for a new conversation', async () => { + const res = await request(makeApp()).get('/conversations/conv-1/messages'); expect(res.status).toBe(200); expect(res.body.messages).toEqual([]); - expect(res.body.hasMore).toBe(false); - expect(res.body.cursor).toBeNull(); + expect(res.body.nextCursor).toBeNull(); }); it('returns messages in ascending chronological order', async () => { - mockDbQuery([makeMessageRow(3), makeMessageRow(2), makeMessageRow(1)]); - const res = await request(makeApp()).get('/conversations/conv-1'); + // The query fetches newest-first; the handler reverses for the response. + mockMessageFindMany.mockResolvedValue([ + makeMessageRow(3), + makeMessageRow(2), + makeMessageRow(1), + ]); + const res = await request(makeApp()).get('/conversations/conv-1/messages'); expect(res.status).toBe(200); - const ids = res.body.messages.map((m: { id: string }) => m.id); - expect(ids).toEqual(['msg-001', 'msg-002', 'msg-003']); + expect(res.body.messages.map((m: { id: string }) => m.id)).toEqual([ + 'msg-001', + 'msg-002', + 'msg-003', + ]); }); - it('sets hasMore true when more pages exist', async () => { - const rows = Array.from({ length: 51 }, (_, i) => makeMessageRow(i + 1)); - mockDbQuery(rows); - const res = await request(makeApp()).get('/conversations/conv-1'); + it('caps the page at the requested limit and reports a next cursor', async () => { + // One extra row over the limit is what tells the handler more remain. + const rows = Array.from({ length: 51 }, (_, i) => makeMessageRow(51 - i)); + mockMessageFindMany.mockResolvedValue(rows); + + const res = await request(makeApp()).get('/conversations/conv-1/messages?limit=50'); expect(res.status).toBe(200); - expect(res.body.hasMore).toBe(true); expect(res.body.messages).toHaveLength(50); + // Cursor points at the oldest message returned, to page further back. + expect(res.body.nextCursor).toBe(res.body.messages[0].id); }); - it('returns a cursor pointing to the oldest message in the set', async () => { - mockDbQuery([makeMessageRow(10), makeMessageRow(9)]); - const res = await request(makeApp()).get('/conversations/conv-1?before=msg-011'); + it('returns a null cursor when the last page is reached', async () => { + mockMessageFindMany.mockResolvedValue([makeMessageRow(2), makeMessageRow(1)]); + const res = await request(makeApp()).get('/conversations/conv-1/messages'); expect(res.status).toBe(200); - expect(res.body.cursor).toBe('msg-009'); + expect(res.body.nextCursor).toBeNull(); + }); + + it('rejects a cursor that does not resolve to a message', async () => { + mockMessageFindFirst.mockResolvedValue(undefined); + const res = await request(makeApp()).get('/conversations/conv-1/messages?before=nope'); + expect(res.status).toBe(400); }); it('marks messages with no envelope for this device as unavailable', async () => { - mockDbQuery([makeMessageRow(1, true), makeMessageRow(2, false)]); - const res = await request(makeApp()).get('/conversations/conv-1'); + mockMessageFindMany.mockResolvedValue([makeMessageRow(2, false), makeMessageRow(1, true)]); + + const res = await request(makeApp()).get('/conversations/conv-1/messages'); expect(res.status).toBe(200); - const msg1 = res.body.messages.find((m: { id: string }) => m.id === 'msg-001'); - const msg2 = res.body.messages.find((m: { id:string }) => m.id === 'msg-002'); - expect(msg1.unavailable).toBeUndefined(); - expect(msg2.unavailable).toBe(true); - expect(msg2.ciphertext).toBeNull(); + + const withEnvelope = res.body.messages.find((m: { id: string }) => m.id === 'msg-001'); + const withoutEnvelope = res.body.messages.find((m: { id: string }) => m.id === 'msg-002'); + + expect(withEnvelope.unavailable).toBeUndefined(); + expect(withEnvelope.ciphertext).toBe('cipher-1'); + expect(withoutEnvelope.unavailable).toBe(true); + expect(withoutEnvelope.ciphertext).toBeNull(); }); -}); \ No newline at end of file + + it('never leaks the raw envelopes relation into the response', async () => { + mockMessageFindMany.mockResolvedValue([makeMessageRow(1)]); + const res = await request(makeApp()).get('/conversations/conv-1/messages'); + expect(res.body.messages[0]).not.toHaveProperty('envelopes'); + expect(res.body.messages[0]).not.toHaveProperty('deletedAt'); + }); + + it('excludes superseded versions so an edit chain collapses to its newest row', async () => { + mockSupersededIds([{ id: 'msg-001' }]); + mockMessageFindMany.mockResolvedValue([makeMessageRow(2)]); + + const res = await request(makeApp()).get('/conversations/conv-1/messages'); + expect(res.status).toBe(200); + + // The superseded id is passed to the query as a NOT IN filter rather than + // being filtered out after the fact. + const { notInArray } = (await import('drizzle-orm')) as unknown as { + notInArray: ReturnType; + }; + expect(notInArray).toHaveBeenCalledWith(expect.anything(), ['msg-001']); + }); +}); diff --git a/apps/backend/src/__tests__/mls.history.test.ts b/apps/backend/src/__tests__/mls.history.test.ts index 0c85c48..0f33adb 100644 --- a/apps/backend/src/__tests__/mls.history.test.ts +++ b/apps/backend/src/__tests__/mls.history.test.ts @@ -30,7 +30,7 @@ vi.mock('../db/index.js', () => ({ insert: vi.fn(), update: vi.fn(), delete: vi.fn(), - select: vi.fn(), + select: vi.fn(() => ({ from: () => ({ where: async () => [] }) })), transaction: vi.fn(), }, })); @@ -38,7 +38,12 @@ vi.mock('../db/index.js', () => ({ vi.mock('../db/schema.js', () => ({ conversationMembers: { conversationId: 'conversationId', userId: 'userId' }, conversations: { id: 'id' }, - messages: { id: 'id', conversationId: 'conversationId', createdAt: 'createdAt' }, + messages: { + id: 'id', + conversationId: 'conversationId', + createdAt: 'createdAt', + editsMessageId: 'editsMessageId', + }, messageEnvelopes: { recipientDeviceId: 'recipientDeviceId' }, tokenTransfers: {}, devices: {}, @@ -51,8 +56,11 @@ vi.mock('drizzle-orm', () => ({ desc: vi.fn((col: unknown) => col), eq: vi.fn((col: unknown, val: unknown) => ({ op: 'eq', col, val })), inArray: vi.fn(), + isNotNull: vi.fn((col: unknown) => ({ op: 'isNotNull', col })), + isNull: vi.fn((col: unknown) => ({ op: 'isNull', col })), lt: vi.fn((col: unknown, val: unknown) => ({ op: 'lt', col, val })), ne: vi.fn(), + notInArray: vi.fn((col: unknown, arr: unknown) => ({ op: 'notInArray', col, arr })), or: vi.fn((...args: unknown[]) => ({ op: 'or', args })), sql: vi.fn(), })); @@ -73,6 +81,27 @@ vi.mock('../services/mlsGroups.js', () => ({ const DEVICE_ID = 'device-new'; +vi.mock('../services/groupControl.js', () => ({ + appendGroupControlEvent: vi.fn(), + broadcastGroupControlEvent: vi.fn(), + getGroupState: vi.fn(), + readGroupControlEvents: vi.fn(), + serializeGroupControlEvent: (e: unknown) => e, + DEFAULT_GROUP_CONTROL_PAGE_SIZE: 100, + MAX_GROUP_CONTROL_PAGE_SIZE: 500, + MAX_GROUP_CONTROL_PAYLOAD_BYTES: 65536, +})); + +vi.mock('../services/rateLimit.js', () => ({ + checkFirstContactLimit: vi.fn().mockResolvedValue({ allowed: true, count: 0 }), + checkGroupInviteLimit: vi.fn().mockResolvedValue({ allowed: true, count: 0 }), +})); + +vi.mock('../services/auditLog.js', () => ({ + actorFromRequest: vi.fn(() => ({})), + recordAuditEvent: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('../middleware/auth.js', () => ({ requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { (req as express.Request & { auth: { userId: string; deviceId: string } }).auth = { diff --git a/apps/backend/src/__tests__/otpAtomicity.concurrency.test.ts b/apps/backend/src/__tests__/otpAtomicity.concurrency.test.ts index c94522e..dc445bd 100644 --- a/apps/backend/src/__tests__/otpAtomicity.concurrency.test.ts +++ b/apps/backend/src/__tests__/otpAtomicity.concurrency.test.ts @@ -59,6 +59,8 @@ vi.mock('drizzle-orm', () => ({ ilike: vi.fn(), exists: vi.fn(), isNull: vi.fn((col: unknown) => ({ op: 'isNull', col })), + asc: vi.fn((col: unknown) => ({ op: 'asc', col })), + count: vi.fn(() => 'count(*)'), sql: vi.fn(), })); @@ -112,7 +114,11 @@ function createFakeOtpStore(seedCount: number) { return { select: () => ({ from: () => ({ + // The claim transaction also counts the remaining OTPs, awaiting + // where() directly, so it has to be thenable as well as chainable. where: () => ({ + then: (resolve: (value: unknown) => void) => + resolve([{ total: rows.filter((r) => !r.consumed).length }]), orderBy: () => ({ limit: () => ({ for: async () => { diff --git a/apps/backend/src/__tests__/privacy.messaging.test.ts b/apps/backend/src/__tests__/privacy.messaging.test.ts index bff78df..7f77b06 100644 --- a/apps/backend/src/__tests__/privacy.messaging.test.ts +++ b/apps/backend/src/__tests__/privacy.messaging.test.ts @@ -111,6 +111,22 @@ function makeIo() { return { to: vi.fn(() => ({ emit: vi.fn(), volatile: { emit: vi.fn() } })) }; } +// Handlers now run exclusively through the enveloped 'dispatch' path (#342) +// — there's no more raw socket.on(type, ...) listener to grab directly. +let envelopeSeq = 0; +function dispatchEvent(socket: EventEmitter, type: string) { + return async (payload: unknown) => { + envelopeSeq += 1; + EventEmitter.prototype.emit.call(socket, 'dispatch', { + eventId: `test-evt-${envelopeSeq}`, + type, + timestamp: Date.now(), + payload, + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + }; +} + describe('conversation privacy guards', () => { beforeEach(() => { vi.clearAllMocks(); @@ -135,9 +151,7 @@ describe('conversation privacy guards', () => { const { registerMessagingHandlers } = await import('../socket/messaging.js'); registerMessagingHandlers(io as never, socket as never); - const handler = (socket as EventEmitter).listeners('create_conversation')[0] as ( - payload: unknown, - ) => Promise; + const handler = dispatchEvent(socket, 'create_conversation'); await handler({ type: 'dm', memberIds: ['user-2'] }); expect(socket.emit).toHaveBeenCalledWith( diff --git a/apps/backend/src/__tests__/push.test.ts b/apps/backend/src/__tests__/push.test.ts index 81e39f0..51e500d 100644 --- a/apps/backend/src/__tests__/push.test.ts +++ b/apps/backend/src/__tests__/push.test.ts @@ -2,46 +2,36 @@ * Tests for the file-message push path (services/push.ts, #176). * * This used to send one uncoalesced webpush.sendNotification directly, with - * no rate limiting and no dead-subscription pruning. It now delegates to - * pushNotification.ts's shared queueCoalescedPush so file-message pushes get - * the same coalescing/rate-limit/hygiene behavior as text-message pushes — - * this test verifies the delegation and the recipient-filtering logic that - * stays local to this call site (mute, pushEnabled, online-skip). + * no rate limiting and no dead-subscription pruning, and it resolved + * recipients with its own bespoke query. It now does neither: recipient + * resolution goes through the shared `getEligiblePushRecipients` filter (the + * same one `dispatchOfflinePush` uses, so mute/pushEnabled/online rules cannot + * drift between the two paths), and delivery goes through the shared + * `queueCoalescedPush` so file messages get the same coalescing window, + * per-device rate limit and pruning hygiene as text messages. + * + * The filtering rules themselves are covered by pushFilter.test.ts; this file + * covers the wiring — that push.ts delegates to both, passes the right + * arguments, and never throws. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const mockMembersFindMany = vi.fn(); -const mockDevicesFindMany = vi.fn(); - -vi.mock('../db/index.js', () => ({ - db: { - query: { - conversationMembers: { findMany: mockMembersFindMany }, - devices: { findMany: mockDevicesFindMany }, - }, - }, -})); - -vi.mock('../db/schema.js', () => ({ - conversationMembers: { conversationId: 'conversation_id', userId: 'user_id' }, - devices: { userId: 'user_id', pushEnabled: 'push_enabled', revokedAt: 'revoked_at' }, -})); - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...args: unknown[]) => args), - eq: vi.fn((col: unknown, val: unknown) => ({ col, val })), - isNull: vi.fn((col: unknown) => ({ col, isNull: true })), +const mockGetEligiblePushRecipients = vi.fn(); +vi.mock('../services/pushFilter.js', () => ({ + getEligiblePushRecipients: mockGetEligiblePushRecipients, })); -const mockIsOnline = vi.fn(); -vi.mock('../services/presence.js', () => ({ isOnline: mockIsOnline })); - const mockQueueCoalescedPush = vi.fn(); vi.mock('../services/pushNotification.js', () => ({ queueCoalescedPush: mockQueueCoalescedPush, })); -vi.mock('../lib/redis.js', () => ({ redis: { fake: true } })); +const fakeRedis = { fake: true }; +vi.mock('../lib/redis.js', () => ({ + get redis() { + return fakeRedis; + }, +})); const { sendPushForMessage } = await import('../services/push.js'); @@ -49,13 +39,12 @@ const CTX = { conversationId: 'conv-1', messageId: 'msg-1', senderId: 'sender-1' beforeEach(() => { vi.clearAllMocks(); - mockIsOnline.mockResolvedValue(false); + mockGetEligiblePushRecipients.mockResolvedValue([]); }); describe('sendPushForMessage (#176)', () => { - it('queues a coalesced push for each active, push-enabled device of an offline, unmuted member', async () => { - mockMembersFindMany.mockResolvedValue([{ userId: 'recipient-1', isMuted: false }]); - mockDevicesFindMany.mockResolvedValue([{ id: 'device-a' }, { id: 'device-b' }]); + it('queues a coalesced push for every eligible recipient device', async () => { + mockGetEligiblePushRecipients.mockResolvedValue(['device-a', 'device-b']); await sendPushForMessage(CTX); @@ -64,47 +53,36 @@ describe('sendPushForMessage (#176)', () => { expect(mockQueueCoalescedPush).toHaveBeenCalledWith('device-b', 'conv-1', 'msg-1'); }); - it('skips the sender', async () => { - mockMembersFindMany.mockResolvedValue([{ userId: CTX.senderId, isMuted: false }]); - + it('resolves recipients through the shared filter, not a bespoke query', async () => { await sendPushForMessage(CTX); - expect(mockDevicesFindMany).not.toHaveBeenCalled(); - expect(mockQueueCoalescedPush).not.toHaveBeenCalled(); + expect(mockGetEligiblePushRecipients).toHaveBeenCalledWith({ + conversationId: CTX.conversationId, + senderId: CTX.senderId, + redis: fakeRedis, + }); }); - it('skips members who muted the conversation', async () => { - mockMembersFindMany.mockResolvedValue([{ userId: 'recipient-1', isMuted: true }]); + it('sends nothing when the filter returns no eligible devices', async () => { + mockGetEligiblePushRecipients.mockResolvedValue([]); await sendPushForMessage(CTX); expect(mockQueueCoalescedPush).not.toHaveBeenCalled(); }); - it('skips members who are currently online', async () => { - mockMembersFindMany.mockResolvedValue([{ userId: 'recipient-1', isMuted: false }]); - mockIsOnline.mockResolvedValue(true); - - await sendPushForMessage(CTX); - - expect(mockDevicesFindMany).not.toHaveBeenCalled(); - expect(mockQueueCoalescedPush).not.toHaveBeenCalled(); - }); - - it('only resolves active, push-enabled devices (query scoped correctly)', async () => { - mockMembersFindMany.mockResolvedValue([{ userId: 'recipient-1', isMuted: false }]); - mockDevicesFindMany.mockResolvedValue([]); - - await sendPushForMessage(CTX); + it('never throws — push is best-effort', async () => { + mockGetEligiblePushRecipients.mockRejectedValue(new Error('db down')); - expect(mockDevicesFindMany).toHaveBeenCalledWith( - expect.objectContaining({ where: expect.anything() }), - ); + await expect(sendPushForMessage(CTX)).resolves.toBeUndefined(); expect(mockQueueCoalescedPush).not.toHaveBeenCalled(); }); - it('never throws — push is best-effort', async () => { - mockMembersFindMany.mockRejectedValue(new Error('db down')); + it('does not let one failing queue call abort the rest', async () => { + mockGetEligiblePushRecipients.mockResolvedValue(['device-a', 'device-b']); + mockQueueCoalescedPush.mockImplementationOnce(() => { + throw new Error('queue exploded'); + }); await expect(sendPushForMessage(CTX)).resolves.toBeUndefined(); }); diff --git a/apps/backend/src/__tests__/pushFilter.test.ts b/apps/backend/src/__tests__/pushFilter.test.ts index 6df83c7..73c3888 100644 --- a/apps/backend/src/__tests__/pushFilter.test.ts +++ b/apps/backend/src/__tests__/pushFilter.test.ts @@ -50,7 +50,7 @@ const { getEligiblePushRecipients } = await import('../services/pushFilter.js'); beforeEach(() => { vi.clearAllMocks(); mockIsOnline.mockResolvedValue(false); - mockIsDeviceConnected.mockReturnValue(false); + mockIsDeviceConnected.mockResolvedValue(false); }); describe('Push Filter Parity', () => { @@ -147,9 +147,9 @@ describe('Push Filter Parity', () => { { id: 'device-offline', userId: 'recipient-1' }, ]); - mockIsDeviceConnected.mockImplementation((deviceId: string) => { - return deviceId === 'device-connected'; - }); + mockIsDeviceConnected.mockImplementation( + async (_redis: unknown, deviceId: string) => deviceId === 'device-connected', + ); const result = await getEligiblePushRecipients({ conversationId: 'conv-1', @@ -197,9 +197,10 @@ describe('Push Filter Parity', () => { { id: 'eligible-device-2', userId: 'eligible-user' }, ]); - mockIsDeviceConnected.mockImplementation((deviceId: string) => { - return deviceId === 'eligible-device-2'; // One device is connected - }); + mockIsDeviceConnected.mockImplementation( + // One device is connected. + async (_redis: unknown, deviceId: string) => deviceId === 'eligible-device-2', + ); const result = await getEligiblePushRecipients({ conversationId: 'conv-1', diff --git a/apps/backend/src/__tests__/rateLimit.test.ts b/apps/backend/src/__tests__/rateLimit.test.ts index 1c6d9cb..7989dd8 100644 --- a/apps/backend/src/__tests__/rateLimit.test.ts +++ b/apps/backend/src/__tests__/rateLimit.test.ts @@ -7,72 +7,59 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { - checkRateLimit, + checkSocketEventRateLimit, checkPayloadSize, checkEnvelopeSizes, recordViolation, clearViolations, } from '../services/rateLimit.js'; +import { clearLocalRateLimitCounters } from '../services/rateLimiter.js'; +import { RATE_LIMIT_DEFAULTS } from '../config/rateLimits.js'; -describe('checkRateLimit', () => { - it('allows all requests when redis is null', async () => { - const result = await checkRateLimit(null, 'socket-1'); - expect(result.allowed).toBe(true); +// Budgets live in config/rateLimits.ts (#375); this module only decides which +// bucket an event is charged to. With no Redis configured, consumeRateLimit +// falls back to in-process counters, so these run without a server. +describe('checkSocketEventRateLimit', () => { + beforeEach(() => { + clearLocalRateLimitCounters(); }); - it('allows requests under the per-second limit', async () => { - const redis = { - incr: vi.fn().mockResolvedValue(1), - expire: vi.fn().mockResolvedValue(1), - }; - const result = await checkRateLimit(redis as never, 'socket-1'); - expect(result.allowed).toBe(true); - expect(result.count).toBe(1); + it('charges the device, not the socket, so reconnecting cannot reset the budget', async () => { + const first = await checkSocketEventRateLimit('send_message', 'device-1'); + const second = await checkSocketEventRateLimit('send_message', 'device-1'); + + expect(first.allowed).toBe(true); + expect(second.allowed).toBe(true); + expect(second.remaining).toBeLessThan(first.remaining); }); - it('sets a 1s expiry on the first increment', async () => { - const redis = { - incr: vi.fn().mockResolvedValue(1), - expire: vi.fn().mockResolvedValue(1), - }; - await checkRateLimit(redis as never, 'socket-1'); - expect(redis.expire).toHaveBeenCalledWith('rl:socket:socket-1', 1); + it('tracks each device independently', async () => { + await checkSocketEventRateLimit('send_message', 'device-1'); + const other = await checkSocketEventRateLimit('send_message', 'device-2'); + + expect(other.remaining).toBe(RATE_LIMIT_DEFAULTS.socket_send_message.limit - 1); }); - it('does not re-set expiry on subsequent increments', async () => { - const redis = { - incr: vi.fn().mockResolvedValue(2), - expire: vi.fn().mockResolvedValue(1), - }; - await checkRateLimit(redis as never, 'socket-1'); - expect(redis.expire).not.toHaveBeenCalled(); + it('routes an event with no dedicated bucket to socket_default', async () => { + const result = await checkSocketEventRateLimit('some_unmapped_event', 'device-1'); + expect(result.limit).toBe(RATE_LIMIT_DEFAULTS.socket_default.limit); }); - it('rejects requests once the configured per-second limit is exceeded', async () => { - vi.stubEnv('SOCKET_RATE_LIMIT_PER_SEC', '5'); - vi.resetModules(); - const { checkRateLimit: checkRateLimitFresh } = await import('../services/rateLimit.js'); - const redis = { - incr: vi.fn().mockResolvedValue(6), - expire: vi.fn().mockResolvedValue(1), - }; - const result = await checkRateLimitFresh(redis as never, 'socket-1'); - expect(result.allowed).toBe(false); - expect(result.count).toBe(6); - vi.unstubAllEnvs(); + it('routes send_file_message to the same bucket as send_message', async () => { + const result = await checkSocketEventRateLimit('send_file_message', 'device-1'); + expect(result.limit).toBe(RATE_LIMIT_DEFAULTS.socket_send_message.limit); }); - it('falls back to the default limit of 10 when env var is unset', async () => { - vi.stubEnv('SOCKET_RATE_LIMIT_PER_SEC', ''); - vi.resetModules(); - const { checkRateLimit: checkRateLimitFresh } = await import('../services/rateLimit.js'); - const redis = { - incr: vi.fn().mockResolvedValue(10), - expire: vi.fn().mockResolvedValue(1), - }; - const result = await checkRateLimitFresh(redis as never, 'socket-1'); - expect(result.allowed).toBe(true); - vi.unstubAllEnvs(); + it('rejects once the bucket limit is exhausted', async () => { + const limit = RATE_LIMIT_DEFAULTS.socket_ask_assistant.limit; + let last = await checkSocketEventRateLimit('ask_assistant', 'device-1'); + for (let i = 1; i < limit; i += 1) { + last = await checkSocketEventRateLimit('ask_assistant', 'device-1'); + } + expect(last.allowed).toBe(true); + + const overflow = await checkSocketEventRateLimit('ask_assistant', 'device-1'); + expect(overflow.allowed).toBe(false); }); }); @@ -138,9 +125,8 @@ describe('checkEnvelopeSizes', () => { it('respects a configured MAX_ENVELOPE_SIZE override', async () => { vi.stubEnv('MAX_ENVELOPE_SIZE', '5'); vi.resetModules(); - const { checkEnvelopeSizes: checkEnvelopeSizesFresh } = await import( - '../services/rateLimit.js' - ); + const { checkEnvelopeSizes: checkEnvelopeSizesFresh } = + await import('../services/rateLimit.js'); const result = checkEnvelopeSizesFresh([ { recipientDeviceId: 'dev-1', ciphertext: 'this-is-too-long' }, ]); diff --git a/apps/backend/src/__tests__/readReceipts-new.test.ts b/apps/backend/src/__tests__/readReceipts-new.test.ts index 4eb27a0..641513e 100644 --- a/apps/backend/src/__tests__/readReceipts-new.test.ts +++ b/apps/backend/src/__tests__/readReceipts-new.test.ts @@ -7,11 +7,10 @@ const mockUserFindFirst = vi.fn(); const mockConversationMemberFindFirst = vi.fn(); const mockMessageFindFirst = vi.fn(); const mockFindMany = vi.fn(); -let dbUpdateMock; -const setMock = vi.fn().mockReturnThis(); const whereMock = vi.fn().mockResolvedValue(undefined); -dbUpdateMock = vi.fn(() => ({ set: setMock, where: whereMock })); +const setMock = vi.fn(() => ({ where: whereMock })); +const dbUpdateMock = vi.fn(() => ({ set: setMock })); vi.mock('../db/index.js', () => ({ db: { @@ -38,14 +37,15 @@ vi.mock('../db/schema.js', () => ({ vi.mock('../lib/redis.js', () => ({ redis: null })); vi.mock('drizzle-orm', () => ({ - and: (...args) => `and(${args.join(', ')})`, - eq: (col, val) => `eq(${col}, ${val})`, - lt: (col, val) => `lt(${col}, ${val})`, - lte: (col, val) => `lte(${col}, ${val})`, - desc: (col) => `desc(${col})`, - sql: (strings, ...values) => `sql(${strings.join('?')}, ${values.join(', ')})`, - inArray: (col, values) => `inArray(${col}, [${values.join(', ')}])`, - isNull: (col) => `isNull(${col})`, + and: (...args: unknown[]) => `and(${args.join(', ')})`, + eq: (col: unknown, val: unknown) => `eq(${col}, ${val})`, + lt: (col: unknown, val: unknown) => `lt(${col}, ${val})`, + lte: (col: unknown, val: unknown) => `lte(${col}, ${val})`, + desc: (col: unknown) => `desc(${col})`, + sql: (strings: TemplateStringsArray, ...values: unknown[]) => + `sql(${strings.join('?')}, ${values.join(', ')})`, + inArray: (col: unknown, values: unknown[]) => `inArray(${col}, [${values.join(', ')}])`, + isNull: (col: unknown) => `isNull(${col})`, })); // ── Mock Socket helpers ──────────────────────────────────────────────────── @@ -83,8 +83,23 @@ function makeIo() { // ── Tests ────────────────────────────────────────────────────────────────── -describe('[NEW] message_read socket event', () => { +// Handlers now run exclusively through the enveloped 'dispatch' path (#342) +// — there's no more raw socket.on(type, ...) listener to grab directly. +let envelopeSeq = 0; +function dispatchEvent(socket: EventEmitter, type: string) { + return async (payload: unknown) => { + envelopeSeq += 1; + EventEmitter.prototype.emit.call(socket, 'dispatch', { + eventId: `test-evt-${envelopeSeq}`, + type, + timestamp: Date.now(), + payload, + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + }; +} +describe('[NEW] message_read socket event', () => { beforeEach(() => { vi.resetAllMocks(); setMock.mockClear(); @@ -107,7 +122,7 @@ describe('[NEW] message_read socket event', () => { const { registerMessagingHandlers } = await import('../socket/messaging.js'); registerMessagingHandlers(io as never, socket as never); - const handler = (socket as EventEmitter).listeners('message_read')[0] as (p: any) => Promise; + const handler = dispatchEvent(socket, 'message_read'); await handler({ conversationId, lastReadMessageId }); expect(io.to).not.toHaveBeenCalled(); @@ -122,8 +137,13 @@ describe('[NEW] message_read socket event', () => { const attemptedReadId = 'msg-5'; mockUserFindFirst.mockResolvedValue({ id: userId, sendReadReceipts: true }); - mockConversationMemberFindFirst.mockResolvedValue({ id: 'cm-2', userId, conversationId, lastReadMessageId: currentReadId }); - + mockConversationMemberFindFirst.mockResolvedValue({ + id: 'cm-2', + userId, + conversationId, + lastReadMessageId: currentReadId, + }); + const newerDate = new Date(); const olderDate = new Date(newerDate.getTime() - 1000); @@ -135,8 +155,8 @@ describe('[NEW] message_read socket event', () => { const io = makeIo(); const { registerMessagingHandlers } = await import('../socket/messaging.js'); registerMessagingHandlers(io as never, socket as never); - - const handler = (socket as EventEmitter).listeners('message_read')[0] as (p: any) => Promise; + + const handler = dispatchEvent(socket, 'message_read'); await handler({ conversationId, lastReadMessageId: attemptedReadId }); expect(dbUpdateMock).not.toHaveBeenCalled(); @@ -162,14 +182,13 @@ describe('[NEW] message_read socket event', () => { const { registerMessagingHandlers } = await import('../socket/messaging.js'); registerMessagingHandlers(io as never, socket as never); - const handler = (socket as EventEmitter).listeners('message_read')[0] as (p: any) => Promise; + const handler = dispatchEvent(socket, 'message_read'); await handler({ conversationId, lastReadMessageId }); - // conversationMembers update + messageEnvelopes update expect(dbUpdateMock).toHaveBeenCalledTimes(2); - - const updateCall = dbUpdateMock.mock.calls[1]; - const setCall = setMock.mock.calls[1][0]; + + const updateCall = dbUpdateMock.mock.calls[1] as unknown[]; + const setCall = (setMock.mock.calls[1] as unknown[])[0] as { readAt: unknown }; expect(updateCall[0]).toBe('messageEnvelopes'); expect(setCall.readAt).toBeInstanceOf(Date); diff --git a/apps/backend/src/__tests__/readReceipts.test.ts b/apps/backend/src/__tests__/readReceipts.test.ts index 230a859..8c561c6 100644 --- a/apps/backend/src/__tests__/readReceipts.test.ts +++ b/apps/backend/src/__tests__/readReceipts.test.ts @@ -155,9 +155,12 @@ describe('message_read socket event', () => { const conversationId = 'conv-privacy'; const lastReadMessageId = 'msg-privacy'; - mockFindFirst - .mockResolvedValueOnce({ id: 'membership-1', userId, conversationId }) - .mockResolvedValueOnce({ id: lastReadMessageId, conversationId }); + mockConversationMemberFindFirst.mockResolvedValueOnce({ + id: 'membership-1', + userId, + conversationId, + }); + mockMessageFindFirst.mockResolvedValueOnce({ id: lastReadMessageId, conversationId }); mockUserFindFirst.mockResolvedValueOnce({ sendReadReceipts: false }); const setFn = vi.fn().mockReturnThis(); @@ -171,15 +174,13 @@ describe('message_read socket event', () => { const { registerMessagingHandlers } = await import('../socket/messaging.js'); registerMessagingHandlers(io as never, socket as never); - const handler = (socket as EventEmitter).listeners('message_read')[0] as ( - p: unknown, - ) => Promise; + const handler = (payload: unknown) => dispatchEnvelope(socket, 'message_read', payload); await handler({ conversationId, lastReadMessageId }); - expect(mockUpdate).toHaveBeenCalledTimes(2); // once for member, once for envelopes + // The cursor still advances — only the fan-out is suppressed. expect(setFn).toHaveBeenCalledWith({ lastReadMessageId }); - expect(io.to).toHaveBeenCalledWith(conversationId); - expect(io.roomEmitted[0].event).toBe('read_receipt'); + expect(io.to).not.toHaveBeenCalled(); + expect(io.roomEmitted).toHaveLength(0); }); it('emits error when caller is not a conversation member', async () => { @@ -256,6 +257,7 @@ describe('message_read socket event', () => { conversationId, }); mockMessageFindFirst.mockResolvedValueOnce(lastReadMessage); + mockFindMany.mockResolvedValueOnce([{ id: lastReadMessageId }]); const setFn = vi.fn().mockReturnThis(); const whereFn = vi.fn().mockResolvedValue(undefined); @@ -267,9 +269,7 @@ describe('message_read socket event', () => { const { registerMessagingHandlers } = await import('../socket/messaging.js'); registerMessagingHandlers(io as never, socket as never); - const handler = (socket as EventEmitter).listeners('message_read')[0] as ( - p: unknown, - ) => Promise; + const handler = (payload: unknown) => dispatchEnvelope(socket, 'message_read', payload); await handler({ conversationId, lastReadMessageId }); expect(mockUpdate).toHaveBeenCalledTimes(2); // member and envelopes are still updated @@ -306,9 +306,7 @@ describe('message_read socket event', () => { const io = makeIo(); const { registerMessagingHandlers } = await import('../socket/messaging.js'); registerMessagingHandlers(io as never, socket as never); - const handler = (socket as EventEmitter).listeners('message_read')[0] as ( - p: unknown, - ) => Promise; + const handler = (payload: unknown) => dispatchEnvelope(socket, 'message_read', payload); await handler({ conversationId, lastReadMessageId: 'msg-old' }); expect(mockUpdate).not.toHaveBeenCalled(); @@ -345,9 +343,7 @@ describe('message_read socket event', () => { const { registerMessagingHandlers } = await import('../socket/messaging.js'); registerMessagingHandlers(io as never, socket as never); - const handler = (socket as EventEmitter).listeners('message_read')[0] as ( - p: unknown, - ) => Promise; + const handler = (payload: unknown) => dispatchEnvelope(socket, 'message_read', payload); await handler({ conversationId, lastReadMessageId }); // one update for conversationMembers, one for messageEnvelopes @@ -359,12 +355,14 @@ describe('message_read socket event', () => { const whereCall = whereFn.mock.calls[1]; expect(secondUpdateCall).toBeDefined(); - expect(setCall[0].readAt).toBeInstanceOf(Date); - expect(whereCall[0]).toEqual( + expect((setCall![0] as { readAt: unknown }).readAt).toBeInstanceOf(Date); + // The column stubs are opaque here; what matters is which values the + // envelope update is scoped to. + expect(whereCall![0]).toEqual( expect.arrayContaining([ - { col: {}, val: deviceId }, - { col: {}, vals: ['msg-98', 'msg-99'] }, - { col: {}, op: 'isNull' }, + expect.objectContaining({ val: deviceId }), + expect.objectContaining({ vals: ['msg-98', 'msg-99'] }), + expect.objectContaining({ op: 'isNull' }), ]), ); }); diff --git a/apps/backend/src/__tests__/roomManager.test.ts b/apps/backend/src/__tests__/roomManager.test.ts index 4a7f671..53a7d3f 100644 --- a/apps/backend/src/__tests__/roomManager.test.ts +++ b/apps/backend/src/__tests__/roomManager.test.ts @@ -168,8 +168,8 @@ describe('Room Manager', () => { await rebuildRoomsAfterRestart(mockIo); // Verify each socket joined user room - expect(mockSockets[0].join).toHaveBeenCalledWith('room:user:user-123'); - expect(mockSockets[1].join).toHaveBeenCalledWith('room:user:user-124'); + expect(mockSockets[0]!.join).toHaveBeenCalledWith('room:user:user-123'); + expect(mockSockets[1]!.join).toHaveBeenCalledWith('room:user:user-124'); // Verify conversation rooms were joined (mockFindMany returns conversations) expect(mockFindMany).toHaveBeenCalledTimes(2); diff --git a/apps/backend/src/__tests__/selfSync.test.ts b/apps/backend/src/__tests__/selfSync.test.ts index 6b7477b..1a10598 100644 --- a/apps/backend/src/__tests__/selfSync.test.ts +++ b/apps/backend/src/__tests__/selfSync.test.ts @@ -80,6 +80,12 @@ vi.mock('../lib/conversationCache.js', () => ({ vi.mock('../lib/redis.js', () => ({ redis: null })); +// Protocol negotiation has its own suite (e2eeProtocol.test.ts); stubbed here +// so these tests stay about sibling-device envelope coverage. +vi.mock('../services/e2eeProtocol.js', () => ({ + checkEnvelopeProtocols: vi.fn().mockResolvedValue({ ok: true }), +})); + vi.mock('../services/pushNotification.js', () => ({ dispatchOfflinePush: vi.fn().mockResolvedValue(undefined), FILE_CONTENT_TYPES: new Set(), diff --git a/apps/backend/src/__tests__/signalInvariants.devices.test.ts b/apps/backend/src/__tests__/signalInvariants.devices.test.ts index be1fe0e..f51ec1b 100644 --- a/apps/backend/src/__tests__/signalInvariants.devices.test.ts +++ b/apps/backend/src/__tests__/signalInvariants.devices.test.ts @@ -101,27 +101,41 @@ beforeEach(() => { vi.clearAllMocks(); }); -describe('POST /devices — session/private-key state rejection', () => { +// Device registration moved behind a fresh-wallet-signature challenge (#233): +// POST /devices itself is now a hard 403 pointing at the link flow, so the +// payload invariant is enforced on POST /devices/link/verify, the route that +// actually creates the device row. +describe('POST /devices/link/verify — session/private-key state rejection', () => { + const LINK_BODY = { ...VALID_DEVICE_BODY, signature: 'sig', nonce: 'nonce' }; + it('rejects an unrecognized top-level field with 400', async () => { const res = await request(makeApp()) - .post('/devices') - .send({ ...VALID_DEVICE_BODY, sessionState: 'opaque-session-blob' }); + .post('/devices/link/verify') + .send({ ...LINK_BODY, sessionState: 'opaque-session-blob' }); expect(res.status).toBe(400); - expect(mockDeviceFindFirst).not.toHaveBeenCalled(); expect(mockInsert).not.toHaveBeenCalled(); }); it('rejects a private-key field with 400', async () => { const res = await request(makeApp()) - .post('/devices') - .send({ ...VALID_DEVICE_BODY, identityPrivateKey: 'should-never-leave-the-client' }); + .post('/devices/link/verify') + .send({ ...LINK_BODY, identityPrivateKey: 'should-never-leave-the-client' }); expect(res.status).toBe(400); expect(mockInsert).not.toHaveBeenCalled(); }); }); +describe('POST /devices — retired in favour of the link flow', () => { + it('refuses to register a device without a fresh wallet signature', async () => { + const res = await request(makeApp()).post('/devices').send(VALID_DEVICE_BODY); + + expect(res.status).toBe(403); + expect(mockInsert).not.toHaveBeenCalled(); + }); +}); + describe('POST /devices/:id/prekeys — session/private-key state rejection', () => { it('rejects an unrecognized top-level field with 400', async () => { mockDeviceFindFirst.mockResolvedValue(ACTIVE_DEVICE); @@ -172,9 +186,7 @@ describe('POST /devices/:id/prekeys — session/private-key state rejection', () const values = vi.fn().mockReturnValue({ onConflictDoUpdate, onConflictDoNothing }); mockInsert.mockReturnValue({ values }); - const res = await request(makeApp()) - .post('/devices/device-1/prekeys') - .send(VALID_PREKEYS_BODY); + const res = await request(makeApp()).post('/devices/device-1/prekeys').send(VALID_PREKEYS_BODY); expect(res.status).toBe(200); }); diff --git a/apps/backend/src/__tests__/signalInvariants.socket.test.ts b/apps/backend/src/__tests__/signalInvariants.socket.test.ts index 7aaa778..6118e64 100644 --- a/apps/backend/src/__tests__/signalInvariants.socket.test.ts +++ b/apps/backend/src/__tests__/signalInvariants.socket.test.ts @@ -46,6 +46,18 @@ vi.mock('../lib/conversationCache.js', () => ({ vi.mock('../lib/redis.js', () => ({ redis: null })); +// The protocol/fan-out checks have their own suites; here they're stubbed so +// these tests stay about the session-state rejection invariant. +vi.mock('../services/e2eeProtocol.js', () => ({ + checkEnvelopeProtocols: vi.fn().mockResolvedValue({ ok: true }), +})); + +vi.mock('../services/mlsGroups.js', () => ({ + getConversationEpochWindow: vi.fn().mockResolvedValue({ hasGroup: false, window: null }), + getGroupByConversation: vi.fn().mockResolvedValue(null), + isActiveMember: vi.fn().mockResolvedValue(false), +})); + vi.mock('../services/pushNotification.js', () => ({ dispatchOfflinePush: vi.fn().mockResolvedValue(undefined), FILE_CONTENT_TYPES: new Set(), @@ -95,10 +107,22 @@ function makeIo() { }; } +// Handlers now run exclusively through the enveloped 'dispatch' path (#342) +// — there's no more raw socket.on(type, ...) listener to grab directly. +let envelopeSeq = 0; async function getHandler(eventName: string, socket: EventEmitter, io: unknown) { const { registerMessagingHandlers } = await import('../socket/messaging.js'); registerMessagingHandlers(io as never, socket as never); - return socket.listeners(eventName)[0] as (p: unknown) => Promise; + return async (payload: unknown) => { + envelopeSeq += 1; + EventEmitter.prototype.emit.call(socket, 'dispatch', { + eventId: `test-evt-${envelopeSeq}`, + type: eventName, + timestamp: Date.now(), + payload, + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + }; } const USER_ID = 'sender-1'; diff --git a/apps/backend/src/__tests__/uploads.test.ts b/apps/backend/src/__tests__/uploads.test.ts index 61a5ae0..cd26c6d 100644 --- a/apps/backend/src/__tests__/uploads.test.ts +++ b/apps/backend/src/__tests__/uploads.test.ts @@ -23,6 +23,7 @@ const mockFileFindFirst = vi.fn(); const mockInsert = vi.fn(); const mockUpdate = vi.fn(); const mockVerifyFileIntegrity = vi.fn(); +const mockHeadObject = vi.fn(); vi.mock('../db/index.js', () => ({ db: { @@ -65,6 +66,12 @@ vi.mock('../lib/fileIntegrity.js', () => ({ verifyFileIntegrity: mockVerifyFileIntegrity, })); +// Confirm verifies the object is actually present at the expected size before +// flipping status to 'ready' (#356), so the store is stubbed rather than hit. +vi.mock('../lib/objectStore.js', () => ({ + getObjectStore: () => ({ headObject: mockHeadObject }), +})); + vi.mock('../middleware/auth.js', () => ({ requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { (req as express.Request & { auth?: { userId: string; deviceId: string } }).auth = { @@ -114,6 +121,7 @@ function mockSuccessfulIntegrityCheck() { expectedHash: 'abc123', computedHash: 'abc123', }); + mockHeadObject.mockResolvedValue({ exists: true, size: 1024 }); } // ── Tests ───────────────────────────────────────────────────────────────────── @@ -190,7 +198,7 @@ describe('POST /uploads — issue #226', () => { await request(app).post('/uploads').send(VALID_BODY); - const insertedValues = valuesSpy.mock.calls[0][0] as Record; + const insertedValues = valuesSpy.mock.calls[0]![0] as Record; expect(insertedValues.status).toBe('pending'); }); @@ -227,7 +235,9 @@ describe('POST /uploads/:fileId/confirm', () => { id: 'file-001', uploaderId: 'user-abc', status: 'pending', + size: 1024, sha256: 'abc123', + storageKey: 'uploads/conv-123/abc123def456', }); mockHeadObject.mockResolvedValueOnce({ exists: true, size: 1024 }); mockUpdate.mockReturnValueOnce({ @@ -252,7 +262,9 @@ describe('POST /uploads/:fileId/confirm', () => { id: 'file-001', uploaderId: 'someone-else', status: 'pending', + size: 1024, sha256: 'abc123', + storageKey: 'uploads/conv-123/abc123def456', }); const res = await request(app).post('/uploads/file-001/confirm').send({ sha256: 'abc123' }); expect(res.status).toBe(403); @@ -263,7 +275,9 @@ describe('POST /uploads/:fileId/confirm', () => { id: 'file-001', uploaderId: 'user-abc', status: 'ready', + size: 1024, sha256: 'abc123', + storageKey: 'uploads/conv-123/abc123def456', }); const res = await request(app).post('/uploads/file-001/confirm').send({ sha256: 'abc123' }); expect(res.status).toBe(409); @@ -274,7 +288,9 @@ describe('POST /uploads/:fileId/confirm', () => { id: 'file-001', uploaderId: 'user-abc', status: 'deleted', + size: 1024, sha256: 'abc123', + storageKey: 'uploads/conv-123/abc123def456', }); const res = await request(app).post('/uploads/file-001/confirm').send({ sha256: 'abc123' }); expect(res.status).toBe(409); @@ -285,7 +301,9 @@ describe('POST /uploads/:fileId/confirm', () => { id: 'file-001', uploaderId: 'user-abc', status: 'pending', + size: 1024, sha256: 'abc123', + storageKey: 'uploads/conv-123/abc123def456', }); const res = await request(app).post('/uploads/file-001/confirm').send({}); @@ -298,7 +316,9 @@ describe('POST /uploads/:fileId/confirm', () => { id: 'file-001', uploaderId: 'user-abc', status: 'pending', + size: 1024, sha256: 'abc123', + storageKey: 'uploads/conv-123/abc123def456', }); const res = await request(app).post('/uploads/file-001/confirm').send({ sha256: 'mismatch' }); @@ -314,6 +334,7 @@ describe('POST /uploads/:fileId/confirm', () => { id: 'file-001', uploaderId: 'user-abc', status: 'pending', + size: 1024, sha256: 'abc123', storageKey: 'uploads/conv-123/abc123def456', }); @@ -360,7 +381,7 @@ describe('Thumbnail handling — issue #230', () => { .post('/uploads') .send({ ...VALID_BODY, mimeType: 'image/jpeg', isThumbnail: true }); expect(res.status).toBe(201); - const inserted = valuesSpy.mock.calls[0][0] as Record; + const inserted = valuesSpy.mock.calls[0]![0] as Record; expect(inserted.isThumbnail).toBe(true); }); @@ -373,7 +394,7 @@ describe('Thumbnail handling — issue #230', () => { }); await request(app).post('/uploads').send(VALID_BODY); - const inserted = valuesSpy.mock.calls[0][0] as Record; + const inserted = valuesSpy.mock.calls[0]![0] as Record; expect(inserted.isThumbnail).toBe(false); }); diff --git a/apps/backend/src/__tests__/users.bundle.test.ts b/apps/backend/src/__tests__/users.bundle.test.ts index 0f37680..2f620c4 100644 --- a/apps/backend/src/__tests__/users.bundle.test.ts +++ b/apps/backend/src/__tests__/users.bundle.test.ts @@ -194,21 +194,29 @@ describe('GET /users/:userId/devices/:deviceId/key-bundle', () => { const claimed = { id: 'otp-row-1', keyId: 10, publicKey: 'otp-pub' }; let locked = false; const updateWhere = vi.fn().mockResolvedValue(undefined); + // Two selects run inside the claim transaction: the locking OTP claim + // (…orderBy().limit().for('update', {skipLocked})) and the remaining-count + // query, which is awaited straight off where(). So where() has to be both + // chainable and thenable. + const remainingRows = [{ total: 4 }]; const tx = { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ orderBy: vi.fn().mockReturnValue({ limit: vi.fn().mockReturnValue({ - for: vi.fn().mockImplementation(async (_mode: string, options: { skipLocked?: boolean }) => { - expect(_mode).toBe('update'); - expect(options).toEqual({ skipLocked: true }); - if (locked) return []; - locked = true; - return [claimed]; - }), + for: vi + .fn() + .mockImplementation(async (_mode: string, options: { skipLocked?: boolean }) => { + expect(_mode).toBe('update'); + expect(options).toEqual({ skipLocked: true }); + if (locked) return []; + locked = true; + return [claimed]; + }), }), }), + then: (resolve: (value: unknown) => void) => resolve(remainingRows), }), }), }), @@ -223,7 +231,9 @@ describe('GET /users/:userId/devices/:deviceId/key-bundle', () => { expect(firstRes.status).toBe(200); expect(secondRes.status).toBe(200); - expect([firstRes.body.oneTimePreKey, secondRes.body.oneTimePreKey].filter(Boolean)).toHaveLength(1); + expect( + [firstRes.body.oneTimePreKey, secondRes.body.oneTimePreKey].filter(Boolean), + ).toHaveLength(1); expect(updateWhere).toHaveBeenCalledTimes(1); }); diff --git a/apps/backend/src/__tests__/users.test.ts b/apps/backend/src/__tests__/users.test.ts index 8266d45..d8f2e66 100644 --- a/apps/backend/src/__tests__/users.test.ts +++ b/apps/backend/src/__tests__/users.test.ts @@ -313,19 +313,17 @@ describe('PATCH /users/me', () => { lastSeenVisible: false, } as any); - const mockReturning = vi - .fn() - .mockResolvedValue([ - { - id: 'auth-user-id', - username: 'alice', - presenceVisible: false, - lastSeenVisible: false, - sendReadReceipts: false, - allowDirectMessages: true, - allowGroupInvites: false, - }, - ]); + const mockReturning = vi.fn().mockResolvedValue([ + { + id: 'auth-user-id', + username: 'alice', + presenceVisible: false, + lastSeenVisible: false, + sendReadReceipts: false, + allowDirectMessages: true, + allowGroupInvites: false, + }, + ]); const mockWhere = vi.fn(() => ({ returning: mockReturning })); const mockSet = vi.fn(() => ({ where: mockWhere })); vi.mocked(db.update).mockReturnValue({ set: mockSet } as never); @@ -361,16 +359,13 @@ describe('PATCH /users/me', () => { const mockSet = vi.fn(() => ({ where: mockWhere })); vi.mocked(db.update).mockReturnValue({ set: mockSet } as never); - const res = await request(app) - .patch('/users/me') - .set('Authorization', AUTH_HEADER) - .send({ - presenceVisible: true, - lastSeenVisible: true, - sendReadReceipts: true, - allowDirectMessages: false, - allowGroupInvites: true, - }); + const res = await request(app).patch('/users/me').set('Authorization', AUTH_HEADER).send({ + presenceVisible: true, + lastSeenVisible: true, + sendReadReceipts: true, + allowDirectMessages: false, + allowGroupInvites: true, + }); expect(res.status).toBe(200); expect(mockSet).toHaveBeenCalledWith( diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 7039ad8..6fcd9b5 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -18,8 +18,12 @@ import { syncRouter } from './routes/sync.js'; import { userDevicesRouter } from './routes/userDevices.js'; import { mlsRouter } from './routes/mls.js'; import { localStorageRouter } from './routes/localStorage.js'; +import { securityRouter } from './routes/security.js'; import { requireAuth, type AuthRequest } from './middleware/auth.js'; import { registry } from './lib/metrics.js'; +import { trustProxyHops, isOriginAllowed, allowedOrigins } from './lib/transportSecurity.js'; +import { enforceTransportSecurity, enforceOriginPolicy } from './middleware/transportSecurity.js'; +import { rateLimit, ipIdentifier } from './middleware/rateLimit.js'; const packageJson = JSON.parse( readFileSync(new URL('../package.json', import.meta.url), 'utf8'), @@ -88,6 +92,14 @@ app.use('/sync', syncRouter); app.use('/user-devices', userDevicesRouter); app.use('/security', securityRouter); +// #330 — dev/test only: serves the fs-backed object store so presigned URLs +// issued locally are real, working URLs. Deliberately outside requireAuth +// (a presigned URL carries its own HMAC + expiry, exactly like S3), and never +// mounted in production, where the real object store answers these requests. +if (process.env['NODE_ENV'] !== 'production') { + app.use('/local-storage', localStorageRouter); +} + // #393 — Prometheus scrape endpoint. Never includes message content: only // counters/histograms defined in lib/metrics.ts, which take no ciphertext // or free-text labels. diff --git a/apps/backend/src/config/rateLimits.ts b/apps/backend/src/config/rateLimits.ts index aa968ff..d3b33c2 100644 --- a/apps/backend/src/config/rateLimits.ts +++ b/apps/backend/src/config/rateLimits.ts @@ -48,6 +48,18 @@ export const RATE_LIMIT_DEFAULTS = { windowSeconds: MINUTE, description: 'Signature verification attempts', }, + // Mirrors the auth challenge/verify limits but in separate buckets so + // hammering the device-link flow cannot lock out sign-in. + device_link_challenge: { + limit: 10, + windowSeconds: MINUTE, + description: 'Device-link challenge nonce issuance', + }, + device_link_verify: { + limit: 5, + windowSeconds: MINUTE, + description: 'Device-link signature verification attempts', + }, // ── HTTP: authenticated ──────────────────────────────────────────────────── key_bundle: { diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index cc36f2b..6c2de64 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -680,6 +680,44 @@ export const auditLogs = pgTable( export type AuditLog = typeof auditLogs.$inferSelect; export type NewAuditLog = typeof auditLogs.$inferInsert; +export const groupControlEventTypeEnum = pgEnum('group_control_event_type', [ + 'member_added', + 'member_removed', + 'member_left', + 'commit', +]); + +export type GroupControlEventType = (typeof groupControlEventTypeEnum.enumValues)[number]; + +export const groupControlEvents = pgTable( + 'group_control_events', + { + id: uuid('id').primaryKey().defaultRandom(), + conversationId: uuid('conversation_id') + .notNull() + .references(() => conversations.id, { onDelete: 'cascade' }), + /** Strictly increasing from 1, gap-free within a conversation. */ + sequence: integer('sequence').notNull(), + /** Group epoch after this event was applied. */ + epoch: integer('epoch').notNull(), + eventType: groupControlEventTypeEnum('event_type').notNull(), + actorUserId: uuid('actor_user_id').references(() => users.id, { onDelete: 'set null' }), + targetUserId: uuid('target_user_id').references(() => users.id, { onDelete: 'set null' }), + /** The system message emitted for this event, when one was created. */ + messageId: uuid('message_id').references(() => messages.id, { onDelete: 'set null' }), + /** Opaque client-supplied MLS commit/welcome material. Never parsed here. */ + payload: text('payload'), + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + (table) => [ + // Both the gap-free guarantee and the catch-up read path. + uniqueIndex('group_control_conversation_sequence_idx').on(table.conversationId, table.sequence), + ], +); + +export type GroupControlEvent = typeof groupControlEvents.$inferSelect; +export type NewGroupControlEvent = typeof groupControlEvents.$inferInsert; + // ─── Relations ──────────────────────────────────────────────────────────────── export const usersRelations = relations(users, ({ many }) => ({ @@ -702,6 +740,7 @@ export const conversationsRelations = relations(conversations, ({ one, many }) = treasuryProposals: many(treasuryProposals), files: many(files), mlsGroup: one(mlsGroups), + groupControlEvents: many(groupControlEvents), })); export const mlsGroupsRelations = relations(mlsGroups, ({ one, many }) => ({ @@ -731,7 +770,6 @@ export const mlsCommitsRelations = relations(mlsCommits, ({ one }) => ({ export const mlsWelcomesRelations = relations(mlsWelcomes, ({ one }) => ({ group: one(mlsGroups, { fields: [mlsWelcomes.mlsGroupId], references: [mlsGroups.id] }), device: one(devices, { fields: [mlsWelcomes.deviceId], references: [devices.id] }), - groupControlEvents: many(groupControlEvents), })); export const groupControlEventsRelations = relations(groupControlEvents, ({ one }) => ({ diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 296d533..78e2447 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -229,6 +229,7 @@ io.on('connection', async (socket: AuthSocket) => { columns: { presenceVisible: true, lastSeenVisible: true }, }); const presenceVisible = connectUser?.presenceVisible ?? true; + const lastSeenVisible = connectUser?.lastSeenVisible ?? false; if (becameOnline && presenceVisible && !cancelledPendingOffline) { for (const m of memberships) { io.to(conversationRoom(m.conversationId)).emit('user_online', { userId }); @@ -344,20 +345,23 @@ io.on('connection', async (socket: AuthSocket) => { }); const { lastSeen } = await deriveDevicePresence(userId); + // Honour the per-user last-seen setting: a user may be visible as + // offline without disclosing *when* they were last around. + const lastSeenField = lastSeenVisible && lastSeen ? { lastSeen } : {}; for (const m of memberships) { io.to(conversationRoom(m.conversationId)).emit('user_offline', { userId }); io.to(conversationRoom(m.conversationId)).emit('presence_update', { userId, online: false, - ...(lastSeen ? { lastSeen } : {}), + ...lastSeenField, }); // Also emit to direct conversation room for backward compatibility io.to(m.conversationId).emit('user_offline', { userId }); io.to(m.conversationId).emit('presence_update', { userId, online: false, - ...(lastSeen ? { lastSeen } : {}), + ...lastSeenField, }); } await recordPresenceForCoMembers( diff --git a/apps/backend/src/lib/ciphertextInvariant.ts b/apps/backend/src/lib/ciphertextInvariant.ts index b3067fa..dbc6f0f 100644 --- a/apps/backend/src/lib/ciphertextInvariant.ts +++ b/apps/backend/src/lib/ciphertextInvariant.ts @@ -22,8 +22,7 @@ export const FORBIDDEN_PERSISTED_OR_UPLOADED_FIELDS = [ 'ratchet_state', ] as const; -const normaliseFieldName = (field: string): string => - field.replace(/[-_]/g, '').toLowerCase(); +const normaliseFieldName = (field: string): string => field.replace(/[-_]/g, '').toLowerCase(); const FORBIDDEN_NORMALISED_FIELDS = new Set( FORBIDDEN_PERSISTED_OR_UPLOADED_FIELDS.map(normaliseFieldName), diff --git a/apps/backend/src/lib/fileIntegrity.ts b/apps/backend/src/lib/fileIntegrity.ts index 8b939e5..0d94ddc 100644 --- a/apps/backend/src/lib/fileIntegrity.ts +++ b/apps/backend/src/lib/fileIntegrity.ts @@ -16,6 +16,17 @@ export interface IntegrityCheckResult { error?: string; } +/** + * `ObjectStoreLike.getObject` is deliberately typed `unknown` because the S3 + * SDK response and the local disk store's plain object differ in optionality. + * Both expose the two fields this module needs, so narrow to just those. + */ +type ObjectBody = { Body?: unknown; ContentLength?: number }; + +function asObjectBody(response: unknown): ObjectBody { + return (response ?? {}) as ObjectBody; +} + /** * Compute SHA-256 hash of a stream (streaming hashing for large files). * @@ -68,7 +79,7 @@ export async function verifyFileIntegrity( const store = getObjectStore(); // Fetch the object from storage - const response = await store.getObject(storageKey); + const response = asObjectBody(await store.getObject(storageKey)); if (!response.Body) { return { @@ -110,7 +121,7 @@ export async function verifyFileIntegrity( export async function verifyFileSize(storageKey: string, expectedSize: number): Promise { try { const store = getObjectStore(); - const response = await store.getObject(storageKey); + const response = asObjectBody(await store.getObject(storageKey)); if (!response.Body) { return false; diff --git a/apps/backend/src/lib/localObjectStore.ts b/apps/backend/src/lib/localObjectStore.ts index f7b3e34..ac26f22 100644 --- a/apps/backend/src/lib/localObjectStore.ts +++ b/apps/backend/src/lib/localObjectStore.ts @@ -101,6 +101,15 @@ export class LocalDiskObjectStore implements ObjectStoreLike { await rm(metaPath(key), { force: true }); } + async headObject(key: string): Promise<{ exists: boolean; size?: number }> { + try { + const info = await stat(resolvePath(key)); + return { exists: true, size: info.size }; + } catch { + return { exists: false }; + } + } + async getPresignedPutUrl( key: string, _contentType: string | undefined, diff --git a/apps/backend/src/lib/messages.ts b/apps/backend/src/lib/messages.ts index bba3e61..d907958 100644 --- a/apps/backend/src/lib/messages.ts +++ b/apps/backend/src/lib/messages.ts @@ -22,6 +22,9 @@ export function serializeMessage( ciphertext: string | null; unavailable?: boolean; } { + // `content` is pulled out purely so a legacy plaintext column can never + // survive into a serialized response; it is intentionally discarded. + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { deletedAt, envelopes, ciphertext: baseCiphertext, content, ...rest } = message; if (deletedAt) { diff --git a/apps/backend/src/lib/objectStore.ts b/apps/backend/src/lib/objectStore.ts index 8e9e19f..ea54233 100644 --- a/apps/backend/src/lib/objectStore.ts +++ b/apps/backend/src/lib/objectStore.ts @@ -23,6 +23,8 @@ export interface ObjectStoreLike { // nothing in this codebase consumes `getObject()` polymorphically today. getObject(key: string): Promise; deleteObject(key: string): Promise; + /** Existence + size check used by upload-confirm verification (#356). */ + headObject(key: string): Promise<{ exists: boolean; size?: number }>; getPresignedPutUrl( key: string, contentType: string | undefined, diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index 8a4c962..c35ba29 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -158,7 +158,9 @@ authRouter.post( ...(registrationId !== undefined ? { registrationId } : {}), // A client re-verifying with a newer `capabilities` set is the // "upgrade" path (#180-follow-on) — no re-registration needed. - ...(capabilities !== undefined ? { capabilities: normalizeCapabilities(capabilities) } : {}), + ...(capabilities !== undefined + ? { capabilities: normalizeCapabilities(capabilities) } + : {}), }) .where(eq(devices.id, deviceId)); } else { @@ -171,7 +173,9 @@ authRouter.post( platform: platform ?? null, registrationId: registrationId ?? null, lastSeenAt: new Date(), - ...(capabilities !== undefined ? { capabilities: normalizeCapabilities(capabilities) } : {}), + ...(capabilities !== undefined + ? { capabilities: normalizeCapabilities(capabilities) } + : {}), }) .returning({ id: devices.id }); if (!newDevice) { diff --git a/apps/backend/src/routes/conversations.ts b/apps/backend/src/routes/conversations.ts index a41a486..28a9a55 100644 --- a/apps/backend/src/routes/conversations.ts +++ b/apps/backend/src/routes/conversations.ts @@ -23,6 +23,7 @@ import { messageEnvelopes, devices, users, + type Conversation, } from '../db/schema.js'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { redis, CONV_CACHE_TTL, convCacheKey } from '../lib/redis.js'; @@ -34,6 +35,17 @@ import { applyMlsVisibility } from '../lib/mlsVisibility.js'; import { getConversationEpochWindow } from '../services/mlsGroups.js'; import { checkGroupInviteLimit } from '../services/rateLimit.js'; import { actorFromRequest, recordAuditEvent } from '../services/auditLog.js'; +import { + appendGroupControlEvent, + broadcastGroupControlEvent, + getGroupState, + readGroupControlEvents, + serializeGroupControlEvent, + DEFAULT_GROUP_CONTROL_PAGE_SIZE, + MAX_GROUP_CONTROL_PAGE_SIZE, + MAX_GROUP_CONTROL_PAYLOAD_BYTES, +} from '../services/groupControl.js'; +import { normalizeCapabilities, selectProtocol } from '../lib/capabilities.js'; export const conversationsRouter: IRouter = Router(); @@ -640,7 +652,10 @@ conversationsRouter.get('/:id/messages', async (req: AuthRequest, res) => { const visible = hasGroup ? page.map((message) => applyMlsVisibility(message, window)) : page; - res.json({ messages: visible, nextCursor }); + // #336 — run every row through the same serializer `GET /:id` uses, so the + // shape matches across endpoints and a message this device holds no envelope + // for is explicitly marked `unavailable` instead of arriving as a silent null. + res.json({ messages: visible.map((message) => serializeMessage(message)), nextCursor }); }); conversationsRouter.get('/:id/search', async (req: AuthRequest, res) => { diff --git a/apps/backend/src/routes/devices.ts b/apps/backend/src/routes/devices.ts index 47e08de..ebf6d7a 100644 --- a/apps/backend/src/routes/devices.ts +++ b/apps/backend/src/routes/devices.ts @@ -8,8 +8,8 @@ */ import { createHash } from 'node:crypto'; -import { Router, type Router as RouterType } from 'express'; -import rateLimit, { type RateLimitRequestHandler } from 'express-rate-limit'; +import { Router, type Router as RouterType, type RequestHandler } from 'express'; +import { rateLimit } from '../middleware/rateLimit.js'; import { Keypair } from '@stellar/stellar-sdk'; import { eq, and, ne, count, desc, isNull, inArray, sql } from 'drizzle-orm'; import { z } from 'zod'; @@ -25,6 +25,7 @@ import { import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { validate } from '../middleware/validate.js'; import { DeviceLinkVerifySchema, type DeviceLinkVerifyBody } from '../schemas/auth.schemas.js'; +import { normalizeCapabilities } from '../lib/capabilities.js'; import { createDeviceLinkNonce, consumeDeviceLinkNonce } from '../lib/nonce.js'; import { getSocketServer } from '../lib/socket.js'; import { invalidateConversationCaches } from '../lib/conversationCache.js'; @@ -65,8 +66,6 @@ const UploadPreKeysSchema = z }) .strict(); -const RegisterDeviceSchema = DeviceSchema; - /** * MLS key package batch upload. `keyPackage` is validated as base64 of a * 32–4096 byte TLS-serialised KeyPackage by the shared key validator; the @@ -92,26 +91,12 @@ const UploadMlsKeyPackagesSchema = z.object({ const OTP_CAP = 200; // ─── Device-link rate limiters ──────────────────────────────────────────────── -// Mirrors the auth challenge/verify limiters (src/routes/auth.ts). Kept as -// separate instances so hammering the link flow cannot lock out sign-in. - -const rateLimitedResponse = { error: 'Too many requests' }; +// Mirrors the auth challenge/verify limits (config/rateLimits.ts). Kept as +// separate buckets so hammering the link flow cannot lock out sign-in. -export const deviceLinkChallengeLimiter: RateLimitRequestHandler = rateLimit({ - windowMs: 60 * 1000, - limit: 10, - standardHeaders: 'draft-7', - legacyHeaders: false, - message: rateLimitedResponse, -}); +export const deviceLinkChallengeLimiter: RequestHandler = rateLimit('device_link_challenge'); -export const deviceLinkVerifyLimiter: RateLimitRequestHandler = rateLimit({ - windowMs: 60 * 1000, - limit: 5, - standardHeaders: 'draft-7', - legacyHeaders: false, - message: rateLimitedResponse, -}); +export const deviceLinkVerifyLimiter: RequestHandler = rateLimit('device_link_verify'); // ─── GET /devices ───────────────────────────────────────────────────────────── diff --git a/apps/backend/src/routes/push.ts b/apps/backend/src/routes/push.ts index f408f37..7d004ae 100644 --- a/apps/backend/src/routes/push.ts +++ b/apps/backend/src/routes/push.ts @@ -27,7 +27,7 @@ pushRouter.get('/vapid-public-key', (_req: AuthRequest, res) => { res.status(200).json({ configured: true, vapidPublicKey }); }); -pushRouter.post('/subscriptions', async (req: AuthRequest, res) => { +pushRouter.post('/subscriptions', rateLimit('push_subscribe'), async (req: AuthRequest, res) => { const deviceId = req.auth!.deviceId; const { endpoint, keys } = req.body; diff --git a/apps/backend/src/routes/uploads.ts b/apps/backend/src/routes/uploads.ts index ee5a80e..1d78d44 100644 --- a/apps/backend/src/routes/uploads.ts +++ b/apps/backend/src/routes/uploads.ts @@ -5,6 +5,8 @@ import { z } from 'zod'; import { db } from '../db/index.js'; import { files, conversationMembers } from '../db/schema.js'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; +import { rateLimit, defaultIdentifier } from '../middleware/rateLimit.js'; +import { consumeRateLimit } from '../services/rateLimiter.js'; import { generatePresignedPut, generateStorageKey } from '../lib/storage.js'; import { getGroupByConversation, isActiveMember } from '../services/mlsGroups.js'; import { getObjectStore } from '../lib/objectStore.js'; @@ -161,6 +163,22 @@ uploadsRouter.post('/:fileId/confirm', async (req: AuthRequest, res) => { return; } + // The client re-declares the hash it believes it uploaded. Checking it + // against the value recorded when the slot was issued catches a confused + // client before any object is read. + const parsedConfirm = ConfirmUploadSchema.safeParse(req.body); + if (!parsedConfirm.success) { + res.status(422).json({ error: 'sha256 is required' }); + return; + } + + if (parsedConfirm.data.sha256 !== file.sha256) { + res.status(422).json({ error: 'sha256 mismatch' }); + return; + } + + // #356 — the object must actually exist, at the size the slot was issued + // for. Without this a file can be marked ready that was never uploaded. const head = await getObjectStore().headObject(file.storageKey); if (!head.exists) { @@ -177,6 +195,26 @@ uploadsRouter.post('/:fileId/confirm', async (req: AuthRequest, res) => { return; } + // #348 — presence and size prove *something* landed at the key; hashing the + // stored bytes proves it is the exact content the client intended. A file + // that fails here is tombstoned rather than left pending, so a corrupt blob + // can never be referenced by a message. + const integrity = await verifyFileIntegrity(file.storageKey, file.sha256); + + if (!integrity.valid) { + await db + .update(files) + .set({ status: 'deleted', deletedAt: new Date() }) + .where(eq(files.id, fileId)); + + res.status(422).json({ + error: 'File integrity verification failed', + expectedHash: integrity.expectedHash, + computedHash: integrity.computedHash, + }); + return; + } + await db.update(files).set({ status: 'ready' }).where(eq(files.id, fileId)); res.status(200).json({ fileId, status: 'ready' }); diff --git a/apps/backend/src/routes/users.ts b/apps/backend/src/routes/users.ts index 177facd..409981c 100644 --- a/apps/backend/src/routes/users.ts +++ b/apps/backend/src/routes/users.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto'; import { Router, type Router as RouterType } from 'express'; -import { eq, and, or, ilike, exists, sql, isNull, count } from 'drizzle-orm'; +import { asc, eq, and, or, ilike, exists, sql, isNull, count } from 'drizzle-orm'; import { db } from '../db/index.js'; import { users, @@ -23,33 +23,13 @@ import { } from '../services/mlsKeyPackages.js'; import { signalPrekeysLowIfNeeded } from '../services/prekeyLowSignal.js'; import { actorFromRequest, recordAuditEvent } from '../services/auditLog.js'; +import { normalizeCapabilities } from '../lib/capabilities.js'; import { prekeyConsumedTotal } from '../lib/metrics.js'; export const usersRouter: RouterType = Router(); usersRouter.use(requireAuth); -const rateLimitedResponse = { error: 'Too many requests' }; - -/** - * Limits key-bundle claims per authenticated caller and target device. - * Ten requests per minute permits normal parallel session establishment while - * making it impractical to drain a device's one-time prekey pool quickly. - */ -export const keyBundleLimiter: RateLimitRequestHandler = rateLimit({ - windowMs: 60 * 1000, - limit: 10, - keyGenerator: (req) => { - const callerId = (req as AuthRequest).auth?.userId ?? 'anonymous'; - const targetUserId = req.params['userId'] ?? 'unknown-user'; - const deviceId = req.params['deviceId'] ?? 'unknown-device'; - return `${callerId}:${targetUserId}:${deviceId}`; - }, - standardHeaders: 'draft-7', - legacyHeaders: false, - message: rateLimitedResponse, -}); - usersRouter.get('/search', async (req: AuthRequest, res) => { const raw = req.query['q']; const q = typeof raw === 'string' ? raw.trim() : ''; @@ -238,7 +218,6 @@ usersRouter.get('/:id/presence', async (req: AuthRequest, res) => { */ usersRouter.get( '/:userId/devices/:deviceId/key-bundle', - keyBundleLimiter, // Two buckets guard the same endpoint (#375): the per-minute limit stops a // scraper enumerating device bundles, and the daily quota stops a slow drip // that never trips it from draining a victim's one-time prekeys — which @@ -268,35 +247,6 @@ usersRouter.get( return; } - const claimedOneTimePreKey = await db.transaction(async (tx) => { - const [candidate] = await tx - .select({ - id: devicePrekeys.id, - keyId: devicePrekeys.keyId, - publicKey: devicePrekeys.publicKey, - }) - .from(devicePrekeys) - .where( - and( - eq(devicePrekeys.deviceId, deviceId), - eq(devicePrekeys.keyType, 'one_time'), - eq(devicePrekeys.consumed, false), - ), - ) - .orderBy(devicePrekeys.createdAt) - .limit(1) - .for('update', { skipLocked: true }); - - if (!candidate) return null; - - await tx - .update(devicePrekeys) - .set({ consumed: true }) - .where(eq(devicePrekeys.id, candidate.id)); - - return { keyId: candidate.keyId, publicKey: candidate.publicKey }; - }); - const claimed = await db.transaction(async (tx) => { const [candidate] = await tx .select({ @@ -344,39 +294,19 @@ usersRouter.get( const claimedOneTimePreKey = claimed?.oneTimePreKey ?? null; - // Fire-and-forget: the device that owns this bundle is told to replenish - // once per threshold crossing. Never blocks or fails the bundle response. // A one-time prekey was consumed and cannot be handed out again (#376). // Draining a device's supply forces every later session with it down from // 4-DH to 3-DH, and it happens quietly, so the count left is the signal an // incident responder actually needs. Subject is the device owner — the // account this was done *to* — while the actor is whoever fetched it. - if (claimedOneTimePreKey) { - // The remaining count is the useful part but only a nice-to-have: if the - // count query fails, still record that a prekey was consumed rather than - // losing the event, and never fail the bundle fetch over bookkeeping. - let remaining: number | null = null; - try { - const [remainingRow] = await db - .select({ remaining: sql`count(*)::int` }) - .from(devicePrekeys) - .where( - and( - eq(devicePrekeys.deviceId, deviceId), - eq(devicePrekeys.keyType, 'one_time'), - eq(devicePrekeys.consumed, false), - ), - ); - remaining = remainingRow?.remaining ?? 0; - } catch { - // Leave it null — the event itself is what must not be lost. - } + if (claimed) { + const remaining = claimed.remaining; + + prekeyConsumedTotal.inc(); // Fire-and-forget: the device that owns this bundle is told to replenish // once per threshold crossing. Never blocks or fails the bundle response. - if (remaining !== null) { - void signalPrekeysLowIfNeeded(deviceId, remaining); - } + void signalPrekeysLowIfNeeded(deviceId, remaining); void recordAuditEvent({ action: 'key_bundle_drained', @@ -708,7 +638,7 @@ usersRouter.patch('/me', async (req: AuthRequest, res) => { usersRouter.get('/:id/key-history', async (req: AuthRequest, res) => { const targetUserId = req.params['id']; - if (!targetUserId) { + if (typeof targetUserId !== 'string' || !targetUserId) { res.status(400).json({ error: 'User id is required' }); return; } diff --git a/apps/backend/src/schemas/auth.schemas.ts b/apps/backend/src/schemas/auth.schemas.ts index ef9cea0..45523fe 100644 --- a/apps/backend/src/schemas/auth.schemas.ts +++ b/apps/backend/src/schemas/auth.schemas.ts @@ -51,10 +51,14 @@ export const VerifySchema = z * proof of wallet ownership *now* — the caller's JWT alone is not enough to * add a device to an account. */ +// Strict: this is the only endpoint that registers a device, so an +// unrecognized field must be rejected outright rather than silently stripped. +// The server never accepts session, ratchet, or private-key state — a payload +// carrying one is a client bug worth surfacing, not something to quietly drop. export const DeviceLinkVerifySchema = DeviceSchema.extend({ signature: z.string().min(1, 'signature is required'), nonce: z.string().min(1, 'nonce is required'), -}); +}).strict(); export type ChallengeBody = z.infer; export type DeviceBody = z.infer; diff --git a/apps/backend/src/services/deviceGc.ts b/apps/backend/src/services/deviceGc.ts index 15066e6..deff339 100644 --- a/apps/backend/src/services/deviceGc.ts +++ b/apps/backend/src/services/deviceGc.ts @@ -104,7 +104,11 @@ export async function runDeviceStaleFlagPass(): Promise { .update(devices) .set({ staleFlaggedAt: new Date() }) .where( - and(isNotNull(devices.revokedAt), lt(devices.revokedAt, cutoff), isNull(devices.staleFlaggedAt)), + and( + isNotNull(devices.revokedAt), + lt(devices.revokedAt, cutoff), + isNull(devices.staleFlaggedAt), + ), ) .returning({ id: devices.id }); diff --git a/apps/backend/src/services/envelopeGc.ts b/apps/backend/src/services/envelopeGc.ts index 533334e..5f3a3aa 100644 --- a/apps/backend/src/services/envelopeGc.ts +++ b/apps/backend/src/services/envelopeGc.ts @@ -47,7 +47,10 @@ export async function runEnvelopeGcPass(): Promise { .delete(messageEnvelopes) .where( or( - and(isNotNull(messageEnvelopes.deliveredAt), lt(messageEnvelopes.deliveredAt, deliveredCutoff)), + and( + isNotNull(messageEnvelopes.deliveredAt), + lt(messageEnvelopes.deliveredAt, deliveredCutoff), + ), lt(messageEnvelopes.createdAt, maxAgeCutoff), ), ) diff --git a/apps/backend/src/services/fileCleanup.ts b/apps/backend/src/services/fileCleanup.ts index 97049a2..d684da1 100644 --- a/apps/backend/src/services/fileCleanup.ts +++ b/apps/backend/src/services/fileCleanup.ts @@ -61,7 +61,8 @@ export async function softDeleteFile(fileId: string): Promise { export async function runHardDeletePass(): Promise { const graceCutoff = new Date(Date.now() - getHardDeleteGraceMs()); const candidates = await db.query.files.findMany({ - where: (f) => and(isNotNull(f.deletedAt), isNull(f.hardDeletedAt), lt(f.deletedAt, graceCutoff)), + where: (f) => + and(isNotNull(f.deletedAt), isNull(f.hardDeletedAt), lt(f.deletedAt, graceCutoff)), columns: { id: true, storageKey: true }, }); diff --git a/apps/backend/src/services/presence.ts b/apps/backend/src/services/presence.ts index 5fcae7f..a246ec0 100644 --- a/apps/backend/src/services/presence.ts +++ b/apps/backend/src/services/presence.ts @@ -230,7 +230,10 @@ export async function getDeviceSocketIds(redis: Redis, deviceId: string): Promis return redis.smembers(deviceSocketsByDeviceKey(deviceId)); } -export async function isDeviceConnectedInRegistry(redis: Redis, deviceId: string): Promise { +export async function isDeviceConnectedInRegistry( + redis: Redis, + deviceId: string, +): Promise { const count = await redis.scard(deviceSocketsByDeviceKey(deviceId)); return count > 0; } diff --git a/apps/backend/src/services/pushFilter.ts b/apps/backend/src/services/pushFilter.ts index 83bcf74..dd26e89 100644 --- a/apps/backend/src/services/pushFilter.ts +++ b/apps/backend/src/services/pushFilter.ts @@ -106,7 +106,7 @@ export async function getEligiblePushRecipients(options: PushFilterOptions): Pro // Step 5: Filter out connected devices (realtime WebSocket connection exists) const offlineDeviceIds: string[] = []; for (const device of candidateDevices) { - if (!isDeviceConnected(device.id)) { + if (!(await isDeviceConnected(redis ?? null, device.id))) { offlineDeviceIds.push(device.id); } } diff --git a/apps/backend/src/services/pushNotification.ts b/apps/backend/src/services/pushNotification.ts index e791746..81ac580 100644 --- a/apps/backend/src/services/pushNotification.ts +++ b/apps/backend/src/services/pushNotification.ts @@ -11,7 +11,7 @@ import webpush from 'web-push'; import { and, eq, isNull } from 'drizzle-orm'; import { db } from '../db/index.js'; import { pushSubscriptions } from '../db/schema.js'; -import { isDeviceConnected } from './deviceRevocation.js'; +import { getEligiblePushRecipients } from './pushFilter.js'; import { pushResultTotal } from '../lib/metrics.js'; import { redis } from '../lib/redis.js'; diff --git a/apps/backend/src/services/rateLimit.ts b/apps/backend/src/services/rateLimit.ts index 5d59895..cc38d75 100644 --- a/apps/backend/src/services/rateLimit.ts +++ b/apps/backend/src/services/rateLimit.ts @@ -6,6 +6,7 @@ * `config/rateLimits.ts` (#375). This module only decides *what* to charge for * a given socket event. */ +import type { Redis } from 'ioredis'; import { socketEventBucket } from '../config/rateLimits.js'; import { consumeRateLimit, type RateLimitResult } from '../services/rateLimiter.js'; diff --git a/apps/backend/src/services/replay-protection.service.spec.ts b/apps/backend/src/services/replay-protection.service.spec.ts index 655ebfb..c4753d1 100644 --- a/apps/backend/src/services/replay-protection.service.spec.ts +++ b/apps/backend/src/services/replay-protection.service.spec.ts @@ -163,7 +163,7 @@ describe('ReplayProtectionService', () => { process.env['REPLAY_PROTECTION_TTL_SECONDS'] = '0'; const deviceId = 'device-1'; const eventId = 'event-1'; - const below1 = await isReplay(redis, deviceId, eventId); + expect(await isReplay(redis, deviceId, eventId)).toBe(false); // Should use default TTL instead const keyBelow = getReplayProtectionRedisKey(deviceId, eventId); const ttlBelow = await redis.ttl(keyBelow); @@ -174,7 +174,7 @@ describe('ReplayProtectionService', () => { // Invalid: above 86400 process.env['REPLAY_PROTECTION_TTL_SECONDS'] = '100000'; const eventId2 = 'event-2'; - const above = await isReplay(redis, deviceId, eventId2); + expect(await isReplay(redis, deviceId, eventId2)).toBe(false); // Should use default TTL instead const keyAbove = getReplayProtectionRedisKey(deviceId, eventId2); const ttlAbove = await redis.ttl(keyAbove); @@ -185,7 +185,7 @@ describe('ReplayProtectionService', () => { // Invalid: non-integer process.env['REPLAY_PROTECTION_TTL_SECONDS'] = 'invalid'; const eventId3 = 'event-3'; - const invalid = await isReplay(redis, deviceId, eventId3); + expect(await isReplay(redis, deviceId, eventId3)).toBe(false); // Should use default TTL instead const keyInvalid = getReplayProtectionRedisKey(deviceId, eventId3); const ttlInvalid = await redis.ttl(keyInvalid); diff --git a/apps/backend/src/services/replay-protection.service.ts b/apps/backend/src/services/replay-protection.service.ts index d9b5cb6..ab507a2 100644 --- a/apps/backend/src/services/replay-protection.service.ts +++ b/apps/backend/src/services/replay-protection.service.ts @@ -65,7 +65,10 @@ export async function isReplay( return result === null; // null means key existed (is a replay) } catch (err) { // Redis error — fail open and log a warning - console.warn('[replay-protection] Redis error during SET:', err instanceof Error ? err.message : String(err)); + console.warn( + '[replay-protection] Redis error during SET:', + err instanceof Error ? err.message : String(err), + ); return false; // Allow the event through } } @@ -105,6 +108,9 @@ export async function markSeen( try { await redis.setex(key, ttl, '1'); } catch (err) { - console.warn('[replay-protection] Redis error during SETEX:', err instanceof Error ? err.message : String(err)); + console.warn( + '[replay-protection] Redis error during SETEX:', + err instanceof Error ? err.message : String(err), + ); } } diff --git a/apps/backend/src/services/roomManager.ts b/apps/backend/src/services/roomManager.ts index a9786bb..11608e6 100644 --- a/apps/backend/src/services/roomManager.ts +++ b/apps/backend/src/services/roomManager.ts @@ -59,7 +59,9 @@ export async function rebuildRoomsAfterRestart(io: Server): Promise { const sockets = await io.fetchSockets(); for (const socket of sockets) { - const authSocket = socket as AuthSocket; + // `fetchSockets()` returns RemoteSocket, which carries the same `auth` + // data attached at handshake but none of the local Socket methods. + const authSocket = socket as unknown as AuthSocket; const userId = authSocket.auth?.userId; const deviceId = authSocket.auth?.deviceId; diff --git a/apps/backend/src/services/stellarListener.ts b/apps/backend/src/services/stellarListener.ts index 260a176..086f12e 100644 --- a/apps/backend/src/services/stellarListener.ts +++ b/apps/backend/src/services/stellarListener.ts @@ -18,7 +18,7 @@ import { rpc } from '@stellar/stellar-sdk'; import { db } from '../db/index.js'; import { tokenTransfers, messages, conversations, users, treasuryProposals } from '../db/schema.js'; -import { eq, sql } from 'drizzle-orm'; +import { and, eq, sql } from 'drizzle-orm'; import { getSocketServer } from '../lib/socket.js'; const DEFAULT_POLL_INTERVAL_MS = 5_000; @@ -180,13 +180,18 @@ async function defaultPersistTreasuryEvent(event: TreasuryProposalEvent): Promis rejectionsCount: event.rejectionsCount !== undefined ? event.rejectionsCount : undefined, updatedAt: sql`now()`, }) - .where(eq(treasuryProposals.onChainId, Number(event.proposalId))) + .where( + and( + eq(treasuryProposals.contractId, event.contractId), + eq(treasuryProposals.proposalId, event.proposalId), + ), + ) .returning(); if (!row) return; const payload = { - proposalId: row.onChainId, + proposalId: row.proposalId, status: row.status, approvalsCount: row.approvalsCount, rejectionsCount: row.rejectionsCount, @@ -194,7 +199,9 @@ async function defaultPersistTreasuryEvent(event: TreasuryProposalEvent): Promis // Emit to the linked conversation room if known. const room = row.conversationId; - getSocketServer()?.to(room).emit('treasury_proposal_updated', payload); + if (room) { + getSocketServer()?.to(room).emit('treasury_proposal_updated', payload); + } } /** diff --git a/apps/backend/src/socket/dispatcher.spec.ts b/apps/backend/src/socket/dispatcher.spec.ts index e2cead0..b6fb3ca 100644 --- a/apps/backend/src/socket/dispatcher.spec.ts +++ b/apps/backend/src/socket/dispatcher.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import RedisMock from 'ioredis-mock'; import type { Redis } from 'ioredis'; import type { Server as SocketIOServer } from 'socket.io'; @@ -28,21 +28,26 @@ describe('EventDispatcher with Replay Protection', () => { auth: { userId: 'test-user', deviceId: 'test-device', + walletAddress: 'GTEST', }, emit: vi.fn(), - on: vi.fn((event, handler) => { + on: vi.fn((event: string, handler: unknown) => { if (event === 'dispatch') { // Store the dispatch listener for manual invocation in tests - (mockSocket as any).dispatchHandler = handler; + (mockSocket as Record)['dispatchHandler'] = handler; } - }), + return mockSocket as AuthSocket; + }) as unknown as AuthSocket['on'], rooms: new Set(['test-room']), }; dispatcher = new EventDispatcher(mockIo as SocketIOServer, mockSocket as AuthSocket, redis); // Register a test event handler - dispatcher.register('test_event', handlerSpy); + dispatcher.register( + 'join_room', + handlerSpy as unknown as (p: Record) => Promise, + ); dispatcher.listen(); }); @@ -58,8 +63,8 @@ describe('EventDispatcher with Replay Protection', () => { const eventId1 = 'event-1'; const eventId2 = 'event-2'; - const envelope1 = createEnvelope('test_event', { message: 'first' }, eventId1); - const envelope2 = createEnvelope('test_event', { message: 'second' }, eventId2); + const envelope1 = createEnvelope('join_room', { message: 'first' }, eventId1); + const envelope2 = createEnvelope('join_room', { message: 'second' }, eventId2); // Manually invoke dispatch handler await (mockSocket as any).dispatchHandler(envelope1); @@ -72,13 +77,15 @@ describe('EventDispatcher with Replay Protection', () => { it('should drop duplicate event (same eventId, same device) and not persist it twice', async () => { const eventId = 'event-123'; - const envelope = createEnvelope('test_event', { message: 'duplicate' }, eventId); + const envelope = createEnvelope('join_room', { message: 'duplicate' }, eventId); // First invocation await (mockSocket as any).dispatchHandler(envelope); - // Reset spy to check second call + // Reset spies so the assertions below see only the replay's effects, + // not the first (legitimate) invocation's ack. handlerSpy.mockClear(); + (mockSocket.emit as any).mockClear(); // Second invocation (replay) await (mockSocket as any).dispatchHandler(envelope); @@ -95,7 +102,7 @@ describe('EventDispatcher with Replay Protection', () => { it('should allow same eventId from different device', async () => { const eventId = 'event-123'; - const envelope = createEnvelope('test_event', { message: 'test' }, eventId); + const envelope = createEnvelope('join_room', { message: 'test' }, eventId); // First device await (mockSocket as any).dispatchHandler(envelope); @@ -107,6 +114,7 @@ describe('EventDispatcher with Replay Protection', () => { mockSocket.auth = { userId: 'test-user', deviceId: 'different-device', + walletAddress: 'GTEST', }; // Recreate dispatcher with same Redis but different socket @@ -114,19 +122,25 @@ describe('EventDispatcher with Replay Protection', () => { auth: { userId: 'test-user', deviceId: 'different-device', + walletAddress: 'GTEST', }, emit: vi.fn(), - on: vi.fn((event, handler) => { + on: vi.fn((event: string, handler: unknown) => { if (event === 'dispatch') { - (mockSocket2 as any).dispatchHandler = handler; + (mockSocket2 as Record)['dispatchHandler'] = handler; } - }), + return mockSocket2 as AuthSocket; + }) as unknown as AuthSocket['on'], rooms: new Set(['test-room']), }; - const dispatcher2 = new EventDispatcher(mockIo as SocketIOServer, mockSocket2 as AuthSocket, redis); + const dispatcher2 = new EventDispatcher( + mockIo as SocketIOServer, + mockSocket2 as AuthSocket, + redis, + ); const handler2Spy = vi.fn(); - dispatcher2.register('test_event', handler2Spy); + dispatcher2.register('join_room', handler2Spy); dispatcher2.listen(); // Same eventId from different device should be allowed @@ -138,7 +152,7 @@ describe('EventDispatcher with Replay Protection', () => { process.env['REPLAY_PROTECTION_TTL_SECONDS'] = '1'; const eventId = 'event-123'; - const envelope = createEnvelope('test_event', { message: 'test' }, eventId); + const envelope = createEnvelope('join_room', { message: 'test' }, eventId); // First occurrence await (mockSocket as any).dispatchHandler(envelope); @@ -164,7 +178,7 @@ describe('EventDispatcher with Replay Protection', () => { // eventId is required by schema, so this should fail validation before replay check const invalidEnvelope = { // Missing eventId - type: 'test_event', + type: 'join_room', timestamp: Date.now(), payload: {}, }; @@ -184,7 +198,7 @@ describe('EventDispatcher with Replay Protection', () => { const consoleDebugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); const eventId = 'event-123'; - const envelope = createEnvelope('test_event', { message: 'test' }, eventId); + const envelope = createEnvelope('join_room', { message: 'test' }, eventId); // First occurrence await (mockSocket as any).dispatchHandler(envelope); @@ -206,7 +220,7 @@ describe('EventDispatcher with Replay Protection', () => { it('should emit dispatch_ack with duplicate flag for replays', async () => { const eventId = 'event-123'; - const envelope = createEnvelope('test_event', { message: 'test' }, eventId); + const envelope = createEnvelope('join_room', { message: 'test' }, eventId); // First occurrence await (mockSocket as any).dispatchHandler(envelope); @@ -229,7 +243,7 @@ describe('EventDispatcher with Replay Protection', () => { it('should emit dispatch_ack with duplicate: false for first occurrence', async () => { const eventId = 'event-123'; - const envelope = createEnvelope('test_event', { message: 'test' }, eventId); + const envelope = createEnvelope('join_room', { message: 'test' }, eventId); await (mockSocket as any).dispatchHandler(envelope); @@ -248,13 +262,15 @@ describe('EventDispatcher with Replay Protection', () => { auth: { userId: 'test-user', deviceId: 'test-device', + walletAddress: 'GTEST', }, emit: vi.fn(), - on: vi.fn((event, handler) => { + on: vi.fn((event: string, handler: unknown) => { if (event === 'dispatch') { - (mockSocketNoRedis as any).dispatchHandler = handler; + (mockSocketNoRedis as Record)['dispatchHandler'] = handler; } - }), + return mockSocketNoRedis as AuthSocket; + }) as unknown as AuthSocket['on'], rooms: new Set(['test-room']), }; @@ -266,11 +282,11 @@ describe('EventDispatcher with Replay Protection', () => { ); const handlerNoRedis = vi.fn(); - dispatcherNoRedis.register('test_event', handlerNoRedis); + dispatcherNoRedis.register('join_room', handlerNoRedis); dispatcherNoRedis.listen(); const eventId = 'event-123'; - const envelope = createEnvelope('test_event', { message: 'test' }, eventId); + const envelope = createEnvelope('join_room', { message: 'test' }, eventId); // First occurrence — should process await (mockSocketNoRedis as any).dispatchHandler(envelope); diff --git a/apps/backend/src/socket/dispatcher.ts b/apps/backend/src/socket/dispatcher.ts index 0e52af8..7185a85 100644 --- a/apps/backend/src/socket/dispatcher.ts +++ b/apps/backend/src/socket/dispatcher.ts @@ -11,7 +11,6 @@ import { isReplay } from '../services/replay-protection.service.js'; type Handler = (payload: Record) => Promise; -const IDEMPOTENCY_TTL_SECONDS = 86_400; // 24 h const SOCKET_EVENT_MAX_AGE_MS = parseInt(process.env['SOCKET_EVENT_MAX_AGE_MS'] ?? '300000', 10); const SOCKET_EVENT_MAX_FUTURE_SKEW_MS = parseInt( process.env['SOCKET_EVENT_MAX_FUTURE_SKEW_MS'] ?? '30000', @@ -90,17 +89,18 @@ export class EventDispatcher { return; } - // Idempotency check: skip already-processed eventIds. - if (this.redis) { - const idempotencyKey = `event:idempotency:${envelope.eventId}`; - const set = await this.redis - .set(idempotencyKey, '1', 'EX', getIdempotencyTtlSeconds(), 'NX') - .catch(() => null); - if (set === null) { - // Already processed — acknowledge without re-running. - this.socket.emit('dispatch_ack', { eventId: envelope.eventId, duplicate: true }); - return; - } + // Replay/idempotency check (#344). Scoped to the sending device, not + // global: two devices legitimately generating the same eventId must not + // block each other. Fails open when Redis is unavailable. + if (await isReplay(this.redis, this.socket.auth.deviceId, envelope.eventId)) { + console.debug('[replay-protection] Dropping replay event', { + deviceId: this.socket.auth.deviceId, + eventId: envelope.eventId, + type: envelope.type, + }); + // Already processed — acknowledge without re-running. + this.socket.emit('dispatch_ack', { eventId: envelope.eventId, duplicate: true }); + return; } const handler = this.handlers.get(envelope.type); diff --git a/apps/backend/src/socket/messaging.ts b/apps/backend/src/socket/messaging.ts index 78e295c..20e64cc 100644 --- a/apps/backend/src/socket/messaging.ts +++ b/apps/backend/src/socket/messaging.ts @@ -22,6 +22,7 @@ import { import { validateMessagePayload } from '../lib/validateMessagePayload.js'; import { checkEnvelopeSizes } from '../services/rateLimit.js'; import { dispatchOfflinePush, FILE_CONTENT_TYPES } from '../services/pushNotification.js'; +import { consumeRateLimit } from '../services/rateLimiter.js'; import { deliverMessage } from '../services/deliveryPipeline.js'; import { publishEphemeral, readMissedEvents } from '../services/resumeStream.js'; import { handleDeviceDeliveryReceipt } from '../services/deliveryAggregation.js'; @@ -461,32 +462,50 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void messageId?: string; fileId: string; content: string; - ciphertext?: string; contentType: 'file' | 'image' | 'video' | 'audio'; + envelopes?: Array<{ recipientDeviceId: string; ciphertext: string; protocol?: E2eeProtocol }>; }) => { - const { conversationId, messageId, fileId, content, contentType } = payload; + const { conversationId, messageId, fileId, content, contentType, envelopes } = payload; + const deviceId = socket.auth!.deviceId; if (!messageId) { + socket.emit('error', { event: 'send_file_message', message: 'messageId is required' }); + return; + } + + if (!content?.trim()) { socket.emit('error', { event: 'send_file_message', - message: 'messageId is required', + message: 'Content (envelope ciphertext) must not be empty', }); return; } - if (!content?.trim()) { + // Same shared validator the other send paths use (#335), so the + // content-type rules — known type, fileId present, and non-empty + // envelopes carrying the encrypted file key — cannot drift between + // send_message, POST /messages and this handler. + const validation = validateMessagePayload({ + contentType, + ciphertext: content, + envelopes, + fileId, + }); + if (!validation.ok) { socket.emit('error', { event: 'send_file_message', - message: 'Content (envelope ciphertext) must not be empty', + code: validation.code, + message: validation.message, }); return; } - const validContentTypes = ['file', 'image', 'video', 'audio'] as const; - if (!validContentTypes.includes(contentType)) { + const envelopeSizeCheck = checkEnvelopeSizes(envelopes); + if (!envelopeSizeCheck.valid) { socket.emit('error', { event: 'send_file_message', - message: 'contentType must be one of: file, image, video, audio', + code: 'envelope_too_large', + message: `Envelope for device ${envelopeSizeCheck.oversizedDeviceId} exceeds size limit`, }); return; } @@ -506,6 +525,8 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void return; } + // Idempotency before any file work: a retry of an already-persisted + // message must ack without re-validating (or re-reading) the file. const existing = await db.query.messages.findFirst({ where: eq(messages.id, messageId), columns: { createdAt: true }, @@ -549,7 +570,20 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void return; } + // Enforce full sibling-device coverage (#188) — the same fan-out + // guarantee send_message and edit_message already require. + const missingSiblings = await findMissingSiblingDeviceIds(userId, deviceId, envelopes); + if (missingSiblings.length > 0) { + socket.emit('error', { + event: 'device_set_mismatch', + message: `Missing envelopes for ${missingSiblings.length} sibling device(s)`, + missingDeviceIds: missingSiblings, + }); + return; + } + let message; + let recipientDeviceIds: string[] = []; try { message = await db.transaction(async (tx) => { const [insertedMessage] = await tx @@ -558,13 +592,16 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void id: messageId, conversationId, senderId: userId, + senderDeviceId: deviceId, ciphertext: content.trim(), contentType, fileId, }) .returning(); - return insertedMessage; + recipientDeviceIds = await insertMessageEnvelopes(tx, messageId, envelopes); + + return insertedMessage!; }); } catch (error) { console.error('Transaction failed for file message:', error); @@ -577,23 +614,17 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void if (message) { socket.emit('message_ack', { messageId, createdAt: message.createdAt }); - io.to(conversationId).emit('new_message', message); + await deliverMessage(io, message, conversationId); const members = await db.query.conversationMembers.findMany({ where: eq(conversationMembers.conversationId, conversationId), columns: { userId: true }, }); await invalidateConversationCaches(members.map((member) => member.userId)); - - sendPushForMessage({ - conversationId, - messageId: message.id, - senderId: userId, - }); + void dispatchOfflinePush(conversationId, messageId, recipientDeviceIds, userId); } }, ); - // ── message_history ──────────────────────────────────────────────────────── dispatcher.register('message_history', async (payload) => { const { conversationId, before } = payload as { diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index d22d4a3..42fb54f 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -1,9 +1,11 @@ { // Visit https://aka.ms/tsconfig to read more about this file "compilerOptions": { - // File Layout - // "rootDir": "./src", - // "outDir": "./dist", + // File Layout — build output must land in dist/, not beside the sources. + // Without these, `pnpm build` emits .js/.d.ts/.js.map throughout src/, + // which then get linted as if they were source files. + "rootDir": "./src", + "outDir": "./dist", // Environment Settings // See also https://aka.ms/tsconfig/module @@ -40,5 +42,10 @@ "noUncheckedSideEffectImports": true, "moduleDetection": "force", "skipLibCheck": true - } + }, + + // Scoped to the source tree so `rootDir` holds: root-level config files + // (drizzle.config.ts, vitest.config.ts) are tooling, not build inputs. + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] } diff --git a/apps/backend/vitest.config.ts b/apps/backend/vitest.config.ts index 3756f13..9f39cc4 100644 --- a/apps/backend/vitest.config.ts +++ b/apps/backend/vitest.config.ts @@ -5,5 +5,10 @@ export default defineConfig({ environment: 'node', setupFiles: ['./src/__tests__/setup.ts'], testTimeout: 15000, + // Only the TypeScript sources are tests. `pnpm build` emits a compiled + // copy of every spec into dist/, which would otherwise be collected and + // run a second time against stale output. + include: ['src/**/*.{test,spec}.ts'], + exclude: ['node_modules', 'dist'], }, }); diff --git a/apps/web/package.json b/apps/web/package.json index 4f3de66..0324a96 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,20 +1,27 @@ { "name": "web", - "version": "1.0.0", + "version": "0.1.0", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint", + "lint": "eslint", + "lint:fix": "eslint --fix", "test": "vitest run" }, "dependencies": { - "@openmls/wasm": "^0.5.0", - "@stellar/stellar-sdk": "^11.2.0", - "next": "^14.2.0", - "react": "^18.3.0", - "react-dom": "^18.3.0" + "@noble/curves": "^1.9", + "@noble/hashes": "^1.8", + "@stellar/freighter-api": "^6.0.1", + "framer-motion": "^12.41.0", + "idb": "^8.0.2", + "lucide-react": "^1.21.0", + "next": "16.2.0", + "react": "19.2.4", + "react-dom": "19.2.4", + "socket.io-client": "^4.5.0", + "stellar-sdk": "^11.0.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/apps/web/src/app/app/conversations/[id]/page.tsx b/apps/web/src/app/app/conversations/[id]/page.tsx index 870ab91..bd51c70 100644 --- a/apps/web/src/app/app/conversations/[id]/page.tsx +++ b/apps/web/src/app/app/conversations/[id]/page.tsx @@ -703,7 +703,7 @@ export default function ConversationPage() { token={transfer.token} txHash={transfer.txHash} /> - {message.unavailable ? ( + ) : message.unavailable ? ( ) : message.filePayload && (message.contentType === 'image' || message.contentType === 'video') ? ( diff --git a/apps/web/src/lib/crypto.identityGuard.test.ts b/apps/web/src/lib/crypto.identityGuard.test.ts index e50ec72..f92943a 100644 --- a/apps/web/src/lib/crypto.identityGuard.test.ts +++ b/apps/web/src/lib/crypto.identityGuard.test.ts @@ -9,8 +9,8 @@ */ import { describe, it, expect } from 'vitest'; -import { assertDevicesTrusted, type DeviceRecord } from './crypto.js'; -import { IdentityKeyChangedError, trustDevices } from './identityTrust.js'; +import { assertDevicesTrusted, type DeviceRecord } from './crypto'; +import { IdentityKeyChangedError, trustDevices } from './identityTrust'; let counter = 0; function freshUserId(): string { diff --git a/apps/web/src/lib/crypto.test.ts b/apps/web/src/lib/crypto.test.ts index f562922..547fbff 100644 --- a/apps/web/src/lib/crypto.test.ts +++ b/apps/web/src/lib/crypto.test.ts @@ -71,6 +71,9 @@ describe('sealed-box crypto', () => { return { device: { id: `device-${index + 1}`, + // Identity pinning is keyed by userId, so a DeviceRecord always + // carries the owning user. + userId: `user-${index + 1}`, identityPublicKey: bytesToB64(publicKey), } satisfies DeviceRecord, privateKey: keyPair.privateKey, diff --git a/apps/web/src/lib/crypto.ts b/apps/web/src/lib/crypto.ts index ac0cb81..e266c40 100644 --- a/apps/web/src/lib/crypto.ts +++ b/apps/web/src/lib/crypto.ts @@ -17,7 +17,7 @@ * buildEnvelopes() → Array<{ recipientDeviceId, ciphertext }> */ -import { checkIdentityChange, IdentityKeyChangedError } from './identityTrust.js'; +import { checkIdentityChange, IdentityKeyChangedError } from './identityTrust'; export { IdentityKeyChangedError }; diff --git a/apps/web/src/lib/crypto/doubleRatchet.ts b/apps/web/src/lib/crypto/doubleRatchet.ts index be57869..f926b3b 100644 --- a/apps/web/src/lib/crypto/doubleRatchet.ts +++ b/apps/web/src/lib/crypto/doubleRatchet.ts @@ -1,457 +1,263 @@ -const DB_NAME = 'driptide-crypto'; -const DB_VERSION = 1; -const STORE_NAME = 'double-ratchet-sessions'; -const PROTOCOL_VERSION = 1; -const ZERO_SALT = new Uint8Array(32); - -type BufferLike = Uint8Array; - -export type RatchetEnvelope = { - recipientDeviceId: string; - ciphertext: string; -}; - -type RatchetHeader = { - v: number; - dh: string; - n: number; - pn: number; -}; - -type RatchetState = { - sessionId: string; - rootKey: string; - sendChainKey: string; - receiveChainKey: string; - sendingPrivateKey: string; - sendingPublicKey: string; - remotePublicKey: string; - sendMessageNumber: number; - receiveMessageNumber: number; - previousSendingLength: number; - sendRatchetPending: boolean; - updatedAt: number; -}; - -type StoredRatchetState = RatchetState & { id: string }; - -export type RatchetSession = { - sessionId: string; - initiator: boolean; - initialSecret: Uint8Array; - remotePublicKey?: Uint8Array; -}; - -function webCrypto(): Crypto { - if (typeof crypto === 'undefined' || !crypto.subtle) { - throw new Error('Web Crypto API is unavailable'); - } - return crypto; -} - -function bytes(value: string): Uint8Array { - return new TextEncoder().encode(value); -} - -function text(value: Uint8Array): string { - return new TextDecoder().decode(value); +/** + * Double Ratchet primitives with out-of-order delivery support (#360). + * + * Keeps skipped message keys (bounded by MAX_SKIP per chain and + * MAX_SKIPPED_KEYS overall) so a message that arrives late can still be + * decrypted instead of being rejected outright. + * + * `ratchetSession.ts` is a separate, IndexedDB-backed session layer that + * predates this and does NOT support out-of-order delivery. The two are not + * yet wired together — see the note in that file. + */ +import { x25519 } from '@noble/curves/ed25519'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha2'; + +export const MAX_SKIP = 100; +export const MAX_SKIPPED_KEYS = 1000; + +const INFO_RK = new TextEncoder().encode('DoubleRatchetRK'); +const INFO_CK = new TextEncoder().encode('DoubleRatchetCK'); +const INFO_MESSAGE_KEY = new TextEncoder().encode('DoubleRatchetMK'); + +export interface KeyPair { + privateKey: Uint8Array; + publicKey: Uint8Array; } -function source(value: Uint8Array): BufferSource { - return value as unknown as BufferSource; +export interface Header { + dh: Uint8Array; + pn: number; + n: number; } -function toBase64(value: Uint8Array): string { - let result = ''; - for (let offset = 0; offset < value.length; offset += 0x8000) { - result += String.fromCharCode(...value.subarray(offset, offset + 0x8000)); - } - return btoa(result); +export interface EncryptedMessagePayload { + header: { + dh: string; // base64 + pn: number; + n: number; + }; + ciphertext: string; // base64 + iv: string; // base64 } -function fromBase64(value: string): Uint8Array { - const decoded = atob(value); - const result = new Uint8Array(decoded.length); - for (let index = 0; index < decoded.length; index += 1) { - result[index] = decoded.charCodeAt(index); - } - return result; +export interface DoubleRatchetState { + DHs: KeyPair; + DHr: Uint8Array | null; + RK: Uint8Array; + CKs: Uint8Array | null; + CKr: Uint8Array | null; + Ns: number; + Nr: number; + PN: number; + MKSKIPPED: Map; } -async function hkdf( - input: BufferLike, - salt: BufferLike, - info: string, - length: number, -): Promise { - const subtle = webCrypto().subtle; - const key = await subtle.importKey('raw', source(input), 'HKDF', false, ['deriveBits']); - const result = await subtle.deriveBits( - { name: 'HKDF', hash: 'SHA-256', salt: source(salt), info: source(bytes(info)) }, - key, - length * 8, - ); - return new Uint8Array(result); +export function toBase64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64'); } -async function hmac(keyBytes: Uint8Array, label: string): Promise { - const subtle = webCrypto().subtle; - const key = await subtle.importKey( - 'raw', - source(keyBytes), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'], - ); - return new Uint8Array(await subtle.sign('HMAC', key, source(bytes(label)))); +export function fromBase64(b64: string): Uint8Array { + return new Uint8Array(Buffer.from(b64, 'base64')); } -async function generateDhKeyPair(): Promise { - return (await webCrypto().subtle.generateKey( - { name: 'ECDH', namedCurve: 'P-256' }, - true, - ['deriveBits'], - )) as CryptoKeyPair; +function toBufferSource(arr: Uint8Array): BufferSource { + return new Uint8Array(Array.from(arr)); } -async function exportPublicKey(key: CryptoKey): Promise { - return new Uint8Array(await webCrypto().subtle.exportKey('raw', key)); +export function generateKeyPair(): KeyPair { + const privateKey = x25519.utils.randomSecretKey(); + const publicKey = x25519.getPublicKey(privateKey); + return { privateKey, publicKey }; } -async function exportPrivateKey(key: CryptoKey): Promise { - return new Uint8Array(await webCrypto().subtle.exportKey('pkcs8', key)); +export function kdfRk(rk: Uint8Array, dhOut: Uint8Array): { rk: Uint8Array; ck: Uint8Array } { + const derived = hkdf(sha256, dhOut, rk, INFO_RK, 64); + return { + rk: derived.slice(0, 32), + ck: derived.slice(32, 64), + }; } -async function importPrivateKey(value: Uint8Array): Promise { - return webCrypto().subtle.importKey( - 'pkcs8', - source(value), - { name: 'ECDH', namedCurve: 'P-256' }, - false, - ['deriveBits'], - ); +export function kdfCk(ck: Uint8Array): { ck: Uint8Array; mk: Uint8Array } { + const nextCk = hkdf(sha256, new Uint8Array([0x01]), ck, INFO_CK, 32); + const mk = hkdf(sha256, new Uint8Array([0x02]), ck, INFO_MESSAGE_KEY, 32); + return { ck: nextCk, mk }; } -async function importPublicKey(value: Uint8Array): Promise { - return webCrypto().subtle.importKey( - 'raw', - source(value), - { name: 'ECDH', namedCurve: 'P-256' }, - false, - [], - ); +function skippedKeyId(dhPub: Uint8Array | null, n: number): string { + const dhB64 = dhPub ? toBase64(dhPub) : 'none'; + return `${dhB64}:${n}`; } -async function dh(privateKey: CryptoKey, publicKey: CryptoKey): Promise { - return new Uint8Array( - await webCrypto().subtle.deriveBits( - { name: 'ECDH', public: publicKey }, - privateKey, - 256, - ), - ); -} +export function initAlice(sharedKey: Uint8Array, bobDhPublicKey: Uint8Array): DoubleRatchetState { + const DHs = generateKeyPair(); + const DHr = bobDhPublicKey; + const dhOut = x25519.getSharedSecret(DHs.privateKey, DHr); + const { rk: RK, ck: CKs } = kdfRk(sharedKey, dhOut); -async function rootStep( - rootKey: Uint8Array, - sharedSecret: Uint8Array, -): Promise<{ rootKey: Uint8Array; chainKey: Uint8Array }> { - const material = await hkdf( - sharedSecret, - rootKey, - 'DripTide Double Ratchet root step', - 64, - ); return { - rootKey: material.subarray(0, 32), - chainKey: material.subarray(32, 64), + DHs, + DHr, + RK, + CKs, + CKr: null, + Ns: 0, + Nr: 0, + PN: 0, + MKSKIPPED: new Map(), }; } -async function chainStep( - chainKey: Uint8Array, -): Promise<{ messageKey: Uint8Array; nextChainKey: Uint8Array }> { +export function initBob(sharedKey: Uint8Array, bobKeyPair: KeyPair): DoubleRatchetState { return { - messageKey: await hmac(chainKey, 'DripTide Double Ratchet message key'), - nextChainKey: await hmac(chainKey, 'DripTide Double Ratchet chain key'), + DHs: bobKeyPair, + DHr: null, + RK: sharedKey, + CKs: null, + CKr: null, + Ns: 0, + Nr: 0, + PN: 0, + MKSKIPPED: new Map(), }; } -function headerBytes(header: RatchetHeader): Uint8Array { - return bytes(JSON.stringify(header)); -} - -async function encryptWithKey( - messageKey: Uint8Array, - plaintext: string, - aad: Uint8Array, -): Promise { - const iv = new Uint8Array(12); - webCrypto().getRandomValues(iv); - const key = await webCrypto().subtle.importKey( - 'raw', - source(messageKey), - 'AES-GCM', - false, - ['encrypt'], - ); - const encrypted = new Uint8Array( - await webCrypto().subtle.encrypt( - { name: 'AES-GCM', iv: source(iv), additionalData: source(aad) }, - key, - source(bytes(plaintext)), - ), - ); - const result = new Uint8Array(iv.length + encrypted.length); - result.set(iv); - result.set(encrypted, iv.length); - return result; -} - -async function decryptWithKey( - messageKey: Uint8Array, - payload: Uint8Array, - aad: Uint8Array, -): Promise { - if (payload.length < 13) throw new Error('Invalid ratchet ciphertext'); - const key = await webCrypto().subtle.importKey( - 'raw', - source(messageKey), - 'AES-GCM', - false, - ['decrypt'], - ); - const plaintext = await webCrypto().subtle.decrypt( - { - name: 'AES-GCM', - iv: source(payload.subarray(0, 12)), - additionalData: source(aad), - }, - key, - source(payload.subarray(12)), - ); - return text(new Uint8Array(plaintext)); -} - -function openDatabase(): Promise { - if (typeof indexedDB === 'undefined') { - return Promise.reject( - new Error('IndexedDB is unavailable; ratchet state cannot be persisted'), - ); +function pruneSkippedKeys(mkSkipped: Map): void { + while (mkSkipped.size > MAX_SKIPPED_KEYS) { + const firstKey = mkSkipped.keys().next().value; + if (firstKey !== undefined) { + mkSkipped.delete(firstKey); + } else { + break; + } } - - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); - request.onerror = () => { - reject(request.error ?? new Error('Unable to open crypto database')); - }; - request.onupgradeneeded = () => { - const database = request.result; - if (!database.objectStoreNames.contains(STORE_NAME)) { - database.createObjectStore(STORE_NAME, { keyPath: 'id' }); - } - }; - request.onsuccess = () => resolve(request.result); - }); -} - -async function readState(sessionId: string): Promise { - const database = await openDatabase(); - return new Promise((resolve, reject) => { - const request = database - .transaction(STORE_NAME, 'readonly') - .objectStore(STORE_NAME) - .get(sessionId); - request.onerror = () => { - reject(request.error ?? new Error('Unable to read ratchet state')); - }; - request.onsuccess = () => resolve(request.result as RatchetState | undefined); - }); -} - -async function writeState(state: RatchetState): Promise { - const database = await openDatabase(); - await new Promise((resolve, reject) => { - const request = database - .transaction(STORE_NAME, 'readwrite') - .objectStore(STORE_NAME) - .put({ ...state, id: state.sessionId } satisfies StoredRatchetState); - request.onerror = () => { - reject(request.error ?? new Error('Unable to write ratchet state')); - }; - request.onsuccess = () => resolve(); - }); } -export async function deleteRatchetSession(sessionId: string): Promise { - const database = await openDatabase(); - await new Promise((resolve, reject) => { - const request = database - .transaction(STORE_NAME, 'readwrite') - .objectStore(STORE_NAME) - .delete(sessionId); - request.onerror = () => { - reject(request.error ?? new Error('Unable to delete ratchet state')); - }; - request.onsuccess = () => resolve(); - }); -} - -export async function createRatchetSession(session: RatchetSession): Promise { - if (!session.sessionId) throw new Error('Ratchet sessionId is required'); - if (session.initialSecret.length < 32) { - throw new Error('Ratchet initial secret must be at least 32 bytes'); +export function skipMessageKeys(state: DoubleRatchetState, until: number): void { + if (state.Nr + MAX_SKIP < until) { + throw new Error('Too many skipped messages'); } - if (await readState(session.sessionId)) return; - - const pair = await generateDhKeyPair(); - const publicKey = await exportPublicKey(pair.publicKey); - const privateKey = await exportPrivateKey(pair.privateKey); - const initial = await hkdf( - session.initialSecret, - ZERO_SALT, - 'DripTide Double Ratchet initial root', - 64, - ); - const initialSendChain = initial.subarray(32, 64); - const initialReceiveChain = await hkdf( - initial.subarray(0, 32), - initialSendChain, - 'DripTide receive chain', - 32, - ); - await writeState({ - sessionId: session.sessionId, - rootKey: toBase64(initial.subarray(0, 32)), - sendChainKey: toBase64(session.initiator ? initialSendChain : initialReceiveChain), - receiveChainKey: toBase64(session.initiator ? initialReceiveChain : initialSendChain), - sendingPrivateKey: toBase64(privateKey), - sendingPublicKey: toBase64(publicKey), - remotePublicKey: session.remotePublicKey ? toBase64(session.remotePublicKey) : '', - sendMessageNumber: 0, - receiveMessageNumber: 0, - previousSendingLength: 0, - sendRatchetPending: !session.initiator, - updatedAt: Date.now(), - }); + if (state.CKr) { + while (state.Nr < until) { + const { ck, mk } = kdfCk(state.CKr); + state.CKr = ck; + const keyId = skippedKeyId(state.DHr, state.Nr); + state.MKSKIPPED.set(keyId, mk); + pruneSkippedKeys(state.MKSKIPPED); + state.Nr++; + } + } } -export async function loadRatchetSession(sessionId: string): Promise { - return (await readState(sessionId)) !== undefined; +async function encryptAesGcm(key: Uint8Array, plaintext: Uint8Array, ad: Uint8Array): Promise<{ ciphertext: Uint8Array; iv: Uint8Array }> { + const cryptoKey = await crypto.subtle.importKey('raw', toBufferSource(key), { name: 'AES-GCM' }, false, ['encrypt']); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv, additionalData: toBufferSource(ad) }, + cryptoKey, + toBufferSource(plaintext), + ); + return { ciphertext: new Uint8Array(encrypted), iv }; } -async function performSendingRatchet(state: RatchetState): Promise { - if (!state.remotePublicKey) return; - - const remoteKey = await importPublicKey(fromBase64(state.remotePublicKey)); - const pair = await generateDhKeyPair(); - const stepped = await rootStep( - fromBase64(state.rootKey), - await dh(pair.privateKey, remoteKey), +async function decryptAesGcm(key: Uint8Array, ciphertext: Uint8Array, iv: Uint8Array, ad: Uint8Array): Promise { + const cryptoKey = await crypto.subtle.importKey('raw', toBufferSource(key), { name: 'AES-GCM' }, false, ['decrypt']); + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: toBufferSource(iv), additionalData: toBufferSource(ad) }, + cryptoKey, + toBufferSource(ciphertext), ); - - state.rootKey = toBase64(stepped.rootKey); - state.sendChainKey = toBase64(stepped.chainKey); - state.sendingPrivateKey = toBase64(await exportPrivateKey(pair.privateKey)); - state.sendingPublicKey = toBase64(await exportPublicKey(pair.publicKey)); - state.previousSendingLength = state.sendMessageNumber; - state.sendMessageNumber = 0; - state.sendRatchetPending = false; + return new Uint8Array(decrypted); } -export async function encryptDm( - sessionId: string, - recipientDeviceId: string, - plaintext: string, -): Promise { - const state = await readState(sessionId); - if (!state) throw new Error('Ratchet session is not initialized'); - - if (state.sendRatchetPending) { - await performSendingRatchet(state); +export async function ratchetEncrypt( + state: DoubleRatchetState, + plaintext: string | Uint8Array, + associatedData: Uint8Array = new Uint8Array(0), +): Promise { + if (!state.CKs) { + throw new Error('Send chain key not initialized'); } - const header: RatchetHeader = { - v: PROTOCOL_VERSION, - dh: state.sendingPublicKey, - n: state.sendMessageNumber, - pn: state.previousSendingLength, + const plaintextBytes = typeof plaintext === 'string' ? new TextEncoder().encode(plaintext) : plaintext; + const { ck, mk } = kdfCk(state.CKs); + state.CKs = ck; + + const header: Header = { + dh: state.DHs.publicKey, + pn: state.PN, + n: state.Ns, }; - const step = await chainStep(fromBase64(state.sendChainKey)); - const encrypted = await encryptWithKey(step.messageKey, plaintext, headerBytes(header)); - state.sendChainKey = toBase64(step.nextChainKey); - state.sendMessageNumber += 1; - state.updatedAt = Date.now(); - await writeState(state); + state.Ns++; + + const { ciphertext, iv } = await encryptAesGcm(mk, plaintextBytes, associatedData); return { - recipientDeviceId, - ciphertext: JSON.stringify({ - v: PROTOCOL_VERSION, - h: header, - c: toBase64(encrypted), - }), + header: { + dh: toBase64(header.dh), + pn: header.pn, + n: header.n, + }, + ciphertext: toBase64(ciphertext), + iv: toBase64(iv), }; } -export async function decryptDm(sessionId: string, ciphertext: string): Promise { - const state = await readState(sessionId); - if (!state) throw new Error('Ratchet session is not initialized'); - - let envelope: { v: number; h: RatchetHeader; c: string }; - try { - envelope = JSON.parse(ciphertext) as { v: number; h: RatchetHeader; c: string }; - } catch { - throw new Error('Invalid ratchet envelope'); +export async function ratchetDecrypt( + state: DoubleRatchetState, + payload: EncryptedMessagePayload, + associatedData: Uint8Array = new Uint8Array(0), +): Promise { + const headerDh = fromBase64(payload.header.dh); + const ciphertext = fromBase64(payload.ciphertext); + const iv = fromBase64(payload.iv); + + const keyId = skippedKeyId(headerDh, payload.header.n); + if (state.MKSKIPPED.has(keyId)) { + const mk = state.MKSKIPPED.get(keyId)!; + state.MKSKIPPED.delete(keyId); + const plaintextBytes = await decryptAesGcm(mk, ciphertext, iv, associatedData); + return new TextDecoder().decode(plaintextBytes); } - if ( - envelope.v !== PROTOCOL_VERSION || - !envelope.h || - typeof envelope.h.dh !== 'string' || - !Number.isInteger(envelope.h.n) || - !Number.isInteger(envelope.h.pn) || - typeof envelope.c !== 'string' - ) { - throw new Error('Unsupported ratchet envelope'); - } - if (envelope.h.n !== state.receiveMessageNumber) { - throw new Error('Out-of-order ratchet messages are not supported'); + const dhChanged = + !state.DHr || + headerDh.length !== state.DHr.length || + !headerDh.every((b, i) => b === state.DHr![i]); + + if (dhChanged) { + skipMessageKeys(state, payload.header.pn); + + state.DHr = headerDh; + const dhSendReceive = x25519.getSharedSecret(state.DHs.privateKey, state.DHr); + const { rk: rk1, ck: ckRecv } = kdfRk(state.RK, dhSendReceive); + state.RK = rk1; + state.CKr = ckRecv; + + state.DHs = generateKeyPair(); + const dhSendSend = x25519.getSharedSecret(state.DHs.privateKey, state.DHr); + const { rk: rk2, ck: ckSend } = kdfRk(state.RK, dhSendSend); + state.RK = rk2; + state.CKs = ckSend; + + state.PN = state.Ns; + state.Ns = 0; + state.Nr = 0; } - const isFirstPeerMessage = !state.remotePublicKey; - const peerChanged = - !isFirstPeerMessage && envelope.h.dh !== state.remotePublicKey; - - if (peerChanged) { - const privateKey = await importPrivateKey(fromBase64(state.sendingPrivateKey)); - const peerKey = await importPublicKey(fromBase64(envelope.h.dh)); - const stepped = await rootStep( - fromBase64(state.rootKey), - await dh(privateKey, peerKey), - ); - state.rootKey = toBase64(stepped.rootKey); - state.receiveChainKey = toBase64(stepped.chainKey); - state.receiveMessageNumber = 0; - state.remotePublicKey = envelope.h.dh; - state.sendRatchetPending = true; - } else if (isFirstPeerMessage) { - state.remotePublicKey = envelope.h.dh; - state.sendRatchetPending = true; + skipMessageKeys(state, payload.header.n); + + if (!state.CKr) { + throw new Error('Receive chain key not initialized'); } - const step = await chainStep(fromBase64(state.receiveChainKey)); - const plaintext = await decryptWithKey( - step.messageKey, - fromBase64(envelope.c), - headerBytes(envelope.h), - ); + const { ck, mk } = kdfCk(state.CKr); + state.CKr = ck; + state.Nr++; - state.receiveChainKey = toBase64(step.nextChainKey); - state.receiveMessageNumber += 1; - state.updatedAt = Date.now(); - await writeState(state); - return plaintext; + const plaintextBytes = await decryptAesGcm(mk, ciphertext, iv, associatedData); + return new TextDecoder().decode(plaintextBytes); } diff --git a/apps/web/src/lib/crypto/e2ee.test.ts b/apps/web/src/lib/crypto/e2ee.test.ts index 1a0573f..4643152 100644 --- a/apps/web/src/lib/crypto/e2ee.test.ts +++ b/apps/web/src/lib/crypto/e2ee.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import { sealedBoxEncrypt, buildEnvelopes } from '../crypto'; import { setSessionKey, @@ -26,12 +26,14 @@ function bytesToB64(bytes: Uint8Array): string { } function generateEd25519SpkiPublicKey(): string { - const raw = new Uint8Array(32); - crypto.getRandomValues(raw); + // Ed25519 SPKI DER is a fixed 12-byte header followed by the 32-byte key: + // 30 2A SEQUENCE(42) + // 30 05 SEQUENCE(5) 06 03 2B 65 70 OID 1.3.101.112 + // 03 21 00 BIT STRING(33, 0 unused) const spki = new Uint8Array(44); - spki.set([48, 42, 48, 5, 6, 3, 43, 101, 112, 3, 34, 0, 4, 32], 0); - spki.set(raw, 14); - return bytesToB64(spki); + spki.set([0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00], 0); + crypto.getRandomValues(spki.subarray(12)); + return bytesToB64(spki as Uint8Array); } function generateTestKey(): string { @@ -45,13 +47,64 @@ function buildEncryptedEnvelopePlaintext(iv: string, ct: string, sig?: string): return btoa(JSON.stringify(payload)); } +/** + * crypto.getRandomValues rejects requests over 65,536 bytes, so anything + * larger has to be filled in chunks. + */ +function randomBytes(length: number): Uint8Array { + const bytes = new Uint8Array(length); + const MAX_CHUNK = 65_536; + for (let offset = 0; offset < length; offset += MAX_CHUNK) { + crypto.getRandomValues(bytes.subarray(offset, Math.min(offset + MAX_CHUNK, length))); + } + return bytes; +} + +/** + * downloadAndDecryptFile does two real fetches (presigned URL, then the + * ciphertext). These are round-trip tests of the crypto, not of the transport, + * so both are served from memory — no backend required. + */ +function stubDownload(cipherBlob: Blob): void { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/files/')) { + return new Response(JSON.stringify({ url: 'https://storage.test/object' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response(await cipherBlob.arrayBuffer(), { status: 200 }); + }), + ); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +/** + * Identity pinning is trust-on-first-use, keyed by userId. Each test gets a + * fresh owner so one test's pinned keys can't make the next look like a key + * change. + */ +let deviceOwnerSeq = 0; +function makeDevices(ids: string[]) { + deviceOwnerSeq += 1; + const userId = `user-${deviceOwnerSeq}`; + return ids.map((id) => ({ + id, + userId, + identityPublicKey: generateEd25519SpkiPublicKey(), + })); +} + describe('Per-device message encryption (#353)', () => { it('produces distinct ciphertext per recipient device', async () => { const plaintext = 'Hello, E2EE world!'; - const devices = [ - { id: 'device-a', identityPublicKey: generateEd25519SpkiPublicKey() }, - { id: 'device-b', identityPublicKey: generateEd25519SpkiPublicKey() }, - ]; + const devices = makeDevices(['device-a', 'device-b']); const envelopes = await buildEnvelopes(plaintext, devices); @@ -63,9 +116,7 @@ describe('Per-device message encryption (#353)', () => { it('ciphertext differs from plaintext input', async () => { const plaintext = 'Sensitive message content'; - const devices = [ - { id: 'device-a', identityPublicKey: generateEd25519SpkiPublicKey() }, - ]; + const devices = makeDevices(['device-a']); const envelopes = await buildEnvelopes(plaintext, devices); @@ -75,9 +126,7 @@ describe('Per-device message encryption (#353)', () => { it('each envelope is a valid sealed box wire format', async () => { const plaintext = 'Test message'; - const devices = [ - { id: 'device-a', identityPublicKey: generateEd25519SpkiPublicKey() }, - ]; + const devices = makeDevices(['device-a']); const envelopes = await buildEnvelopes(plaintext, devices); @@ -96,9 +145,6 @@ describe('Inbound decrypt round trip (#354)', () => { false, ['encrypt', 'decrypt'], ); - const rawKey = new Uint8Array(await crypto.subtle.exportKey('raw', sessionKey)); - const keyB64 = bytesToB64(rawKey); - setSessionKey(senderDeviceId, sessionKey); const iv = crypto.getRandomValues(new Uint8Array(12)); @@ -156,6 +202,8 @@ describe('File encryption round trip (#355)', () => { expect(fileKeyB64.length).toBeGreaterThan(0); expect(ivB64.length).toBeGreaterThan(0); + stubDownload(cipherBlob); + const decryptedBlob = await downloadAndDecryptFile( 'fake-file-id', fileKeyB64, @@ -192,12 +240,13 @@ describe('File encryption round trip (#355)', () => { describe('Encrypted thumbnail round trip (#356)', () => { it('encrypts and decrypts a thumbnail-sized blob', async () => { - const thumbnailBytes = new Uint8Array(320 * 320 * 3); - crypto.getRandomValues(thumbnailBytes); - const thumbnailBlob = new Blob([thumbnailBytes], { type: 'image/jpeg' }); + const thumbnailBytes = randomBytes(320 * 320 * 3); + const thumbnailBlob = new Blob([new Uint8Array(thumbnailBytes)], { type: 'image/jpeg' }); const { cipherBlob, fileKeyB64, ivB64 } = await encryptFile(thumbnailBlob); + stubDownload(cipherBlob); + const decrypted = await downloadAndDecryptFile( 'fake-thumb-id', fileKeyB64, @@ -211,9 +260,8 @@ describe('Encrypted thumbnail round trip (#356)', () => { }); it('thumbnail ciphertext never exposes plaintext bytes', async () => { - const thumbnailBytes = new Uint8Array(100); - crypto.getRandomValues(thumbnailBytes); - const thumbnailBlob = new Blob([thumbnailBytes], { type: 'image/jpeg' }); + const thumbnailBytes = randomBytes(100); + const thumbnailBlob = new Blob([new Uint8Array(thumbnailBytes)], { type: 'image/jpeg' }); const { cipherBlob } = await encryptFile(thumbnailBlob); const cipherArray = new Uint8Array(await cipherBlob.arrayBuffer()); diff --git a/apps/web/src/lib/crypto/ratchetSession.ts b/apps/web/src/lib/crypto/ratchetSession.ts new file mode 100644 index 0000000..abba9f3 --- /dev/null +++ b/apps/web/src/lib/crypto/ratchetSession.ts @@ -0,0 +1,465 @@ +/** + * IndexedDB-backed Double Ratchet session store. + * + * NOTE: this layer deliberately rejects out-of-order messages + * ("Out-of-order ratchet messages are not supported" in decryptDm). The + * skipped-message-key handling required by #360 lives in `doubleRatchet.ts`; + * these two have not been reconciled yet, and neither is wired into the app. + */ +const DB_NAME = 'driptide-crypto'; +const DB_VERSION = 1; +const STORE_NAME = 'double-ratchet-sessions'; +const PROTOCOL_VERSION = 1; +const ZERO_SALT = new Uint8Array(32); + +type BufferLike = Uint8Array; + +export type RatchetEnvelope = { + recipientDeviceId: string; + ciphertext: string; +}; + +type RatchetHeader = { + v: number; + dh: string; + n: number; + pn: number; +}; + +type RatchetState = { + sessionId: string; + rootKey: string; + sendChainKey: string; + receiveChainKey: string; + sendingPrivateKey: string; + sendingPublicKey: string; + remotePublicKey: string; + sendMessageNumber: number; + receiveMessageNumber: number; + previousSendingLength: number; + sendRatchetPending: boolean; + updatedAt: number; +}; + +type StoredRatchetState = RatchetState & { id: string }; + +export type RatchetSession = { + sessionId: string; + initiator: boolean; + initialSecret: Uint8Array; + remotePublicKey?: Uint8Array; +}; + +function webCrypto(): Crypto { + if (typeof crypto === 'undefined' || !crypto.subtle) { + throw new Error('Web Crypto API is unavailable'); + } + return crypto; +} + +function bytes(value: string): Uint8Array { + return new TextEncoder().encode(value); +} + +function text(value: Uint8Array): string { + return new TextDecoder().decode(value); +} + +function source(value: Uint8Array): BufferSource { + return value as unknown as BufferSource; +} + +function toBase64(value: Uint8Array): string { + let result = ''; + for (let offset = 0; offset < value.length; offset += 0x8000) { + result += String.fromCharCode(...value.subarray(offset, offset + 0x8000)); + } + return btoa(result); +} + +function fromBase64(value: string): Uint8Array { + const decoded = atob(value); + const result = new Uint8Array(decoded.length); + for (let index = 0; index < decoded.length; index += 1) { + result[index] = decoded.charCodeAt(index); + } + return result; +} + +async function hkdf( + input: BufferLike, + salt: BufferLike, + info: string, + length: number, +): Promise { + const subtle = webCrypto().subtle; + const key = await subtle.importKey('raw', source(input), 'HKDF', false, ['deriveBits']); + const result = await subtle.deriveBits( + { name: 'HKDF', hash: 'SHA-256', salt: source(salt), info: source(bytes(info)) }, + key, + length * 8, + ); + return new Uint8Array(result); +} + +async function hmac(keyBytes: Uint8Array, label: string): Promise { + const subtle = webCrypto().subtle; + const key = await subtle.importKey( + 'raw', + source(keyBytes), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + return new Uint8Array(await subtle.sign('HMAC', key, source(bytes(label)))); +} + +async function generateDhKeyPair(): Promise { + return (await webCrypto().subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, + true, + ['deriveBits'], + )) as CryptoKeyPair; +} + +async function exportPublicKey(key: CryptoKey): Promise { + return new Uint8Array(await webCrypto().subtle.exportKey('raw', key)); +} + +async function exportPrivateKey(key: CryptoKey): Promise { + return new Uint8Array(await webCrypto().subtle.exportKey('pkcs8', key)); +} + +async function importPrivateKey(value: Uint8Array): Promise { + return webCrypto().subtle.importKey( + 'pkcs8', + source(value), + { name: 'ECDH', namedCurve: 'P-256' }, + false, + ['deriveBits'], + ); +} + +async function importPublicKey(value: Uint8Array): Promise { + return webCrypto().subtle.importKey( + 'raw', + source(value), + { name: 'ECDH', namedCurve: 'P-256' }, + false, + [], + ); +} + +async function dh(privateKey: CryptoKey, publicKey: CryptoKey): Promise { + return new Uint8Array( + await webCrypto().subtle.deriveBits( + { name: 'ECDH', public: publicKey }, + privateKey, + 256, + ), + ); +} + +async function rootStep( + rootKey: Uint8Array, + sharedSecret: Uint8Array, +): Promise<{ rootKey: Uint8Array; chainKey: Uint8Array }> { + const material = await hkdf( + sharedSecret, + rootKey, + 'DripTide Double Ratchet root step', + 64, + ); + return { + rootKey: material.subarray(0, 32), + chainKey: material.subarray(32, 64), + }; +} + +async function chainStep( + chainKey: Uint8Array, +): Promise<{ messageKey: Uint8Array; nextChainKey: Uint8Array }> { + return { + messageKey: await hmac(chainKey, 'DripTide Double Ratchet message key'), + nextChainKey: await hmac(chainKey, 'DripTide Double Ratchet chain key'), + }; +} + +function headerBytes(header: RatchetHeader): Uint8Array { + return bytes(JSON.stringify(header)); +} + +async function encryptWithKey( + messageKey: Uint8Array, + plaintext: string, + aad: Uint8Array, +): Promise { + const iv = new Uint8Array(12); + webCrypto().getRandomValues(iv); + const key = await webCrypto().subtle.importKey( + 'raw', + source(messageKey), + 'AES-GCM', + false, + ['encrypt'], + ); + const encrypted = new Uint8Array( + await webCrypto().subtle.encrypt( + { name: 'AES-GCM', iv: source(iv), additionalData: source(aad) }, + key, + source(bytes(plaintext)), + ), + ); + const result = new Uint8Array(iv.length + encrypted.length); + result.set(iv); + result.set(encrypted, iv.length); + return result; +} + +async function decryptWithKey( + messageKey: Uint8Array, + payload: Uint8Array, + aad: Uint8Array, +): Promise { + if (payload.length < 13) throw new Error('Invalid ratchet ciphertext'); + const key = await webCrypto().subtle.importKey( + 'raw', + source(messageKey), + 'AES-GCM', + false, + ['decrypt'], + ); + const plaintext = await webCrypto().subtle.decrypt( + { + name: 'AES-GCM', + iv: source(payload.subarray(0, 12)), + additionalData: source(aad), + }, + key, + source(payload.subarray(12)), + ); + return text(new Uint8Array(plaintext)); +} + +function openDatabase(): Promise { + if (typeof indexedDB === 'undefined') { + return Promise.reject( + new Error('IndexedDB is unavailable; ratchet state cannot be persisted'), + ); + } + + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onerror = () => { + reject(request.error ?? new Error('Unable to open crypto database')); + }; + request.onupgradeneeded = () => { + const database = request.result; + if (!database.objectStoreNames.contains(STORE_NAME)) { + database.createObjectStore(STORE_NAME, { keyPath: 'id' }); + } + }; + request.onsuccess = () => resolve(request.result); + }); +} + +async function readState(sessionId: string): Promise { + const database = await openDatabase(); + return new Promise((resolve, reject) => { + const request = database + .transaction(STORE_NAME, 'readonly') + .objectStore(STORE_NAME) + .get(sessionId); + request.onerror = () => { + reject(request.error ?? new Error('Unable to read ratchet state')); + }; + request.onsuccess = () => resolve(request.result as RatchetState | undefined); + }); +} + +async function writeState(state: RatchetState): Promise { + const database = await openDatabase(); + await new Promise((resolve, reject) => { + const request = database + .transaction(STORE_NAME, 'readwrite') + .objectStore(STORE_NAME) + .put({ ...state, id: state.sessionId } satisfies StoredRatchetState); + request.onerror = () => { + reject(request.error ?? new Error('Unable to write ratchet state')); + }; + request.onsuccess = () => resolve(); + }); +} + +export async function deleteRatchetSession(sessionId: string): Promise { + const database = await openDatabase(); + await new Promise((resolve, reject) => { + const request = database + .transaction(STORE_NAME, 'readwrite') + .objectStore(STORE_NAME) + .delete(sessionId); + request.onerror = () => { + reject(request.error ?? new Error('Unable to delete ratchet state')); + }; + request.onsuccess = () => resolve(); + }); +} + +export async function createRatchetSession(session: RatchetSession): Promise { + if (!session.sessionId) throw new Error('Ratchet sessionId is required'); + if (session.initialSecret.length < 32) { + throw new Error('Ratchet initial secret must be at least 32 bytes'); + } + if (await readState(session.sessionId)) return; + + const pair = await generateDhKeyPair(); + const publicKey = await exportPublicKey(pair.publicKey); + const privateKey = await exportPrivateKey(pair.privateKey); + const initial = await hkdf( + session.initialSecret, + ZERO_SALT, + 'DripTide Double Ratchet initial root', + 64, + ); + const initialSendChain = initial.subarray(32, 64); + const initialReceiveChain = await hkdf( + initial.subarray(0, 32), + initialSendChain, + 'DripTide receive chain', + 32, + ); + + await writeState({ + sessionId: session.sessionId, + rootKey: toBase64(initial.subarray(0, 32)), + sendChainKey: toBase64(session.initiator ? initialSendChain : initialReceiveChain), + receiveChainKey: toBase64(session.initiator ? initialReceiveChain : initialSendChain), + sendingPrivateKey: toBase64(privateKey), + sendingPublicKey: toBase64(publicKey), + remotePublicKey: session.remotePublicKey ? toBase64(session.remotePublicKey) : '', + sendMessageNumber: 0, + receiveMessageNumber: 0, + previousSendingLength: 0, + sendRatchetPending: !session.initiator, + updatedAt: Date.now(), + }); +} + +export async function loadRatchetSession(sessionId: string): Promise { + return (await readState(sessionId)) !== undefined; +} + +async function performSendingRatchet(state: RatchetState): Promise { + if (!state.remotePublicKey) return; + + const remoteKey = await importPublicKey(fromBase64(state.remotePublicKey)); + const pair = await generateDhKeyPair(); + const stepped = await rootStep( + fromBase64(state.rootKey), + await dh(pair.privateKey, remoteKey), + ); + + state.rootKey = toBase64(stepped.rootKey); + state.sendChainKey = toBase64(stepped.chainKey); + state.sendingPrivateKey = toBase64(await exportPrivateKey(pair.privateKey)); + state.sendingPublicKey = toBase64(await exportPublicKey(pair.publicKey)); + state.previousSendingLength = state.sendMessageNumber; + state.sendMessageNumber = 0; + state.sendRatchetPending = false; +} + +export async function encryptDm( + sessionId: string, + recipientDeviceId: string, + plaintext: string, +): Promise { + const state = await readState(sessionId); + if (!state) throw new Error('Ratchet session is not initialized'); + + if (state.sendRatchetPending) { + await performSendingRatchet(state); + } + + const header: RatchetHeader = { + v: PROTOCOL_VERSION, + dh: state.sendingPublicKey, + n: state.sendMessageNumber, + pn: state.previousSendingLength, + }; + const step = await chainStep(fromBase64(state.sendChainKey)); + const encrypted = await encryptWithKey(step.messageKey, plaintext, headerBytes(header)); + + state.sendChainKey = toBase64(step.nextChainKey); + state.sendMessageNumber += 1; + state.updatedAt = Date.now(); + await writeState(state); + + return { + recipientDeviceId, + ciphertext: JSON.stringify({ + v: PROTOCOL_VERSION, + h: header, + c: toBase64(encrypted), + }), + }; +} + +export async function decryptDm(sessionId: string, ciphertext: string): Promise { + const state = await readState(sessionId); + if (!state) throw new Error('Ratchet session is not initialized'); + + let envelope: { v: number; h: RatchetHeader; c: string }; + try { + envelope = JSON.parse(ciphertext) as { v: number; h: RatchetHeader; c: string }; + } catch { + throw new Error('Invalid ratchet envelope'); + } + + if ( + envelope.v !== PROTOCOL_VERSION || + !envelope.h || + typeof envelope.h.dh !== 'string' || + !Number.isInteger(envelope.h.n) || + !Number.isInteger(envelope.h.pn) || + typeof envelope.c !== 'string' + ) { + throw new Error('Unsupported ratchet envelope'); + } + if (envelope.h.n !== state.receiveMessageNumber) { + throw new Error('Out-of-order ratchet messages are not supported'); + } + + const isFirstPeerMessage = !state.remotePublicKey; + const peerChanged = + !isFirstPeerMessage && envelope.h.dh !== state.remotePublicKey; + + if (peerChanged) { + const privateKey = await importPrivateKey(fromBase64(state.sendingPrivateKey)); + const peerKey = await importPublicKey(fromBase64(envelope.h.dh)); + const stepped = await rootStep( + fromBase64(state.rootKey), + await dh(privateKey, peerKey), + ); + state.rootKey = toBase64(stepped.rootKey); + state.receiveChainKey = toBase64(stepped.chainKey); + state.receiveMessageNumber = 0; + state.remotePublicKey = envelope.h.dh; + state.sendRatchetPending = true; + } else if (isFirstPeerMessage) { + state.remotePublicKey = envelope.h.dh; + state.sendRatchetPending = true; + } + + const step = await chainStep(fromBase64(state.receiveChainKey)); + const plaintext = await decryptWithKey( + step.messageKey, + fromBase64(envelope.c), + headerBytes(envelope.h), + ); + + state.receiveChainKey = toBase64(step.nextChainKey); + state.receiveMessageNumber += 1; + state.updatedAt = Date.now(); + await writeState(state); + return plaintext; +} diff --git a/apps/web/src/lib/cryptoStore.ts b/apps/web/src/lib/cryptoStore.ts index 2c2c2c9..b5f705d 100644 --- a/apps/web/src/lib/cryptoStore.ts +++ b/apps/web/src/lib/cryptoStore.ts @@ -221,6 +221,32 @@ class CryptoStore { return keyData.publicKey; } + /** + * The identity private key in JWK form. + * + * Used to derive the local cache-encryption key: deriving it from the public + * key would be pointless, since anyone who can read the cache can also read + * the public key. Prefers exporting the stored CryptoKey so the value stays + * correct even if the legacy record is absent. + */ + async getIdentityPrivateKeyJwk(): Promise { + const stored = await this.dbGet<{ keyPair: CryptoKeyPair; createdAt: number }>( + 'identityKeyPair', + 'current', + ); + + if (stored?.keyPair?.privateKey) { + try { + return await getWebCrypto().subtle.exportKey('jwk', stored.keyPair.privateKey); + } catch { + // Non-extractable key (older record): fall through to the stored JWK. + } + } + + const legacyKey = await this.dbGet('keys', 'identity_keypair'); + return legacyKey?.privateKey ?? null; + } + /** * Initialize or retrieve the identity key, ensuring the private key is persisted. */ diff --git a/apps/web/src/lib/fileEncryption.ts b/apps/web/src/lib/fileEncryption.ts index f3aa91d..f9c6e28 100644 --- a/apps/web/src/lib/fileEncryption.ts +++ b/apps/web/src/lib/fileEncryption.ts @@ -22,7 +22,7 @@ * ✓ Download path decrypts + verifies AEAD tag */ -import { buildEnvelopes, type DeviceRecord, type MessageEnvelope } from './crypto.js'; +import { buildEnvelopes, type DeviceRecord, type MessageEnvelope } from './crypto'; // ─── Types ──────────────────────────────────────────────────────────────────── diff --git a/apps/web/src/lib/identityTrust.test.ts b/apps/web/src/lib/identityTrust.test.ts index 250d437..45ab86a 100644 --- a/apps/web/src/lib/identityTrust.test.ts +++ b/apps/web/src/lib/identityTrust.test.ts @@ -17,7 +17,7 @@ import { onSessionReset, trustDevices, type TrustedDevice, -} from './identityTrust.js'; +} from './identityTrust'; let userCounter = 0; function freshUserId(): string { diff --git a/apps/web/src/lib/mls.ts b/apps/web/src/lib/mls.ts index 131360e..225ee8c 100644 --- a/apps/web/src/lib/mls.ts +++ b/apps/web/src/lib/mls.ts @@ -58,14 +58,14 @@ async function loadBinding(): Promise { throw new Error('MLS is only available in a browser context'); } - const module = (await import(MODULE_NAME)) as OpenMlsModule; - const initializer = module.default ?? module.init; + const binding = (await import(MODULE_NAME)) as OpenMlsModule; + const initializer = binding.default ?? binding.init; if (initializer) await initializer(); - return module; + return binding; } -function createBinding(module: OpenMlsModule, options: MlsClientOptions): OpenMlsInstance { - const Constructor = module.MlsClient ?? module.WasmClient ?? module.Client; +function createBinding(binding: OpenMlsModule, options: MlsClientOptions): OpenMlsInstance { + const Constructor = binding.MlsClient ?? binding.WasmClient ?? binding.Client; if (!Constructor) throw new Error('OpenMLS WASM client constructor is unavailable'); return new Constructor(options.credential, MLS_CIPHERSUITE, options.state ?? null); @@ -81,8 +81,8 @@ export class MlsClient { if (!groupId) throw new Error('groupId is required'); if (!(options.credential instanceof Uint8Array)) throw new Error('credential must be Uint8Array'); - const module = await loadBinding(); - return new MlsClient(createBinding(module, options), groupId); + const loaded = await loadBinding(); + return new MlsClient(createBinding(loaded, options), groupId); } async createGroup(): Promise { diff --git a/apps/web/src/lib/session.ts b/apps/web/src/lib/session.ts index 4ffca8d..676f3ef 100644 --- a/apps/web/src/lib/session.ts +++ b/apps/web/src/lib/session.ts @@ -15,12 +15,12 @@ * docs/signal-integration.md (created in this commit). */ -import type { DeviceRecord, MessageEnvelope } from './crypto.js'; +import type { DeviceRecord, MessageEnvelope } from './crypto'; import { assertDevicesTrusted, buildEnvelopes as phase1BuildEnvelopes, sealedBoxEncrypt, -} from './crypto.js'; +} from './crypto'; // ─── Interface ──────────────────────────────────────────────────────────────── diff --git a/apps/web/src/lib/signalClient.test.ts b/apps/web/src/lib/signalClient.test.ts index 93de188..f0320c6 100644 --- a/apps/web/src/lib/signalClient.test.ts +++ b/apps/web/src/lib/signalClient.test.ts @@ -15,15 +15,15 @@ import { rawEd25519PublicKeyToSpki, toBase64, type PreKeyBundle, -} from './x3dh.js'; -import type { DeviceRecord } from './crypto.js'; -import { clearAllSessions, hasSession } from './signalSession.js'; +} from './x3dh'; +import type { DeviceRecord } from './crypto'; +import { clearAllSessions, hasSession } from './signalSession'; import { configureSignalClient, resetSignalClientConfig, SignalClient, type FetchKeyBundle, -} from './signalClient.js'; +} from './signalClient'; function buildBundle(deviceId: string): PreKeyBundle { const identity = generateIdentityKeyPair(); diff --git a/apps/web/src/lib/signalClient.ts b/apps/web/src/lib/signalClient.ts index b0ab443..3b19477 100644 --- a/apps/web/src/lib/signalClient.ts +++ b/apps/web/src/lib/signalClient.ts @@ -33,14 +33,14 @@ * & audit status: see docs/signal-integration.md. */ -import type { DeviceRecord, MessageEnvelope } from './crypto.js'; -import { toBase64, type IdentityKeyPair, type PreKeyBundle } from './x3dh.js'; +import type { DeviceRecord, MessageEnvelope } from './crypto'; +import { toBase64, type IdentityKeyPair, type PreKeyBundle } from './x3dh'; import { establishSession, hasSession, ratchetEncryptStep, type InitialMessageHeader, -} from './signalSession.js'; +} from './signalSession'; export { hasSession }; @@ -107,21 +107,34 @@ async function ensureSession(device: DeviceRecord): Promise { // ─── AES-GCM message encryption ─────────────────────────────────────────────── +/** + * WebCrypto's BufferSource requires a view backed by a plain ArrayBuffer, + * while a Uint8Array may be backed by a SharedArrayBuffer. Copying into a + * fresh array satisfies the type and costs nothing at these sizes. + */ +function asBufferSource(bytes: Uint8Array): BufferSource { + return new Uint8Array(bytes); +} + async function aesGcmEncrypt( messageKey: Uint8Array, associatedData: Uint8Array, plaintext: string, ): Promise<{ iv: string; ciphertext: string }> { - const key = await crypto.subtle.importKey('raw', messageKey, { name: 'AES-GCM' }, false, [ - 'encrypt', - ]); + const key = await crypto.subtle.importKey( + 'raw', + asBufferSource(messageKey), + { name: 'AES-GCM' }, + false, + ['encrypt'], + ); const iv = crypto.getRandomValues(new Uint8Array(12)); const plaintextBytes = new TextEncoder().encode(plaintext); const encrypted = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv, additionalData: associatedData }, + { name: 'AES-GCM', iv, additionalData: asBufferSource(associatedData) }, key, - plaintextBytes, + asBufferSource(plaintextBytes), ); return { iv: toBase64(iv), ciphertext: toBase64(new Uint8Array(encrypted)) }; diff --git a/apps/web/src/lib/signalSession.test.ts b/apps/web/src/lib/signalSession.test.ts index 3847dd4..19e2ddb 100644 --- a/apps/web/src/lib/signalSession.test.ts +++ b/apps/web/src/lib/signalSession.test.ts @@ -12,8 +12,8 @@ import { rawEd25519PublicKeyToSpki, toBase64, type PreKeyBundle, -} from './x3dh.js'; -import { checkIdentityChange } from './identityTrust.js'; +} from './x3dh'; +import { checkIdentityChange } from './identityTrust'; import { clearAllSessions, deleteSession, @@ -21,7 +21,7 @@ import { getSession, hasSession, ratchetEncryptStep, -} from './signalSession.js'; +} from './signalSession'; function buildResponder(deviceId: string) { const identity = generateIdentityKeyPair(); diff --git a/apps/web/src/lib/signalSession.ts b/apps/web/src/lib/signalSession.ts index e0e8796..7d6abe0 100644 --- a/apps/web/src/lib/signalSession.ts +++ b/apps/web/src/lib/signalSession.ts @@ -23,7 +23,7 @@ import { hkdf } from '@noble/hashes/hkdf'; import { sha256 } from '@noble/hashes/sha2'; -import { onSessionReset } from './identityTrust.js'; +import { onSessionReset } from './identityTrust'; import { initiateSession, rawEd25519PublicKeyToSpki, @@ -31,7 +31,7 @@ import { type IdentityKeyPair, type InitialMessageHeader, type PreKeyBundle, -} from './x3dh.js'; +} from './x3dh'; export type { InitialMessageHeader }; diff --git a/apps/web/src/lib/thumbnail.ts b/apps/web/src/lib/thumbnail.ts index 96dee79..feab76d 100644 --- a/apps/web/src/lib/thumbnail.ts +++ b/apps/web/src/lib/thumbnail.ts @@ -28,7 +28,7 @@ import { requestPresignedUpload, downloadAndDecryptFile, type FileMessagePayload, -} from './fileEncryption.js'; +} from './fileEncryption'; // ─── Constants ──────────────────────────────────────────────────────────────── diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e27607..2069784 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,6 +193,12 @@ importers: '@tailwindcss/postcss': specifier: ^4 version: 4.2.2 + '@testing-library/jest-dom': + specifier: ^7.0.0 + version: 7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.6) + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/node': specifier: ^20 version: 20.19.37 @@ -208,6 +214,9 @@ importers: eslint-config-next: specifier: 16.2.0 version: 16.2.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 tailwindcss: specifier: ^4 version: 4.2.2 @@ -220,6 +229,9 @@ importers: packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -386,6 +398,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -1770,6 +1786,35 @@ packages: '@tailwindcss/postcss@4.2.2': resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@7.0.1': + resolution: {integrity: sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + peerDependencies: + '@testing-library/dom': '>=10 <11' + vitest: '>= 0.32' + peerDependenciesMeta: + vitest: + optional: true + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@tsconfig/node10@1.0.12': resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} @@ -1815,6 +1860,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -2201,6 +2249,10 @@ packages: ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -2215,6 +2267,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -2513,6 +2568,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -2592,6 +2650,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -2615,6 +2677,12 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dotenv@17.3.1: resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} engines: {node: '>=12'} @@ -2966,6 +3034,10 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -3241,6 +3313,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -3587,6 +3663,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3664,6 +3744,10 @@ packages: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} @@ -3930,6 +4014,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-format@29.7.0: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3996,6 +4084,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -4014,6 +4105,10 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + redis-errors@1.2.0: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} @@ -4297,6 +4392,10 @@ packages: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} engines: {node: '>=12'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -4778,6 +4877,8 @@ packages: snapshots: + '@adobe/css-tools@4.5.0': {} + '@alloc/quick-lru@5.2.0': {} '@aws-crypto/crc32@5.2.0': @@ -5095,6 +5196,8 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/runtime@7.29.7': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -6067,6 +6170,39 @@ snapshots: postcss: 8.5.8 tailwindcss: 4.2.2 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.6)': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.1 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + optionalDependencies: + vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.37)(@vitest/coverage-v8@4.1.6)(vite@8.0.12(@types/node@20.19.37)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@tsconfig/node10@1.0.12': {} '@tsconfig/node12@1.0.11': {} @@ -6098,6 +6234,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -6559,6 +6697,8 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-regex@5.0.1: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -6569,6 +6709,10 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -6887,6 +7031,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css.escape@1.5.1: {} + csstype@3.2.3: {} damerau-levenshtein@1.0.8: {} @@ -6949,6 +7095,8 @@ snapshots: depd@2.0.0: {} + dequal@2.0.3: {} + destroy@1.2.0: {} detect-libc@2.1.2: {} @@ -6966,6 +7114,10 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dotenv@17.3.1: {} drizzle-kit@0.31.10: @@ -7551,6 +7703,8 @@ snapshots: extend@3.0.2: {} + fake-indexeddb@6.2.5: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.1: @@ -7824,6 +7978,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + inherits@2.0.4: {} internal-slot@1.1.0: @@ -8164,6 +8320,8 @@ snapshots: dependencies: react: 19.2.4 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -8219,6 +8377,8 @@ snapshots: mimic-fn@4.0.0: {} + min-indent@1.0.1: {} + minimalistic-assert@1.0.1: {} minimatch@10.2.4: @@ -8485,6 +8645,12 @@ snapshots: prettier@3.9.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-format@29.7.0: dependencies: '@jest/schemas': 29.6.3 @@ -8555,6 +8721,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-is@18.3.1: {} react@19.2.4: {} @@ -8573,6 +8741,11 @@ snapshots: real-require@0.2.0: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + redis-errors@1.2.0: {} redis-parser@3.0.0: @@ -9057,6 +9230,10 @@ snapshots: strip-final-newline@3.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@3.1.1: {} strip-literal@2.1.1: From d60b648e2efc5499e18d1a06637fe46785e45938 Mon Sep 17 00:00:00 2001 From: codebestia Date: Fri, 21 Aug 2026 04:05:33 +0100 Subject: [PATCH 3/4] fix(major): full fix --- .prettierignore | 26 + SECURITY_FIXES_SUMMARY.md | 15 + apps/ai_agent/docs/api-chat.md | 23 +- apps/ai_agent/docs/api-index-search.md | 61 +- apps/ai_agent/docs/api-proposals-summarise.md | 52 +- apps/ai_agent/docs/api-transfers-analyse.md | 44 +- .../docs/concepts-transfer-risk-analysis.md | 12 + .../docs/contracts-pydantic-models.md | 16 +- .../docs/contracts-weaviate-schema.md | 13 +- apps/backend/docs/api-auth.md | 50 +- apps/backend/docs/api-conversations.md | 180 +- apps/backend/docs/api-devices.md | 36 +- apps/backend/docs/api-files-uploads.md | 50 +- apps/backend/docs/api-messages-sync.md | 113 +- apps/backend/docs/api-push.md | 10 +- apps/backend/docs/api-treasury.md | 95 +- apps/backend/docs/api-users.md | 316 +-- apps/backend/docs/concepts-delivery-fanout.md | 43 +- .../docs/concepts-gateway-architecture.md | 61 +- .../docs/concepts-storage-push-jobs.md | 2 +- apps/backend/docs/contracts-jwt-auth.md | 28 +- apps/backend/docs/contracts-rest-schemas.md | 227 +- .../docs/contracts-websocket-payloads.md | 24 +- apps/backend/docs/e2ee-onboarding.md | 4 +- .../docs/message-encryption-migration.md | 2 +- apps/backend/docs/security-hardening.md | 72 +- ...e_mandarin.sql => 0000_lean_scrambler.sql} | 137 +- .../0001_add_system_payload_to_messages.sql | 2 - apps/backend/drizzle/0001_audit_logs.sql | 35 - .../drizzle/0001_device_key_history.sql | 19 - .../drizzle/0001_gc_background_jobs.sql | 21 - .../drizzle/0001_group_control_events.sql | 20 - apps/backend/drizzle/0001_mls_group_state.sql | 55 - .../backend/drizzle/0001_mls_key_packages.sql | 15 - .../backend/drizzle/0001_privacy_settings.sql | 5 - .../drizzle/0002_device_capabilities.sql | 8 - .../0002_strengthen_system_payload_check.sql | 29 - .../drizzle/0003_ciphertext_only_messages.sql | 102 - .../drizzle/0004_envelope_protocol.sql | 14 - apps/backend/drizzle/meta/0000_snapshot.json | 1464 +++++++++-- apps/backend/drizzle/meta/0001_snapshot.json | 2201 ----------------- apps/backend/drizzle/meta/0002_snapshot.json | 1512 ----------- apps/backend/drizzle/meta/_journal.json | 29 +- .../0003_ciphertext_only_messages.down.sql | 35 - apps/backend/package.json | 2 +- .../backend/src/lib/validateMessagePayload.ts | 3 +- apps/backend/src/services/e2eeProtocol.ts | 3 +- apps/backend/src/services/mlsGroups.ts | 3 +- apps/web/docs/api-rest-client.md | 64 +- apps/web/docs/api-soroban-client.md | 22 +- apps/web/docs/api-websocket-client.md | 52 +- .../docs/concepts-auth-device-lifecycle.md | 19 +- apps/web/docs/concepts-e2ee-architecture.md | 22 +- apps/web/docs/concepts-file-encryption.md | 18 +- apps/web/docs/concepts-local-search.md | 6 +- apps/web/docs/concepts-message-pipeline.md | 67 +- apps/web/docs/concepts-wallet-treasury-ui.md | 12 +- apps/web/docs/contracts-auth-session.md | 16 +- apps/web/docs/contracts-indexeddb-schemas.md | 155 +- apps/web/docs/contracts-response-types.md | 193 +- .../src/app/app/conversations/[id]/page.tsx | 35 +- apps/web/src/app/app/profile/page.tsx | 3 +- .../web/src/hooks/usePushSubscription.test.ts | 5 +- apps/web/src/lib/__tests__/ecdh-fix.test.ts | 21 +- .../__tests__/identity-persistence.test.ts | 6 +- apps/web/src/lib/crypto.test.ts | 8 +- apps/web/src/lib/crypto/decrypt.ts | 5 +- apps/web/src/lib/crypto/doubleRatchet.ts | 32 +- apps/web/src/lib/crypto/e2ee.test.ts | 21 +- apps/web/src/lib/crypto/ratchetSession.ts | 58 +- apps/web/src/lib/crypto/sessionStore.ts | 1 - apps/web/src/lib/cryptoStore.ts | 4 +- apps/web/src/lib/deviceIdentity.test.ts | 13 +- apps/web/src/lib/fileEncryption.ts | 8 +- apps/web/src/lib/identityTrust.ts | 5 +- apps/web/src/lib/mls.ts | 28 +- apps/web/src/lib/safetyNumber.test.ts | 7 +- apps/web/src/lib/search/db.test.ts | 4 +- apps/web/src/lib/search/db.ts | 4 +- apps/web/src/lib/sessionStore.ts | 11 +- apps/web/src/lib/signalClient.test.ts | 2 +- apps/web/src/lib/x3dh.vectors.test.ts | 12 +- contracts/docs/api-deployment-invocation.md | 42 +- contracts/docs/api-proposals.md | 79 +- contracts/docs/concepts-proposal-lifecycle.md | 90 +- .../docs/concepts-token-transfer-flow.md | 6 +- .../docs/contracts-token-transfer-storage.md | 10 +- docs/observability.md | 26 +- docs/runbook.md | 8 +- docs/signal-integration.md | 2 +- docs/threat-model.md | 28 +- package.json | 2 +- pnpm-lock.yaml | 13 +- scripts/loadtest/run.ts | 28 +- scripts/loadtest/seed.ts | 7 +- 95 files changed, 3079 insertions(+), 5490 deletions(-) create mode 100644 .prettierignore rename apps/backend/drizzle/{0000_stale_mandarin.sql => 0000_lean_scrambler.sql} (56%) delete mode 100644 apps/backend/drizzle/0001_add_system_payload_to_messages.sql delete mode 100644 apps/backend/drizzle/0001_audit_logs.sql delete mode 100644 apps/backend/drizzle/0001_device_key_history.sql delete mode 100644 apps/backend/drizzle/0001_gc_background_jobs.sql delete mode 100644 apps/backend/drizzle/0001_group_control_events.sql delete mode 100644 apps/backend/drizzle/0001_mls_group_state.sql delete mode 100644 apps/backend/drizzle/0001_mls_key_packages.sql delete mode 100644 apps/backend/drizzle/0001_privacy_settings.sql delete mode 100644 apps/backend/drizzle/0002_device_capabilities.sql delete mode 100644 apps/backend/drizzle/0002_strengthen_system_payload_check.sql delete mode 100644 apps/backend/drizzle/0003_ciphertext_only_messages.sql delete mode 100644 apps/backend/drizzle/0004_envelope_protocol.sql delete mode 100644 apps/backend/drizzle/meta/0001_snapshot.json delete mode 100644 apps/backend/drizzle/meta/0002_snapshot.json delete mode 100644 apps/backend/drizzle/rollback/0003_ciphertext_only_messages.down.sql diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..dacf59d --- /dev/null +++ b/.prettierignore @@ -0,0 +1,26 @@ +node_modules/ +dist/ +.next/ +.turbo/ +target/ +coverage/ + +# ── Generated artifacts ─────────────────────────────────────────────────────── +# All tool output, not hand-edited source. Reformatting them produces noisy +# diffs and they get rewritten on the next build/test run anyway. + +# drizzle-kit migration snapshots and journal +apps/backend/drizzle/meta/ + +# TypeScript declaration output +**/*.d.ts + +# Next.js ambient types +apps/web/next-env.d.ts + +# Soroban test snapshots (regenerated by `cargo test`) +contracts/**/test_snapshots/ + +# Rust / Python build output +contracts/target/ +apps/ai_agent/.venv/ diff --git a/SECURITY_FIXES_SUMMARY.md b/SECURITY_FIXES_SUMMARY.md index 89f5301..b735ffb 100644 --- a/SECURITY_FIXES_SUMMARY.md +++ b/SECURITY_FIXES_SUMMARY.md @@ -9,6 +9,7 @@ This implementation addresses four critical security vulnerabilities as a cohesi ### Frontend (Web App) #### Modified Files + 1. **`apps/web/src/lib/cryptoStore.ts`** - ✅ Fixed identity key persistence using IndexedDB structured clone - ✅ Private CryptoKey now persists across page reloads @@ -21,6 +22,7 @@ This implementation addresses four critical security vulnerabilities as a cohesi - ✅ Correct WebCrypto ECDH usage: `deriveBits(algo_with_peer_public, caller_private, bits)` #### New Test Files + 3. **`apps/web/src/lib/__tests__/ecdh-fix.test.ts`** - ✅ Verifies Alice and Bob derive identical shared secrets - ✅ Tests correct ECDH key usage @@ -34,6 +36,7 @@ This implementation addresses four critical security vulnerabilities as a cohesi ### Backend #### Modified Files + 5. **`apps/backend/src/routes/uploads.ts`** - ✅ Added SHA-256 integrity verification to upload confirmation - ✅ Hash mismatch marks file as corrupted (deleted status) @@ -53,6 +56,7 @@ This implementation addresses four critical security vulnerabilities as a cohesi - ✅ Updated `dispatchOfflinePush` call to include `senderId` #### New Files + 9. **`apps/backend/src/services/pushFilter.ts`** - ✅ NEW: Shared push recipient filtering logic - ✅ Single source of truth for all push paths @@ -64,6 +68,7 @@ This implementation addresses four critical security vulnerabilities as a cohesi - ✅ Works with local storage, S3, MinIO #### New Test Files + 11. **`apps/backend/src/__tests__/pushFilter.test.ts`** - ✅ Tests all filtering combinations - ✅ Verifies sender filtering, mute checks, pushEnabled @@ -80,6 +85,7 @@ This implementation addresses four critical security vulnerabilities as a cohesi - ✅ Documents the complete security improvements #### Documentation + 14. **`apps/backend/docs/security-hardening.md`** - ✅ Comprehensive documentation of all fixes - ✅ Problem/solution for each issue @@ -93,6 +99,7 @@ This implementation addresses four critical security vulnerabilities as a cohesi **Problem**: `deriveSharedSecret` imported both keys as public keys, which is cryptographically invalid. **Solution**: + - Updated signature to accept `callerPrivateKey: CryptoKey` and `peerPublicKeyJwk: JsonWebKey` - Fixed `deriveBits` to use private key as base key - Updated `establishSession` to pass identity private key @@ -104,6 +111,7 @@ This implementation addresses four critical security vulnerabilities as a cohesi **Problem**: Private keys were generated as non-extractable and discarded. `getIdentityPrivateKey` regenerated new keys on every call. **Solution**: + - Generate keypairs with `extractable=true` - Persist full `CryptoKeyPair` via IndexedDB structured clone - Retrieve same private key across page reloads @@ -115,6 +123,7 @@ This implementation addresses four critical security vulnerabilities as a cohesi **Problem**: `dispatchOfflinePush` ignored `isMuted` and `pushEnabled`, while `sendPushForMessage` respected them. **Solution**: + - Created shared `pushFilter.ts` with `getEligiblePushRecipients` - Both push paths now use identical filtering logic - Filters: sender, muted, online, pushEnabled, connection state @@ -126,6 +135,7 @@ This implementation addresses four critical security vulnerabilities as a cohesi **Problem**: Upload confirmation never verified SHA-256 hash; corrupted files could be marked ready. **Solution**: + - Created `fileIntegrity.ts` with streaming SHA-256 computation - Upload confirmation now verifies hash before marking ready - Hash mismatch marks file as corrupted @@ -135,18 +145,21 @@ This implementation addresses four critical security vulnerabilities as a cohesi ## Testing Coverage ### Unit Tests + - ✅ ECDH key agreement (Alice/Bob derive identical secrets) - ✅ Identity persistence (keys survive page reloads) - ✅ Push filtering (all combinations: mute, pushEnabled, online, connected) - ✅ File integrity (hash computation, verification, tamper detection) ### Integration Tests + - ✅ End-to-end encrypted messaging flow - ✅ Push notification consistency - ✅ File upload with integrity verification - ✅ Regression prevention ### Test Commands + ```bash # Frontend tests cd apps/web @@ -166,10 +179,12 @@ pnpm test fileIntegrity ## Backwards Compatibility ### Breaking Changes + - **ECDH**: `establishSession` now requires `myPrivateKey: CryptoKey` instead of `myPublicKey: JsonWebKey` - **Migration**: Callers must retrieve private key via `cryptoStore.getIdentityPrivateKey()` ### Non-Breaking Changes + - **Identity Persistence**: Automatic migration; old keys continue working - **Push Filter**: Fully backwards compatible; improved filtering - **File Integrity**: Only applies to new uploads; existing files unaffected diff --git a/apps/ai_agent/docs/api-chat.md b/apps/ai_agent/docs/api-chat.md index 10a90ca..cb95978 100644 --- a/apps/ai_agent/docs/api-chat.md +++ b/apps/ai_agent/docs/api-chat.md @@ -18,12 +18,13 @@ The `POST /chat` endpoint provides an interactive chat interface powered by Open Sourced from `ChatRequest` Pydantic model in `main.py`: -| Field | Type | Required | Description | -|---|---|---|---| -| `message` | `string` | Yes | The user's input query or message prompt. | -| `conversation_id` | `string` | Yes | Unique identifier for tracking the chat thread or session context. | +| Field | Type | Required | Description | +| ----------------- | -------- | -------- | ------------------------------------------------------------------ | +| `message` | `string` | Yes | The user's input query or message prompt. | +| `conversation_id` | `string` | Yes | Unique identifier for tracking the chat thread or session context. | #### JSON Schema Example: + ```json { "message": "What is Clicked and how do payments work?", @@ -37,11 +38,12 @@ Sourced from `ChatRequest` Pydantic model in `main.py`: Sourced from `ChatResponse` Pydantic model in `main.py`: -| Field | Type | Description | -|---|---|---| +| Field | Type | Description | +| ------- | -------- | ---------------------------------------------- | | `reply` | `string` | The AI assistant's generated response message. | #### JSON Schema Example: + ```json { "reply": "Clicked is a decentralised messaging and payment platform built on the Stellar blockchain. You can send XLM token payments directly inside chat threads." @@ -66,9 +68,11 @@ Sourced from `ChatResponse` Pydantic model in `main.py`: All failure modes and HTTP status codes match the application implementation in `main.py` and unit tests in `tests/test_chat.py`. ### 1. `422 Unprocessable Entity` — Request Validation Error + Triggered automatically by FastAPI when request payload fails Pydantic schema validation (e.g., missing required `message` or `conversation_id` field). **Response Body Shape**: + ```json { "detail": [ @@ -82,9 +86,11 @@ Triggered automatically by FastAPI when request payload fails Pydantic schema va ``` ### 2. `500 Internal Server Error` — Missing API Key + Triggered when the `OPENAI_API_KEY` environment variable is not configured on the server. **Response Body Shape**: + ```json { "detail": "OPENAI_API_KEY is not configured" @@ -92,9 +98,11 @@ Triggered when the `OPENAI_API_KEY` environment variable is not configured on th ``` ### 3. `500 Internal Server Error` — Missing Dependency + Triggered if the required `openai` Python package is not installed in the application runtime environment. **Response Body Shape**: + ```json { "detail": "openai package is not installed" @@ -102,6 +110,7 @@ Triggered if the required `openai` Python package is not installed in the applic ``` ### 4. Upstream OpenAI API Errors / Timeout + If upstream OpenAI services encounter connection failures or timeout beyond the 30-second window, an unhandled exception will result in a standard HTTP 500 error response. --- @@ -109,6 +118,7 @@ If upstream OpenAI services encounter connection failures or timeout beyond the ## Worked Example ### Worked Request + ```http POST /chat HTTP/1.1 Host: localhost:8000 @@ -121,6 +131,7 @@ Content-Type: application/json ``` ### Worked Response + ```http HTTP/1.1 200 OK Content-Type: application/json diff --git a/apps/ai_agent/docs/api-index-search.md b/apps/ai_agent/docs/api-index-search.md index 06d08b1..f1c9f07 100644 --- a/apps/ai_agent/docs/api-index-search.md +++ b/apps/ai_agent/docs/api-index-search.md @@ -17,7 +17,7 @@ OpenAI (`text-embedding-3-small`) and stores it alongside the message metadata. ### Request | Field | Type | Required | Description | -|------------------|----------|----------|-----------------------------------------------| +| ---------------- | -------- | -------- | --------------------------------------------- | | `messageId` | `string` | Yes | Unique identifier for the message | | `conversationId` | `string` | Yes | ID of the conversation the message belongs to | | `senderId` | `string` | Yes | ID of the message sender | @@ -41,7 +41,7 @@ OpenAI (`text-embedding-3-small`) and stores it alongside the message metadata. The endpoint uses an **upsert** pattern based on the `messageId`: | Scenario | Behavior | -|--------------------------------------------|-------------------------------------------------------------------------| +| ------------------------------------------ | ----------------------------------------------------------------------- | | `messageId` **does not exist** in Weaviate | **Insert** a new record with the provided metadata and embedding vector | | `messageId` **already exists** in Weaviate | **Replace** the existing record with new metadata and embedding vector | @@ -55,6 +55,7 @@ else: ``` **Why this matters:** + - Insert: Creates a new searchable entry for a new message - Replace: Updates the embedding and content for edited messages - Both actions keep the same messageId for consistent lookups @@ -68,7 +69,7 @@ else: ### Status Codes | Status Code | Description | Response Example | -|-----------------------------|------------------------------|----------------------------------------------------| +| --------------------------- | ---------------------------- | -------------------------------------------------- | | `200 OK` | Message indexed successfully | `{ "status": "ok" }` | | `503 Service Unavailable` | Weaviate connection failed | `{ "detail": "Weaviate connection failed" }` | | `500 Internal Server Error` | OpenAI API key missing | `{ "detail": "OPENAI_API_KEY is not configured" }` | @@ -101,7 +102,7 @@ relevant messages based on the query text, filtered by conversation. ### Query Parameters | Parameter | Type | Required | Description | -|------------------|----------|----------|-------------------------------------------| +| ---------------- | -------- | -------- | ----------------------------------------- | | `q` | `string` | Yes | Search query text | | `conversationId` | `string` | Yes | Filter results to a specific conversation | @@ -171,11 +172,10 @@ Success Response "content": "You can send XLM by clicking the send button in the chat." } ] -} +} ``` -# auiqe empeza otra vez sss----------------------------------------- - +# auiqe empeza otra vez sss----------------------------------------- # Index & Search API Documentation @@ -196,12 +196,12 @@ The `/index/message` endpoint indexes a message into Weaviate for semantic searc ### 1.2 Request -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `messageId` | `string` | Yes | Unique identifier for the message | -| `conversationId` | `string` | Yes | ID of the conversation the message belongs to | -| `senderId` | `string` | Yes | ID of the message sender | -| `content` | `string` | Yes | The message content to be indexed | +| Field | Type | Required | Description | +| ---------------- | -------- | -------- | --------------------------------------------- | +| `messageId` | `string` | Yes | Unique identifier for the message | +| `conversationId` | `string` | Yes | ID of the conversation the message belongs to | +| `senderId` | `string` | Yes | ID of the message sender | +| `content` | `string` | Yes | The message content to be indexed | --- @@ -222,10 +222,10 @@ The `/index/message` endpoint indexes a message into Weaviate for semantic searc The endpoint uses an **upsert** pattern based on the `messageId`: -| Scenario | Behavior | -|----------|----------| +| Scenario | Behavior | +| ------------------------------------------ | ----------------------------------------------------------------------- | | `messageId` **does not exist** in Weaviate | **Insert** a new record with the provided metadata and embedding vector | -| `messageId` **already exists** in Weaviate | **Replace** the existing record with new metadata and embedding vector | +| `messageId` **already exists** in Weaviate | **Replace** the existing record with new metadata and embedding vector | **Decision Logic:** @@ -246,11 +246,11 @@ The endpoint uses an **upsert** pattern based on the `messageId`: ### 1.5 Status Codes -| Status Code | Description | Response Example | -|-------------|-------------|------------------| -| `200 OK` | Message indexed successfully | `{ "status": "ok" }` | -| `503 Service Unavailable` | Weaviate connection failed | `{ "detail": "Weaviate connection failed" }` | -| `500 Internal Server Error` | OpenAI API key missing | `{ "detail": "OPENAI_API_KEY is not configured" }` | +| Status Code | Description | Response Example | +| --------------------------- | ---------------------------- | -------------------------------------------------- | +| `200 OK` | Message indexed successfully | `{ "status": "ok" }` | +| `503 Service Unavailable` | Weaviate connection failed | `{ "detail": "Weaviate connection failed" }` | +| `500 Internal Server Error` | OpenAI API key missing | `{ "detail": "OPENAI_API_KEY is not configured" }` | **Error Response Example (503):** @@ -280,10 +280,10 @@ The `/search` endpoint performs semantic search across indexed messages using ve ### 2.2 Query Parameters -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `q` | `string` | Yes | Search query text | -| `conversationId` | `string` | Yes | Filter results to a specific conversation | +| Parameter | Type | Required | Description | +| ---------------- | -------- | -------- | ----------------------------------------- | +| `q` | `string` | Yes | Search query text | +| `conversationId` | `string` | Yes | Filter results to a specific conversation | --- @@ -330,11 +330,11 @@ If the `Message` collection does not exist in Weaviate, the endpoint returns an ### 2.6 Status Codes -| Status Code | Description | Response Example | -|-------------|-------------|------------------| -| `200 OK` | Search completed successfully | `{ "results": [...] }` | -| `503 Service Unavailable` | Weaviate connection failed | `{ "detail": "Weaviate connection failed" }` | -| `500 Internal Server Error` | OpenAI API key missing | `{ "detail": "OPENAI_API_KEY is not configured" }` | +| Status Code | Description | Response Example | +| --------------------------- | ----------------------------- | -------------------------------------------------- | +| `200 OK` | Search completed successfully | `{ "results": [...] }` | +| `503 Service Unavailable` | Weaviate connection failed | `{ "detail": "Weaviate connection failed" }` | +| `500 Internal Server Error` | OpenAI API key missing | `{ "detail": "OPENAI_API_KEY is not configured" }` | --- @@ -396,4 +396,3 @@ This error occurs when the OpenAI API key is not configured. > "detail": "OPENAI_API_KEY is not configured" > } > ``` - diff --git a/apps/ai_agent/docs/api-proposals-summarise.md b/apps/ai_agent/docs/api-proposals-summarise.md index 0efcbaf..adcd7dd 100644 --- a/apps/ai_agent/docs/api-proposals-summarise.md +++ b/apps/ai_agent/docs/api-proposals-summarise.md @@ -15,13 +15,12 @@ impact. --- - ## 2. Request ### Request Body | Field | Type | Required | Description | -|---------------|----------|----------|-----------------------------------------------------------| +| ------------- | -------- | -------- | --------------------------------------------------------- | | `title` | `string` | Yes | Title of the governance proposal | | `description` | `string` | Yes | Detailed description of the proposal's purpose and impact | | `amount` | `float` | Yes | Amount in XLM requested for the proposal | @@ -43,14 +42,14 @@ impact. ### Response Body | Field | Type | Description | -|-----------|----------|----------------------------------------------------| +| --------- | -------- | -------------------------------------------------- | | `summary` | `string` | A 2-sentence plain-English summary of the proposal | | `risk` | `string` | Risk level: `"low"`, `"medium"`, or `"high"` | ### Risk Levels | Level | Description | Typical Triggers | -|------------|----------------------------|--------------------------------------------------| +| ---------- | -------------------------- | ------------------------------------------------ | | `"low"` | Safe, well-scoped proposal | Small amounts, clear purpose, low impact | | `"medium"` | Moderate risk | Moderate amounts, some ambiguity, mixed impact | | `"high"` | High risk | Large amounts, unclear intent, obvious red flags | @@ -69,7 +68,7 @@ impact. ## 4. Status Codes | Status Code | Description | Response Example | -|-----------------------------|-----------------------------------------------|----------------------------------------------------| +| --------------------------- | --------------------------------------------- | -------------------------------------------------- | | `200 OK` | Proposal successfully summarized | `{ "summary": "...", "risk": "low" }` | | `502 Bad Gateway` | LLM did not return a summary (empty response) | `{ "detail": "LLM did not return a summary" }` | | `500 Internal Server Error` | OpenAI API key missing or LLM service error | `{ "detail": "OPENAI_API_KEY is not configured" }` | @@ -95,7 +94,7 @@ impact. The endpoint includes defensive fallbacks to handle invalid or missing LLM responses: | Scenario | Behavior | -|------------------------------------------------|----------------------------------------------| +| ---------------------------------------------- | -------------------------------------------- | | `risk` is not `"low"`, `"medium"`, or `"high"` | Defaults to `"medium"` | | `summary` is empty or missing | Returns `502 Bad Gateway` with error message | | OpenAI API key is missing | Returns `500 Internal Server Error` | @@ -132,64 +131,63 @@ markdown ## 6. Worked Examples ->### Example 1: Low Risk Proposal +> ### Example 1: Low Risk Proposal > > **Request:** > -> ->```json ->{ -> "title": "Community Events Fund", +> ```json +> { +> "title": "Community Events Fund", > "description": "Requesting funds to organize 3 community events in Q4 to increase platform adoption and user engagement. Events will include workshops, AMAs, and networking sessions.", -> "amount": 5000.0 -> } ->``` +> "amount": 5000.0 +> } +> ``` +> > Response: -> +> > ```json > { > "summary": "Requesting 5,000 XLM to host three community events in Q4 to boost platform adoption. The funds will cover venue costs, marketing, and speaker fees.", > "risk": "low" -> } +> } > ``` > Example 2: Medium Risk Proposal > Request: -> +> > ```json > { > "title": "Development Team Expansion", > "description": "Hiring two additional developers to accelerate platform development. The team will work on new features and bug fixes.", > "amount": 50000.0 -> } +> } > ``` +> > Response: -> +> > ```json > { > "summary": "Requesting 50,000 XLM to hire two developers for platform development. The funds will support salaries and onboarding costs for the new team members.", > "risk": "medium" -> } -> ``` -> +> } +> ``` > Example 3: High Risk Proposal > Request: -> +> > ```json > { > "title": "Major Protocol Upgrade", > "description": "Requesting funds for a protocol upgrade that will change the platform's tokenomics.", > "amount": 500000.0 -> } +> } > ``` +> > Response: -> +> > ```json > { > "summary": "Requesting 500,000 XLM for a major protocol upgrade that will modify tokenomics. The proposal lacks detail on implementation timeline and community feedback.", > "risk": "high" > } > ``` -> -> \ No newline at end of file diff --git a/apps/ai_agent/docs/api-transfers-analyse.md b/apps/ai_agent/docs/api-transfers-analyse.md index f646c75..41945a6 100644 --- a/apps/ai_agent/docs/api-transfers-analyse.md +++ b/apps/ai_agent/docs/api-transfers-analyse.md @@ -13,12 +13,12 @@ Analyses a Stellar transfer for fraud risk. The endpoint has two code paths: ### Request body (`TransferAnalyseRequest`) -| Field | Type | Description | -|-------------|---------|-------------------------------------------------------| -| `amount` | `float` | Transfer amount in XLM. | -| `sender` | `str` | Sender Stellar account address (ed25519 public key). | -| `recipient` | `str` | Recipient Stellar account address. | -| `memo` | `str` | Transfer memo text. | +| Field | Type | Description | +| ----------- | ------- | ---------------------------------------------------- | +| `amount` | `float` | Transfer amount in XLM. | +| `sender` | `str` | Sender Stellar account address (ed25519 public key). | +| `recipient` | `str` | Recipient Stellar account address. | +| `memo` | `str` | Transfer memo text. | Example: @@ -41,11 +41,11 @@ Example: When the threshold is exceeded the LLM is **never called**. The response is returned immediately with hardcoded values: -| Field | Value | -|--------------|----------------------------------| -| `flagged` | `true` | +| Field | Value | +| ------------ | ------------------------------------------------ | +| `flagged` | `true` | | `reason` | `"Amount {n} XLM exceeds 10000.0 XLM threshold"` | -| `confidence` | `0.99` | +| `confidence` | `0.99` | Transfers **equal to** 10 000.0 XLM take the LLM path; only values strictly above trigger the short-circuit. @@ -63,20 +63,20 @@ The LLM call has a 10-second timeout. Requests are billed normally per OpenAI us ## Response (`TransferAnalyseResponse`) -| Field | Type | Description | -|--------------|---------------|-----------------------------------------------------------------------------| -| `flagged` | `bool` | Whether the transfer is considered suspicious. | -| `reason` | `str` or null | Human-readable explanation. `null` when the transfer is not flagged. | +| Field | Type | Description | +| ------------ | ------------- | ------------------------------------------------------------------------------------- | +| `flagged` | `bool` | Whether the transfer is considered suspicious. | +| `reason` | `str` or null | Human-readable explanation. `null` when the transfer is not flagged. | | `confidence` | `float` | Confidence in the assessment, ranging from `0.0` (low confidence) to `1.0` (certain). | ### Fallback defaults If the LLM response omits a key, the following defaults are applied: -| Omitted key | Default | -|---------------|----------| -| `flagged` | `false` | -| `confidence` | `0.0` | +| Omitted key | Default | +| ------------ | ------- | +| `flagged` | `false` | +| `confidence` | `0.0` | `reason` is passed through as-is (`null` if absent or explicitly `null`). @@ -84,11 +84,11 @@ If the LLM response omits a key, the following defaults are applied: ## Errors -| Status | Condition | Body detail | -|--------|------------------------------------------|------------------------------------| -| 422 | Missing required field or type mismatch | Validation error per FastAPI | +| Status | Condition | Body detail | +| ------ | ------------------------------------------------ | ------------------------------------ | +| 422 | Missing required field or type mismatch | Validation error per FastAPI | | 500 | `OPENAI_API_KEY` environment variable is not set | `"OPENAI_API_KEY is not configured"` | -| 500 | `openai` package is not installed | `"openai package is not installed"` | +| 500 | `openai` package is not installed | `"openai package is not installed"` | --- diff --git a/apps/ai_agent/docs/concepts-transfer-risk-analysis.md b/apps/ai_agent/docs/concepts-transfer-risk-analysis.md index edb2142..c08fd80 100644 --- a/apps/ai_agent/docs/concepts-transfer-risk-analysis.md +++ b/apps/ai_agent/docs/concepts-transfer-risk-analysis.md @@ -39,12 +39,16 @@ To balance deterministic security, response latency, and intelligent threat dete ## 2. Rule-Based Path: High-Value Threshold Short-Circuit ### Rule Threshold & Condition + Sourced from `main.py`: + - **Threshold Value**: `_HIGH_VALUE_THRESHOLD = 10_000.0` (XLM) - **Evaluation Condition**: `if request.amount > _HIGH_VALUE_THRESHOLD:` ### Execution Behavior + If a transfer request exceeds `10,000.0 XLM`: + - **Immediate Short-Circuit**: Bypasses the OpenAI LLM invocation entirely. - **Fixed Response**: - `flagged`: `true` @@ -52,6 +56,7 @@ If a transfer request exceeds `10,000.0 XLM`: - `confidence`: `0.99` ### Design Rationale + 1. **Security Guarantee & Non-Determinism Prevention**: High-value transactions pose maximum protocol risk. Relying solely on an LLM for large amounts introduces non-deterministic risks (e.g. potential prompt injections, hallucinations, or upstream API outages). 2. **Zero-Latency Execution**: Rule evaluation operates instantly in memory without network latency or external service dependencies. 3. **Cost Efficiency**: Avoids unnecessary LLM token consumption on transfers that warrant automatic flagging due to monetary policy. @@ -63,12 +68,16 @@ If a transfer request exceeds `10,000.0 XLM`: For transfers where `amount <= 10,000.0 XLM`, the service delegates analysis to OpenAI's `gpt-4o-mini` model. ### Why Simple Rules Are Insufficient + Traditional rule-based systems rely on static thresholds or address blacklists. They cannot easily detect: + - **Suspicious Memo Content**: Social engineering tactics, phishing links, coercion phrasing, or scam keywords inside transaction memos. - **Contextual Anomaly Detection**: Subtle patterns across combined transaction metadata (`sender`, `recipient`, `memo`). ### LLM Prompt & Criteria + Sourced from `main.py`: + ```text Analyse this Stellar transfer for fraud risk. Amount: {request.amount} XLM @@ -80,6 +89,7 @@ Reply with JSON only using keys: flagged (bool), reason (string under 100 chars ``` ### Response Mapping + - **Model**: `gpt-4o-mini` with `response_format={"type": "json_object"}` and a 10-second timeout. - **Output Schema**: Returns a `TransferAnalyseResponse` containing `flagged` (boolean), `reason` (short explanatory message or `null`), and `confidence` (float between `0.0` and `1.0`). @@ -88,9 +98,11 @@ Reply with JSON only using keys: flagged (bool), reason (string under 100 chars ## 4. Usage Guidelines & Risk Heuristic Disclaimer ### Heuristic Nature Notice + > **Important**: The transfer risk analysis score is a **fraud/risk-signal heuristic**, not a definitive guarantee or cryptographic transaction block. It evaluates input signals to estimate risk probability. ### Caller & Integration Recommendations + Client applications consuming `POST /transfers/analyse` are expected to use the results as an advisory guardrail: - **When `flagged=true` & `confidence >= 0.8`**: diff --git a/apps/ai_agent/docs/contracts-pydantic-models.md b/apps/ai_agent/docs/contracts-pydantic-models.md index d0ff0ff..40202cb 100644 --- a/apps/ai_agent/docs/contracts-pydantic-models.md +++ b/apps/ai_agent/docs/contracts-pydantic-models.md @@ -5,25 +5,33 @@ This document serves as a single reference for all Pydantic data models defined ## Chat Models ### `ChatRequest` + Used for submitting a user message to the AI agent. + - `message` (`str`): The content of the user's message. Must be a valid string. - `conversation_id` (`str`): The unique identifier for the conversation. Must be a valid string. ### `ChatResponse` + The agent's reply to a chat request. + - `reply` (`str`): The AI-generated response text. Must be a valid string. ## Transfer Analysis Models ### `TransferAnalyseRequest` + Payload for requesting fraud risk analysis on a Stellar transfer. + - `amount` (`float`): The transfer amount in XLM. Must be a valid float. - `sender` (`str`): The Stellar address of the sender. Must be a valid string. - `recipient` (`str`): The Stellar address of the recipient. Must be a valid string. - `memo` (`str`): The memo string attached to the transaction. Must be a valid string. ### `TransferAnalyseResponse` + The result of the transfer risk analysis. + - `flagged` (`bool`): Whether the transfer was flagged as suspicious. Must be a valid boolean. - `reason` (`str | None`): The reason it was flagged, or null/None if not flagged. Must be a string or null. - `confidence` (`float`): The model's confidence in the flagging decision, between 0.0 and 1.0. Must be a valid float. @@ -31,7 +39,9 @@ The result of the transfer risk analysis. ## Message Indexing Models ### `IndexMessageRequest` + Payload for upserting a message into the Weaviate vector database for semantic search. + - `messageId` (`str`): The unique identifier of the message. Must be a valid string. - `conversationId` (`str`): The unique identifier of the conversation. Must be a valid string. - `senderId` (`str`): The unique identifier of the sender. Must be a valid string. @@ -40,16 +50,20 @@ Payload for upserting a message into the Weaviate vector database for semantic s ## Proposal Summarisation Models ### `ProposalSummariseRequest` + Payload for generating a frontend-friendly summary and risk assessment for a DAO proposal. + - `title` (`str`): The title of the proposal. Must be a valid string. - `description` (`str`): The full text description of the proposal. Must be a valid string. - `amount` (`float`): The funding amount requested in XLM. Must be a valid float. ### `ProposalSummariseResponse` + The generated summary and evaluated risk level. + - `summary` (`str`): A concise 2-sentence summary of the proposal. Must be a valid string. - `risk` (`RiskLevel`): The evaluated risk of the proposal. Must be exactly one of the literal values: `"low"`, `"medium"`, or `"high"`. --- -*Note: Validation constraints for these models are enforced natively by Pydantic based on their type annotations. The AI Agent endpoint strictly coerces payloads and raises HTTP 422 Unprocessable Entity if payloads do not match these definitions.* +_Note: Validation constraints for these models are enforced natively by Pydantic based on their type annotations. The AI Agent endpoint strictly coerces payloads and raises HTTP 422 Unprocessable Entity if payloads do not match these definitions._ diff --git a/apps/ai_agent/docs/contracts-weaviate-schema.md b/apps/ai_agent/docs/contracts-weaviate-schema.md index 15bf3fe..e49f8e6 100644 --- a/apps/ai_agent/docs/contracts-weaviate-schema.md +++ b/apps/ai_agent/docs/contracts-weaviate-schema.md @@ -15,12 +15,12 @@ The service manages a single vector collection within Weaviate to store and inde Sourced directly from the collection indexing logic in `main.py` (`index_message` & `search_messages` functions): -| Property Name | Type | Description | Indexing & Filter Role | -|---|---|---|---| -| `messageId` | `string` (UUID) | Unique identifier for the message object. Serves as the primary Weaviate object UUID. | Object ID / Lookup | -| `conversationId` | `string` | ID of the conversation thread or chat room to which the message belongs. | **Filter Field** (`/search` scoping) | -| `senderId` | `string` | Stellar account or user ID of the sender. | Stored Property | -| `content` | `string` | Raw textual body content of the chat message. | Embedded Text Property | +| Property Name | Type | Description | Indexing & Filter Role | +| ---------------- | --------------- | ------------------------------------------------------------------------------------- | ------------------------------------ | +| `messageId` | `string` (UUID) | Unique identifier for the message object. Serves as the primary Weaviate object UUID. | Object ID / Lookup | +| `conversationId` | `string` | ID of the conversation thread or chat room to which the message belongs. | **Filter Field** (`/search` scoping) | +| `senderId` | `string` | Stellar account or user ID of the sender. | Stored Property | +| `content` | `string` | Raw textual body content of the chat message. | Embedded Text Property | --- @@ -41,6 +41,7 @@ The vector embeddings for messages are generated externally using OpenAI's embed When searching indexed messages via `GET /search?q={query}&conversationId={conversationId}`, vector search results are strictly filtered to prevent cross-conversation data leaks. ### Search Criteria + - **Similarity Search**: `collection.query.near_vector(near_vector=vector, limit=5, filters=...)` - **Primary Filter Field**: `conversationId` - **Filter Constraint**: `Filter.by_property("conversationId").equal(conversationId)` diff --git a/apps/backend/docs/api-auth.md b/apps/backend/docs/api-auth.md index 58d4163..4702148 100644 --- a/apps/backend/docs/api-auth.md +++ b/apps/backend/docs/api-auth.md @@ -11,10 +11,10 @@ There are **no refresh or logout routes** in `auth.ts`. Session expiry is handle Two named rate-limiters are applied to individual routes. Both use express-rate-limit with `standardHeaders: 'draft-7'` and `legacyHeaders: false`. -| Limiter | Window | Max requests | Applied to | -|---|---|---|---| -| `challengeLimiter` | 60 s | 10 | `POST /auth/challenge` | -| `verifyLimiter` | 60 s | 5 | `POST /auth/verify` | +| Limiter | Window | Max requests | Applied to | +| ------------------ | ------ | ------------ | ---------------------- | +| `challengeLimiter` | 60 s | 10 | `POST /auth/challenge` | +| `verifyLimiter` | 60 s | 5 | `POST /auth/verify` | When a limiter is breached the server responds with HTTP `429`: @@ -40,9 +40,9 @@ Validated against `ChallengeSchema` (`apps/backend/src/schemas/auth.schemas.ts`) } ``` -| Field | Type | Required | Description | -|---|---|---|---| -| `walletAddress` | `string` | yes | Stellar public key (starting with `G`) | +| Field | Type | Required | Description | +| --------------- | -------- | -------- | -------------------------------------- | +| `walletAddress` | `string` | yes | Stellar public key (starting with `G`) | ### Responses @@ -55,10 +55,10 @@ Validated against `ChallengeSchema` (`apps/backend/src/schemas/auth.schemas.ts`) } ``` -| Field | Type | Description | -|---|---|---| +| Field | Type | Description | +| --------- | -------- | ------------------------------------------------------------------------------------------------------------------ | | `message` | `string` | Human-readable message the wallet must sign. Format: `Sign in to Clicked\nWallet: {walletAddress}\nNonce: {nonce}` | -| `nonce` | `string` | Hex-encoded 16-byte random nonce. Single-use, expires after 5 minutes. | +| `nonce` | `string` | Hex-encoded 16-byte random nonce. Single-use, expires after 5 minutes. | #### `400` — Validation error @@ -110,17 +110,17 @@ Validated against `VerifySchema` (`apps/backend/src/schemas/auth.schemas.ts`). } ``` -| Field | Type | Required | Description | -|---|---|---|---| -| `walletAddress` | `string` | yes | Stellar public key (starting with `G`) | -| `signature` | `string` | yes | Wallet signature of the challenge message. Accepts both hex and base64 encodings (the server tries both). | -| `nonce` | `string` | yes | The nonce returned by `POST /auth/challenge`. Single-use. | -| `identityPublicKey` | `string` | yes | Base64-encoded Ed25519 SPKI DER public key (44 bytes decoded). The long-term device identity key. | -| `device` | `object` | no | Optional device metadata. | -| `device.deviceName` | `string` | no | Human-readable device name (max 100 chars). | -| `device.platform` | `string` | no | One of `"web"`, `"ios"`, or `"android"`. | -| `device.registrationId` | `number` | no | Non-negative integer for push notification routing. | -| `device.identityPublicKey` | `string` | no | If provided, must match the top-level `identityPublicKey`. Validated via `superRefine`. | +| Field | Type | Required | Description | +| -------------------------- | -------- | -------- | --------------------------------------------------------------------------------------------------------- | +| `walletAddress` | `string` | yes | Stellar public key (starting with `G`) | +| `signature` | `string` | yes | Wallet signature of the challenge message. Accepts both hex and base64 encodings (the server tries both). | +| `nonce` | `string` | yes | The nonce returned by `POST /auth/challenge`. Single-use. | +| `identityPublicKey` | `string` | yes | Base64-encoded Ed25519 SPKI DER public key (44 bytes decoded). The long-term device identity key. | +| `device` | `object` | no | Optional device metadata. | +| `device.deviceName` | `string` | no | Human-readable device name (max 100 chars). | +| `device.platform` | `string` | no | One of `"web"`, `"ios"`, or `"android"`. | +| `device.registrationId` | `number` | no | Non-negative integer for push notification routing. | +| `device.identityPublicKey` | `string` | no | If provided, must match the top-level `identityPublicKey`. Validated via `superRefine`. | ### Responses @@ -133,10 +133,10 @@ Validated against `VerifySchema` (`apps/backend/src/schemas/auth.schemas.ts`). } ``` -| Field | Type | Description | -|---|---|---| -| `token` | `string` | Signed JWT (7-day expiry). Payload: `{ userId, walletAddress, deviceId }`. | -| `deviceId` | `string` | UUID of the resolved or newly-registered device row. | +| Field | Type | Description | +| ---------- | -------- | -------------------------------------------------------------------------- | +| `token` | `string` | Signed JWT (7-day expiry). Payload: `{ userId, walletAddress, deviceId }`. | +| `deviceId` | `string` | UUID of the resolved or newly-registered device row. | #### `400` — Validation error diff --git a/apps/backend/docs/api-conversations.md b/apps/backend/docs/api-conversations.md index 8f7b117..5c6f1a7 100644 --- a/apps/backend/docs/api-conversations.md +++ b/apps/backend/docs/api-conversations.md @@ -10,12 +10,12 @@ The JWT is device-scoped — it encodes both `userId` and `deviceId` (see [`midd Common auth failure responses (identical across every route in this file, since they all sit behind the same middleware): -| Status | Body | Cause | -|---|---|---| -| 401 | `{ "error": "Missing or invalid Authorization header" }` | No `Bearer` token | -| 401 | `{ "error": "Invalid or expired token" }` | JWT fails verification | -| 401 | `{ "error": "Token missing deviceId" }` | Token isn't device-scoped | -| 401 | `{ "error": "Device not found or has been revoked" }` | Device was revoked since the token was issued | +| Status | Body | Cause | +| ------ | -------------------------------------------------------- | --------------------------------------------- | +| 401 | `{ "error": "Missing or invalid Authorization header" }` | No `Bearer` token | +| 401 | `{ "error": "Invalid or expired token" }` | JWT fails verification | +| 401 | `{ "error": "Token missing deviceId" }` | Token isn't device-scoped | +| 401 | `{ "error": "Device not found or has been revoked" }` | Device was revoked since the token was issued | Below, only per-route authorization/validation failures are listed in addition to these. @@ -29,8 +29,8 @@ List all conversations the authenticated user belongs to. **Query params** -| Param | Type | Default | Description | -|---|---|---|---| +| Param | Type | Default | Description | +| ---------- | ------------------- | ------- | ----------------------------------------------------------------------------------------------- | | `archived` | `"true"` \| omitted | omitted | Pass `archived=true` to list archived conversations instead of the default (non-archived) view. | **Response `200`** — array of conversation objects, each augmented with the caller's per-membership flags and counts: @@ -67,6 +67,7 @@ List all conversations the authenticated user belongs to. ``` Notes: + - `messages` is the latest message only (used for list previews), pre-filtered to this device's envelope — see [Per-device ciphertext scoping](#per-device-ciphertext-scoping). - `unreadCount` is computed from `conversationMembers.lastReadMessageId`: it's `0` when the member has no read position established yet (`lastReadMessageId IS NULL`), otherwise it's the count of non-deleted messages created after that message's `createdAt`. - **Caching**: for the default (non-archived) view only, the full response is cached in Redis under a per-user key for `CONV_CACHE_TTL` seconds (30s). `archived=true` always bypasses the cache (it's a different result set). Cache reads/writes fail open — any Redis error falls through to a live DB query rather than erroring the request. The cache is invalidated on writes that affect membership/conversation data (see `invalidateConversationCaches` calls in the mutation routes below) and on settings changes. @@ -81,11 +82,11 @@ Fetch a single conversation by ID, including members and the latest message (env **Errors** -| Status | Body | Cause | -|---|---|---| -| 400 | `{ "error": "Conversation id is required" }` | Missing `:id` param (defensive; shouldn't occur via routing) | -| 404 | `{ "error": "Conversation not found" }` | No conversation with that ID | -| 403 | `{ "error": "Not a member of this conversation" }` | Caller isn't a member | +| Status | Body | Cause | +| ------ | -------------------------------------------------- | ------------------------------------------------------------ | +| 400 | `{ "error": "Conversation id is required" }` | Missing `:id` param (defensive; shouldn't occur via routing) | +| 404 | `{ "error": "Conversation not found" }` | No conversation with that ID | +| 403 | `{ "error": "Not a member of this conversation" }` | Caller isn't a member | --- @@ -95,33 +96,33 @@ Cursor-paginated message history for a conversation. This is the primary route w **Query params** -| Param | Type | Default | Description | -|---|---|---|---| -| `limit` | integer | `30` (`DEFAULT_MESSAGES_LIMIT`) | Clamped to a max of `50` (`MAX_MESSAGES_LIMIT`). Non-numeric or non-positive values fall back to the default. | -| `before` | message UUID | none | Cursor — see [Pagination](#pagination) below. | +| Param | Type | Default | Description | +| -------- | ------------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `limit` | integer | `30` (`DEFAULT_MESSAGES_LIMIT`) | Clamped to a max of `50` (`MAX_MESSAGES_LIMIT`). Non-numeric or non-positive values fall back to the default. | +| `before` | message UUID | none | Cursor — see [Pagination](#pagination) below. | **Response `200`** ```jsonc { - "messages": [ /* ascending (oldest-first) order, envelope-filtered for this device */ ], - "nextCursor": "uuid | null" + "messages": [/* ascending (oldest-first) order, envelope-filtered for this device */], + "nextCursor": "uuid | null", } ``` **Errors** -| Status | Body | Cause | -|---|---|---| -| 403 | `{ "error": "Not a member of this conversation" }` | Caller isn't a member | -| 400 | `{ "error": "Invalid cursor" }` | `before` doesn't reference an existing message | +| Status | Body | Cause | +| ------ | -------------------------------------------------- | ---------------------------------------------- | +| 403 | `{ "error": "Not a member of this conversation" }` | Caller isn't a member | +| 400 | `{ "error": "Invalid cursor" }` | `before` doesn't reference an existing message | ### Pagination - Pagination is **backward** (walking from newest toward oldest) using the `before` cursor, which must be a message `id` from a previous page's `nextCursor` (or omitted to fetch the most recent page). - The cursor is resolved server-side to that message's `(createdAt, id)` pair, and the next page is every message with `createdAt < cursor.createdAt`, OR (`createdAt == cursor.createdAt` AND `id < cursor.id`). The `id` tie-break exists because `createdAt` alone can silently skip or duplicate rows across pages when multiple messages share the same millisecond timestamp under concurrent writes. - Internally the query fetches DESC by `(createdAt, id)` — i.e. newest-first — requests `limit + 1` rows to detect whether a further page exists, trims to `limit`, and then **reverses the page before returning it**. The client-facing `messages` array is therefore always in **ascending (oldest-first) order**, regardless of pagination direction. -- `nextCursor` is the `id` of the oldest message in the *untrimmed, pre-reverse* page (i.e., the next `before` value to fetch the page immediately preceding this one) — `null` when there is no older page (fewer than `limit + 1` rows existed). +- `nextCursor` is the `id` of the oldest message in the _untrimmed, pre-reverse_ page (i.e., the next `before` value to fetch the page immediately preceding this one) — `null` when there is no older page (fewer than `limit + 1` rows existed). - There is no forward/"after" cursor on this route — pagination is one-directional (backward from the most recent message, or from an explicit `before` point). --- @@ -136,21 +137,21 @@ List all members of a conversation, ordered by `joinedAt` ascending. { "members": [ { - "id": "uuid", // user id + "id": "uuid", // user id "username": "string | null", "avatarUrl": "string | null", "primaryWalletAddress": "string | null", // primary wallet, else first wallet, else null - "joinedAt": "ISO timestamp" - } - ] + "joinedAt": "ISO timestamp", + }, + ], } ``` **Errors** -| Status | Body | Cause | -|---|---|---| -| 403 | `{ "error": "Not a member of this conversation" }` | Caller isn't a member | +| Status | Body | Cause | +| ------ | -------------------------------------------------- | --------------------- | +| 403 | `{ "error": "Not a member of this conversation" }` | Caller isn't a member | --- @@ -168,10 +169,10 @@ Add a member to a group conversation. Requires the caller to already be a member ```jsonc { - "id": "uuid", // conversationMembers row id + "id": "uuid", // conversationMembers row id "conversationId": "uuid", "userId": "uuid", - "joinedAt": "ISO timestamp" + "joinedAt": "ISO timestamp", } ``` @@ -179,15 +180,15 @@ Side effects: invalidates the conversation-list cache for every member of the co **Errors** -| Status | Body | Cause | -|---|---|---| -| 400 | `{ "error": "Conversation id is required" }` | Missing `:id` | -| 400 | `{ "error": "userId is required" }` | Missing/non-string `userId` in body | -| 404 | `{ "error": "Conversation not found" }` | No conversation with that ID | -| 400 | `{ "error": "DM conversations cannot add members" }` | `conversation.type === 'dm'` | -| 403 | `{ "error": "Not a member of this conversation" }` | Caller isn't a member | -| 409 | `{ "error": "User is already a member" }` | Target user already has a membership row | -| 409 | `{ "error": "Database conflict or validation error" }` | Insert failed (e.g. race / constraint violation) | +| Status | Body | Cause | +| ------ | ------------------------------------------------------ | ------------------------------------------------ | +| 400 | `{ "error": "Conversation id is required" }` | Missing `:id` | +| 400 | `{ "error": "userId is required" }` | Missing/non-string `userId` in body | +| 404 | `{ "error": "Conversation not found" }` | No conversation with that ID | +| 400 | `{ "error": "DM conversations cannot add members" }` | `conversation.type === 'dm'` | +| 403 | `{ "error": "Not a member of this conversation" }` | Caller isn't a member | +| 409 | `{ "error": "User is already a member" }` | Target user already has a membership row | +| 409 | `{ "error": "Database conflict or validation error" }` | Insert failed (e.g. race / constraint violation) | --- @@ -207,15 +208,15 @@ Side effects: invalidates the conversation-list cache for every member, and emit **Errors** -| Status | Body | Cause | -|---|---|---| -| 400 | `{ "error": "Conversation id is required" }` | Missing `:id` | -| 400 | `{ "error": "At least one of name or avatarUrl must be provided" }` | Empty body | -| 400 | `{ "error": "name must be a string" }` / `{ "error": "avatarUrl must be a string" }` | Wrong type | -| 404 | `{ "error": "Conversation not found" }` | No conversation with that ID | -| 400 | `{ "error": "DM conversations cannot be updated" }` | `conversation.type === 'dm'` | -| 403 | `{ "error": "Not a member of this conversation" }` | Caller isn't a member | -| 500 | `{ "error": "Failed to update conversation" }` | Update returned no row, or threw | +| Status | Body | Cause | +| ------ | ------------------------------------------------------------------------------------ | -------------------------------- | +| 400 | `{ "error": "Conversation id is required" }` | Missing `:id` | +| 400 | `{ "error": "At least one of name or avatarUrl must be provided" }` | Empty body | +| 400 | `{ "error": "name must be a string" }` / `{ "error": "avatarUrl must be a string" }` | Wrong type | +| 404 | `{ "error": "Conversation not found" }` | No conversation with that ID | +| 400 | `{ "error": "DM conversations cannot be updated" }` | `conversation.type === 'dm'` | +| 403 | `{ "error": "Not a member of this conversation" }` | Caller isn't a member | +| 500 | `{ "error": "Failed to update conversation" }` | Update returned no row, or threw | --- @@ -239,11 +240,11 @@ Side effects: deletes the caller's conversation-list cache entry (forcing a fres **Errors** -| Status | Body | Cause | -|---|---|---| -| 400 | `{ "error": "Conversation id is required" }` | Missing `:id` | -| 400 | `{ "error": "At least one of muted or archived is required" }` | Empty body | -| 403 | `{ "error": "Not a member of this conversation" }` | Caller isn't a member | +| Status | Body | Cause | +| ------ | -------------------------------------------------------------- | --------------------- | +| 400 | `{ "error": "Conversation id is required" }` | Missing `:id` | +| 400 | `{ "error": "At least one of muted or archived is required" }` | Empty body | +| 403 | `{ "error": "Not a member of this conversation" }` | Caller isn't a member | --- @@ -259,12 +260,12 @@ Side effects: invalidates the conversation-list cache for every member who was i **Errors** -| Status | Body | Cause | -|---|---|---| -| 400 | `{ "error": "Conversation id is required" }` | Missing `:id` | -| 404 | `{ "error": "Conversation not found" }` | No conversation with that ID | -| 400 | `{ "error": "DM conversations cannot be left" }` | `conversation.type === 'dm'` | -| 404 | `{ "error": "Conversation membership not found" }` | Caller has no membership row for this conversation | +| Status | Body | Cause | +| ------ | -------------------------------------------------- | -------------------------------------------------- | +| 400 | `{ "error": "Conversation id is required" }` | Missing `:id` | +| 404 | `{ "error": "Conversation not found" }` | No conversation with that ID | +| 400 | `{ "error": "DM conversations cannot be left" }` | `conversation.type === 'dm'` | +| 404 | `{ "error": "Conversation membership not found" }` | Caller has no membership row for this conversation | --- @@ -282,9 +283,9 @@ Returns the full set of active (non-revoked) devices belonging to every member o "userId": "uuid", "identityPublicKey": "string", "deviceName": "string | null", - "platform": "string | null" - } - ] + "platform": "string | null", + }, + ], } ``` @@ -292,10 +293,10 @@ Only devices with `revokedAt IS NULL` are included. **Errors** -| Status | Body | Cause | -|---|---|---| -| 400 | `{ "error": "Conversation id is required" }` | Missing `:id` | -| 403 | `{ "error": "Not a member of this conversation" }` | Caller isn't a member | +| Status | Body | Cause | +| ------ | -------------------------------------------------- | --------------------- | +| 400 | `{ "error": "Conversation id is required" }` | Missing `:id` | +| 403 | `{ "error": "Not a member of this conversation" }` | Caller isn't a member | --- @@ -322,11 +323,11 @@ Record an on-chain token transfer against a conversation (e.g. after a client su ```json { - "recipient_address": "string", // or recipientAddress + "recipient_address": "string", // or recipientAddress "amount": "string | number", - "token_contract_id": "string", // or tokenContractId - "tx_hash": "string", // or txHash - "memo": "string | null" // optional + "token_contract_id": "string", // or tokenContractId + "tx_hash": "string", // or txHash + "memo": "string | null" // optional } ``` @@ -336,13 +337,13 @@ Both snake_case and camelCase field names are accepted (`recipient_address`/`rec **Errors** -| Status | Body | Cause | -|---|---|---| -| 400 | `{ "error": "Conversation id is required" }` | Missing `:id` | -| 403 | `{ "error": "Not a member of this conversation" }` | Caller isn't a member | -| 400 | `{ "error": "recipientAddress, amount, tokenContractId, and txHash are required" }` | Missing required field | -| 409 | `{ "error": "Transaction hash already exists" }` | `txHash` already recorded (idempotency guard) | -| 409 | `{ "error": "Database conflict or validation error" }` | Insert failed | +| Status | Body | Cause | +| ------ | ----------------------------------------------------------------------------------- | --------------------------------------------- | +| 400 | `{ "error": "Conversation id is required" }` | Missing `:id` | +| 403 | `{ "error": "Not a member of this conversation" }` | Caller isn't a member | +| 400 | `{ "error": "recipientAddress, amount, tokenContractId, and txHash are required" }` | Missing required field | +| 409 | `{ "error": "Transaction hash already exists" }` | `txHash` already recorded (idempotency guard) | +| 409 | `{ "error": "Database conflict or validation error" }` | Insert failed | --- @@ -354,11 +355,11 @@ List token transfers recorded against a conversation, newest first. **Errors** -| Status | Body | Cause | -|---|---|---| -| 400 | `{ "error": "Conversation id is required" }` | Missing `:id` | -| 403 | `{ "error": "Not a member of this conversation" }` | Caller isn't a member | -| 500 | `{ "error": "Failed to retrieve transfers" }` | Query threw | +| Status | Body | Cause | +| ------ | -------------------------------------------------- | --------------------- | +| 400 | `{ "error": "Conversation id is required" }` | Missing `:id` | +| 403 | `{ "error": "Not a member of this conversation" }` | Caller isn't a member | +| 500 | `{ "error": "Failed to retrieve transfers" }` | Query threw | --- @@ -366,7 +367,7 @@ List token transfers recorded against a conversation, newest first. **Why two callers can fetch the same conversation and get different `ciphertext` bytes for the same `messages[]` entry:** -Messages are end-to-end encrypted per-recipient-*device*, not per-recipient-*user* or per-conversation. When a message is sent, the sender's client encrypts the plaintext separately for every active device of every recipient (fetched via `GET /conversations/:id/devices`) and uploads one `message_envelopes` row per device: +Messages are end-to-end encrypted per-recipient-_device_, not per-recipient-_user_ or per-conversation. When a message is sent, the sender's client encrypts the plaintext separately for every active device of every recipient (fetched via `GET /conversations/:id/devices`) and uploads one `message_envelopes` row per device: ``` message_envelopes(messageId, recipientDeviceId, recipientUserId, ciphertext, deliveredAt, readAt) @@ -375,7 +376,7 @@ message_envelopes(messageId, recipientDeviceId, recipientUserId, ciphertext, del Every route in this file that returns message content (`GET /conversations` latest-message preview, `GET /conversations/:id` latest message, `GET /conversations/:id/messages`) filters the `envelopes` relation to: ```ts -where: eq(messageEnvelopes.recipientDeviceId, req.auth!.deviceId) +where: eq(messageEnvelopes.recipientDeviceId, req.auth!.deviceId); ``` — i.e. **only the envelope addressed to the calling device**. This is resolved in `serializeMessage()` (`src/lib/messages.ts`): @@ -383,9 +384,10 @@ where: eq(messageEnvelopes.recipientDeviceId, req.auth!.deviceId) 1. If the message is soft-deleted (`deletedAt` set), `ciphertext` is always `null`. 2. Else if an envelope exists for this device, that envelope's `ciphertext` is returned (this is the normal case for E2EE messages). 3. Else if the message row itself has a `ciphertext` (system messages, or legacy pre-envelope messages), that's returned instead. -4. Else `ciphertext: null` with `unavailable: true` — the caller's device doesn't have an envelope for this message (e.g. it's a device that was added *after* the message was sent, or was offline during fan-out) and cannot decrypt it. +4. Else `ciphertext: null` with `unavailable: true` — the caller's device doesn't have an envelope for this message (e.g. it's a device that was added _after_ the message was sent, or was offline during fan-out) and cannot decrypt it. **Consequences**: -- Two devices belonging to the *same* user, or two different members of a group conversation, each hold their own device-scoped ciphertext for the same logical message — the bytes differ because each was encrypted against a different device's public key, even though the underlying plaintext is identical. + +- Two devices belonging to the _same_ user, or two different members of a group conversation, each hold their own device-scoped ciphertext for the same logical message — the bytes differ because each was encrypted against a different device's public key, even though the underlying plaintext is identical. - A message can be `unavailable: true` for one device while fully present for another — this is expected when a device joins late or missed the original fan-out, not a bug. Clients should treat `unavailable: true` as "cannot decrypt on this device" rather than "message doesn't exist." - Because filtering happens per-request based on `req.auth!.deviceId` (the device embedded in the caller's JWT), the same user calling from two different logged-in devices will see different `ciphertext` for the same message ID in the same `GET /conversations/:id/messages` response shape — this is expected behavior, not a caching bug. diff --git a/apps/backend/docs/api-devices.md b/apps/backend/docs/api-devices.md index 188aa7e..082b4fb 100644 --- a/apps/backend/docs/api-devices.md +++ b/apps/backend/docs/api-devices.md @@ -40,6 +40,7 @@ no ownership parameter to check. **Request:** no body. **Response `200`:** + ```json [ { @@ -55,6 +56,7 @@ no ownership parameter to check. } ] ``` + - `oneTimePreKeysRemaining` — count of unconsumed `one_time` rows in `device_prekeys` for that device (`0` if none uploaded, or if the device has never had prekeys). @@ -77,6 +79,7 @@ previously-revoked one. (`req.auth.userId`) — there is no cross-user parameter. **Request body** (validated by `RegisterDeviceSchema`, i.e. `DeviceSchema`): + ```json { "deviceName": "Jesse's iPhone", @@ -85,6 +88,7 @@ previously-revoked one. "registrationId": 12345 } ``` + - `deviceName`: string, 1–100 chars, **required**. - `platform`: one of `"web" | "ios" | "android"`, **required**. - `identityPublicKey`: base64 Ed25519 public key, **required** (32 raw bytes, @@ -92,6 +96,7 @@ previously-revoked one. - `registrationId`: non-negative integer, **optional**. **Behavior:** + - Looked up by `(userId, identityPublicKey)`. - If a **non-revoked** row already exists for that identity key: `409` `{ "error": "Device already registered for this user" }`. @@ -106,6 +111,7 @@ previously-revoked one. with `'device_added'` instead of `'device_revoked'`). **Response `201`:** + ```json { "id": "b6b6c3b0-...", "createdAt": "2026-07-29T10:00:00.000Z" } ``` @@ -113,6 +119,7 @@ previously-revoked one. **Response `409`:** device already registered (see above). **Response `400`:** Zod validation failure — + ```json { "error": "Validation failed", @@ -143,6 +150,7 @@ state rather than re-running side effects. caller's non-revoked devices. **Response `200`** (device revoked now, or already was): + ```json { "id": "b6b6c3b0-...", "revokedAt": "2026-07-29T10:00:00.000Z" } ``` @@ -170,9 +178,11 @@ non-revoked device except the one making the request **Request:** no body. **Response `200`:** + ```json { "revokedCount": 3 } ``` + `revokedCount` is the number of devices actually revoked (excludes the caller's current device, and excludes devices that were already revoked). @@ -192,6 +202,7 @@ Uploads a signed prekey and a batch of one-time prekeys for a device the caller owns. **Auth / ownership:** + - Device must exist → otherwise `404` `{ "error": "Device not found" }`. - Device must belong to the caller → otherwise `403` `{ "error": "Only the device owner may upload prekeys" }`. @@ -200,6 +211,7 @@ caller owns. **Request body** (validated by a schema requiring both a signed prekey and at least one one-time prekey): + ```json { "signedPreKey": { @@ -213,6 +225,7 @@ at least one one-time prekey): ] } ``` + - `oneTimePreKeys` must contain **at least 1** entry — an empty array is a `400` schema-validation failure, not a no-op. - `signedPreKey.signature` is verified as an Ed25519 signature over @@ -224,6 +237,7 @@ See [Prekey upload contract](#prekey-upload-contract) below for the full signed-vs-one-time distinction and the 200-key cap/trim behavior. **Response `200`:** + ```json { "uploadedSignedPreKey": true, @@ -251,6 +265,7 @@ encrypting to a sender you've received a message from. **Auth / ownership:** this is a **cross-user** lookup, gated differently from every other route on this page: + - The target device (`:id`) must exist **and be non-revoked** — a revoked or nonexistent device returns `404` `{ "error": "Device not found or revoked" }`. @@ -258,9 +273,10 @@ from every other route on this page: device's owner — otherwise `403` `{ "error": "No shared conversation with device owner" }`. There is no ownership requirement that the caller own the device itself; this route - is explicitly for looking up *other* users' keys. + is explicitly for looking up _other_ users' keys. **Response `200`:** + ```json { "id": "b6b6c3b0-...", @@ -319,13 +335,13 @@ with a `device_added` change type instead — it does **not** run steps 1–4 ### Signed vs. one-time prekeys -| | Signed prekey | One-time prekeys | -|---|---|---| -| Count per device | Exactly one, upserted (replaced) on every upload | Many; new ones are added to the existing pool | -| Fields | `keyId`, `publicKey`, `signature` | `keyId`, `publicKey` | -| Conflict handling | `ON CONFLICT` on `(deviceId, keyType='signed')` → **updates** the existing row (`keyId`, `publicKey`, `signature`, `createdAt` all overwritten) | `ON CONFLICT` on `(deviceId, keyType, keyId)` → **ignored** (`onConflictDoNothing`); re-uploading the same `keyId` is a silent no-op, not an error | -| Signature check | `signature` is verified as an Ed25519 signature over `publicKey`, using the device's `identityPublicKey`. Invalid → `400`, nothing written | Not signature-checked individually | -| Consumption | Never marked `consumed` — it's reused across sessions | Each row has a `consumed` boolean; consumption itself happens outside this route (e.g. when another user fetches a key bundle) — this route only ever inserts unconsumed rows | +| | Signed prekey | One-time prekeys | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Count per device | Exactly one, upserted (replaced) on every upload | Many; new ones are added to the existing pool | +| Fields | `keyId`, `publicKey`, `signature` | `keyId`, `publicKey` | +| Conflict handling | `ON CONFLICT` on `(deviceId, keyType='signed')` → **updates** the existing row (`keyId`, `publicKey`, `signature`, `createdAt` all overwritten) | `ON CONFLICT` on `(deviceId, keyType, keyId)` → **ignored** (`onConflictDoNothing`); re-uploading the same `keyId` is a silent no-op, not an error | +| Signature check | `signature` is verified as an Ed25519 signature over `publicKey`, using the device's `identityPublicKey`. Invalid → `400`, nothing written | Not signature-checked individually | +| Consumption | Never marked `consumed` — it's reused across sessions | Each row has a `consumed` boolean; consumption itself happens outside this route (e.g. when another user fetches a key bundle) — this route only ever inserts unconsumed rows | Every upload **replaces the signed prekey** (there is only ever one active signed prekey per device) while **adding to** the one-time pool (existing @@ -362,6 +378,6 @@ one-time array. --- -*Verified against `apps/backend/src/routes/devices.ts` and +_Verified against `apps/backend/src/routes/devices.ts` and `apps/backend/src/routes/userDevices.ts` as of this writing. If either file -changes, this doc should be updated in the same PR.* \ No newline at end of file +changes, this doc should be updated in the same PR._ diff --git a/apps/backend/docs/api-files-uploads.md b/apps/backend/docs/api-files-uploads.md index ed09830..f40ae7f 100644 --- a/apps/backend/docs/api-files-uploads.md +++ b/apps/backend/docs/api-files-uploads.md @@ -12,13 +12,13 @@ All three routes require authentication (see `POST /auth/verify` in [`api-auth.m ## Size & MIME constraints -| Constraint | Value | Source | -|---|---|---| -| Max file size | **100 MB** (`100 * 1024 * 1024` bytes) | `routes/uploads.ts:14` | +| Constraint | Value | Source | +| ------------------ | ---------------------------------------------------- | ------------------------- | +| Max file size | **100 MB** (`100 * 1024 * 1024` bytes) | `routes/uploads.ts:14` | | Allowed MIME types | `image/jpeg`, `image/png`, `image/gif`, `image/webp` | `routes/uploads.ts:16-21` | -| | `video/mp4`, `video/webm` | | -| | `audio/mpeg`, `audio/ogg`, `audio/wav` | | -| | `application/pdf`, `application/octet-stream` | | +| | `video/mp4`, `video/webm` | | +| | `audio/mpeg`, `audio/ogg`, `audio/wav` | | +| | `application/pdf`, `application/octet-stream` | | Any MIME type outside this set is rejected with HTTP `415` during slot request. @@ -42,13 +42,13 @@ Requests a presigned upload slot for a file. The caller must be a member of the } ``` -| Field | Type | Required | Description | -|---|---|---|---| -| `conversationId` | `string` (uuid) | yes | Target conversation UUID. | -| `size` | `number` (int) | yes | File size in bytes. Must be 1 ≤ size ≤ 100,000,000. | -| `mimeType` | `string` | yes | Must be in the allowed MIME types set (see above). | -| `sha256` | `string` | yes | Hex-encoded SHA-256 hash of the file content. | -| `isThumbnail` | `boolean` | no | Whether this is a thumbnail of a larger file. Defaults to `false`. | +| Field | Type | Required | Description | +| ---------------- | --------------- | -------- | ------------------------------------------------------------------ | +| `conversationId` | `string` (uuid) | yes | Target conversation UUID. | +| `size` | `number` (int) | yes | File size in bytes. Must be 1 ≤ size ≤ 100,000,000. | +| `mimeType` | `string` | yes | Must be in the allowed MIME types set (see above). | +| `sha256` | `string` | yes | Hex-encoded SHA-256 hash of the file content. | +| `isThumbnail` | `boolean` | no | Whether this is a thumbnail of a larger file. Defaults to `false`. | ### Responses @@ -61,9 +61,9 @@ Requests a presigned upload slot for a file. The caller must be a member of the } ``` -| Field | Type | Description | -|---|---|---| -| `fileId` | `string` | UUID of the newly created file row (status: `pending`). | +| Field | Type | Description | +| ----------- | -------- | ------------------------------------------------------------------------------ | +| `fileId` | `string` | UUID of the newly created file row (status: `pending`). | | `uploadUrl` | `string` | Presigned PUT URL. Valid for 15 minutes (production) or a fake URL (dev/test). | The client must perform a `PUT` request to `uploadUrl` with the file bytes as the body and the declared `mimeType` as `Content-Type`. **There is no server-side storage verification** — see confirm step below. @@ -107,9 +107,9 @@ Marks a `pending` file as `ready`. The client should call this after successfull ### Path parameter -| Parameter | Description | -|---|---| -| `fileId` | UUID of the file row returned by `POST /uploads`. | +| Parameter | Description | +| --------- | ------------------------------------------------- | +| `fileId` | UUID of the file row returned by `POST /uploads`. | ### Responses @@ -159,11 +159,13 @@ Returned when the authenticated user is not the original uploader. ### What confirm does and does not verify **Does:** + - Asserts the requesting user is the original uploader (`uploaderId` check). - Asserts the file exists and is in a confirmable state (`pending`). - Transitions status from `pending` → `ready`. **Does NOT:** + - Verify the file bytes were actually uploaded to storage. - Check the uploaded content's SHA-256 hash against the value declared in the slot request. - Re-check file size or MIME type against the stored metadata. @@ -181,9 +183,9 @@ Issues a short-lived presigned GET URL so the client can download the file (ciph ### Path parameter -| Parameter | Description | -|---|---| -| `fileId` | UUID of the file row. | +| Parameter | Description | +| --------- | --------------------- | +| `fileId` | UUID of the file row. | ### Responses @@ -197,8 +199,8 @@ Issues a short-lived presigned GET URL so the client can download the file (ciph The presigned URL is valid for **5 minutes** (300 seconds). The client should start the download immediately. In dev/test environments the URL is a structurally-plausible fake (see `storage.ts:15-19`). -| Field | Type | Description | -|---|---|---| +| Field | Type | Description | +| ----- | -------- | ------------------ | | `url` | `string` | Presigned GET URL. | #### `400` — Missing fileId diff --git a/apps/backend/docs/api-messages-sync.md b/apps/backend/docs/api-messages-sync.md index a8010cb..094d843 100644 --- a/apps/backend/docs/api-messages-sync.md +++ b/apps/backend/docs/api-messages-sync.md @@ -62,16 +62,16 @@ Authorization: Bearer } ``` -| Field | Type | Required | Notes | -|---|---|---|---| -| `conversationId` | UUID | Yes | The conversation to post into | -| `messageId` | UUID | Yes | Client-generated; used for idempotency | -| `contentType` | string | No | Defaults to `"text"`. Trimmed and lowercased. | -| `ciphertext` | string | No | The encrypted message body | -| `envelopes` | array | No | Per-recipient-device encrypted payloads | -| `envelopes[].recipientDeviceId` | UUID | Yes (within envelope) | Must be a valid `devices.id` row | -| `envelopes[].ciphertext` | string | Yes (within envelope) | Must be at least 1 character | -| `fileId` | UUID | No | Required when `contentType` is `file`, `image`, `video`, or `audio` | +| Field | Type | Required | Notes | +| ------------------------------- | ------ | --------------------- | ------------------------------------------------------------------- | +| `conversationId` | UUID | Yes | The conversation to post into | +| `messageId` | UUID | Yes | Client-generated; used for idempotency | +| `contentType` | string | No | Defaults to `"text"`. Trimmed and lowercased. | +| `ciphertext` | string | No | The encrypted message body | +| `envelopes` | array | No | Per-recipient-device encrypted payloads | +| `envelopes[].recipientDeviceId` | UUID | Yes (within envelope) | Must be a valid `devices.id` row | +| `envelopes[].ciphertext` | string | Yes (within envelope) | Must be at least 1 character | +| `fileId` | UUID | No | Required when `contentType` is `file`, `image`, `video`, or `audio` | ### Content-type validation @@ -115,12 +115,12 @@ existing record without inserting duplicates: ### Error responses -| Status | Body | Condition | -|---|---|---| -| 400 | `{"error":"…"}` | Zod validation failure (missing required field, bad UUID format) | -| 400/422 | `{"error":"…"}` | Content-type rule violation (e.g. file type without `fileId`) | -| 403 | `{"error":"Not a member of this conversation"}` | Authenticated user is not in the conversation | -| 500 | `{"error":"Failed to persist message"}` | Database transaction failure | +| Status | Body | Condition | +| ------- | ----------------------------------------------- | ---------------------------------------------------------------- | +| 400 | `{"error":"…"}` | Zod validation failure (missing required field, bad UUID format) | +| 400/422 | `{"error":"…"}` | Content-type rule violation (e.g. file type without `fileId`) | +| 403 | `{"error":"Not a member of this conversation"}` | Authenticated user is not in the conversation | +| 500 | `{"error":"Failed to persist message"}` | Database transaction failure | ### Side effects @@ -150,9 +150,9 @@ Authorization: Bearer ### Path parameter -| Parameter | Type | Required | Notes | -|---|---|---|---| -| `:id` | UUID | Yes | The `messageId` to delete | +| Parameter | Type | Required | Notes | +| --------- | ---- | -------- | ------------------------- | +| `:id` | UUID | Yes | The `messageId` to delete | ### Success response @@ -164,11 +164,11 @@ Body is empty. ### Error responses -| Status | Body | Condition | -|---|---|---| -| 400 | `{"error":"Message id is required"}` | Missing path parameter | -| 403 | `{"error":"You can only delete your own messages"}` | Authenticated user is not the sender | -| 404 | `{"error":"Message not found"}` | No message with that id exists | +| Status | Body | Condition | +| ------ | --------------------------------------------------- | ------------------------------------ | +| 400 | `{"error":"Message id is required"}` | Missing path parameter | +| 403 | `{"error":"You can only delete your own messages"}` | Authenticated user is not the sender | +| 404 | `{"error":"Message not found"}` | No message with that id exists | ### Side effects @@ -205,11 +205,11 @@ Authorization: Bearer ### Query parameters -| Parameter | Type | Required | Default | Notes | -|---|---|---|---|---| -| `deviceId` | UUID | **Yes** | — | The E2E device id (from JWT or `/devices`) | -| `cursor` | string | No | — | Opaque cursor from a previous response's `nextCursor`; omit to start from the beginning of the retention window | -| `limit` | integer | No | `50` | Page size; capped at `SYNC_PAGE_SIZE` (configurable via env, default 50) | +| Parameter | Type | Required | Default | Notes | +| ---------- | ------- | -------- | ------- | --------------------------------------------------------------------------------------------------------------- | +| `deviceId` | UUID | **Yes** | — | The E2E device id (from JWT or `/devices`) | +| `cursor` | string | No | — | Opaque cursor from a previous response's `nextCursor`; omit to start from the beginning of the retention window | +| `limit` | integer | No | `50` | Page size; capped at `SYNC_PAGE_SIZE` (configurable via env, default 50) | ### Request example @@ -235,12 +235,13 @@ For example: **Why this format?** The cursor uses the envelope's own creation timestamp and id (not the message's per-conversation sequence number), because offline-catchup -sync needs to order envelopes across *different* conversations for a single +sync needs to order envelopes across _different_ conversations for a single device. The envelope's `(createdAt, id)` pair is comparable across every conversation the device has envelopes in. The `id` serves as a tie-breaker for envelopes created in the same millisecond. Clients must: + - Store the `nextCursor` value from the most recent successful response - Send it as the `cursor` parameter on the next sync request - Treat the cursor as an opaque token — do not parse or interpret its @@ -269,21 +270,21 @@ Clients must: } ``` -| Field | Type | Notes | -|---|---|---| -| `envelopes` | array | Ordered by `(createdAt ASC, id ASC)` across all conversations | -| `envelopes[].id` | UUID | The `messageEnvelopes.id` — this is what the cursor encodes | -| `envelopes[].messageId` | UUID | The parent message | -| `envelopes[].conversationId` | UUID | Which conversation this belongs to | -| `envelopes[].senderId` | UUID | The user who sent the message | -| `envelopes[].senderDeviceId` | UUID \| null | The sending device | -| `envelopes[].contentType` | string | Message content type | -| `envelopes[].ciphertext` | string | Encrypted payload for this specific recipient device | -| `envelopes[].deliveredAt` | ISO 8601 \| null | When this envelope was first delivered via `/sync` | -| `envelopes[].createdAt` | ISO 8601 | Envelope creation time (drives ordering) | -| `envelopes[].messageCreatedAt` | ISO 8601 | When the parent message was sent | -| `nextCursor` | string \| null | Cursor for the next page; `null` when no more pages or same as input when page is empty | -| `hasMore` | boolean | `true` if more pages exist beyond the current one | +| Field | Type | Notes | +| ------------------------------ | ---------------- | --------------------------------------------------------------------------------------- | +| `envelopes` | array | Ordered by `(createdAt ASC, id ASC)` across all conversations | +| `envelopes[].id` | UUID | The `messageEnvelopes.id` — this is what the cursor encodes | +| `envelopes[].messageId` | UUID | The parent message | +| `envelopes[].conversationId` | UUID | Which conversation this belongs to | +| `envelopes[].senderId` | UUID | The user who sent the message | +| `envelopes[].senderDeviceId` | UUID \| null | The sending device | +| `envelopes[].contentType` | string | Message content type | +| `envelopes[].ciphertext` | string | Encrypted payload for this specific recipient device | +| `envelopes[].deliveredAt` | ISO 8601 \| null | When this envelope was first delivered via `/sync` | +| `envelopes[].createdAt` | ISO 8601 | Envelope creation time (drives ordering) | +| `envelopes[].messageCreatedAt` | ISO 8601 | When the parent message was sent | +| `nextCursor` | string \| null | Cursor for the next page; `null` when no more pages or same as input when page is empty | +| `hasMore` | boolean | `true` if more pages exist beyond the current one | ### How a client should persist/resume the cursor @@ -329,25 +330,25 @@ while (hasMore) { returns all undelivered envelopes still within the TTL retention window. **On subsequent reconnects**: send the last persisted `nextCursor`. The server -returns only envelopes created *after* that cursor. Because the pagination uses +returns only envelopes created _after_ that cursor. Because the pagination uses an exclusive lower bound (`>` not `>=`), re-issuing the same cursor never re-delivers an envelope the client already processed. ### Error responses -| Status | Body | Condition | -|---|---|---| -| 400 | `{"error":"deviceId is required"}` | Missing required `deviceId` query parameter | -| 400 | `{"error":"Invalid cursor"}` | Malformed cursor string | -| 403 | `{"error":"Device not found or not owned by this user"}` | Device does not exist or belongs to a different user | +| Status | Body | Condition | +| ------ | -------------------------------------------------------- | ---------------------------------------------------- | +| 400 | `{"error":"deviceId is required"}` | Missing required `deviceId` query parameter | +| 400 | `{"error":"Invalid cursor"}` | Malformed cursor string | +| 403 | `{"error":"Device not found or not owned by this user"}` | Device does not exist or belongs to a different user | ### Retention window / TTL for undelivered envelopes The server retains undelivered envelopes for a configurable duration controlled by the environment variable `ENVELOPE_TTL_SECONDS`: -| Env variable | Default | Description | -|---|---|---| +| Env variable | Default | Description | +| ---------------------- | ----------------- | ----------------------------------------------------------------------- | | `ENVELOPE_TTL_SECONDS` | `604800` (7 days) | Maximum age of undelivered envelopes before they are excluded from sync | #### What happens when a device is offline longer than the TTL window @@ -377,10 +378,10 @@ practice the cursor has already advanced. ### Configuration summary -| Env variable | Default | Description | -|---|---|---| +| Env variable | Default | Description | +| ---------------------- | -------- | ---------------------------------------------------- | | `ENVELOPE_TTL_SECONDS` | `604800` | Retention window for undelivered envelopes (seconds) | -| `SYNC_PAGE_SIZE` | `50` | Maximum number of envelopes per page | +| `SYNC_PAGE_SIZE` | `50` | Maximum number of envelopes per page | --- diff --git a/apps/backend/docs/api-push.md b/apps/backend/docs/api-push.md index 22130e0..d177ea1 100644 --- a/apps/backend/docs/api-push.md +++ b/apps/backend/docs/api-push.md @@ -140,11 +140,11 @@ Status: `500` — database delete failed. Web Push requires VAPID keys to authenticate the application server with the push service. The backend reads these from environment variables: -| Variable | Required | Default | Description | -|---|---|---|---| -| `VAPID_PUBLIC_KEY` | no (push disabled if absent) | — | VAPID application server public key | -| `VAPID_PRIVATE_KEY` | no (push disabled if absent) | — | VAPID application server private key | -| `VAPID_SUBJECT` | no | `mailto:admin@clicked.app` | Contact URI for the push service | +| Variable | Required | Default | Description | +| ------------------- | ---------------------------- | -------------------------- | ------------------------------------ | +| `VAPID_PUBLIC_KEY` | no (push disabled if absent) | — | VAPID application server public key | +| `VAPID_PRIVATE_KEY` | no (push disabled if absent) | — | VAPID application server private key | +| `VAPID_SUBJECT` | no | `mailto:admin@clicked.app` | Contact URI for the push service | These are declared as optional in the environment schema (`apps/backend/src/config.ts:18-20`). When both `VAPID_PUBLIC_KEY` and diff --git a/apps/backend/docs/api-treasury.md b/apps/backend/docs/api-treasury.md index 2884003..4c45423 100644 --- a/apps/backend/docs/api-treasury.md +++ b/apps/backend/docs/api-treasury.md @@ -11,6 +11,7 @@ This document covers the REST API specifications for the Treasury module (`route The `routes/treasury.ts` module provides REST endpoints for managing treasury proposals and votes using a PostgreSQL database. **What this module does:** + - Accepts proposal creation requests with validation - Stores proposals in the `treasuryProposals` table - Records user votes in the `proposalVotes` table @@ -19,34 +20,36 @@ The `routes/treasury.ts` module provides REST endpoints for managing treasury pr - Uses `contractId` from `GROUP_TREASURY_CONTRACT_ID` env var (defaults to `'stub'`) **Database Tables:** + - `treasuryProposals`: Stores proposal data (id, contractId, proposalId, amount, recipient, token, status, threshold, etc.) - `proposalVotes`: Stores user votes (treasuryProposalId, userId, vote, signature) **Authentication:** + - All endpoints require authentication via `requireAuth` middleware - User ID is extracted from the authenticated request - Used for tracking votes and preventing duplicate voting > **⚠️ Note:** Current implementation is off-chain only. Soroban contract integration is not yet implemented. - --- ## 2. TTL-per-Ledger Conversion Table Soroban measures Time-To-Live (TTL) and expiration in **ledgers**. On Stellar (Mainnet & Testnet), 1 ledger is closed approximately every **5 seconds**. -| TTL Value | Human Duration | Ledger Count (≈ 5s/ledger) | Description | -| :--- | :--- | :--- | :--- | -| `'24h'` | 24 Hours (1 day) | `17,280` ledgers | Short-term or urgent proposals | -| `'72h'` | 72 Hours (3 days) | `51,840` ledgers | Medium-term standard proposals | -| `'7d'` | 7 Days (1 week) | `120,960` ledgers | Long-term major allocations | +| TTL Value | Human Duration | Ledger Count (≈ 5s/ledger) | Description | +| :-------- | :---------------- | :------------------------- | :----------------------------- | +| `'24h'` | 24 Hours (1 day) | `17,280` ledgers | Short-term or urgent proposals | +| `'72h'` | 72 Hours (3 days) | `51,840` ledgers | Medium-term standard proposals | +| `'7d'` | 7 Days (1 week) | `120,960` ledgers | Long-term major allocations | **Conversion Formula:** $$\text{Ledgers} = \frac{\text{Duration in Seconds}}{5}$$ **Example:** + - 24 hours = 24 × 60 × 60 = 86,400 seconds - 86,400 / 5 = 17,280 ledgers ✅ @@ -55,10 +58,13 @@ $$\text{Ledgers} = \frac{\text{Duration in Seconds}}{5}$$ ## 3. Endpoints Specification ### 1. Propose a Withdrawal ->- **Route**: POST /api/treasury/propose + +> - **Route**: POST /api/treasury/propose + - **Description**: Creates a new withdrawal proposal for a group treasury. #### Validation Rules + - `amount`: Positive number (> 0) - `token`: Non-empty string (token identifier) - `recipient`: Valid Stellar public key (must start with `G` and be 56 characters) @@ -67,7 +73,8 @@ $$\text{Ledgers} = \frac{\text{Duration in Seconds}}{5}$$ - `threshold` (optional): Integer >= 1 (defaults to `3` if not provided) - All requests require authentication via bearer token ->#### Request Body Example +> #### Request Body Example +> > ```json > { > "amount": 1000, @@ -79,8 +86,10 @@ $$\text{Ledgers} = \frac{\text{Duration in Seconds}}{5}$$ > } > ``` > ->#### Response Shapes & Status Codes ->- **201 Created**: Proposal created successfully in the database. +> #### Response Shapes & Status Codes +> +> - **201 Created**: Proposal created successfully in the database. +> > ```json > { > "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", @@ -97,48 +106,59 @@ $$\text{Ledgers} = \frac{\text{Duration in Seconds}}{5}$$ > "updatedAt": "2026-07-29T08:00:00.000Z" > } > ``` ->- **400 Bad Request**: Invalid input (e.g., invalid recipient address format, invalid TTL value, invalid amount). ->- **401 Unauthorized**: Authentication missing or invalid bearer token. ->- **500 Internal Server Error**: Database error or server issue. +> +> - **400 Bad Request**: Invalid input (e.g., invalid recipient address format, invalid TTL value, invalid amount). +> - **401 Unauthorized**: Authentication missing or invalid bearer token. +> - **500 Internal Server Error**: Database error or server issue. + ### 2. Approve a Proposal ->- **Route**: POST /api/treasury/proposals/:id/approve +> - **Route**: POST /api/treasury/proposals/:id/approve - **Description**: Registers an approval vote for a specific treasury proposal. The vote is recorded in the database with the authenticated user's ID. #### Validation Rules + - `id` (path parameter): Valid proposal UUID identifier. - `signature` (body): Optional string for cryptographic verification. - Proposal must exist in the database. - Proposal must have status `'active'`. - User cannot vote twice on the same proposal (unique constraint violation). ->#### Request Body Example +> #### Request Body Example +> > ```json > { > "signature": "a1b2c3d4e5f6..." > } > ``` > ->#### Response Shapes & Status Codes ->- **200 OK**: Vote recorded successfully. +> #### Response Shapes & Status Codes +> +> - **200 OK**: Vote recorded successfully. +> > ```json > { > "success": true > } > ``` ->- **404 Not Found**: Proposal ID does not exist. +> +> - **404 Not Found**: Proposal ID does not exist. +> > ```json > { > "error": "Proposal not found" > } > ``` ->- **409 Conflict**: Proposal is no longer active or user has already voted on this proposal. +> +> - **409 Conflict**: Proposal is no longer active or user has already voted on this proposal. +> > ```json > { > "error": "Proposal is no longer active" > } > ``` +> > ```json > { > "error": "Already voted on this proposal" @@ -146,47 +166,57 @@ $$\text{Ledgers} = \frac{\text{Duration in Seconds}}{5}$$ > ``` > **⚠️ Technical Note:** Both endpoints (`/approve` and `/reject`) use the same `handleVote()` function internally. The only difference is the vote value passed to the function. Consider refactoring to a single endpoint `POST /treasury/proposals/:id/vote` with `{ vote: 'approve' | 'reject' }` in the body for better RESTful design. + --- ### 3. Reject a Proposal ->- **Route**: POST /api/treasury/proposals/:id/reject +> - **Route**: POST /api/treasury/proposals/:id/reject - **Description**: Registers a rejection vote for a specific treasury proposal. The vote is recorded in the database with the authenticated user's ID. #### Validation Rules + - `id` (path parameter): Valid proposal UUID identifier. - `signature` (body): Optional string for cryptographic verification. - Proposal must exist in the database. - Proposal must have status `'active'`. - User cannot vote twice on the same proposal (unique constraint violation). ->#### Request Body Example +> #### Request Body Example +> > ```json > { > "signature": "a1b2c3d4e5f6..." > } > ``` > ->#### Response Shapes & Status Codes ->- **200 OK**: Vote recorded successfully. +> #### Response Shapes & Status Codes +> +> - **200 OK**: Vote recorded successfully. +> > ```json > { > "success": true > } > ``` ->- **404 Not Found**: Proposal ID does not exist. +> +> - **404 Not Found**: Proposal ID does not exist. +> > ```json > { > "error": "Proposal not found" > } > ``` ->- **409 Conflict**: Proposal is no longer active or user has already voted on this proposal. +> +> - **409 Conflict**: Proposal is no longer active or user has already voted on this proposal. +> > ```json > { > "error": "Proposal is no longer active" > } > ``` +> > ```json > { > "error": "Already voted on this proposal" @@ -199,15 +229,18 @@ $$\text{Ledgers} = \frac{\text{Duration in Seconds}}{5}$$ ### 4. List Proposals ->- **Route**: GET /api/treasury/proposals +> - **Route**: GET /api/treasury/proposals - **Description**: Retrieves all treasury proposals, optionally filtered by conversationId. Includes the authenticated user's vote status for each proposal. #### Query Parameters + - `conversationId` (optional): Valid UUID to filter proposals by conversation. ->#### Response Shapes & Status Codes ->- **200 OK**: Returns array of proposals. +> #### Response Shapes & Status Codes +> +> - **200 OK**: Returns array of proposals. +> > ```json > [ > { @@ -229,10 +262,12 @@ $$\text{Ledgers} = \frac{\text{Duration in Seconds}}{5}$$ > ``` > > **Empty response (no proposals):** +> > ```json > [] > ``` > ->- **401 Unauthorized**: Authentication missing or invalid bearer token. ->- **500 Internal Server Error**: Database error. +> - **401 Unauthorized**: Authentication missing or invalid bearer token. +> - **500 Internal Server Error**: Database error. + --- diff --git a/apps/backend/docs/api-users.md b/apps/backend/docs/api-users.md index 7499099..398942b 100644 --- a/apps/backend/docs/api-users.md +++ b/apps/backend/docs/api-users.md @@ -41,12 +41,12 @@ The middleware: ### Auth error responses -| Condition | Status | Body | -|---|---|---| -| Missing or non-Bearer header | `401` | `{ "error": "Missing or invalid Authorization header" }` | -| Invalid or expired JWT | `401` | `{ "error": "Invalid or expired token" }` | -| JWT missing `deviceId` claim | `401` | `{ "error": "Token missing deviceId" }` | -| Device not found or revoked | `401` | `{ "error": "Device not found or has been revoked" }` | +| Condition | Status | Body | +| ---------------------------- | ------ | -------------------------------------------------------- | +| Missing or non-Bearer header | `401` | `{ "error": "Missing or invalid Authorization header" }` | +| Invalid or expired JWT | `401` | `{ "error": "Invalid or expired token" }` | +| JWT missing `deviceId` claim | `401` | `{ "error": "Token missing deviceId" }` | +| Device not found or revoked | `401` | `{ "error": "Device not found or has been revoked" }` | --- @@ -61,11 +61,12 @@ GET /users/search?q= Authorization: Bearer ``` -| Parameter | In | Type | Required | Description | -|---|---|---|---|---| -| `q` | query | string | Yes | Username prefix (case-insensitive) **or** exact Stellar wallet address | +| Parameter | In | Type | Required | Description | +| --------- | ----- | ------ | -------- | ---------------------------------------------------------------------- | +| `q` | query | string | Yes | Username prefix (case-insensitive) **or** exact Stellar wallet address | **Matching behaviour:** + - Username matching is a case-insensitive prefix search (`ILIKE '%'`). - Special LIKE wildcard characters in `q` (`\`, `%`, `_`) are automatically escaped so user input is always treated literally. - Wallet address matching is an exact equality check against the `wallets.address` column — no prefix expansion. @@ -85,21 +86,21 @@ Authorization: Bearer ] ``` -| Field | Type | Notes | -|---|---|---| -| `id` | `string` (UUID) | User ID | -| `username` | `string \| null` | Display name | -| `avatarUrl` | `string \| null` | Avatar image URL | +| Field | Type | Notes | +| ---------------------- | ---------------- | ----------------------------------------------------------------------- | +| `id` | `string` (UUID) | User ID | +| `username` | `string \| null` | Display name | +| `avatarUrl` | `string \| null` | Avatar image URL | | `primaryWalletAddress` | `string \| null` | The wallet where `isPrimary = true`; `null` if no primary wallet is set | The response is a flat array. Wallet details beyond the primary address are not exposed — the per-user `wallets` array is not included. ### Error responses -| Status | Body | Condition | -|---|---|---| -| `400` | `{ "error": "Query parameter \"q\" is required" }` | `q` is absent or after trimming is an empty string | -| `500` | `{ "error": "Search failed" }` | Unexpected database error | +| Status | Body | Condition | +| ------ | -------------------------------------------------- | -------------------------------------------------- | +| `400` | `{ "error": "Query parameter \"q\" is required" }` | `q` is absent or after trimming is an empty string | +| `500` | `{ "error": "Search failed" }` | Unexpected database error | --- @@ -132,20 +133,20 @@ No path parameters or query parameters. } ``` -| Field | Type | Notes | -|---|---|---| -| `id` | `string` (UUID) | Stable user identifier | -| `username` | `string \| null` | `null` until the user sets one | -| `avatarUrl` | `string \| null` | | -| `presenceVisible` | `boolean` | When `false`, the user's online status is hidden from other users | -| `wallets` | `Array<{ address: string, isPrimary: boolean }>` | All linked wallets | -| `createdAt` | `string` (ISO 8601) | Account creation timestamp | +| Field | Type | Notes | +| ----------------- | ------------------------------------------------ | ----------------------------------------------------------------- | +| `id` | `string` (UUID) | Stable user identifier | +| `username` | `string \| null` | `null` until the user sets one | +| `avatarUrl` | `string \| null` | | +| `presenceVisible` | `boolean` | When `false`, the user's online status is hidden from other users | +| `wallets` | `Array<{ address: string, isPrimary: boolean }>` | All linked wallets | +| `createdAt` | `string` (ISO 8601) | Account creation timestamp | ### Error responses -| Status | Body | Condition | -|---|---|---| -| `404` | `{ "error": "User not found" }` | The user record for `req.auth.userId` no longer exists | +| Status | Body | Condition | +| ------ | ------------------------------- | ------------------------------------------------------ | +| `404` | `{ "error": "User not found" }` | The user record for `req.auth.userId` no longer exists | --- @@ -169,11 +170,11 @@ Content-Type: application/json } ``` -| Field | Type | Required | Constraints | -|---|---|---|---| -| `username` | `string` | No | 3–30 characters; only `[a-zA-Z0-9_]` allowed | -| `avatarUrl` | `string \| null` | No | No server-side format validation | -| `presenceVisible` | `boolean` | No | Must be a strict boolean, not a string | +| Field | Type | Required | Constraints | +| ----------------- | ---------------- | -------- | -------------------------------------------- | +| `username` | `string` | No | 3–30 characters; only `[a-zA-Z0-9_]` allowed | +| `avatarUrl` | `string \| null` | No | No server-side format validation | +| `presenceVisible` | `boolean` | No | Must be a strict boolean, not a string | ### Success response — `200 OK` @@ -206,13 +207,13 @@ Events are only emitted if the user has an active WebSocket connection (checked ### Error responses -| Status | Body | Condition | -|---|---|---| -| `400` | `{ "error": "Username must be 3-30 alphanumeric characters and underscores only" }` | `username` fails regex `/^[a-zA-Z0-9_]{3,30}$/` | -| `400` | `{ "error": "presenceVisible must be a boolean" }` | `presenceVisible` is provided but is not a strict `boolean` | -| `404` | `{ "error": "User not found" }` | The authenticated user no longer exists after the update attempt | -| `409` | `{ "error": "Username is already taken" }` | `username` is in use by a different user | -| `409` | `{ "error": "Username conflict or database error" }` | Unexpected database error (e.g., race-condition conflict not caught by the pre-check) | +| Status | Body | Condition | +| ------ | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| `400` | `{ "error": "Username must be 3-30 alphanumeric characters and underscores only" }` | `username` fails regex `/^[a-zA-Z0-9_]{3,30}$/` | +| `400` | `{ "error": "presenceVisible must be a boolean" }` | `presenceVisible` is provided but is not a strict `boolean` | +| `404` | `{ "error": "User not found" }` | The authenticated user no longer exists after the update attempt | +| `409` | `{ "error": "Username is already taken" }` | `username` is in use by a different user | +| `409` | `{ "error": "Username conflict or database error" }` | Unexpected database error (e.g., race-condition conflict not caught by the pre-check) | --- @@ -227,9 +228,9 @@ GET /users/:id Authorization: Bearer ``` -| Parameter | In | Type | Description | -|---|---|---|---| -| `id` | path | string (UUID) | Target user's ID | +| Parameter | In | Type | Description | +| --------- | ---- | ------------- | ---------------- | +| `id` | path | string (UUID) | Target user's ID | ### Success response — `200 OK` @@ -245,20 +246,20 @@ Authorization: Bearer } ``` -| Field | Type | Notes | -|---|---|---| -| `id` | `string` (UUID) | | -| `username` | `string \| null` | | -| `avatarUrl` | `string \| null` | | -| `wallets` | `Array<{ address: string, isPrimary: boolean }>` | All linked wallets | +| Field | Type | Notes | +| ----------- | ------------------------------------------------ | ------------------ | +| `id` | `string` (UUID) | | +| `username` | `string \| null` | | +| `avatarUrl` | `string \| null` | | +| `wallets` | `Array<{ address: string, isPrimary: boolean }>` | All linked wallets | Internal fields (`createdAt`, `updatedAt`, wallet `id`/`userId`/`createdAt`) are explicitly stripped by the handler and never appear in the response. ### Error responses -| Status | Body | Condition | -|---|---|---| -| `404` | `{ "error": "User not found" }` | No user with that ID exists, or the database query threw (e.g. malformed UUID) | +| Status | Body | Condition | +| ------ | ------------------------------- | ------------------------------------------------------------------------------ | +| `404` | `{ "error": "User not found" }` | No user with that ID exists, or the database query threw (e.g. malformed UUID) | --- @@ -273,9 +274,9 @@ GET /users/:id/presence Authorization: Bearer ``` -| Parameter | In | Type | Description | -|---|---|---|---| -| `id` | path | string (UUID) | Target user's ID | +| Parameter | In | Type | Description | +| --------- | ---- | ------------- | ---------------- | +| `id` | path | string (UUID) | Target user's ID | ### Presence resolution logic @@ -288,35 +289,39 @@ The server applies the following ordered strategy: ### Success responses — `200 OK` **Presence hidden (user opted out):** + ```json { "online": "unknown" } ``` **User is online (Redis or device check):** + ```json { "online": true } ``` **User is offline — `lastSeen` known:** + ```json { "online": false, "lastSeen": "2026-05-31T09:00:00.000Z" } ``` **User is offline — no `lastSeen` available:** + ```json { "online": false } ``` -| Field | Type | Notes | -|---|---|---| -| `online` | `true \| false \| "unknown"` | `"unknown"` when the user has disabled presence visibility | +| Field | Type | Notes | +| ---------- | ------------------------------ | ------------------------------------------------------------------------------------- | +| `online` | `true \| false \| "unknown"` | `"unknown"` when the user has disabled presence visibility | | `lastSeen` | `string` (ISO 8601) — optional | Only present when `online: false` and at least one device has a recorded `lastSeenAt` | ### Error responses -| Status | Body | Condition | -|---|---|---| -| `404` | `{ "error": "User not found" }` | No user with that ID exists | +| Status | Body | Condition | +| ------ | ------------------------------- | --------------------------- | +| `404` | `{ "error": "User not found" }` | No user with that ID exists | --- @@ -331,9 +336,9 @@ GET /users/:id/key-fingerprint Authorization: Bearer ``` -| Parameter | In | Type | Description | -|---|---|---|---| -| `id` | path | string (UUID) | Target user's ID | +| Parameter | In | Type | Description | +| --------- | ---- | ------------- | ---------------- | +| `id` | path | string (UUID) | Target user's ID | ### Success response — `200 OK` @@ -345,11 +350,11 @@ Authorization: Bearer } ``` -| Field | Type | Notes | -|---|---|---| -| `userId` | `string` (UUID) | Echoes the path parameter | -| `fingerprint` | `string` | Raw 60-digit decimal string (no spaces) | -| `formatted` | `string` | 12 groups of 5 digits, space-separated (Signal safety number display format) | +| Field | Type | Notes | +| ------------- | --------------- | ---------------------------------------------------------------------------- | +| `userId` | `string` (UUID) | Echoes the path parameter | +| `fingerprint` | `string` | Raw 60-digit decimal string (no spaces) | +| `formatted` | `string` | 12 groups of 5 digits, space-separated (Signal safety number display format) | `fingerprint` and `formatted` encode the same number. Clients should strip spaces before comparing: `formatted.replace(/ /g, '') === fingerprint`. @@ -399,19 +404,22 @@ segmentB = (valueB % 10n**30n).toString().padStart(30, '0') The two 15-byte windows are non-overlapping within the 32-byte digest (indices 0–14 and 15–29), providing two statistically independent segments. -**Step 7 — Concatenate segments** +**Step 7 — Concatenate segments** + ``` fingerprint = segmentA + segmentB // 60 digits ``` **Step 8 — Format for display** Split the 60-digit string into groups of 5 and join with spaces: + ``` formatted = fingerprint.match(/.{5}/g).join(' ') // → "XXXXX XXXXX ... XXXXX" (12 groups of 5, 11 spaces) ``` **Derivation notes:** + - The algorithm matches Signal's safety-number scheme — two independent 30-digit numbers from non-overlapping digest halves. - The identity keys used as input are the **base64 string representations** stored in the database. Clients must use the same encoding they submitted during device registration. - Revoked devices are excluded from the computation. A new fingerprint must be fetched (and re-verified out-of-band) whenever the user's active device set changes. @@ -419,11 +427,11 @@ formatted = fingerprint.match(/.{5}/g).join(' ') ### Error responses -| Status | Body | Condition | -|---|---|---| -| `404` | `{ "error": "User not found" }` | No user with that ID exists | -| `404` | `{ "error": "No active devices found for this user" }` | User exists but has zero non-revoked devices | -| `500` | `{ "error": "Failed to compute key fingerprint" }` | Unexpected error during computation | +| Status | Body | Condition | +| ------ | ------------------------------------------------------ | -------------------------------------------- | +| `404` | `{ "error": "User not found" }` | No user with that ID exists | +| `404` | `{ "error": "No active devices found for this user" }` | User exists but has zero non-revoked devices | +| `500` | `{ "error": "Failed to compute key fingerprint" }` | Unexpected error during computation | --- @@ -441,9 +449,9 @@ GET /users/:userId/devices/:deviceId/key-bundle Authorization: Bearer ``` -| Parameter | In | Type | Description | -|---|---|---|---| -| `userId` | path | string (UUID) | ID of the user who owns the target device | +| Parameter | In | Type | Description | +| ---------- | ---- | ------------- | ----------------------------------------------- | +| `userId` | path | string (UUID) | ID of the user who owns the target device | | `deviceId` | path | string (UUID) | ID of the specific device to fetch a bundle for | No query parameters or request body. @@ -485,17 +493,17 @@ No query parameters or request body. } ``` -| Field | Type | Notes | -|---|---|---| -| `deviceId` | `string` (UUID) | Device identifier; echoes `:deviceId` | -| `identityPublicKey` | `string` (base64) | Long-term Ed25519 identity public key for the device | -| `registrationId` | `integer \| null` | X3DH/Signal registration ID set by the device during auth; `null` if not provided at registration | -| `signedPreKey.keyId` | `integer` | Application-assigned ID for this signed prekey | -| `signedPreKey.publicKey` | `string` (base64) | Signed prekey's public component | -| `signedPreKey.signature` | `string` (base64) | Ed25519 signature over `signedPreKey.publicKey`, signed by the device's `identityPublicKey`. **Callers must verify this before using the signed prekey.** | -| `oneTimePreKey` | `object \| null` | Present and non-null when an OTP was successfully claimed; `null` when the device has no remaining OTPs | -| `oneTimePreKey.keyId` | `integer` | Application-assigned ID for the consumed OTP | -| `oneTimePreKey.publicKey` | `string` (base64) | Consumed OTP's public component | +| Field | Type | Notes | +| ------------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `deviceId` | `string` (UUID) | Device identifier; echoes `:deviceId` | +| `identityPublicKey` | `string` (base64) | Long-term Ed25519 identity public key for the device | +| `registrationId` | `integer \| null` | X3DH/Signal registration ID set by the device during auth; `null` if not provided at registration | +| `signedPreKey.keyId` | `integer` | Application-assigned ID for this signed prekey | +| `signedPreKey.publicKey` | `string` (base64) | Signed prekey's public component | +| `signedPreKey.signature` | `string` (base64) | Ed25519 signature over `signedPreKey.publicKey`, signed by the device's `identityPublicKey`. **Callers must verify this before using the signed prekey.** | +| `oneTimePreKey` | `object \| null` | Present and non-null when an OTP was successfully claimed; `null` when the device has no remaining OTPs | +| `oneTimePreKey.keyId` | `integer` | Application-assigned ID for the consumed OTP | +| `oneTimePreKey.publicKey` | `string` (base64) | Consumed OTP's public component | ### OTP consumption semantics — atomic and race-free @@ -522,12 +530,12 @@ COMMIT **Key properties:** -| Property | Detail | -|---|---| -| **Atomic claim** | The `SELECT … FOR UPDATE SKIP LOCKED` + `UPDATE consumed = true` execute in a single transaction. A committed claim is final — the row is never deleted. | -| **Race-free** | `SKIP LOCKED` means concurrent transactions bypass a row that another transaction has locked, so two simultaneous bundle fetches will each claim a distinct OTP or observe exhaustion independently — neither blocks the other and neither can claim the same row. | -| **Audit trail** | `consumed` is flipped to `true`; the row is never deleted. The `device_prekeys_one_time_available_idx` partial index (`keyType = 'one_time' AND consumed = false`) keeps unconsumed key lookups efficient. | -| **FIFO ordering** | OTPs are consumed in upload order (`ORDER BY createdAt ASC`). The oldest unconsumed key is always selected first. | +| Property | Detail | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Atomic claim** | The `SELECT … FOR UPDATE SKIP LOCKED` + `UPDATE consumed = true` execute in a single transaction. A committed claim is final — the row is never deleted. | +| **Race-free** | `SKIP LOCKED` means concurrent transactions bypass a row that another transaction has locked, so two simultaneous bundle fetches will each claim a distinct OTP or observe exhaustion independently — neither blocks the other and neither can claim the same row. | +| **Audit trail** | `consumed` is flipped to `true`; the row is never deleted. The `device_prekeys_one_time_available_idx` partial index (`keyType = 'one_time' AND consumed = false`) keeps unconsumed key lookups efficient. | +| **FIFO ordering** | OTPs are consumed in upload order (`ORDER BY createdAt ASC`). The oldest unconsumed key is always selected first. | | **Graceful null-OTP fallback** | If the transaction finds no unconsumed OTP (exhausted supply), it returns `null` for `oneTimePreKey`. The server responds `200 OK` — not an error — with a signed-prekey-only bundle. The initiator must then perform **3-DH** (identity key + signed prekey only) rather than **4-DH** (identity key + signed prekey + one-time prekey). No `UPDATE` is executed in the exhausted case (verified by the test suite: `tx.update` is not called). | **Client handling for `oneTimePreKey: null`:** @@ -540,10 +548,10 @@ COMMIT ### Error responses -| Status | Body | Condition | -|---|---|---| -| `404` | `{ "error": "Device not found or has been revoked" }` | `:deviceId` does not exist, its `userId` does not match `:userId`, or `revokedAt IS NOT NULL` | -| `409` | `{ "error": "Device has not uploaded a signed prekey yet" }` | The device exists and is active but has not yet uploaded a signed prekey; a session cannot be established | +| Status | Body | Condition | +| ------ | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | +| `404` | `{ "error": "Device not found or has been revoked" }` | `:deviceId` does not exist, its `userId` does not match `:userId`, or `revokedAt IS NOT NULL` | +| `409` | `{ "error": "Device has not uploaded a signed prekey yet" }` | The device exists and is active but has not yet uploaded a signed prekey; a session cannot be established | > **Note on 404 ambiguity:** The route intentionally returns the same `404` body for "device not found", "wrong owner", and "device revoked". This prevents callers from distinguishing between these cases, which would otherwise enable device enumeration across users. @@ -551,14 +559,14 @@ COMMIT ## Common status codes -| Status | Meaning | -|---|---| -| `200` | Success | -| `400` | Client error — invalid input (missing required param, type mismatch, validation failure) | -| `401` | Unauthenticated — see [Authentication](#authentication) | -| `404` | Resource not found | -| `409` | Conflict — typically a uniqueness violation or a precondition not yet met | -| `500` | Unexpected server error | +| Status | Meaning | +| ------ | ---------------------------------------------------------------------------------------- | +| `200` | Success | +| `400` | Client error — invalid input (missing required param, type mismatch, validation failure) | +| `401` | Unauthenticated — see [Authentication](#authentication) | +| `404` | Resource not found | +| `409` | Conflict — typically a uniqueness violation or a precondition not yet met | +| `500` | Unexpected server error | --- @@ -568,53 +576,53 @@ The following database tables underpin the routes documented here. Full schema: ### `users` -| Column | Type | Notes | -|---|---|---| -| `id` | UUID PK | Auto-generated | -| `username` | text (unique, nullable) | Set via `PATCH /users/me` | -| `avatarUrl` | text (nullable) | | -| `presenceVisible` | boolean | Default `true`. Controls whether `GET /users/:id/presence` reveals the real status | -| `sendReadReceipts` | boolean | Default `true`. Privacy setting — whether the user allows sending read receipts to others. Included in `PATCH /users/me` responses (via unfiltered `.returning()`) but not in `GET /users/me` | -| `createdAt` | timestamp | | -| `updatedAt` | timestamp | | +| Column | Type | Notes | +| ------------------ | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | UUID PK | Auto-generated | +| `username` | text (unique, nullable) | Set via `PATCH /users/me` | +| `avatarUrl` | text (nullable) | | +| `presenceVisible` | boolean | Default `true`. Controls whether `GET /users/:id/presence` reveals the real status | +| `sendReadReceipts` | boolean | Default `true`. Privacy setting — whether the user allows sending read receipts to others. Included in `PATCH /users/me` responses (via unfiltered `.returning()`) but not in `GET /users/me` | +| `createdAt` | timestamp | | +| `updatedAt` | timestamp | | ### `wallets` -| Column | Type | Notes | -|---|---|---| -| `id` | UUID PK | | -| `userId` | UUID FK → `users.id` | Cascades on delete | -| `address` | text (unique) | Stellar public key | -| `isPrimary` | boolean | At most one primary wallet per user | +| Column | Type | Notes | +| ----------- | -------------------- | ----------------------------------- | +| `id` | UUID PK | | +| `userId` | UUID FK → `users.id` | Cascades on delete | +| `address` | text (unique) | Stellar public key | +| `isPrimary` | boolean | At most one primary wallet per user | ### `devices` -| Column | Type | Notes | -|---|---|---| -| `id` | UUID PK | | -| `userId` | UUID FK → `users.id` | | -| `identityPublicKey` | text | Base64-encoded Ed25519 public key. Unique per `(userId, identityPublicKey)` pair | -| `registrationId` | integer (nullable) | X3DH registration ID | -| `deviceName` | text (nullable) | | -| `platform` | enum (`web`, `ios`, `android`) | | -| `lastSeenAt` | timestamp (nullable) | Updated on auth and heartbeat | -| `pushEnabled` | boolean | | -| `revokedAt` | timestamp (nullable) | Non-null → device is revoked | +| Column | Type | Notes | +| ------------------- | ------------------------------ | -------------------------------------------------------------------------------- | +| `id` | UUID PK | | +| `userId` | UUID FK → `users.id` | | +| `identityPublicKey` | text | Base64-encoded Ed25519 public key. Unique per `(userId, identityPublicKey)` pair | +| `registrationId` | integer (nullable) | X3DH registration ID | +| `deviceName` | text (nullable) | | +| `platform` | enum (`web`, `ios`, `android`) | | +| `lastSeenAt` | timestamp (nullable) | Updated on auth and heartbeat | +| `pushEnabled` | boolean | | +| `revokedAt` | timestamp (nullable) | Non-null → device is revoked | ### `device_prekeys` Signed and one-time prekeys share this table, discriminated by `keyType`. -| Column | Type | Notes | -|---|---|---| -| `id` | UUID PK | | -| `deviceId` | UUID FK → `devices.id` | Cascades on delete | -| `keyType` | enum (`signed`, `one_time`) | | -| `keyId` | integer | Application-assigned. Unique per `(deviceId, keyType, keyId)` | -| `publicKey` | text | Base64-encoded public key | -| `signature` | text (nullable) | Required when `keyType = 'signed'`; enforced by DB check constraint | -| `consumed` | boolean | Default `false`. Flipped to `true` atomically when an OTP is claimed by the key-bundle endpoint | -| `createdAt` | timestamp | | +| Column | Type | Notes | +| ----------- | --------------------------- | ----------------------------------------------------------------------------------------------- | +| `id` | UUID PK | | +| `deviceId` | UUID FK → `devices.id` | Cascades on delete | +| `keyType` | enum (`signed`, `one_time`) | | +| `keyId` | integer | Application-assigned. Unique per `(deviceId, keyType, keyId)` | +| `publicKey` | text | Base64-encoded public key | +| `signature` | text (nullable) | Required when `keyType = 'signed'`; enforced by DB check constraint | +| `consumed` | boolean | Default `false`. Flipped to `true` atomically when an OTP is claimed by the key-bundle endpoint | +| `createdAt` | timestamp | | **Unique indexes relevant to this router:** @@ -626,13 +634,13 @@ Signed and one-time prekeys share this table, discriminated by `keyType`. ## Implementation references -| File | Purpose | -|---|---| -| [`apps/backend/src/routes/users.ts`](../src/routes/users.ts) | All routes documented here | -| [`apps/backend/src/middleware/auth.ts`](../src/middleware/auth.ts) | `requireAuth` — JWT validation + device revocation check | -| [`apps/backend/src/db/schema.ts`](../src/db/schema.ts) | `users`, `wallets`, `devices`, `devicePrekeys` table definitions | -| [`apps/backend/src/services/presence.ts`](../src/services/presence.ts) | `isOnline`, `deriveDevicePresence` — presence resolution helpers | -| [`apps/backend/src/__tests__/users.test.ts`](../src/__tests__/users.test.ts) | Tests for `/me`, `/:id`, `/search`, `/presence`, `PATCH /me` | -| [`apps/backend/src/__tests__/users.bundle.test.ts`](../src/__tests__/users.bundle.test.ts) | Tests for the key-bundle endpoint and OTP consumption | -| [`apps/backend/src/__tests__/users.fingerprint.test.ts`](../src/__tests__/users.fingerprint.test.ts) | Tests for the key-fingerprint derivation and output format | -| [`apps/backend/docs/e2ee-onboarding.md`](./e2ee-onboarding.md) | End-to-end onboarding sequence; covers `POST /auth/verify`, prekey upload, and bundle fetch ordering | +| File | Purpose | +| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| [`apps/backend/src/routes/users.ts`](../src/routes/users.ts) | All routes documented here | +| [`apps/backend/src/middleware/auth.ts`](../src/middleware/auth.ts) | `requireAuth` — JWT validation + device revocation check | +| [`apps/backend/src/db/schema.ts`](../src/db/schema.ts) | `users`, `wallets`, `devices`, `devicePrekeys` table definitions | +| [`apps/backend/src/services/presence.ts`](../src/services/presence.ts) | `isOnline`, `deriveDevicePresence` — presence resolution helpers | +| [`apps/backend/src/__tests__/users.test.ts`](../src/__tests__/users.test.ts) | Tests for `/me`, `/:id`, `/search`, `/presence`, `PATCH /me` | +| [`apps/backend/src/__tests__/users.bundle.test.ts`](../src/__tests__/users.bundle.test.ts) | Tests for the key-bundle endpoint and OTP consumption | +| [`apps/backend/src/__tests__/users.fingerprint.test.ts`](../src/__tests__/users.fingerprint.test.ts) | Tests for the key-fingerprint derivation and output format | +| [`apps/backend/docs/e2ee-onboarding.md`](./e2ee-onboarding.md) | End-to-end onboarding sequence; covers `POST /auth/verify`, prekey upload, and bundle fetch ordering | diff --git a/apps/backend/docs/concepts-delivery-fanout.md b/apps/backend/docs/concepts-delivery-fanout.md index 029dec6..cd6c40e 100644 --- a/apps/backend/docs/concepts-delivery-fanout.md +++ b/apps/backend/docs/concepts-delivery-fanout.md @@ -31,7 +31,7 @@ Implements a stronger fan-out guarantee: validating that the sender-supplied env ### `services/deviceDelivery.ts` — half-wired -Defines a Redis pub/sub channel per device (`deliver:device:${deviceId}`) intended as an alternate, cross-gateway-instance delivery path. The *subscribe* side (`GatewayDeviceSubscriber`) is booted for every connected socket and would forward anything published on that channel as a `device_envelope` event — and the web client does listen for `device_envelope` (`apps/web/src/hooks/useInboundPipeline.ts:257`). But the *publish* side (`publishToDevice`) has no production callers, so nothing ever actually flows through this channel today. +Defines a Redis pub/sub channel per device (`deliver:device:${deviceId}`) intended as an alternate, cross-gateway-instance delivery path. The _subscribe_ side (`GatewayDeviceSubscriber`) is booted for every connected socket and would forward anything published on that channel as a `device_envelope` event — and the web client does listen for `device_envelope` (`apps/web/src/hooks/useInboundPipeline.ts:257`). But the _publish_ side (`publishToDevice`) has no production callers, so nothing ever actually flows through this channel today. ## Step-by-step trace: `send_message` → every device → receipt back to sender @@ -40,33 +40,36 @@ All of this happens inside `apps/backend/src/socket/messaging.ts`, wired up per- **1. Connection setup** — on `io.on('connection', ...)`, each socket joins a `` `device:${deviceId}` `` room (this is the exact room `deliveryPipeline.ts`'s `deviceRoom()` targets), the user's own `` `room:user:${userId}` `` room, and every conversation room it belongs to. `registerMessagingHandlers` then wires up the event handlers below. **2. Client emits `send_message`** — the handler in `socket/messaging.ts`: - - clears typing timers for the conversation - - validates `messageId`, `contentType`, `ciphertext`/`envelopes`/`fileId` via `validateMessagePayload` - - verifies sender conversation membership - - short-circuits (re-emits `message_ack`) if the message id already exists (idempotency) - - checks that the sender supplied envelopes for all of their **own** other active devices (`fetchSiblingDeviceIds`) — note this only covers the sender's sibling devices, not every recipient's devices (see gaps below) - - persists the `messages` row and the per-recipient-device `messageEnvelopes` rows in one transaction - - emits `message_ack` back to the sender - - calls `deliverMessage(io, message, conversationId)` — the hand-off into fan-out + +- clears typing timers for the conversation +- validates `messageId`, `contentType`, `ciphertext`/`envelopes`/`fileId` via `validateMessagePayload` +- verifies sender conversation membership +- short-circuits (re-emits `message_ack`) if the message id already exists (idempotency) +- checks that the sender supplied envelopes for all of their **own** other active devices (`fetchSiblingDeviceIds`) — note this only covers the sender's sibling devices, not every recipient's devices (see gaps below) +- persists the `messages` row and the per-recipient-device `messageEnvelopes` rows in one transaction +- emits `message_ack` back to the sender +- calls `deliverMessage(io, message, conversationId)` — the hand-off into fan-out **3. Fan-out — `deliveryPipeline.ts`'s `deliverMessage`**: - - re-queries `conversationMembers` for the conversation (source of truth, not room state) - - loads active (non-revoked) devices for those members - - loads the envelopes just persisted, filtered to those active devices - - for each active device with a matching envelope: emits `message_envelope` (with ciphertext) to `` io.to(`device:${deviceId}`) ``. Because every connected socket already joined that room at connect time, and the Socket.IO Redis adapter fans room emits across gateway instances, this reaches the recipient wherever it's connected - - emits a ciphertext-free `new_message` to the conversation room(s) for UI/unread-count updates + +- re-queries `conversationMembers` for the conversation (source of truth, not room state) +- loads active (non-revoked) devices for those members +- loads the envelopes just persisted, filtered to those active devices +- for each active device with a matching envelope: emits `message_envelope` (with ciphertext) to ``io.to(`device:${deviceId}`)``. Because every connected socket already joined that room at connect time, and the Socket.IO Redis adapter fans room emits across gateway instances, this reaches the recipient wherever it's connected +- emits a ciphertext-free `new_message` to the conversation room(s) for UI/unread-count updates **4. Offline fallback** — after `deliverMessage` returns, `send_message` invalidates conversation-list caches and fires `dispatchOfflinePush` for any recipient device with no live socket connection, queuing a Web Push notification. This path is independent of the Socket.IO fan-out and never touches `messageEnvelopes.deliveredAt`. **5. Recipient acks receipt — `message_delivered`** — when a recipient device receives `message_envelope`, its client emits `message_delivered` with `{conversationId, messageId, envelopeId, sequenceNumber}`. The handler validates required fields and membership, then calls `handleDeviceDeliveryReceipt(io, redis, messageId, recipientDeviceId, recipientUserId, conversationId)`. **6. Aggregation — `deliveryAggregation.ts`'s `handleDeviceDeliveryReceipt`**: - - looks up the message's `senderId` - - short-circuits if this device's envelope is already marked delivered (idempotency) - - sets `messageEnvelopes.deliveredAt = now()` for `(messageId, recipientDeviceId)` - - checks `isMessageFullyDeliveredToUser`: whether every active device belonging to that recipient user now has `deliveredAt` set - - **if fully delivered**: emits `message_fully_delivered` to `` `room:user:${senderId}` `` — this is the moment a delivery receipt actually reaches the sender for full multi-device delivery — and republishes it over Redis (`publishEphemeral`) so it survives replay if the sender wasn't connected at that instant - - **always**: also emits a per-device `device_delivery_receipt` to the whole conversation room (`.volatile`), visible to all members, not just the sender + +- looks up the message's `senderId` +- short-circuits if this device's envelope is already marked delivered (idempotency) +- sets `messageEnvelopes.deliveredAt = now()` for `(messageId, recipientDeviceId)` +- checks `isMessageFullyDeliveredToUser`: whether every active device belonging to that recipient user now has `deliveredAt` set +- **if fully delivered**: emits `message_fully_delivered` to `` `room:user:${senderId}` `` — this is the moment a delivery receipt actually reaches the sender for full multi-device delivery — and republishes it over Redis (`publishEphemeral`) so it survives replay if the sender wasn't connected at that instant +- **always**: also emits a per-device `device_delivery_receipt` to the whole conversation room (`.volatile`), visible to all members, not just the sender **7. Read receipts are a separate, simpler mechanism** (see below) and never touch `deliveryAggregation.ts` or `messageEnvelopes`. diff --git a/apps/backend/docs/concepts-gateway-architecture.md b/apps/backend/docs/concepts-gateway-architecture.md index 658588d..4142757 100644 --- a/apps/backend/docs/concepts-gateway-architecture.md +++ b/apps/backend/docs/concepts-gateway-architecture.md @@ -66,11 +66,11 @@ On `SIGTERM`/`SIGINT`: ### 2.1 Room Types -| Room Pattern | Purpose | Membership Source | -|---|---|---| +| Room Pattern | Purpose | Membership Source | +| ------------------------------------- | ----------------------------------------------------------------- | --------------------------------- | | `room:conversation:${conversationId}` | Fan-out for conversation events (messages, typing, read receipts) | PostgreSQL `conversation_members` | -| `room:user:${userId}` | Cross-device synchronization (presence, delivery receipts) | Authenticated user ID | -| `device:${deviceId}` | Per-device delivery (message envelopes) | Authenticated device ID | +| `room:user:${userId}` | Cross-device synchronization (presence, delivery receipts) | Authenticated user ID | +| `device:${deviceId}` | Per-device delivery (message envelopes) | Authenticated device ID | **Conversation rooms** are the primary broadcast channel. When a message is sent, the event is emitted to the conversation room, which reaches all online members across all nodes (via the Redis adapter). @@ -85,12 +85,9 @@ Room membership is **not trusted from room state alone**. Every `join_room` requ ```typescript // src/services/roomManager.ts async function joinConversationRoom(socket, conversationId) { - const isValid = await validateConversationMembership( - socket.auth.userId, - conversationId - ); + const isValid = await validateConversationMembership(socket.auth.userId, conversationId); if (!isValid) { - socket.emit("error", { message: "Not a member of this conversation" }); + socket.emit('error', { message: 'Not a member of this conversation' }); return; } socket.join(conversationRoom(conversationId)); @@ -190,12 +187,12 @@ Stream length is capped at 500 entries. Old entries are trimmed automatically. T Redis-based presence with these key patterns: -| Key Pattern | Type | Purpose | -|---|---|---| -| `presence:user:${userId}` | Hash | `deviceId → lastSeen` timestamp | -| `presence:user:${userId}:device:${deviceId}` | String | Per-device key with 90s TTL | -| `presence:sockets:${userId}` | Set | Active socket IDs | -| `presence:socket:${socketId}` | Hash | `{ userId, deviceId }` mapping | +| Key Pattern | Type | Purpose | +| -------------------------------------------- | ------ | ------------------------------- | +| `presence:user:${userId}` | Hash | `deviceId → lastSeen` timestamp | +| `presence:user:${userId}:device:${deviceId}` | String | Per-device key with 90s TTL | +| `presence:sockets:${userId}` | Set | Active socket IDs | +| `presence:socket:${socketId}` | Hash | `{ userId, deviceId }` mapping | ### 6.1 Boot Reconciliation @@ -205,11 +202,11 @@ On startup, `reconcileBoot()` scans all presence keys and removes stale entries ## 7. Rate Limiting (`apps/backend/src/services/rateLimit.ts`) -| Parameter | Default | Env Variable | Behavior | -|---|---|---|---| -| Events per second | 10 | `SOCKET_RATE_LIMIT_PER_SEC` | Redis `INCR` + `EXPIRE 1` per socket | -| Max payload size | 16384 bytes (16 KB) | `MAX_PAYLOAD_SIZE` | Checked before handler execution | -| Violation threshold | 3 | (hardcoded) | In-memory counter; disconnect on 3rd violation | +| Parameter | Default | Env Variable | Behavior | +| ------------------- | ------------------- | --------------------------- | ---------------------------------------------- | +| Events per second | 10 | `SOCKET_RATE_LIMIT_PER_SEC` | Redis `INCR` + `EXPIRE 1` per socket | +| Max payload size | 16384 bytes (16 KB) | `MAX_PAYLOAD_SIZE` | Checked before handler execution | +| Violation threshold | 3 | (hardcoded) | In-memory counter; disconnect on 3rd violation | ### 7.1 Per-Socket Limiting @@ -225,10 +222,10 @@ The raw message payload is size-checked before any processing. If it exceeds `MA Monitors the WebSocket send buffer (`socket.io`'s `bufferedAmount`) to detect slow consumers. -| Threshold | Default | Env Variable | Action | -|---|---|---|---| -| Shed | 32768 bytes | `SOCKET_SHED_THRESHOLD` | Stop sending new events to this socket | -| Disconnect | 65536 bytes | `SOCKET_BUFFER_THRESHOLD` | Force-disconnect the socket | +| Threshold | Default | Env Variable | Action | +| ---------- | ----------- | ------------------------- | -------------------------------------- | +| Shed | 32768 bytes | `SOCKET_SHED_THRESHOLD` | Stop sending new events to this socket | +| Disconnect | 65536 bytes | `SOCKET_BUFFER_THRESHOLD` | Force-disconnect the socket | ### 8.1 Monitoring @@ -311,14 +308,14 @@ Shedding (stop sending) is reversible — when the buffer drains, normal deliver ## 12. Configuration Reference -| Env Variable | Default | Service | Description | -|---|---|---|---| -| `SOCKET_RATE_LIMIT_PER_SEC` | `10` | rateLimit | Max events per second per socket | -| `MAX_PAYLOAD_SIZE` | `16384` | rateLimit | Max event payload size in bytes | -| `RESUME_STREAM_TTL_SECONDS` | `300` | resumeStream | TTL for ephemeral event stream | -| `RESUME_STREAM_MAXLEN` | `500` | resumeStream | Max entries in ephemeral event stream | -| `SOCKET_SHED_THRESHOLD` | `32768` | backpressure | Buffer size at which to stop sending | -| `SOCKET_BUFFER_THRESHOLD` | `65536` | backpressure | Buffer size at which to disconnect | +| Env Variable | Default | Service | Description | +| --------------------------- | ------- | ------------ | ------------------------------------- | +| `SOCKET_RATE_LIMIT_PER_SEC` | `10` | rateLimit | Max events per second per socket | +| `MAX_PAYLOAD_SIZE` | `16384` | rateLimit | Max event payload size in bytes | +| `RESUME_STREAM_TTL_SECONDS` | `300` | resumeStream | TTL for ephemeral event stream | +| `RESUME_STREAM_MAXLEN` | `500` | resumeStream | Max entries in ephemeral event stream | +| `SOCKET_SHED_THRESHOLD` | `32768` | backpressure | Buffer size at which to stop sending | +| `SOCKET_BUFFER_THRESHOLD` | `65536` | backpressure | Buffer size at which to disconnect | --- diff --git a/apps/backend/docs/concepts-storage-push-jobs.md b/apps/backend/docs/concepts-storage-push-jobs.md index 3c50628..81ba6e1 100644 --- a/apps/backend/docs/concepts-storage-push-jobs.md +++ b/apps/backend/docs/concepts-storage-push-jobs.md @@ -92,4 +92,4 @@ A background cleanup job runs every **5 minutes**. It performs the following tas - Hard-deletes eligible files from object storage. - Removes stale pending uploads older than 24 hours. -- Re-enables push subscriptions whose temporary backoff period has expired. \ No newline at end of file +- Re-enables push subscriptions whose temporary backoff period has expired. diff --git a/apps/backend/docs/contracts-jwt-auth.md b/apps/backend/docs/contracts-jwt-auth.md index 98274b7..4cc9024 100644 --- a/apps/backend/docs/contracts-jwt-auth.md +++ b/apps/backend/docs/contracts-jwt-auth.md @@ -19,11 +19,11 @@ Implementation references: The full and only shape of the token payload is `JwtPayload` (`apps/backend/src/lib/jwt.ts`): -| Claim | Type | Meaning | -| --- | --- | --- | -| `userId` | `string` (uuid) | The backend `users.id` row this token authenticates. | -| `walletAddress` | `string` | The Stellar wallet address (`G...`) that completed the challenge/verify sign-in. Carried for convenience; not re-checked against the DB on every request. | -| `deviceId` | `string` (uuid) | The backend `devices.id` row for the device that signed in. This is the field live re-validation keys off of — see below. | +| Claim | Type | Meaning | +| --------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `userId` | `string` (uuid) | The backend `users.id` row this token authenticates. | +| `walletAddress` | `string` | The Stellar wallet address (`G...`) that completed the challenge/verify sign-in. Carried for convenience; not re-checked against the DB on every request. | +| `deviceId` | `string` (uuid) | The backend `devices.id` row for the device that signed in. This is the field live re-validation keys off of — see below. | There are no other claims beyond what `jsonwebtoken` itself adds (`iat`, `exp`). There is no `roles`/`scope` claim — authorization beyond @@ -132,12 +132,12 @@ immediate-effect guarantee that HTTP's per-request re-validation gives. ## Rejection reasons summary -| Condition | HTTP (`requireAuth`) | WebSocket (`socketAuthMiddleware`) | -| --- | --- | --- | -| Missing token | 401 "Missing or invalid Authorization header" | connection error: "Authentication token required" | -| Bad signature / malformed | 401 "Invalid or expired token" | connection error: "Invalid or expired token" | -| Expired (`exp` passed) | 401 "Invalid or expired token" | connection error: "Invalid or expired token" | -| Legacy token missing `deviceId` | 401 "Invalid or expired token" (thrown by `verifyToken`, caught generically) | connection error: "Invalid or expired token" | -| Device row not found | 401 "Device not found or has been revoked" | connection error: "Device not found or has been revoked" | -| Device revoked (`revokedAt` set) | 401 "Device not found or has been revoked" | connection error: "Device not found or has been revoked" | -| Device revoked **after** an existing socket connected | n/a (HTTP has no persistent connection) | live socket force-disconnected via `device_revoked` broadcast, not via this middleware | +| Condition | HTTP (`requireAuth`) | WebSocket (`socketAuthMiddleware`) | +| ----------------------------------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| Missing token | 401 "Missing or invalid Authorization header" | connection error: "Authentication token required" | +| Bad signature / malformed | 401 "Invalid or expired token" | connection error: "Invalid or expired token" | +| Expired (`exp` passed) | 401 "Invalid or expired token" | connection error: "Invalid or expired token" | +| Legacy token missing `deviceId` | 401 "Invalid or expired token" (thrown by `verifyToken`, caught generically) | connection error: "Invalid or expired token" | +| Device row not found | 401 "Device not found or has been revoked" | connection error: "Device not found or has been revoked" | +| Device revoked (`revokedAt` set) | 401 "Device not found or has been revoked" | connection error: "Device not found or has been revoked" | +| Device revoked **after** an existing socket connected | n/a (HTTP has no persistent connection) | live socket force-disconnected via `device_revoked` broadcast, not via this middleware | diff --git a/apps/backend/docs/contracts-rest-schemas.md b/apps/backend/docs/contracts-rest-schemas.md index b6c8795..5f7090a 100644 --- a/apps/backend/docs/contracts-rest-schemas.md +++ b/apps/backend/docs/contracts-rest-schemas.md @@ -23,22 +23,22 @@ Request body is validated by [`ChallengeSchema`](./contracts-rest-schemas.md#cha ## Schema Index -| Schema | Defined In | Used By | -|--------|-----------|---------| -| `ChallengeSchema` | `schemas/auth.schemas.ts` | `POST /auth/challenge` | -| `VerifySchema` | `schemas/auth.schemas.ts` | `POST /auth/verify` | -| `DeviceSchema` | `schemas/auth.schemas.ts` | `POST /devices` (as `RegisterDeviceSchema`), nested in `VerifySchema` | -| `SendMessageSchema` | `schemas/message.schemas.ts` | `POST /messages` | -| `proposeSchema` | `routes/treasury.ts` | `POST /treasury/propose` | -| `voteSchema` | `routes/treasury.ts` | `POST /treasury/proposals/:id/approve`, `POST /treasury/proposals/:id/reject` | -| `RequestSlotSchema` | `routes/uploads.ts` | `POST /uploads` (via `safeParse`) | -| `UploadPreKeysSchema` | `routes/devices.ts` | `POST /devices/:id/prekeys` | -| `EnvelopeSchema` | `schemas/message.schemas.ts` | Nested in `SendMessageSchema.envelopes[]` | -| `IdentityPublicKeySchema` | `lib/keys.ts` | Nested in `DeviceSchema`, `VerifySchema` | -| `PreKeyEntrySchema` | `lib/keys.ts` | Nested in `UploadPreKeysSchema.oneTimePreKeys[]` | -| `SignedPreKeyEntrySchema` | `lib/keys.ts` | Nested in `UploadPreKeysSchema.signedPreKey` | -| `PreKeyPublicKeySchema` | `lib/keys.ts` | Nested in `PreKeyEntrySchema` | -| `SignatureSchema` | `lib/keys.ts` | Nested in `SignedPreKeyEntrySchema` | +| Schema | Defined In | Used By | +| ------------------------- | ---------------------------- | ----------------------------------------------------------------------------- | +| `ChallengeSchema` | `schemas/auth.schemas.ts` | `POST /auth/challenge` | +| `VerifySchema` | `schemas/auth.schemas.ts` | `POST /auth/verify` | +| `DeviceSchema` | `schemas/auth.schemas.ts` | `POST /devices` (as `RegisterDeviceSchema`), nested in `VerifySchema` | +| `SendMessageSchema` | `schemas/message.schemas.ts` | `POST /messages` | +| `proposeSchema` | `routes/treasury.ts` | `POST /treasury/propose` | +| `voteSchema` | `routes/treasury.ts` | `POST /treasury/proposals/:id/approve`, `POST /treasury/proposals/:id/reject` | +| `RequestSlotSchema` | `routes/uploads.ts` | `POST /uploads` (via `safeParse`) | +| `UploadPreKeysSchema` | `routes/devices.ts` | `POST /devices/:id/prekeys` | +| `EnvelopeSchema` | `schemas/message.schemas.ts` | Nested in `SendMessageSchema.envelopes[]` | +| `IdentityPublicKeySchema` | `lib/keys.ts` | Nested in `DeviceSchema`, `VerifySchema` | +| `PreKeyEntrySchema` | `lib/keys.ts` | Nested in `UploadPreKeysSchema.oneTimePreKeys[]` | +| `SignedPreKeyEntrySchema` | `lib/keys.ts` | Nested in `UploadPreKeysSchema.signedPreKey` | +| `PreKeyPublicKeySchema` | `lib/keys.ts` | Nested in `PreKeyEntrySchema` | +| `SignatureSchema` | `lib/keys.ts` | Nested in `SignedPreKeyEntrySchema` | --- @@ -49,12 +49,12 @@ Request body is validated by [`ChallengeSchema`](./contracts-rest-schemas.md#cha ```typescript z.object({ walletAddress: z.string().min(1, 'walletAddress is required'), -}) +}); ``` -| Field | Type | Required | Constraints | -|-------|------|----------|-------------| -| `walletAddress` | `string` | ✅ | `min(1)` — cannot be empty | +| Field | Type | Required | Constraints | +| --------------- | -------- | -------- | -------------------------- | +| `walletAddress` | `string` | ✅ | `min(1)` — cannot be empty |
Example @@ -64,6 +64,7 @@ z.object({ "walletAddress": "GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ" } ``` +
--- @@ -79,20 +80,20 @@ z.object({ nonce: z.string().min(1, 'nonce is required'), identityPublicKey: IdentityPublicKeySchema, device: DeviceSchema.partial().optional(), -}).superRefine(/* cross-field: device.identityPublicKey must match identityPublicKey */) +}).superRefine(/* cross-field: device.identityPublicKey must match identityPublicKey */); ``` -| Field | Type | Required | Constraints | -|-------|------|----------|-------------| -| `walletAddress` | `string` | ✅ | `min(1)` — cannot be empty | -| `signature` | `string` | ✅ | `min(1)` — cannot be empty (hex or base64-encoded Ed25519 signature) | -| `nonce` | `string` | ✅ | `min(1)` — cannot be empty | -| `identityPublicKey` | `string` | ✅ | Valid base64; must decode to exactly 44 bytes (Ed25519 SPKI DER) | -| `device` | `object?` | ❌ | All sub-fields optional (see [DeviceSchema](#deviceschema)) | -| `device.deviceName` | `string?` | ❌ | `min(1)`, `max(100)` when present | -| `device.platform` | `"web" \| "ios" \| "android"?` | ❌ | Enum when present | -| `device.identityPublicKey` | `string?` | ❌ | If present, **must equal** top-level `identityPublicKey` | -| `device.registrationId` | `number?` | ❌ | Integer, `≥ 0` when present | +| Field | Type | Required | Constraints | +| -------------------------- | ------------------------------ | -------- | -------------------------------------------------------------------- | +| `walletAddress` | `string` | ✅ | `min(1)` — cannot be empty | +| `signature` | `string` | ✅ | `min(1)` — cannot be empty (hex or base64-encoded Ed25519 signature) | +| `nonce` | `string` | ✅ | `min(1)` — cannot be empty | +| `identityPublicKey` | `string` | ✅ | Valid base64; must decode to exactly 44 bytes (Ed25519 SPKI DER) | +| `device` | `object?` | ❌ | All sub-fields optional (see [DeviceSchema](#deviceschema)) | +| `device.deviceName` | `string?` | ❌ | `min(1)`, `max(100)` when present | +| `device.platform` | `"web" \| "ios" \| "android"?` | ❌ | Enum when present | +| `device.identityPublicKey` | `string?` | ❌ | If present, **must equal** top-level `identityPublicKey` | +| `device.registrationId` | `number?` | ❌ | Integer, `≥ 0` when present |
Example @@ -110,6 +111,7 @@ z.object({ } } ``` +
--- @@ -124,15 +126,15 @@ z.object({ platform: z.enum(['web', 'ios', 'android']), identityPublicKey: IdentityPublicKeySchema, registrationId: z.number().int().nonnegative().optional(), -}) +}); ``` -| Field | Type | Required | Constraints | -|-------|------|----------|-------------| -| `deviceName` | `string` | ✅ | `min(1)`, `max(100)` | -| `platform` | `"web" \| "ios" \| "android"` | ✅ | Strict enum | -| `identityPublicKey` | `string` | ✅ | Valid base64; must decode to exactly 44 bytes (Ed25519 SPKI DER) | -| `registrationId` | `number?` | ❌ | Integer, `≥ 0` | +| Field | Type | Required | Constraints | +| ------------------- | ----------------------------- | -------- | ---------------------------------------------------------------- | +| `deviceName` | `string` | ✅ | `min(1)`, `max(100)` | +| `platform` | `"web" \| "ios" \| "android"` | ✅ | Strict enum | +| `identityPublicKey` | `string` | ✅ | Valid base64; must decode to exactly 44 bytes (Ed25519 SPKI DER) | +| `registrationId` | `number?` | ❌ | Integer, `≥ 0` |
Example @@ -145,6 +147,7 @@ z.object({ "registrationId": 123 } ``` +
--- @@ -161,17 +164,17 @@ z.object({ ciphertext: z.string().optional(), envelopes: z.array(EnvelopeSchema).optional(), fileId: z.string().uuid('fileId must be a valid UUID').optional(), -}) +}); ``` -| Field | Type | Required | Default | Constraints | -|-------|------|----------|---------|-------------| -| `conversationId` | `string` | ✅ | — | Valid UUID | -| `messageId` | `string` | ✅ | — | Valid UUID (client-generated, idempotency key) | -| `contentType` | `string` | ❌ | `"text"` | Trimmed, lowercased. Not restricted to an enum at the Zod layer (any string is accepted). Common values: `text`, `file`, `image`, `video`, `audio`, `system` | -| `ciphertext` | `string?` | ❌ | — | Encrypted message body | -| `envelopes` | `EnvelopeSchema[]?` | ❌ | — | Per-device encrypted payloads (see [EnvelopeSchema](#envelopeschema)) | -| `fileId` | `string?` | ❌ | — | Valid UUID referencing an uploaded file. Required when `contentType` is `file`/`image`/`video`/`audio` (enforced by `validateMessagePayload`) | +| Field | Type | Required | Default | Constraints | +| ---------------- | ------------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `conversationId` | `string` | ✅ | — | Valid UUID | +| `messageId` | `string` | ✅ | — | Valid UUID (client-generated, idempotency key) | +| `contentType` | `string` | ❌ | `"text"` | Trimmed, lowercased. Not restricted to an enum at the Zod layer (any string is accepted). Common values: `text`, `file`, `image`, `video`, `audio`, `system` | +| `ciphertext` | `string?` | ❌ | — | Encrypted message body | +| `envelopes` | `EnvelopeSchema[]?` | ❌ | — | Per-device encrypted payloads (see [EnvelopeSchema](#envelopeschema)) | +| `fileId` | `string?` | ❌ | — | Valid UUID referencing an uploaded file. Required when `contentType` is `file`/`image`/`video`/`audio` (enforced by `validateMessagePayload`) | **Note:** Content-type-specific field requirements (fileId, envelopes) are validated at the `validateMessagePayload` layer rather than in Zod, so both REST and WebSocket paths share the @@ -194,6 +197,7 @@ same rules without duplicating discriminated-union schemas. ] } ``` + --- @@ -206,13 +210,13 @@ same rules without duplicating discriminated-union schemas. z.object({ recipientDeviceId: z.string().uuid('recipientDeviceId must be a valid UUID'), ciphertext: z.string().min(1, 'envelope ciphertext is required'), -}) +}); ``` -| Field | Type | Required | Constraints | -|-------|------|----------|-------------| -| `recipientDeviceId` | `string` | ✅ | Valid UUID | -| `ciphertext` | `string` | ✅ | `min(1)` — cannot be empty | +| Field | Type | Required | Constraints | +| ------------------- | -------- | -------- | -------------------------- | +| `recipientDeviceId` | `string` | ✅ | Valid UUID | +| `ciphertext` | `string` | ✅ | `min(1)` — cannot be empty | --- @@ -228,17 +232,17 @@ z.object({ ttl: z.enum(['24h', '72h', '7d']), conversationId: z.string().uuid().optional(), threshold: z.number().int().min(1).optional(), -}) +}); ``` -| Field | Type | Required | Default | Constraints | -|-------|------|----------|---------|-------------| -| `amount` | `number` | ✅ | — | `> 0` (positive) | -| `token` | `string` | ✅ | — | `min(1)` — token contract ID | -| `recipient` | `string` | ✅ | — | Must match `/^G[A-Z2-7]{55}$/` (Stellar public key format) | -| `ttl` | `"24h" \| "72h" \| "7d"` | ✅ | — | Voting window duration (≈17,280 / 51,840 / 120,960 ledgers) | -| `conversationId` | `string?` | ❌ | — | Valid UUID when present | -| `threshold` | `number?` | ❌ | `3` (server default) | Integer, `≥ 1` | +| Field | Type | Required | Default | Constraints | +| ---------------- | ------------------------ | -------- | -------------------- | ----------------------------------------------------------- | +| `amount` | `number` | ✅ | — | `> 0` (positive) | +| `token` | `string` | ✅ | — | `min(1)` — token contract ID | +| `recipient` | `string` | ✅ | — | Must match `/^G[A-Z2-7]{55}$/` (Stellar public key format) | +| `ttl` | `"24h" \| "72h" \| "7d"` | ✅ | — | Voting window duration (≈17,280 / 51,840 / 120,960 ledgers) | +| `conversationId` | `string?` | ❌ | — | Valid UUID when present | +| `threshold` | `number?` | ❌ | `3` (server default) | Integer, `≥ 1` |
Example @@ -253,6 +257,7 @@ z.object({ "threshold": 5 } ``` +
--- @@ -264,12 +269,12 @@ z.object({ ```typescript z.object({ signature: z.string().optional(), -}) +}); ``` -| Field | Type | Required | Constraints | -|-------|------|----------|-------------| -| `signature` | `string?` | ❌ | No min/max length enforced at Zod layer | +| Field | Type | Required | Constraints | +| ----------- | --------- | -------- | --------------------------------------- | +| `signature` | `string?` | ❌ | No min/max length enforced at Zod layer |
Example @@ -279,6 +284,7 @@ z.object({ "signature": "hex-or-base64-signature..." } ``` +
--- @@ -290,20 +296,24 @@ z.object({ ```typescript z.object({ conversationId: z.string().uuid(), - size: z.number().int().positive().max(100 * 1024 * 1024), + size: z + .number() + .int() + .positive() + .max(100 * 1024 * 1024), mimeType: z.string().min(1), sha256: z.string().min(1), isThumbnail: z.boolean().optional().default(false), -}) +}); ``` -| Field | Type | Required | Default | Constraints | -|-------|------|----------|---------|-------------| -| `conversationId` | `string` | ✅ | — | Valid UUID | -| `size` | `number` | ✅ | — | Integer, `> 0`, `≤ 104,857,600` (100 MB) | -| `mimeType` | `string` | ✅ | — | `min(1)`. ⚠ **Post-parse check:** only these types are accepted: `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `video/mp4`, `video/webm`, `audio/mpeg`, `audio/ogg`, `audio/wav`, `application/pdf`, `application/octet-stream`. Zod passes any non-empty string; the route returns `415` for unsupported types. | -| `sha256` | `string` | ✅ | — | `min(1)` — hex-encoded SHA-256 hash of the file | -| `isThumbnail` | `boolean` | ❌ | `false` | Whether this upload is a thumbnail variant | +| Field | Type | Required | Default | Constraints | +| ---------------- | --------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `conversationId` | `string` | ✅ | — | Valid UUID | +| `size` | `number` | ✅ | — | Integer, `> 0`, `≤ 104,857,600` (100 MB) | +| `mimeType` | `string` | ✅ | — | `min(1)`. ⚠ **Post-parse check:** only these types are accepted: `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `video/mp4`, `video/webm`, `audio/mpeg`, `audio/ogg`, `audio/wav`, `application/pdf`, `application/octet-stream`. Zod passes any non-empty string; the route returns `415` for unsupported types. | +| `sha256` | `string` | ✅ | — | `min(1)` — hex-encoded SHA-256 hash of the file | +| `isThumbnail` | `boolean` | ❌ | `false` | Whether this upload is a thumbnail variant |
Example @@ -317,6 +327,7 @@ z.object({ "isThumbnail": false } ``` +
--- @@ -329,28 +340,28 @@ z.object({ z.object({ signedPreKey: SignedPreKeyEntrySchema, oneTimePreKeys: z.array(PreKeyEntrySchema).min(1, 'At least one one-time prekey is required'), -}) +}); ``` -| Field | Type | Required | Constraints | -|-------|------|----------|-------------| -| `signedPreKey` | `SignedPreKeyEntrySchema` | ✅ | See below | -| `oneTimePreKeys` | `PreKeyEntrySchema[]` | ✅ | Array with `≥ 1` entries. Server-side cap: 200 stored per device (excess entries are silently trimmed) | +| Field | Type | Required | Constraints | +| ---------------- | ------------------------- | -------- | ------------------------------------------------------------------------------------------------------ | +| `signedPreKey` | `SignedPreKeyEntrySchema` | ✅ | See below | +| `oneTimePreKeys` | `PreKeyEntrySchema[]` | ✅ | Array with `≥ 1` entries. Server-side cap: 200 stored per device (excess entries are silently trimmed) | ### `signedPreKey` (`SignedPreKeyEntrySchema`) -| Field | Type | Required | Constraints | -|-------|------|----------|-------------| -| `keyId` | `number` | ✅ | Integer, `≥ 0` | -| `publicKey` | `string` | ✅ | Valid base64; must decode to exactly 32 bytes (Ed25519 raw public key) | -| `signature` | `string` | ✅ | Valid base64; must decode to exactly 64 bytes (Ed25519 signature) | +| Field | Type | Required | Constraints | +| ----------- | -------- | -------- | ---------------------------------------------------------------------- | +| `keyId` | `number` | ✅ | Integer, `≥ 0` | +| `publicKey` | `string` | ✅ | Valid base64; must decode to exactly 32 bytes (Ed25519 raw public key) | +| `signature` | `string` | ✅ | Valid base64; must decode to exactly 64 bytes (Ed25519 signature) | ### `oneTimePreKeys[]` (`PreKeyEntrySchema`) -| Field | Type | Required | Constraints | -|-------|------|----------|-------------| -| `keyId` | `number` | ✅ | Integer, `≥ 0` | -| `publicKey` | `string` | ✅ | Valid base64; must decode to exactly 32 bytes (Ed25519 raw public key) | +| Field | Type | Required | Constraints | +| ----------- | -------- | -------- | ---------------------------------------------------------------------- | +| `keyId` | `number` | ✅ | Integer, `≥ 0` | +| `publicKey` | `string` | ✅ | Valid base64; must decode to exactly 32 bytes (Ed25519 raw public key) |
Example @@ -368,6 +379,7 @@ z.object({ ] } ``` +
--- @@ -381,11 +393,11 @@ These schemas are not directly used by routes but are building blocks for the sc ```typescript z.string() .min(1, 'identityPublicKey is required') - .superRefine(/* base64 validity + exact 44-byte length (Ed25519 SPKI DER) */) + .superRefine(/* base64 validity + exact 44-byte length (Ed25519 SPKI DER) */); ``` -| Field | Type | Constraints | -|-------|------|-------------| +| Field | Type | Constraints | +| ------- | -------- | ----------------------------------------- | | (value) | `string` | Valid base64, decodes to exactly 44 bytes | ### `PreKeyPublicKeySchema` @@ -393,11 +405,11 @@ z.string() ```typescript z.string() .min(1, 'publicKey is required') - .superRefine(/* base64 validity + exact 32-byte length (Ed25519 raw) */) + .superRefine(/* base64 validity + exact 32-byte length (Ed25519 raw) */); ``` -| Field | Type | Constraints | -|-------|------|-------------| +| Field | Type | Constraints | +| ------- | -------- | ----------------------------------------- | | (value) | `string` | Valid base64, decodes to exactly 32 bytes | ### `SignatureSchema` @@ -405,11 +417,11 @@ z.string() ```typescript z.string() .min(1, 'signature is required') - .superRefine(/* base64 validity + exact 64-byte length (Ed25519 sig) */) + .superRefine(/* base64 validity + exact 64-byte length (Ed25519 sig) */); ``` -| Field | Type | Constraints | -|-------|------|-------------| +| Field | Type | Constraints | +| ------- | -------- | ----------------------------------------- | | (value) | `string` | Valid base64, decodes to exactly 64 bytes | --- @@ -423,7 +435,10 @@ All `validate()` middleware failures return: "error": "Validation failed", "issues": [ { "field": "walletAddress", "message": "walletAddress is required" }, - { "field": "device.platform", "message": "Invalid enum value. Expected 'web' | 'ios' | 'android'" } + { + "field": "device.platform", + "message": "Invalid enum value. Expected 'web' | 'ios' | 'android'" + } ] } ``` @@ -437,13 +452,13 @@ error shape but follow the same `400` convention. ## Key Cryptography Constants -| Constant | Value | Usage | -|----------|-------|-------| -| `ED25519_SPKI_BYTES` | 44 | Identity public key byte length (decoded) | -| `ED25519_RAW_KEY_BYTES` | 32 | Raw Ed25519 public key byte length (decoded) | -| `ED25519_SIG_BYTES` | 64 | Ed25519 signature byte length (decoded) | -| `MAX_SIZE_BYTES` | 104,857,600 | Max file upload size (100 MB) | -| `OTP_CAP` | 200 | Max stored one-time prekeys per device | +| Constant | Value | Usage | +| ----------------------- | ----------- | -------------------------------------------- | +| `ED25519_SPKI_BYTES` | 44 | Identity public key byte length (decoded) | +| `ED25519_RAW_KEY_BYTES` | 32 | Raw Ed25519 public key byte length (decoded) | +| `ED25519_SIG_BYTES` | 64 | Ed25519 signature byte length (decoded) | +| `MAX_SIZE_BYTES` | 104,857,600 | Max file upload size (100 MB) | +| `OTP_CAP` | 200 | Max stored one-time prekeys per device | --- diff --git a/apps/backend/docs/contracts-websocket-payloads.md b/apps/backend/docs/contracts-websocket-payloads.md index a692317..cacc3ce 100644 --- a/apps/backend/docs/contracts-websocket-payloads.md +++ b/apps/backend/docs/contracts-websocket-payloads.md @@ -8,9 +8,9 @@ All events dispatched via the `EventDispatcher` must be wrapped in a standard en ```typescript { - eventId: string; // Required (min 1). Unique identifier for the event. - type: string; // Required (min 1). The event type name. - timestamp: number; // Required. Positive integer representing the time of the event. + eventId: string; // Required (min 1). Unique identifier for the event. + type: string; // Required (min 1). The event type name. + timestamp: number; // Required. Positive integer representing the time of the event. payload: Record; // Optional. Defaults to {}. Contains the event-specific data. } ``` @@ -20,6 +20,7 @@ All events dispatched via the `EventDispatcher` must be wrapped in a standard en The central registry of valid socket event types, as defined in `lib/eventEnvelope.ts`. **Inbound (Client → Server):** + - `join_room` - `send_message` - `message_history` @@ -33,6 +34,7 @@ The central registry of valid socket event types, as defined in `lib/eventEnvelo - `join_device_channel` **Outbound (Server → Client):** + - `room_joined` - `new_message` - `message_ack` @@ -49,6 +51,7 @@ The central registry of valid socket event types, as defined in `lib/eventEnvelo The following are the precise payload shapes expected by the handlers in `socket/messaging.ts`. These schemas correspond to the `payload` property of the `EventEnvelope`. ### `join_room` + ```typescript { conversationId: string; @@ -56,6 +59,7 @@ The following are the precise payload shapes expected by the handlers in `socket ``` ### `send_message` + ```typescript { conversationId: string; @@ -72,6 +76,7 @@ The following are the precise payload shapes expected by the handlers in `socket ``` ### `edit_message` + ```typescript { originalMessageId: string; @@ -86,7 +91,9 @@ The following are the precise payload shapes expected by the handlers in `socket ``` ### `send_file_message` -*Note: Handled directly via `socket.on`, not wrapped in the standard `EventDispatcher` envelope.* + +_Note: Handled directly via `socket.on`, not wrapped in the standard `EventDispatcher` envelope._ + ```typescript { conversationId: string; @@ -97,6 +104,7 @@ The following are the precise payload shapes expected by the handlers in `socket ``` ### `message_history` + ```typescript { conversationId: string; @@ -105,6 +113,7 @@ The following are the precise payload shapes expected by the handlers in `socket ``` ### `delete_message` + ```typescript { messageId: string; @@ -112,6 +121,7 @@ The following are the precise payload shapes expected by the handlers in `socket ``` ### `message_read` + ```typescript { conversationId: string; @@ -120,6 +130,7 @@ The following are the precise payload shapes expected by the handlers in `socket ``` ### `message_delivered` + ```typescript { conversationId?: string; @@ -130,6 +141,7 @@ The following are the precise payload shapes expected by the handlers in `socket ``` ### `resume` + ```typescript { lastEventId?: string; @@ -137,6 +149,7 @@ The following are the precise payload shapes expected by the handlers in `socket ``` ### `create_conversation` + ```typescript { type: 'dm' | 'group'; @@ -146,6 +159,7 @@ The following are the precise payload shapes expected by the handlers in `socket ``` ### `typing_start` + ```typescript { conversationId: string; @@ -154,6 +168,7 @@ The following are the precise payload shapes expected by the handlers in `socket ``` ### `typing_stop` + ```typescript { conversationId: string; @@ -162,6 +177,7 @@ The following are the precise payload shapes expected by the handlers in `socket ``` ### `ask_assistant` + ```typescript { conversationId: string; diff --git a/apps/backend/docs/e2ee-onboarding.md b/apps/backend/docs/e2ee-onboarding.md index c13ad29..2d435c3 100644 --- a/apps/backend/docs/e2ee-onboarding.md +++ b/apps/backend/docs/e2ee-onboarding.md @@ -538,7 +538,7 @@ Required guarantees for this path: ### C) Low-prekey warning before exhaustion Waiting for exhaustion means every sender in the meantime is downgraded to -3-DH, so the backend warns the owning device *before* it runs dry. +3-DH, so the backend warns the owning device _before_ it runs dry. Two surfaces expose this: @@ -618,7 +618,7 @@ For compatibility with the current implementation, clients should rely on this o Recipient bundle fetch and atomic one-time prekey consumption are implemented (see above). Backend work still needed for full first-DM support: -- encrypted envelope submit/store/deliver for the *first* contact between two +- encrypted envelope submit/store/deliver for the _first_ contact between two users specifically (the general send path exists — see `docs/` for the message/envelope model — but hasn't been audited end-to-end against this onboarding sequence) diff --git a/apps/backend/docs/message-encryption-migration.md b/apps/backend/docs/message-encryption-migration.md index c903d1f..b43ab4d 100644 --- a/apps/backend/docs/message-encryption-migration.md +++ b/apps/backend/docs/message-encryption-migration.md @@ -114,7 +114,7 @@ It: **Rollback limitations** (also called out at the top of the script itself): - Only rows present in the archive get plaintext restored. Any message sent - *after* the forward migration ran was only ever stored as ciphertext — + _after_ the forward migration ran was only ever stored as ciphertext — there is nothing to restore for it, by design. - The recreated index is a reasonable equivalent, not necessarily byte-identical to whatever definition existed pre-squash. diff --git a/apps/backend/docs/security-hardening.md b/apps/backend/docs/security-hardening.md index 9fb00d7..d76123a 100644 --- a/apps/backend/docs/security-hardening.md +++ b/apps/backend/docs/security-hardening.md @@ -34,6 +34,7 @@ const sharedBits = await window.crypto.subtle.deriveBits( ``` This is **cryptographically invalid**. WebCrypto's `deriveBits` requires: + - **Algorithm parameter**: `{ name: 'ECDH', public: peerPublicKey }` - **Base key**: Caller's **private key** (not public) @@ -60,7 +61,7 @@ async deriveSharedSecret( callerPrivateKey, // Correct: private key as base 256 ); - + // ... import as AES-GCM key } ``` @@ -75,13 +76,13 @@ async establishSession( myPrivateKey: CryptoKey // Now requires private key ): Promise { // ... fetch bundle, verify signature - + // FIXED: Pass our private key and peer's public key const sharedSecret = await this.protocol.deriveSharedSecret( myPrivateKey, selectedPrekeyPublicKey ); - + // ... cache session } ``` @@ -105,8 +106,8 @@ The identity keypair was generated as **non-extractable**, only the public JWK w // WRONG: extractable=false means private key can't be stored const keyPair = await window.crypto.subtle.generateKey( { name: 'ECDH', namedCurve: 'P-256' }, - false, // BUG: non-extractable - ['deriveKey', 'deriveBits'] + false, // BUG: non-extractable + ['deriveKey', 'deriveBits'], ); // Only public key stored @@ -139,7 +140,7 @@ async storeIdentityKeyPair(keyPair: CryptoKeyPair): Promise { keyPair, // IndexedDB serializes CryptoKey objects directly createdAt: Date.now() }, 'current'); - + // Also maintain legacy public key storage const publicKeyJwk = await window.crypto.subtle.exportKey('jwk', keyPair.publicKey); await this.dbPut('keys', { publicKey: publicKeyJwk }, 'identity_keypair'); @@ -154,11 +155,11 @@ async getIdentityPrivateKey(): Promise { 'identityKeyPair', 'current' ); - + if (stored?.keyPair?.privateKey) { return stored.keyPair.privateKey; // Same key across reloads } - + return null; // No regeneration } ``` @@ -200,12 +201,12 @@ export async function getEligiblePushRecipients( ): Promise { // 1. Get conversation members with mute status const allMembers = await db.query.conversationMembers.findMany(...); - + // 2. Filter out sender and muted members const eligibleMembers = allMembers.filter( m => m.userId !== senderId && !m.isMuted ); - + // 3. Filter out online users (via Redis) const offlineUserIds = []; for (const userId of eligibleUserIds) { @@ -213,7 +214,7 @@ export async function getEligiblePushRecipients( offlineUserIds.push(userId); } } - + // 4. Get active, push-enabled devices const devices = await db.query.devices.findMany({ where: and( @@ -222,12 +223,12 @@ export async function getEligiblePushRecipients( // ... filter by offline users ) }); - + // 5. Filter out connected devices const offlineDeviceIds = devices .filter(d => !isDeviceConnected(d.id)) .map(d => d.id); - + return offlineDeviceIds; } ``` @@ -243,7 +244,7 @@ export async function dispatchOfflinePush(..., senderId?: string) { recipientDeviceIds, redis }); - + for (const deviceId of eligibleDeviceIds) { queueCoalescedPush(deviceId, conversationId, messageId); } @@ -256,7 +257,7 @@ export async function sendPushForMessage(ctx: PushContext) { senderId: ctx.senderId, redis }); - + for (const deviceId of eligibleDeviceIds) { queueCoalescedPush(deviceId, ctx.conversationId, ctx.messageId); } @@ -266,6 +267,7 @@ export async function sendPushForMessage(ctx: PushContext) { ### Filtering Logic Both paths now consistently filter out: + - ✓ The sender themselves - ✓ Members who muted the conversation - ✓ Users currently online (active WebSocket) @@ -287,6 +289,7 @@ Both paths now consistently filter out: ### Problem Upload confirmation (`POST /uploads/:fileId/confirm`) only checked: + - File existence - File size @@ -299,23 +302,23 @@ But **never verified SHA-256 integrity**. Corrupted or tampered files could be m ```typescript export async function verifyFileIntegrity( storageKey: string, - expectedSha256: string + expectedSha256: string, ): Promise { const store = getObjectStore(); const response = await store.getObject(storageKey); - + // Stream hash computation (avoids loading large files into memory) const stream = response.Body as Readable; const computedHash = await computeSha256FromStream(stream); - + // Case-insensitive comparison const valid = computedHash.toLowerCase() === expectedSha256.toLowerCase(); - + return { valid, computedHash, expectedHash: expectedSha256, - ...(valid ? {} : { error: 'Hash mismatch' }) + ...(valid ? {} : { error: 'Hash mismatch' }), }; } ``` @@ -325,30 +328,30 @@ export async function verifyFileIntegrity( ```typescript uploadsRouter.post('/:fileId/confirm', async (req, res) => { // ... auth checks, file lookup - + // SECURITY FIX: Verify SHA-256 integrity - const integrityCheck = await verifyFileIntegrity( - file.storageKey, - file.sha256 - ); - + const integrityCheck = await verifyFileIntegrity(file.storageKey, file.sha256); + if (!integrityCheck.valid) { // Mark file as corrupted — never becomes ready - await db.update(files).set({ - status: 'deleted', - deletedAt: new Date() - }).where(eq(files.id, fileId)); - + await db + .update(files) + .set({ + status: 'deleted', + deletedAt: new Date(), + }) + .where(eq(files.id, fileId)); + return res.status(422).json({ error: 'File integrity verification failed', details: { reason: integrityCheck.error, expectedHash: integrityCheck.expectedHash, - computedHash: integrityCheck.computedHash - } + computedHash: integrityCheck.computedHash, + }, }); } - + // Integrity verified — mark as ready await db.update(files).set({ status: 'ready' }).where(eq(files.id, fileId)); res.status(200).json({ fileId, status: 'ready' }); @@ -409,6 +412,7 @@ uploadsRouter.post('/:fileId/confirm', async (req, res) => { ### Regression Tests All existing tests continue to pass: + - Encrypted messaging flows - File upload/download - Push notification delivery diff --git a/apps/backend/drizzle/0000_stale_mandarin.sql b/apps/backend/drizzle/0000_lean_scrambler.sql similarity index 56% rename from apps/backend/drizzle/0000_stale_mandarin.sql rename to apps/backend/drizzle/0000_lean_scrambler.sql index 599c972..7d41982 100644 --- a/apps/backend/drizzle/0000_stale_mandarin.sql +++ b/apps/backend/drizzle/0000_lean_scrambler.sql @@ -1,10 +1,27 @@ +CREATE TYPE "public"."audit_action" AS ENUM('device_linked', 'device_revoked', 'logout_everywhere', 'key_bundle_drained', 'auth_failed', 'file_access_denied', 'group_member_added', 'group_member_removed');--> statement-breakpoint CREATE TYPE "public"."content_type" AS ENUM('text', 'file', 'image', 'video', 'audio', 'system');--> statement-breakpoint CREATE TYPE "public"."conversation_type" AS ENUM('dm', 'group');--> statement-breakpoint CREATE TYPE "public"."device_platform" AS ENUM('web', 'ios', 'android');--> statement-breakpoint +CREATE TYPE "public"."e2ee_protocol" AS ENUM('sealed_box', 'signal', 'mls');--> statement-breakpoint CREATE TYPE "public"."file_status" AS ENUM('pending', 'ready', 'deleted');--> statement-breakpoint +CREATE TYPE "public"."group_control_event_type" AS ENUM('member_added', 'member_removed', 'member_left', 'commit');--> statement-breakpoint CREATE TYPE "public"."prekey_type" AS ENUM('signed', 'one_time');--> statement-breakpoint CREATE TYPE "public"."proposal_vote_type" AS ENUM('approve', 'reject');--> statement-breakpoint CREATE TYPE "public"."treasury_proposal_status" AS ENUM('active', 'approved', 'rejected', 'executed', 'expired');--> statement-breakpoint +CREATE TABLE "audit_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "action" "audit_action" NOT NULL, + "actor_user_id" uuid, + "actor_device_id" uuid, + "subject_user_id" uuid, + "target_type" text, + "target_id" text, + "ip_address" text, + "user_agent" text, + "metadata" jsonb, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint CREATE TABLE "conversation_members" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "conversation_id" uuid NOT NULL, @@ -20,9 +37,20 @@ CREATE TABLE "conversations" ( "type" "conversation_type" DEFAULT 'dm' NOT NULL, "name" text, "avatar_url" text, + "epoch" integer DEFAULT 0 NOT NULL, "created_at" timestamp DEFAULT now() NOT NULL ); --> statement-breakpoint +CREATE TABLE "device_key_history" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "device_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "previous_key" text, + "new_key" text NOT NULL, + "change_reason" text, + "recorded_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint CREATE TABLE "device_prekeys" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "device_id" uuid NOT NULL, @@ -45,6 +73,8 @@ CREATE TABLE "devices" ( "last_seen_at" timestamp, "push_enabled" boolean DEFAULT true NOT NULL, "revoked_at" timestamp, + "stale_flagged_at" timestamp, + "capabilities" jsonb DEFAULT '{"protocols":["sealed_box"],"ciphersuites":[],"fileTransfer":[]}'::jsonb NOT NULL, "created_at" timestamp DEFAULT now() NOT NULL, "updated_at" timestamp DEFAULT now() NOT NULL ); @@ -65,12 +95,26 @@ CREATE TABLE "files" ( CONSTRAINT "files_storage_key_unique" UNIQUE("storage_key") ); --> statement-breakpoint +CREATE TABLE "group_control_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "conversation_id" uuid NOT NULL, + "sequence" integer NOT NULL, + "epoch" integer NOT NULL, + "event_type" "group_control_event_type" NOT NULL, + "actor_user_id" uuid, + "target_user_id" uuid, + "message_id" uuid, + "payload" text, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint CREATE TABLE "message_envelopes" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "message_id" uuid NOT NULL, "recipient_device_id" uuid NOT NULL, "recipient_user_id" uuid NOT NULL, "ciphertext" text NOT NULL, + "protocol" "e2ee_protocol" DEFAULT 'sealed_box' NOT NULL, "delivered_at" timestamp, "read_at" timestamp, "created_at" timestamp DEFAULT now() NOT NULL @@ -83,10 +127,64 @@ CREATE TABLE "messages" ( "sender_device_id" uuid, "content_type" text DEFAULT 'text' NOT NULL, "ciphertext" text, + "system_payload" jsonb, "file_id" uuid, "edits_message_id" uuid, + "mls_epoch" bigint, + "created_at" timestamp DEFAULT now() NOT NULL, + "deleted_at" timestamp, + CONSTRAINT "messages_system_payload_check" CHECK ("messages"."content_type" <> 'system' OR ("messages"."ciphertext" IS NULL AND "messages"."system_payload" IS NOT NULL)) +); +--> statement-breakpoint +CREATE TABLE "mls_commits" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "mls_group_id" uuid NOT NULL, + "epoch" bigint NOT NULL, + "committer_device_id" uuid, + "commit" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "mls_group_members" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "mls_group_id" uuid NOT NULL, + "device_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "joined_at_epoch" bigint NOT NULL, + "removed_at_epoch" bigint, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "mls_groups" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "conversation_id" uuid NOT NULL, + "group_id" text NOT NULL, + "cipher_suite" integer NOT NULL, + "current_epoch" bigint DEFAULT 0 NOT NULL, "created_at" timestamp DEFAULT now() NOT NULL, - "deleted_at" timestamp + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "mls_key_packages" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "device_id" uuid NOT NULL, + "cipher_suite" integer NOT NULL, + "key_package" text NOT NULL, + "package_hash" text NOT NULL, + "expires_at" timestamp, + "consumed" boolean DEFAULT false NOT NULL, + "consumed_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "mls_welcomes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "mls_group_id" uuid NOT NULL, + "device_id" uuid NOT NULL, + "epoch" bigint NOT NULL, + "welcome" text NOT NULL, + "claimed_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL ); --> statement-breakpoint CREATE TABLE "proposal_votes" ( @@ -143,10 +241,13 @@ CREATE TABLE "users" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "username" text, "avatar_url" text, - "presence_visible" boolean DEFAULT true NOT NULL, + "presence_visible" boolean DEFAULT false NOT NULL, + "last_seen_visible" boolean DEFAULT false NOT NULL, "created_at" timestamp DEFAULT now() NOT NULL, "updated_at" timestamp DEFAULT now() NOT NULL, - "send_read_receipts" boolean DEFAULT true NOT NULL, + "send_read_receipts" boolean DEFAULT false NOT NULL, + "allow_direct_messages" boolean DEFAULT true NOT NULL, + "allow_group_invites" boolean DEFAULT false NOT NULL, CONSTRAINT "users_username_unique" UNIQUE("username") ); --> statement-breakpoint @@ -162,10 +263,16 @@ CREATE TABLE "wallets" ( ALTER TABLE "conversation_members" ADD CONSTRAINT "conversation_members_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "conversation_members" ADD CONSTRAINT "conversation_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "conversation_members" ADD CONSTRAINT "conversation_members_last_read_message_id_messages_id_fk" FOREIGN KEY ("last_read_message_id") REFERENCES "public"."messages"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "device_key_history" ADD CONSTRAINT "device_key_history_device_id_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."devices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "device_key_history" ADD CONSTRAINT "device_key_history_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "device_prekeys" ADD CONSTRAINT "device_prekeys_device_id_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."devices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "devices" ADD CONSTRAINT "devices_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "files" ADD CONSTRAINT "files_uploader_id_users_id_fk" FOREIGN KEY ("uploader_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "files" ADD CONSTRAINT "files_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "group_control_events" ADD CONSTRAINT "group_control_events_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "group_control_events" ADD CONSTRAINT "group_control_events_actor_user_id_users_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "group_control_events" ADD CONSTRAINT "group_control_events_target_user_id_users_id_fk" FOREIGN KEY ("target_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "group_control_events" ADD CONSTRAINT "group_control_events_message_id_messages_id_fk" FOREIGN KEY ("message_id") REFERENCES "public"."messages"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint ALTER TABLE "message_envelopes" ADD CONSTRAINT "message_envelopes_message_id_messages_id_fk" FOREIGN KEY ("message_id") REFERENCES "public"."messages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "message_envelopes" ADD CONSTRAINT "message_envelopes_recipient_device_id_devices_id_fk" FOREIGN KEY ("recipient_device_id") REFERENCES "public"."devices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "message_envelopes" ADD CONSTRAINT "message_envelopes_recipient_user_id_users_id_fk" FOREIGN KEY ("recipient_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint @@ -174,6 +281,15 @@ ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_users_id_fk" FOREIGN K ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_device_id_devices_id_fk" FOREIGN KEY ("sender_device_id") REFERENCES "public"."devices"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint ALTER TABLE "messages" ADD CONSTRAINT "messages_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint ALTER TABLE "messages" ADD CONSTRAINT "messages_edits_message_id_messages_id_fk" FOREIGN KEY ("edits_message_id") REFERENCES "public"."messages"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mls_commits" ADD CONSTRAINT "mls_commits_mls_group_id_mls_groups_id_fk" FOREIGN KEY ("mls_group_id") REFERENCES "public"."mls_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mls_commits" ADD CONSTRAINT "mls_commits_committer_device_id_devices_id_fk" FOREIGN KEY ("committer_device_id") REFERENCES "public"."devices"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mls_group_members" ADD CONSTRAINT "mls_group_members_mls_group_id_mls_groups_id_fk" FOREIGN KEY ("mls_group_id") REFERENCES "public"."mls_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mls_group_members" ADD CONSTRAINT "mls_group_members_device_id_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."devices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mls_group_members" ADD CONSTRAINT "mls_group_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mls_groups" ADD CONSTRAINT "mls_groups_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mls_key_packages" ADD CONSTRAINT "mls_key_packages_device_id_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."devices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mls_welcomes" ADD CONSTRAINT "mls_welcomes_mls_group_id_mls_groups_id_fk" FOREIGN KEY ("mls_group_id") REFERENCES "public"."mls_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mls_welcomes" ADD CONSTRAINT "mls_welcomes_device_id_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."devices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "proposal_votes" ADD CONSTRAINT "proposal_votes_treasury_proposal_id_treasury_proposals_id_fk" FOREIGN KEY ("treasury_proposal_id") REFERENCES "public"."treasury_proposals"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "proposal_votes" ADD CONSTRAINT "proposal_votes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_device_id_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."devices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint @@ -181,13 +297,28 @@ ALTER TABLE "token_transfers" ADD CONSTRAINT "token_transfers_conversation_id_co ALTER TABLE "token_transfers" ADD CONSTRAINT "token_transfers_sender_id_users_id_fk" FOREIGN KEY ("sender_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "treasury_proposals" ADD CONSTRAINT "treasury_proposals_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint ALTER TABLE "wallets" ADD CONSTRAINT "wallets_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "audit_logs_subject_created_idx" ON "audit_logs" USING btree ("subject_user_id","created_at");--> statement-breakpoint +CREATE INDEX "audit_logs_actor_created_idx" ON "audit_logs" USING btree ("actor_user_id","created_at");--> statement-breakpoint +CREATE INDEX "audit_logs_action_created_idx" ON "audit_logs" USING btree ("action","created_at");--> statement-breakpoint +CREATE INDEX "device_key_history_device_idx" ON "device_key_history" USING btree ("device_id","recorded_at");--> statement-breakpoint +CREATE INDEX "device_key_history_user_idx" ON "device_key_history" USING btree ("user_id","recorded_at");--> statement-breakpoint CREATE UNIQUE INDEX "device_prekeys_device_type_keyid_idx" ON "device_prekeys" USING btree ("device_id","key_type","key_id");--> statement-breakpoint CREATE UNIQUE INDEX "device_prekeys_signed_device_idx" ON "device_prekeys" USING btree ("device_id") WHERE "device_prekeys"."key_type" = 'signed';--> statement-breakpoint CREATE INDEX "device_prekeys_one_time_available_idx" ON "device_prekeys" USING btree ("device_id") WHERE "device_prekeys"."key_type" = 'one_time' AND "device_prekeys"."consumed" = false;--> statement-breakpoint CREATE UNIQUE INDEX "devices_user_identity_idx" ON "devices" USING btree ("user_id","identity_public_key");--> statement-breakpoint CREATE INDEX "devices_user_id_active_idx" ON "devices" USING btree ("user_id") WHERE "devices"."revoked_at" IS NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "group_control_conversation_sequence_idx" ON "group_control_events" USING btree ("conversation_id","sequence");--> statement-breakpoint CREATE INDEX "me_recipient_device_created_idx" ON "message_envelopes" USING btree ("recipient_device_id","created_at");--> statement-breakpoint CREATE INDEX "me_message_idx" ON "message_envelopes" USING btree ("message_id");--> statement-breakpoint CREATE INDEX "messages_conversation_created_idx" ON "messages" USING btree ("conversation_id","created_at");--> statement-breakpoint +CREATE UNIQUE INDEX "mls_commits_group_epoch_idx" ON "mls_commits" USING btree ("mls_group_id","epoch");--> statement-breakpoint +CREATE UNIQUE INDEX "mls_group_members_active_idx" ON "mls_group_members" USING btree ("mls_group_id","device_id") WHERE "mls_group_members"."removed_at_epoch" IS NULL;--> statement-breakpoint +CREATE INDEX "mls_group_members_device_idx" ON "mls_group_members" USING btree ("device_id");--> statement-breakpoint +CREATE UNIQUE INDEX "mls_groups_conversation_idx" ON "mls_groups" USING btree ("conversation_id");--> statement-breakpoint +CREATE UNIQUE INDEX "mls_groups_group_id_idx" ON "mls_groups" USING btree ("group_id");--> statement-breakpoint +CREATE UNIQUE INDEX "mls_key_packages_device_hash_idx" ON "mls_key_packages" USING btree ("device_id","package_hash");--> statement-breakpoint +CREATE INDEX "mls_key_packages_available_idx" ON "mls_key_packages" USING btree ("device_id","cipher_suite","created_at") WHERE "mls_key_packages"."consumed" = false;--> statement-breakpoint +CREATE UNIQUE INDEX "mls_welcomes_group_device_epoch_idx" ON "mls_welcomes" USING btree ("mls_group_id","device_id","epoch");--> statement-breakpoint +CREATE INDEX "mls_welcomes_pending_idx" ON "mls_welcomes" USING btree ("device_id") WHERE "mls_welcomes"."claimed_at" IS NULL;--> statement-breakpoint CREATE UNIQUE INDEX "proposal_votes_proposal_user_unique" ON "proposal_votes" USING btree ("treasury_proposal_id","user_id");--> statement-breakpoint CREATE UNIQUE INDEX "treasury_proposals_contract_proposal_idx" ON "treasury_proposals" USING btree ("contract_id","proposal_id"); \ No newline at end of file diff --git a/apps/backend/drizzle/0001_add_system_payload_to_messages.sql b/apps/backend/drizzle/0001_add_system_payload_to_messages.sql deleted file mode 100644 index d88c672..0000000 --- a/apps/backend/drizzle/0001_add_system_payload_to_messages.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "messages" ADD COLUMN "system_payload" jsonb;--> statement-breakpoint -ALTER TABLE "messages" ADD CONSTRAINT "messages_system_payload_only_on_system_type" CHECK ("messages"."content_type" = 'system' OR "messages"."system_payload" IS NULL); \ No newline at end of file diff --git a/apps/backend/drizzle/0001_audit_logs.sql b/apps/backend/drizzle/0001_audit_logs.sql deleted file mode 100644 index 36f3928..0000000 --- a/apps/backend/drizzle/0001_audit_logs.sql +++ /dev/null @@ -1,35 +0,0 @@ -CREATE TYPE "public"."audit_action" AS ENUM('device_linked', 'device_revoked', 'logout_everywhere', 'key_bundle_drained', 'auth_failed', 'file_access_denied', 'group_member_added', 'group_member_removed');--> statement-breakpoint -CREATE TABLE "audit_logs" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "action" "audit_action" NOT NULL, - "actor_user_id" uuid, - "actor_device_id" uuid, - "subject_user_id" uuid, - "target_type" text, - "target_id" text, - "ip_address" text, - "user_agent" text, - "metadata" jsonb, - "created_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE INDEX "audit_logs_subject_created_idx" ON "audit_logs" USING btree ("subject_user_id","created_at");--> statement-breakpoint -CREATE INDEX "audit_logs_actor_created_idx" ON "audit_logs" USING btree ("actor_user_id","created_at");--> statement-breakpoint -CREATE INDEX "audit_logs_action_created_idx" ON "audit_logs" USING btree ("action","created_at");--> statement-breakpoint --- Append-only enforcement (#376). Enforced in the database rather than by --- convention: the log is only useful to an incident responder if the --- application account an attacker would already have reached cannot rewrite --- or erase it. Retention pruning is therefore a deliberate, privileged --- operation — drop the trigger, prune, recreate it — not something a stray --- UPDATE or DELETE can do. The actor/subject columns carry no foreign keys --- for the same reason: a cascade would delete the history along with the --- account it incriminates. -CREATE OR REPLACE FUNCTION audit_logs_reject_mutation() RETURNS trigger AS $$ -BEGIN - RAISE EXCEPTION 'audit_logs is append-only; % is not permitted', TG_OP - USING ERRCODE = 'restrict_violation'; -END; -$$ LANGUAGE plpgsql;--> statement-breakpoint -CREATE TRIGGER audit_logs_no_mutation - BEFORE UPDATE OR DELETE OR TRUNCATE ON "audit_logs" - FOR EACH STATEMENT EXECUTE FUNCTION audit_logs_reject_mutation(); \ No newline at end of file diff --git a/apps/backend/drizzle/0001_device_key_history.sql b/apps/backend/drizzle/0001_device_key_history.sql deleted file mode 100644 index c6a4dd2..0000000 --- a/apps/backend/drizzle/0001_device_key_history.sql +++ /dev/null @@ -1,19 +0,0 @@ --- #379: Key-transparency / device-key-change detection --- Append-only log of identity-key changes per device. Immutable — rows are --- never deleted so clients can detect silent key swaps. - -CREATE TABLE "device_key_history" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "device_id" uuid NOT NULL REFERENCES "devices"("id") ON DELETE CASCADE, - "user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE, - "previous_key" text, - "new_key" text NOT NULL, - "change_reason" text, - "recorded_at" timestamp DEFAULT now() NOT NULL -); - -CREATE INDEX "device_key_history_device_idx" - ON "device_key_history" ("device_id", "recorded_at"); - -CREATE INDEX "device_key_history_user_idx" - ON "device_key_history" ("user_id", "recorded_at"); diff --git a/apps/backend/drizzle/0001_gc_background_jobs.sql b/apps/backend/drizzle/0001_gc_background_jobs.sql deleted file mode 100644 index b31268f..0000000 --- a/apps/backend/drizzle/0001_gc_background_jobs.sql +++ /dev/null @@ -1,21 +0,0 @@ --- Background GC jobs (prekey/envelope/file/device cleanup) — schema support. --- --- Adds: --- * mls_key_packages — one-time MLS KeyPackages per device, mirroring --- device_prekeys' consumed-flag model so issuance stays auditable. --- * devices.stale_flagged_at — informational marker set by the device-GC --- job once a revoked device ages past retention. Never deletes the row. -CREATE TABLE "mls_key_packages" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "device_id" uuid NOT NULL, - "key_package" text NOT NULL, - "consumed" boolean DEFAULT false NOT NULL, - "consumed_at" timestamp, - "created_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -ALTER TABLE "mls_key_packages" ADD CONSTRAINT "mls_key_packages_device_id_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."devices"("id") ON DELETE cascade ON UPDATE no action; ---> statement-breakpoint -CREATE INDEX "mls_key_packages_device_available_idx" ON "mls_key_packages" USING btree ("device_id") WHERE "mls_key_packages"."consumed" = false; ---> statement-breakpoint -ALTER TABLE "devices" ADD COLUMN IF NOT EXISTS "stale_flagged_at" timestamp; diff --git a/apps/backend/drizzle/0001_group_control_events.sql b/apps/backend/drizzle/0001_group_control_events.sql deleted file mode 100644 index fefc847..0000000 --- a/apps/backend/drizzle/0001_group_control_events.sql +++ /dev/null @@ -1,20 +0,0 @@ -CREATE TYPE "public"."group_control_event_type" AS ENUM('member_added', 'member_removed', 'member_left', 'commit');--> statement-breakpoint -CREATE TABLE "group_control_events" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "conversation_id" uuid NOT NULL, - "sequence" integer NOT NULL, - "epoch" integer NOT NULL, - "event_type" "group_control_event_type" NOT NULL, - "actor_user_id" uuid, - "target_user_id" uuid, - "message_id" uuid, - "payload" text, - "created_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -ALTER TABLE "conversations" ADD COLUMN "epoch" integer DEFAULT 0 NOT NULL;--> statement-breakpoint -ALTER TABLE "group_control_events" ADD CONSTRAINT "group_control_events_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "group_control_events" ADD CONSTRAINT "group_control_events_actor_user_id_users_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "group_control_events" ADD CONSTRAINT "group_control_events_target_user_id_users_id_fk" FOREIGN KEY ("target_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "group_control_events" ADD CONSTRAINT "group_control_events_message_id_messages_id_fk" FOREIGN KEY ("message_id") REFERENCES "public"."messages"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -CREATE UNIQUE INDEX "group_control_conversation_sequence_idx" ON "group_control_events" USING btree ("conversation_id","sequence"); \ No newline at end of file diff --git a/apps/backend/drizzle/0001_mls_group_state.sql b/apps/backend/drizzle/0001_mls_group_state.sql deleted file mode 100644 index 065356a..0000000 --- a/apps/backend/drizzle/0001_mls_group_state.sql +++ /dev/null @@ -1,55 +0,0 @@ -CREATE TABLE "mls_commits" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "mls_group_id" uuid NOT NULL, - "epoch" bigint NOT NULL, - "committer_device_id" uuid, - "commit" text NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "mls_group_members" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "mls_group_id" uuid NOT NULL, - "device_id" uuid NOT NULL, - "user_id" uuid NOT NULL, - "joined_at_epoch" bigint NOT NULL, - "removed_at_epoch" bigint, - "created_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "mls_groups" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "conversation_id" uuid NOT NULL, - "group_id" text NOT NULL, - "cipher_suite" integer NOT NULL, - "current_epoch" bigint DEFAULT 0 NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "mls_welcomes" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "mls_group_id" uuid NOT NULL, - "device_id" uuid NOT NULL, - "epoch" bigint NOT NULL, - "welcome" text NOT NULL, - "claimed_at" timestamp, - "created_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -ALTER TABLE "messages" ADD COLUMN "mls_epoch" bigint;--> statement-breakpoint -ALTER TABLE "mls_commits" ADD CONSTRAINT "mls_commits_mls_group_id_mls_groups_id_fk" FOREIGN KEY ("mls_group_id") REFERENCES "public"."mls_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "mls_commits" ADD CONSTRAINT "mls_commits_committer_device_id_devices_id_fk" FOREIGN KEY ("committer_device_id") REFERENCES "public"."devices"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "mls_group_members" ADD CONSTRAINT "mls_group_members_mls_group_id_mls_groups_id_fk" FOREIGN KEY ("mls_group_id") REFERENCES "public"."mls_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "mls_group_members" ADD CONSTRAINT "mls_group_members_device_id_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."devices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "mls_group_members" ADD CONSTRAINT "mls_group_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "mls_groups" ADD CONSTRAINT "mls_groups_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "mls_welcomes" ADD CONSTRAINT "mls_welcomes_mls_group_id_mls_groups_id_fk" FOREIGN KEY ("mls_group_id") REFERENCES "public"."mls_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "mls_welcomes" ADD CONSTRAINT "mls_welcomes_device_id_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."devices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -CREATE UNIQUE INDEX "mls_commits_group_epoch_idx" ON "mls_commits" USING btree ("mls_group_id","epoch");--> statement-breakpoint -CREATE UNIQUE INDEX "mls_group_members_active_idx" ON "mls_group_members" USING btree ("mls_group_id","device_id") WHERE "mls_group_members"."removed_at_epoch" IS NULL;--> statement-breakpoint -CREATE INDEX "mls_group_members_device_idx" ON "mls_group_members" USING btree ("device_id");--> statement-breakpoint -CREATE UNIQUE INDEX "mls_groups_conversation_idx" ON "mls_groups" USING btree ("conversation_id");--> statement-breakpoint -CREATE UNIQUE INDEX "mls_groups_group_id_idx" ON "mls_groups" USING btree ("group_id");--> statement-breakpoint -CREATE UNIQUE INDEX "mls_welcomes_group_device_epoch_idx" ON "mls_welcomes" USING btree ("mls_group_id","device_id","epoch");--> statement-breakpoint -CREATE INDEX "mls_welcomes_pending_idx" ON "mls_welcomes" USING btree ("device_id") WHERE "mls_welcomes"."claimed_at" IS NULL; \ No newline at end of file diff --git a/apps/backend/drizzle/0001_mls_key_packages.sql b/apps/backend/drizzle/0001_mls_key_packages.sql deleted file mode 100644 index ceeb6ec..0000000 --- a/apps/backend/drizzle/0001_mls_key_packages.sql +++ /dev/null @@ -1,15 +0,0 @@ -CREATE TABLE "mls_key_packages" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "device_id" uuid NOT NULL, - "cipher_suite" integer NOT NULL, - "key_package" text NOT NULL, - "package_hash" text NOT NULL, - "expires_at" timestamp, - "consumed" boolean DEFAULT false NOT NULL, - "consumed_at" timestamp, - "created_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -ALTER TABLE "mls_key_packages" ADD CONSTRAINT "mls_key_packages_device_id_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."devices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -CREATE UNIQUE INDEX "mls_key_packages_device_hash_idx" ON "mls_key_packages" USING btree ("device_id","package_hash");--> statement-breakpoint -CREATE INDEX "mls_key_packages_available_idx" ON "mls_key_packages" USING btree ("device_id","cipher_suite","created_at") WHERE "mls_key_packages"."consumed" = false; \ No newline at end of file diff --git a/apps/backend/drizzle/0001_privacy_settings.sql b/apps/backend/drizzle/0001_privacy_settings.sql deleted file mode 100644 index 224873f..0000000 --- a/apps/backend/drizzle/0001_privacy_settings.sql +++ /dev/null @@ -1,5 +0,0 @@ -ALTER TABLE "users" ALTER COLUMN "presence_visible" SET DEFAULT false;--> statement-breakpoint -ALTER TABLE "users" ALTER COLUMN "send_read_receipts" SET DEFAULT false;--> statement-breakpoint -ALTER TABLE "users" ADD COLUMN "last_seen_visible" boolean DEFAULT false NOT NULL;--> statement-breakpoint -ALTER TABLE "users" ADD COLUMN "allow_direct_messages" boolean DEFAULT true NOT NULL;--> statement-breakpoint -ALTER TABLE "users" ADD COLUMN "allow_group_invites" boolean DEFAULT false NOT NULL; diff --git a/apps/backend/drizzle/0002_device_capabilities.sql b/apps/backend/drizzle/0002_device_capabilities.sql deleted file mode 100644 index 80f0b6c..0000000 --- a/apps/backend/drizzle/0002_device_capabilities.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Device capability/version negotiation (#180-follow-on). --- --- Advertises supported protocols/ciphersuites/file-transfer versions per --- device so senders can pick an encryption path both sides support. Rows --- written before this migration default to the sealed_box-only baseline — --- the protocol every device in this codebase already implements — so --- existing devices negotiate correctly without any backfill. -ALTER TABLE "devices" ADD COLUMN IF NOT EXISTS "capabilities" jsonb DEFAULT '{"protocols":["sealed_box"],"ciphersuites":[],"fileTransfer":[]}'::jsonb NOT NULL; diff --git a/apps/backend/drizzle/0002_strengthen_system_payload_check.sql b/apps/backend/drizzle/0002_strengthen_system_payload_check.sql deleted file mode 100644 index 5705c78..0000000 --- a/apps/backend/drizzle/0002_strengthen_system_payload_check.sql +++ /dev/null @@ -1,29 +0,0 @@ --- Backfill: #398 added `system_payload` but no producer ever wrote to it, so --- every pre-existing system row still has its {userId, change} JSON sitting in --- `ciphertext` with `system_payload` NULL. The stricter constraint below would --- reject those rows outright, so move the data across first. Unparseable --- values are preserved under a `legacyCiphertext` key rather than dropped. -DO $$ -DECLARE - row RECORD; -BEGIN - FOR row IN - SELECT id, ciphertext FROM "messages" - WHERE content_type = 'system' AND system_payload IS NULL - LOOP - BEGIN - UPDATE "messages" - SET system_payload = row.ciphertext::jsonb, - ciphertext = NULL - WHERE id = row.id; - EXCEPTION WHEN OTHERS THEN - UPDATE "messages" - SET system_payload = jsonb_build_object('legacyCiphertext', row.ciphertext), - ciphertext = NULL - WHERE id = row.id; - END; - END LOOP; -END $$; ---> statement-breakpoint -ALTER TABLE "messages" DROP CONSTRAINT "messages_system_payload_only_on_system_type";--> statement-breakpoint -ALTER TABLE "messages" ADD CONSTRAINT "messages_system_payload_check" CHECK ("messages"."content_type" <> 'system' OR ("messages"."ciphertext" IS NULL AND "messages"."system_payload" IS NOT NULL)); diff --git a/apps/backend/drizzle/0003_ciphertext_only_messages.sql b/apps/backend/drizzle/0003_ciphertext_only_messages.sql deleted file mode 100644 index 80b27ca..0000000 --- a/apps/backend/drizzle/0003_ciphertext_only_messages.sql +++ /dev/null @@ -1,102 +0,0 @@ --- One-time migration: drop plaintext `messages.content`, reset to the --- ciphertext-only model. --- --- Policy (documented in full at docs/message-encryption-migration.md): --- ARCHIVE THEN PURGE. Any existing plaintext row is copied into --- `message_content_archive` — a table with no read path through the app's --- API — before the column is dropped from `messages`. This keeps `messages` --- fully ciphertext-shaped going forward (so `serializeMessage()` never has a --- plaintext branch to accidentally serve) while preserving a compliance/ --- audit copy of pre-E2EE history on its own retention schedule, instead of --- silently destroying it or leaving a tombstone column on `messages` forever. --- --- Safe to run on a DB that never had a `content` column (this repo's own --- migration history: 0000 already created `messages` in its ciphertext --- shape) — the archive step is a no-op guarded by an information_schema --- check, and every DDL statement below uses IF EXISTS/IF NOT EXISTS so --- nothing errors either way. See docs/message-encryption-migration.md for --- the full rollback plan and its limitations. - -CREATE TABLE IF NOT EXISTS "message_content_archive" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - -- Intentionally not a foreign key: this archive must outlive the - -- `messages` row it was copied from (e.g. hard-deletion by the message - -- GC path must not cascade-delete the compliance copy). - "original_message_id" uuid NOT NULL, - "conversation_id" uuid, - "sender_id" uuid, - "content" text NOT NULL, - "original_created_at" timestamp, - "archived_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE INDEX IF NOT EXISTS "message_content_archive_original_message_idx" ON "message_content_archive" USING btree ("original_message_id"); ---> statement-breakpoint - --- Archive any existing plaintext before the column is dropped. Dynamic SQL --- is required here (unlike the DROP statements below) because a plain --- top-level statement referencing `messages.content` would fail to parse on --- a database where that column never existed. -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = 'messages' AND column_name = 'content' - ) THEN - EXECUTE ' - INSERT INTO message_content_archive - (original_message_id, conversation_id, sender_id, content, original_created_at) - SELECT id, conversation_id, sender_id, content, created_at - FROM messages - WHERE content IS NOT NULL - '; - END IF; -END $$; ---> statement-breakpoint - --- Drop the plaintext column's GIN index. The exact index name from --- pre-squash history is not recoverable, so every plausible historical name --- is dropped defensively — DROP INDEX IF EXISTS is a no-op for names that --- don't exist, so this is safe regardless of which (if any) actually match. -DROP INDEX IF EXISTS "messages_content_gin_idx"; ---> statement-breakpoint -DROP INDEX IF EXISTS "messages_content_search_idx"; ---> statement-breakpoint -DROP INDEX IF EXISTS "messages_content_tsv_idx"; ---> statement-breakpoint -DROP INDEX IF EXISTS "messages_content_idx"; ---> statement-breakpoint - -ALTER TABLE "messages" DROP COLUMN IF EXISTS "content"; ---> statement-breakpoint - --- Defensively ensure the ciphertext-model columns/tables this migration's --- companion schema overhaul introduced are present, for any database whose --- migration history predates the 0000 squash and therefore skipped them. --- A no-op everywhere this repo's own 0000 migration already ran. -ALTER TABLE "messages" ADD COLUMN IF NOT EXISTS "ciphertext" text; ---> statement-breakpoint -ALTER TABLE "messages" ADD COLUMN IF NOT EXISTS "sender_device_id" uuid; ---> statement-breakpoint -ALTER TABLE "messages" ADD COLUMN IF NOT EXISTS "file_id" uuid; ---> statement-breakpoint -ALTER TABLE "messages" ADD COLUMN IF NOT EXISTS "edits_message_id" uuid; ---> statement-breakpoint -ALTER TABLE "messages" ADD COLUMN IF NOT EXISTS "deleted_at" timestamp; ---> statement-breakpoint - --- Note: this fallback intentionally omits FK constraints — on every database --- that already ran this repo's 0000 migration (the only realistic case), --- the table already exists with its constraints and this is a pure no-op. --- A database old enough to hit this branch predates the squash entirely and --- needs a manually-reviewed reconciliation, not a silent constraint add. -CREATE TABLE IF NOT EXISTS "message_envelopes" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "message_id" uuid NOT NULL, - "recipient_device_id" uuid NOT NULL, - "recipient_user_id" uuid NOT NULL, - "ciphertext" text NOT NULL, - "delivered_at" timestamp, - "read_at" timestamp, - "created_at" timestamp DEFAULT now() NOT NULL -); diff --git a/apps/backend/drizzle/0004_envelope_protocol.sql b/apps/backend/drizzle/0004_envelope_protocol.sql deleted file mode 100644 index 6fe451a..0000000 --- a/apps/backend/drizzle/0004_envelope_protocol.sql +++ /dev/null @@ -1,14 +0,0 @@ --- Per-envelope E2EE protocol (#364). --- --- `message_envelopes.protocol` is added NOT NULL DEFAULT 'sealed_box', which --- backfills every existing row to the Phase-1 sealed box in the same --- statement. That is the no-history-loss guarantee: envelopes written before a --- device pair cut over are labelled with the construction that actually --- encrypted them, so they keep decrypting on the Phase-1 path even after that --- pair's `devices.capabilities` has moved on. --- --- Values mirror KNOWN_PROTOCOLS in src/lib/capabilities.ts. No column is added --- to `devices`: capability advertisement already lives in `devices.capabilities` --- (0002_device_capabilities.sql). -CREATE TYPE "public"."e2ee_protocol" AS ENUM('sealed_box', 'signal', 'mls');--> statement-breakpoint -ALTER TABLE "message_envelopes" ADD COLUMN "protocol" "e2ee_protocol" DEFAULT 'sealed_box' NOT NULL; diff --git a/apps/backend/drizzle/meta/0000_snapshot.json b/apps/backend/drizzle/meta/0000_snapshot.json index 898fed6..892b29d 100644 --- a/apps/backend/drizzle/meta/0000_snapshot.json +++ b/apps/backend/drizzle/meta/0000_snapshot.json @@ -1,9 +1,155 @@ { - "id": "d5682005-cccf-4e2e-992d-d66a5d6d3f4c", + "id": "32a9a85e-d303-442d-a16e-4125880121c8", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", "tables": { + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "action": { + "name": "action", + "type": "audit_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_device_id": { + "name": "actor_device_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_subject_created_idx": { + "name": "audit_logs_subject_created_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_actor_created_idx": { + "name": "audit_logs_actor_created_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_action_created_idx": { + "name": "audit_logs_action_created_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.conversation_members": { "name": "conversation_members", "schema": "", @@ -134,6 +280,13 @@ "primaryKey": false, "notNull": false }, + "epoch": { + "name": "epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, "created_at": { "name": "created_at", "type": "timestamp", @@ -150,6 +303,133 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.device_key_history": { + "name": "device_key_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "previous_key": { + "name": "previous_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_key": { + "name": "new_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "change_reason": { + "name": "change_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "device_key_history_device_idx": { + "name": "device_key_history_device_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recorded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_key_history_user_idx": { + "name": "device_key_history_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recorded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_key_history_device_id_devices_id_fk": { + "name": "device_key_history_device_id_devices_id_fk", + "tableFrom": "device_key_history", + "tableTo": "devices", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "device_key_history_user_id_users_id_fk": { + "name": "device_key_history_user_id_users_id_fk", + "tableFrom": "device_key_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.device_prekeys": { "name": "device_prekeys", "schema": "", @@ -355,6 +635,19 @@ "primaryKey": false, "notNull": false }, + "stale_flagged_at": { + "name": "stale_flagged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"protocols\":[\"sealed_box\"],\"ciphersuites\":[],\"fileTransfer\":[]}'::jsonb" + }, "created_at": { "name": "created_at", "type": "timestamp", @@ -512,24 +805,851 @@ "default": "now()" } }, - "indexes": {}, + "indexes": {}, + "foreignKeys": { + "files_uploader_id_users_id_fk": { + "name": "files_uploader_id_users_id_fk", + "tableFrom": "files", + "tableTo": "users", + "columnsFrom": [ + "uploader_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "files_conversation_id_conversations_id_fk": { + "name": "files_conversation_id_conversations_id_fk", + "tableFrom": "files", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "files_storage_key_unique": { + "name": "files_storage_key_unique", + "nullsNotDistinct": false, + "columns": [ + "storage_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_control_events": { + "name": "group_control_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "epoch": { + "name": "epoch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "group_control_event_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_control_conversation_sequence_idx": { + "name": "group_control_conversation_sequence_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_control_events_conversation_id_conversations_id_fk": { + "name": "group_control_events_conversation_id_conversations_id_fk", + "tableFrom": "group_control_events", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_control_events_actor_user_id_users_id_fk": { + "name": "group_control_events_actor_user_id_users_id_fk", + "tableFrom": "group_control_events", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_control_events_target_user_id_users_id_fk": { + "name": "group_control_events_target_user_id_users_id_fk", + "tableFrom": "group_control_events", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_control_events_message_id_messages_id_fk": { + "name": "group_control_events_message_id_messages_id_fk", + "tableFrom": "group_control_events", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_envelopes": { + "name": "message_envelopes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recipient_device_id": { + "name": "recipient_device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol": { + "name": "protocol", + "type": "e2ee_protocol", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'sealed_box'" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "me_recipient_device_created_idx": { + "name": "me_recipient_device_created_idx", + "columns": [ + { + "expression": "recipient_device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "me_message_idx": { + "name": "me_message_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_envelopes_message_id_messages_id_fk": { + "name": "message_envelopes_message_id_messages_id_fk", + "tableFrom": "message_envelopes", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_envelopes_recipient_device_id_devices_id_fk": { + "name": "message_envelopes_recipient_device_id_devices_id_fk", + "tableFrom": "message_envelopes", + "tableTo": "devices", + "columnsFrom": [ + "recipient_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_envelopes_recipient_user_id_users_id_fk": { + "name": "message_envelopes_recipient_user_id_users_id_fk", + "tableFrom": "message_envelopes", + "tableTo": "users", + "columnsFrom": [ + "recipient_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_device_id": { + "name": "sender_device_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_payload": { + "name": "system_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "edits_message_id": { + "name": "edits_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mls_epoch": { + "name": "mls_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "messages_conversation_created_idx": { + "name": "messages_conversation_created_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_sender_id_users_id_fk": { + "name": "messages_sender_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_sender_device_id_devices_id_fk": { + "name": "messages_sender_device_id_devices_id_fk", + "tableFrom": "messages", + "tableTo": "devices", + "columnsFrom": [ + "sender_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "messages_file_id_files_id_fk": { + "name": "messages_file_id_files_id_fk", + "tableFrom": "messages", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "messages_edits_message_id_messages_id_fk": { + "name": "messages_edits_message_id_messages_id_fk", + "tableFrom": "messages", + "tableTo": "messages", + "columnsFrom": [ + "edits_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "messages_system_payload_check": { + "name": "messages_system_payload_check", + "value": "\"messages\".\"content_type\" <> 'system' OR (\"messages\".\"ciphertext\" IS NULL AND \"messages\".\"system_payload\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.mls_commits": { + "name": "mls_commits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mls_group_id": { + "name": "mls_group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "epoch": { + "name": "epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "committer_device_id": { + "name": "committer_device_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "commit": { + "name": "commit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mls_commits_group_epoch_idx": { + "name": "mls_commits_group_epoch_idx", + "columns": [ + { + "expression": "mls_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "epoch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mls_commits_mls_group_id_mls_groups_id_fk": { + "name": "mls_commits_mls_group_id_mls_groups_id_fk", + "tableFrom": "mls_commits", + "tableTo": "mls_groups", + "columnsFrom": [ + "mls_group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mls_commits_committer_device_id_devices_id_fk": { + "name": "mls_commits_committer_device_id_devices_id_fk", + "tableFrom": "mls_commits", + "tableTo": "devices", + "columnsFrom": [ + "committer_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mls_group_members": { + "name": "mls_group_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mls_group_id": { + "name": "mls_group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "joined_at_epoch": { + "name": "joined_at_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "removed_at_epoch": { + "name": "removed_at_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mls_group_members_active_idx": { + "name": "mls_group_members_active_idx", + "columns": [ + { + "expression": "mls_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mls_group_members\".\"removed_at_epoch\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "mls_group_members_device_idx": { + "name": "mls_group_members_device_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mls_group_members_mls_group_id_mls_groups_id_fk": { + "name": "mls_group_members_mls_group_id_mls_groups_id_fk", + "tableFrom": "mls_group_members", + "tableTo": "mls_groups", + "columnsFrom": [ + "mls_group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mls_group_members_device_id_devices_id_fk": { + "name": "mls_group_members_device_id_devices_id_fk", + "tableFrom": "mls_group_members", + "tableTo": "devices", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mls_group_members_user_id_users_id_fk": { + "name": "mls_group_members_user_id_users_id_fk", + "tableFrom": "mls_group_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mls_groups": { + "name": "mls_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cipher_suite": { + "name": "cipher_suite", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "current_epoch": { + "name": "current_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mls_groups_conversation_idx": { + "name": "mls_groups_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mls_groups_group_id_idx": { + "name": "mls_groups_group_id_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, "foreignKeys": { - "files_uploader_id_users_id_fk": { - "name": "files_uploader_id_users_id_fk", - "tableFrom": "files", - "tableTo": "users", - "columnsFrom": [ - "uploader_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "files_conversation_id_conversations_id_fk": { - "name": "files_conversation_id_conversations_id_fk", - "tableFrom": "files", + "mls_groups_conversation_id_conversations_id_fk": { + "name": "mls_groups_conversation_id_conversations_id_fk", + "tableFrom": "mls_groups", "tableTo": "conversations", "columnsFrom": [ "conversation_id" @@ -542,21 +1662,13 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "files_storage_key_unique": { - "name": "files_storage_key_unique", - "nullsNotDistinct": false, - "columns": [ - "storage_key" - ] - } - }, + "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, - "public.message_envelopes": { - "name": "message_envelopes", + "public.mls_key_packages": { + "name": "mls_key_packages", "schema": "", "columns": { "id": { @@ -566,38 +1678,45 @@ "notNull": true, "default": "gen_random_uuid()" }, - "message_id": { - "name": "message_id", + "device_id": { + "name": "device_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "recipient_device_id": { - "name": "recipient_device_id", - "type": "uuid", + "cipher_suite": { + "name": "cipher_suite", + "type": "integer", "primaryKey": false, "notNull": true }, - "recipient_user_id": { - "name": "recipient_user_id", - "type": "uuid", + "key_package": { + "name": "key_package", + "type": "text", "primaryKey": false, "notNull": true }, - "ciphertext": { - "name": "ciphertext", + "package_hash": { + "name": "package_hash", "type": "text", "primaryKey": false, "notNull": true }, - "delivered_at": { - "name": "delivered_at", + "expires_at": { + "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": false }, - "read_at": { - "name": "read_at", + "consumed": { + "name": "consumed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "consumed_at": { + "name": "consumed_at", "type": "timestamp", "primaryKey": false, "notNull": false @@ -611,76 +1730,63 @@ } }, "indexes": { - "me_recipient_device_created_idx": { - "name": "me_recipient_device_created_idx", + "mls_key_packages_device_hash_idx": { + "name": "mls_key_packages_device_hash_idx", "columns": [ { - "expression": "recipient_device_id", + "expression": "device_id", "isExpression": false, "asc": true, "nulls": "last" }, { - "expression": "created_at", + "expression": "package_hash", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, "concurrently": false, "method": "btree", "with": {} }, - "me_message_idx": { - "name": "me_message_idx", + "mls_key_packages_available_idx": { + "name": "mls_key_packages_available_idx", "columns": [ { - "expression": "message_id", + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cipher_suite", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, + "where": "\"mls_key_packages\".\"consumed\" = false", "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { - "message_envelopes_message_id_messages_id_fk": { - "name": "message_envelopes_message_id_messages_id_fk", - "tableFrom": "message_envelopes", - "tableTo": "messages", - "columnsFrom": [ - "message_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "message_envelopes_recipient_device_id_devices_id_fk": { - "name": "message_envelopes_recipient_device_id_devices_id_fk", - "tableFrom": "message_envelopes", + "mls_key_packages_device_id_devices_id_fk": { + "name": "mls_key_packages_device_id_devices_id_fk", + "tableFrom": "mls_key_packages", "tableTo": "devices", "columnsFrom": [ - "recipient_device_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "message_envelopes_recipient_user_id_users_id_fk": { - "name": "message_envelopes_recipient_user_id_users_id_fk", - "tableFrom": "message_envelopes", - "tableTo": "users", - "columnsFrom": [ - "recipient_user_id" + "device_id" ], "columnsTo": [ "id" @@ -695,8 +1801,8 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.messages": { - "name": "messages", + "public.mls_welcomes": { + "name": "mls_welcomes", "schema": "", "columns": { "id": { @@ -706,46 +1812,33 @@ "notNull": true, "default": "gen_random_uuid()" }, - "conversation_id": { - "name": "conversation_id", + "mls_group_id": { + "name": "mls_group_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "sender_id": { - "name": "sender_id", + "device_id": { + "name": "device_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "sender_device_id": { - "name": "sender_device_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "content_type": { - "name": "content_type", - "type": "text", + "epoch": { + "name": "epoch", + "type": "bigint", "primaryKey": false, - "notNull": true, - "default": "'text'" + "notNull": true }, - "ciphertext": { - "name": "ciphertext", + "welcome": { + "name": "welcome", "type": "text", "primaryKey": false, - "notNull": false - }, - "file_id": { - "name": "file_id", - "type": "uuid", - "primaryKey": false, - "notNull": false + "notNull": true }, - "edits_message_id": { - "name": "edits_message_id", - "type": "uuid", + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", "primaryKey": false, "notNull": false }, @@ -755,57 +1848,60 @@ "primaryKey": false, "notNull": true, "default": "now()" - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false } }, "indexes": { - "messages_conversation_created_idx": { - "name": "messages_conversation_created_idx", + "mls_welcomes_group_device_epoch_idx": { + "name": "mls_welcomes_group_device_epoch_idx", "columns": [ { - "expression": "conversation_id", + "expression": "mls_group_id", "isExpression": false, "asc": true, "nulls": "last" }, { - "expression": "created_at", + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "epoch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mls_welcomes_pending_idx": { + "name": "mls_welcomes_pending_idx", + "columns": [ + { + "expression": "device_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, + "where": "\"mls_welcomes\".\"claimed_at\" IS NULL", "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { - "messages_conversation_id_conversations_id_fk": { - "name": "messages_conversation_id_conversations_id_fk", - "tableFrom": "messages", - "tableTo": "conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "messages_sender_id_users_id_fk": { - "name": "messages_sender_id_users_id_fk", - "tableFrom": "messages", - "tableTo": "users", + "mls_welcomes_mls_group_id_mls_groups_id_fk": { + "name": "mls_welcomes_mls_group_id_mls_groups_id_fk", + "tableFrom": "mls_welcomes", + "tableTo": "mls_groups", "columnsFrom": [ - "sender_id" + "mls_group_id" ], "columnsTo": [ "id" @@ -813,43 +1909,17 @@ "onDelete": "cascade", "onUpdate": "no action" }, - "messages_sender_device_id_devices_id_fk": { - "name": "messages_sender_device_id_devices_id_fk", - "tableFrom": "messages", + "mls_welcomes_device_id_devices_id_fk": { + "name": "mls_welcomes_device_id_devices_id_fk", + "tableFrom": "mls_welcomes", "tableTo": "devices", "columnsFrom": [ - "sender_device_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "messages_file_id_files_id_fk": { - "name": "messages_file_id_files_id_fk", - "tableFrom": "messages", - "tableTo": "files", - "columnsFrom": [ - "file_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "messages_edits_message_id_messages_id_fk": { - "name": "messages_edits_message_id_messages_id_fk", - "tableFrom": "messages", - "tableTo": "messages", - "columnsFrom": [ - "edits_message_id" + "device_id" ], "columnsTo": [ "id" ], - "onDelete": "set null", + "onDelete": "cascade", "onUpdate": "no action" } }, @@ -1312,7 +2382,14 @@ "type": "boolean", "primaryKey": false, "notNull": true, - "default": true + "default": false + }, + "last_seen_visible": { + "name": "last_seen_visible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false }, "created_at": { "name": "created_at", @@ -1333,7 +2410,21 @@ "type": "boolean", "primaryKey": false, "notNull": true, + "default": false + }, + "allow_direct_messages": { + "name": "allow_direct_messages", + "type": "boolean", + "primaryKey": false, + "notNull": true, "default": true + }, + "allow_group_invites": { + "name": "allow_group_invites", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false } }, "indexes": {}, @@ -1422,6 +2513,20 @@ } }, "enums": { + "public.audit_action": { + "name": "audit_action", + "schema": "public", + "values": [ + "device_linked", + "device_revoked", + "logout_everywhere", + "key_bundle_drained", + "auth_failed", + "file_access_denied", + "group_member_added", + "group_member_removed" + ] + }, "public.content_type": { "name": "content_type", "schema": "public", @@ -1451,6 +2556,15 @@ "android" ] }, + "public.e2ee_protocol": { + "name": "e2ee_protocol", + "schema": "public", + "values": [ + "sealed_box", + "signal", + "mls" + ] + }, "public.file_status": { "name": "file_status", "schema": "public", @@ -1460,6 +2574,16 @@ "deleted" ] }, + "public.group_control_event_type": { + "name": "group_control_event_type", + "schema": "public", + "values": [ + "member_added", + "member_removed", + "member_left", + "commit" + ] + }, "public.prekey_type": { "name": "prekey_type", "schema": "public", diff --git a/apps/backend/drizzle/meta/0001_snapshot.json b/apps/backend/drizzle/meta/0001_snapshot.json deleted file mode 100644 index 92cb72e..0000000 --- a/apps/backend/drizzle/meta/0001_snapshot.json +++ /dev/null @@ -1,2201 +0,0 @@ -{ - "id": "4d8474b1-4de4-49e3-93a2-a8a320119373", - "prevId": "d5682005-cccf-4e2e-992d-d66a5d6d3f4c", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.conversation_members": { - "name": "conversation_members", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "conversation_id": { - "name": "conversation_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "last_read_message_id": { - "name": "last_read_message_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "is_muted": { - "name": "is_muted", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "is_archived": { - "name": "is_archived", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "joined_at": { - "name": "joined_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "conversation_members_conversation_id_conversations_id_fk": { - "name": "conversation_members_conversation_id_conversations_id_fk", - "tableFrom": "conversation_members", - "tableTo": "conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "conversation_members_user_id_users_id_fk": { - "name": "conversation_members_user_id_users_id_fk", - "tableFrom": "conversation_members", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "conversation_members_last_read_message_id_messages_id_fk": { - "name": "conversation_members_last_read_message_id_messages_id_fk", - "tableFrom": "conversation_members", - "tableTo": "messages", - "columnsFrom": [ - "last_read_message_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.conversations": { - "name": "conversations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "type": { - "name": "type", - "type": "conversation_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'dm'" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "avatar_url": { - "name": "avatar_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "epoch": { - "name": "epoch", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.device_prekeys": { - "name": "device_prekeys", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "device_id": { - "name": "device_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "key_type": { - "name": "key_type", - "type": "prekey_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "key_id": { - "name": "key_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "public_key": { - "name": "public_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "signature": { - "name": "signature", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "consumed": { - "name": "consumed", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "device_prekeys_device_type_keyid_idx": { - "name": "device_prekeys_device_type_keyid_idx", - "columns": [ - { - "expression": "device_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "key_type", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "key_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "device_prekeys_signed_device_idx": { - "name": "device_prekeys_signed_device_idx", - "columns": [ - { - "expression": "device_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "\"device_prekeys\".\"key_type\" = 'signed'", - "concurrently": false, - "method": "btree", - "with": {} - }, - "device_prekeys_one_time_available_idx": { - "name": "device_prekeys_one_time_available_idx", - "columns": [ - { - "expression": "device_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"device_prekeys\".\"key_type\" = 'one_time' AND \"device_prekeys\".\"consumed\" = false", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "device_prekeys_device_id_devices_id_fk": { - "name": "device_prekeys_device_id_devices_id_fk", - "tableFrom": "device_prekeys", - "tableTo": "devices", - "columnsFrom": [ - "device_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "device_prekeys_signed_requires_signature": { - "name": "device_prekeys_signed_requires_signature", - "value": "\"device_prekeys\".\"key_type\" <> 'signed' OR \"device_prekeys\".\"signature\" IS NOT NULL" - } - }, - "isRLSEnabled": false - }, - "public.devices": { - "name": "devices", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "identity_public_key": { - "name": "identity_public_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "registration_id": { - "name": "registration_id", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "device_name": { - "name": "device_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "platform": { - "name": "platform", - "type": "device_platform", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "last_seen_at": { - "name": "last_seen_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "push_enabled": { - "name": "push_enabled", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "revoked_at": { - "name": "revoked_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "devices_user_identity_idx": { - "name": "devices_user_identity_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "identity_public_key", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "devices_user_id_active_idx": { - "name": "devices_user_id_active_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"devices\".\"revoked_at\" IS NULL", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "devices_user_id_users_id_fk": { - "name": "devices_user_id_users_id_fk", - "tableFrom": "devices", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.files": { - "name": "files", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "uploader_id": { - "name": "uploader_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "conversation_id": { - "name": "conversation_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "file_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'pending'" - }, - "size": { - "name": "size", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "mime_type": { - "name": "mime_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "sha256": { - "name": "sha256", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "storage_key": { - "name": "storage_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "is_thumbnail": { - "name": "is_thumbnail", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "hard_deleted_at": { - "name": "hard_deleted_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "files_uploader_id_users_id_fk": { - "name": "files_uploader_id_users_id_fk", - "tableFrom": "files", - "tableTo": "users", - "columnsFrom": [ - "uploader_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "files_conversation_id_conversations_id_fk": { - "name": "files_conversation_id_conversations_id_fk", - "tableFrom": "files", - "tableTo": "conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "files_storage_key_unique": { - "name": "files_storage_key_unique", - "nullsNotDistinct": false, - "columns": [ - "storage_key" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.group_control_events": { - "name": "group_control_events", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "conversation_id": { - "name": "conversation_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "sequence": { - "name": "sequence", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "epoch": { - "name": "epoch", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "event_type": { - "name": "event_type", - "type": "group_control_event_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "actor_user_id": { - "name": "actor_user_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "target_user_id": { - "name": "target_user_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "message_id": { - "name": "message_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "payload": { - "name": "payload", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "group_control_conversation_sequence_idx": { - "name": "group_control_conversation_sequence_idx", - "columns": [ - { - "expression": "conversation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "sequence", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "group_control_events_conversation_id_conversations_id_fk": { - "name": "group_control_events_conversation_id_conversations_id_fk", - "tableFrom": "group_control_events", - "tableTo": "conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "group_control_events_actor_user_id_users_id_fk": { - "name": "group_control_events_actor_user_id_users_id_fk", - "tableFrom": "group_control_events", - "tableTo": "users", - "columnsFrom": [ - "actor_user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "group_control_events_target_user_id_users_id_fk": { - "name": "group_control_events_target_user_id_users_id_fk", - "tableFrom": "group_control_events", - "tableTo": "users", - "columnsFrom": [ - "target_user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "group_control_events_message_id_messages_id_fk": { - "name": "group_control_events_message_id_messages_id_fk", - "tableFrom": "group_control_events", - "tableTo": "messages", - "columnsFrom": [ - "message_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.message_envelopes": { - "name": "message_envelopes", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "message_id": { - "name": "message_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "recipient_device_id": { - "name": "recipient_device_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "recipient_user_id": { - "name": "recipient_user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "ciphertext": { - "name": "ciphertext", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "delivered_at": { - "name": "delivered_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "read_at": { - "name": "read_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "me_recipient_device_created_idx": { - "name": "me_recipient_device_created_idx", - "columns": [ - { - "expression": "recipient_device_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "me_message_idx": { - "name": "me_message_idx", - "columns": [ - { - "expression": "message_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "message_envelopes_message_id_messages_id_fk": { - "name": "message_envelopes_message_id_messages_id_fk", - "tableFrom": "message_envelopes", - "tableTo": "messages", - "columnsFrom": [ - "message_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "message_envelopes_recipient_device_id_devices_id_fk": { - "name": "message_envelopes_recipient_device_id_devices_id_fk", - "tableFrom": "message_envelopes", - "tableTo": "devices", - "columnsFrom": [ - "recipient_device_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "message_envelopes_recipient_user_id_users_id_fk": { - "name": "message_envelopes_recipient_user_id_users_id_fk", - "tableFrom": "message_envelopes", - "tableTo": "users", - "columnsFrom": [ - "recipient_user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.messages": { - "name": "messages", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "conversation_id": { - "name": "conversation_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "sender_id": { - "name": "sender_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "sender_device_id": { - "name": "sender_device_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "content_type": { - "name": "content_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'text'" - }, - "ciphertext": { - "name": "ciphertext", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "file_id": { - "name": "file_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "edits_message_id": { - "name": "edits_message_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "mls_epoch": { - "name": "mls_epoch", - "type": "bigint", - "system_payload": { - "name": "system_payload", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "messages_conversation_created_idx": { - "name": "messages_conversation_created_idx", - "columns": [ - { - "expression": "conversation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "messages_conversation_id_conversations_id_fk": { - "name": "messages_conversation_id_conversations_id_fk", - "tableFrom": "messages", - "tableTo": "conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "messages_sender_id_users_id_fk": { - "name": "messages_sender_id_users_id_fk", - "tableFrom": "messages", - "tableTo": "users", - "columnsFrom": [ - "sender_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "messages_sender_device_id_devices_id_fk": { - "name": "messages_sender_device_id_devices_id_fk", - "tableFrom": "messages", - "tableTo": "devices", - "columnsFrom": [ - "sender_device_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "messages_file_id_files_id_fk": { - "name": "messages_file_id_files_id_fk", - "tableFrom": "messages", - "tableTo": "files", - "columnsFrom": [ - "file_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "messages_edits_message_id_messages_id_fk": { - "name": "messages_edits_message_id_messages_id_fk", - "tableFrom": "messages", - "tableTo": "messages", - "columnsFrom": [ - "edits_message_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mls_commits": { - "name": "mls_commits", - "public.mls_key_packages": { - "name": "mls_key_packages", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "mls_group_id": { - "name": "mls_group_id", - "device_id": { - "name": "device_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "epoch": { - "name": "epoch", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "committer_device_id": { - "name": "committer_device_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "commit": { - "name": "commit", - "cipher_suite": { - "name": "cipher_suite", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "key_package": { - "name": "key_package", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "mls_commits_group_epoch_idx": { - "name": "mls_commits_group_epoch_idx", - "columns": [ - { - "expression": "mls_group_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "epoch", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "mls_commits_mls_group_id_mls_groups_id_fk": { - "name": "mls_commits_mls_group_id_mls_groups_id_fk", - "tableFrom": "mls_commits", - "tableTo": "mls_groups", - "columnsFrom": [ - "mls_group_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "mls_commits_committer_device_id_devices_id_fk": { - "name": "mls_commits_committer_device_id_devices_id_fk", - "tableFrom": "mls_commits", - "tableTo": "devices", - "columnsFrom": [ - "committer_device_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mls_group_members": { - "name": "mls_group_members", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "mls_group_id": { - "name": "mls_group_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "device_id": { - "name": "device_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "joined_at_epoch": { - "name": "joined_at_epoch", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "removed_at_epoch": { - "name": "removed_at_epoch", - "type": "bigint", - "package_hash": { - "name": "package_hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "consumed": { - "name": "consumed", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "consumed_at": { - "name": "consumed_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "mls_group_members_active_idx": { - "name": "mls_group_members_active_idx", - "columns": [ - { - "expression": "mls_group_id", - "mls_key_packages_device_hash_idx": { - "name": "mls_key_packages_device_hash_idx", - "columns": [ - { - "expression": "device_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "device_id", - "expression": "package_hash", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "\"mls_group_members\".\"removed_at_epoch\" IS NULL", - "concurrently": false, - "method": "btree", - "with": {} - }, - "mls_group_members_device_idx": { - "name": "mls_group_members_device_idx", - "mls_key_packages_available_idx": { - "name": "mls_key_packages_available_idx", - "columns": [ - { - "expression": "device_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "mls_group_members_mls_group_id_mls_groups_id_fk": { - "name": "mls_group_members_mls_group_id_mls_groups_id_fk", - "tableFrom": "mls_group_members", - "tableTo": "mls_groups", - "columnsFrom": [ - "mls_group_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "mls_group_members_device_id_devices_id_fk": { - "name": "mls_group_members_device_id_devices_id_fk", - "tableFrom": "mls_group_members", - "tableTo": "devices", - "columnsFrom": [ - "device_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "mls_group_members_user_id_users_id_fk": { - "name": "mls_group_members_user_id_users_id_fk", - "tableFrom": "mls_group_members", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mls_groups": { - "name": "mls_groups", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "conversation_id": { - "name": "conversation_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "group_id": { - "name": "group_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "cipher_suite": { - "name": "cipher_suite", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "current_epoch": { - "name": "current_epoch", - "type": "bigint", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "mls_groups_conversation_idx": { - "name": "mls_groups_conversation_idx", - "columns": [ - { - "expression": "conversation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "mls_groups_group_id_idx": { - "name": "mls_groups_group_id_idx", - "columns": [ - { - "expression": "group_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "mls_groups_conversation_id_conversations_id_fk": { - "name": "mls_groups_conversation_id_conversations_id_fk", - "tableFrom": "mls_groups", - "tableTo": "conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mls_welcomes": { - "name": "mls_welcomes", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "mls_group_id": { - "name": "mls_group_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "device_id": { - "name": "device_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "epoch": { - "name": "epoch", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "welcome": { - "name": "welcome", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "claimed_at": { - "name": "claimed_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "mls_welcomes_group_device_epoch_idx": { - "name": "mls_welcomes_group_device_epoch_idx", - "columns": [ - { - "expression": "mls_group_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "device_id", - }, - { - "expression": "cipher_suite", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "epoch", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "mls_welcomes_pending_idx": { - "name": "mls_welcomes_pending_idx", - "columns": [ - { - "expression": "device_id", - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"mls_welcomes\".\"claimed_at\" IS NULL", - "where": "\"mls_key_packages\".\"consumed\" = false", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "mls_welcomes_mls_group_id_mls_groups_id_fk": { - "name": "mls_welcomes_mls_group_id_mls_groups_id_fk", - "tableFrom": "mls_welcomes", - "tableTo": "mls_groups", - "columnsFrom": [ - "mls_group_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "mls_welcomes_device_id_devices_id_fk": { - "name": "mls_welcomes_device_id_devices_id_fk", - "tableFrom": "mls_welcomes", - "mls_key_packages_device_id_devices_id_fk": { - "name": "mls_key_packages_device_id_devices_id_fk", - "tableFrom": "mls_key_packages", - "tableTo": "devices", - "columnsFrom": [ - "device_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "checkConstraints": { - "messages_system_payload_only_on_system_type": { - "name": "messages_system_payload_only_on_system_type", - "value": "\"messages\".\"content_type\" = 'system' OR \"messages\".\"system_payload\" IS NULL" - } - }, - "isRLSEnabled": false - }, - "public.proposal_votes": { - "name": "proposal_votes", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "treasury_proposal_id": { - "name": "treasury_proposal_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "vote": { - "name": "vote", - "type": "proposal_vote_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "signature": { - "name": "signature", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "proposal_votes_proposal_user_unique": { - "name": "proposal_votes_proposal_user_unique", - "columns": [ - { - "expression": "treasury_proposal_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "proposal_votes_treasury_proposal_id_treasury_proposals_id_fk": { - "name": "proposal_votes_treasury_proposal_id_treasury_proposals_id_fk", - "tableFrom": "proposal_votes", - "tableTo": "treasury_proposals", - "columnsFrom": [ - "treasury_proposal_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "proposal_votes_user_id_users_id_fk": { - "name": "proposal_votes_user_id_users_id_fk", - "tableFrom": "proposal_votes", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.push_subscriptions": { - "name": "push_subscriptions", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "device_id": { - "name": "device_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "endpoint": { - "name": "endpoint", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "p256dh": { - "name": "p256dh", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "auth": { - "name": "auth", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "last_used_at": { - "name": "last_used_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "disabled_at": { - "name": "disabled_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "push_subscriptions_device_id_devices_id_fk": { - "name": "push_subscriptions_device_id_devices_id_fk", - "tableFrom": "push_subscriptions", - "tableTo": "devices", - "columnsFrom": [ - "device_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "push_subscriptions_endpoint_unique": { - "name": "push_subscriptions_endpoint_unique", - "nullsNotDistinct": false, - "columns": [ - "endpoint" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.token_transfers": { - "name": "token_transfers", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "conversation_id": { - "name": "conversation_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "sender_id": { - "name": "sender_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "recipient_address": { - "name": "recipient_address", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "amount": { - "name": "amount", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token_contract_id": { - "name": "token_contract_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "tx_hash": { - "name": "tx_hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "memo": { - "name": "memo", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "token_transfers_conversation_id_conversations_id_fk": { - "name": "token_transfers_conversation_id_conversations_id_fk", - "tableFrom": "token_transfers", - "tableTo": "conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "token_transfers_sender_id_users_id_fk": { - "name": "token_transfers_sender_id_users_id_fk", - "tableFrom": "token_transfers", - "tableTo": "users", - "columnsFrom": [ - "sender_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "token_transfers_tx_hash_unique": { - "name": "token_transfers_tx_hash_unique", - "nullsNotDistinct": false, - "columns": [ - "tx_hash" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.treasury_proposals": { - "name": "treasury_proposals", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "contract_id": { - "name": "contract_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "proposal_id": { - "name": "proposal_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "conversation_id": { - "name": "conversation_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "treasury_proposal_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'active'" - }, - "approvals_count": { - "name": "approvals_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "rejections_count": { - "name": "rejections_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "recipient": { - "name": "recipient", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "amount": { - "name": "amount", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "threshold": { - "name": "threshold", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 3 - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "treasury_proposals_contract_proposal_idx": { - "name": "treasury_proposals_contract_proposal_idx", - "columns": [ - { - "expression": "contract_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "proposal_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "treasury_proposals_conversation_id_conversations_id_fk": { - "name": "treasury_proposals_conversation_id_conversations_id_fk", - "tableFrom": "treasury_proposals", - "tableTo": "conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.users": { - "name": "users", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "username": { - "name": "username", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "avatar_url": { - "name": "avatar_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "presence_visible": { - "name": "presence_visible", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "send_read_receipts": { - "name": "send_read_receipts", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "users_username_unique": { - "name": "users_username_unique", - "nullsNotDistinct": false, - "columns": [ - "username" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.wallets": { - "name": "wallets", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "address": { - "name": "address", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "is_primary": { - "name": "is_primary", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "wallets_user_id_users_id_fk": { - "name": "wallets_user_id_users_id_fk", - "tableFrom": "wallets", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "wallets_address_unique": { - "name": "wallets_address_unique", - "nullsNotDistinct": false, - "columns": [ - "address" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": { - "public.content_type": { - "name": "content_type", - "schema": "public", - "values": [ - "text", - "file", - "image", - "video", - "audio", - "system" - ] - }, - "public.conversation_type": { - "name": "conversation_type", - "schema": "public", - "values": [ - "dm", - "group" - ] - }, - "public.device_platform": { - "name": "device_platform", - "schema": "public", - "values": [ - "web", - "ios", - "android" - ] - }, - "public.file_status": { - "name": "file_status", - "schema": "public", - "values": [ - "pending", - "ready", - "deleted" - ] - }, - "public.group_control_event_type": { - "name": "group_control_event_type", - "schema": "public", - "values": [ - "member_added", - "member_removed", - "member_left", - "commit" - ] - }, - "public.prekey_type": { - "name": "prekey_type", - "schema": "public", - "values": [ - "signed", - "one_time" - ] - }, - "public.proposal_vote_type": { - "name": "proposal_vote_type", - "schema": "public", - "values": [ - "approve", - "reject" - ] - }, - "public.treasury_proposal_status": { - "name": "treasury_proposal_status", - "schema": "public", - "values": [ - "active", - "approved", - "rejected", - "executed", - "expired" - ] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/apps/backend/drizzle/meta/0002_snapshot.json b/apps/backend/drizzle/meta/0002_snapshot.json deleted file mode 100644 index fbd93e3..0000000 --- a/apps/backend/drizzle/meta/0002_snapshot.json +++ /dev/null @@ -1,1512 +0,0 @@ -{ - "id": "c5f993d0-71a3-4040-bb92-b7a90bc11ca1", - "prevId": "9ea97ad2-6999-44b6-a433-e809d3209adf", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.conversation_members": { - "name": "conversation_members", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "conversation_id": { - "name": "conversation_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "last_read_message_id": { - "name": "last_read_message_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "is_muted": { - "name": "is_muted", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "is_archived": { - "name": "is_archived", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "joined_at": { - "name": "joined_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "conversation_members_conversation_id_conversations_id_fk": { - "name": "conversation_members_conversation_id_conversations_id_fk", - "tableFrom": "conversation_members", - "tableTo": "conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "conversation_members_user_id_users_id_fk": { - "name": "conversation_members_user_id_users_id_fk", - "tableFrom": "conversation_members", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "conversation_members_last_read_message_id_messages_id_fk": { - "name": "conversation_members_last_read_message_id_messages_id_fk", - "tableFrom": "conversation_members", - "tableTo": "messages", - "columnsFrom": [ - "last_read_message_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.conversations": { - "name": "conversations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "type": { - "name": "type", - "type": "conversation_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'dm'" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "avatar_url": { - "name": "avatar_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.device_prekeys": { - "name": "device_prekeys", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "device_id": { - "name": "device_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "key_type": { - "name": "key_type", - "type": "prekey_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "key_id": { - "name": "key_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "public_key": { - "name": "public_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "signature": { - "name": "signature", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "consumed": { - "name": "consumed", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "device_prekeys_device_type_keyid_idx": { - "name": "device_prekeys_device_type_keyid_idx", - "columns": [ - { - "expression": "device_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "key_type", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "key_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "device_prekeys_signed_device_idx": { - "name": "device_prekeys_signed_device_idx", - "columns": [ - { - "expression": "device_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "\"device_prekeys\".\"key_type\" = 'signed'", - "concurrently": false, - "method": "btree", - "with": {} - }, - "device_prekeys_one_time_available_idx": { - "name": "device_prekeys_one_time_available_idx", - "columns": [ - { - "expression": "device_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"device_prekeys\".\"key_type\" = 'one_time' AND \"device_prekeys\".\"consumed\" = false", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "device_prekeys_device_id_devices_id_fk": { - "name": "device_prekeys_device_id_devices_id_fk", - "tableFrom": "device_prekeys", - "tableTo": "devices", - "columnsFrom": [ - "device_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "device_prekeys_signed_requires_signature": { - "name": "device_prekeys_signed_requires_signature", - "value": "\"device_prekeys\".\"key_type\" <> 'signed' OR \"device_prekeys\".\"signature\" IS NOT NULL" - } - }, - "isRLSEnabled": false - }, - "public.devices": { - "name": "devices", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "identity_public_key": { - "name": "identity_public_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "registration_id": { - "name": "registration_id", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "device_name": { - "name": "device_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "platform": { - "name": "platform", - "type": "device_platform", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "last_seen_at": { - "name": "last_seen_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "push_enabled": { - "name": "push_enabled", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "revoked_at": { - "name": "revoked_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "devices_user_identity_idx": { - "name": "devices_user_identity_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "identity_public_key", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "devices_user_id_active_idx": { - "name": "devices_user_id_active_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"devices\".\"revoked_at\" IS NULL", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "devices_user_id_users_id_fk": { - "name": "devices_user_id_users_id_fk", - "tableFrom": "devices", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.files": { - "name": "files", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "uploader_id": { - "name": "uploader_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "conversation_id": { - "name": "conversation_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "file_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'pending'" - }, - "size": { - "name": "size", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "mime_type": { - "name": "mime_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "sha256": { - "name": "sha256", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "storage_key": { - "name": "storage_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "is_thumbnail": { - "name": "is_thumbnail", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "hard_deleted_at": { - "name": "hard_deleted_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "files_uploader_id_users_id_fk": { - "name": "files_uploader_id_users_id_fk", - "tableFrom": "files", - "tableTo": "users", - "columnsFrom": [ - "uploader_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "files_conversation_id_conversations_id_fk": { - "name": "files_conversation_id_conversations_id_fk", - "tableFrom": "files", - "tableTo": "conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "files_storage_key_unique": { - "name": "files_storage_key_unique", - "nullsNotDistinct": false, - "columns": [ - "storage_key" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.message_envelopes": { - "name": "message_envelopes", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "message_id": { - "name": "message_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "recipient_device_id": { - "name": "recipient_device_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "recipient_user_id": { - "name": "recipient_user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "ciphertext": { - "name": "ciphertext", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "delivered_at": { - "name": "delivered_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "read_at": { - "name": "read_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "me_recipient_device_created_idx": { - "name": "me_recipient_device_created_idx", - "columns": [ - { - "expression": "recipient_device_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "me_message_idx": { - "name": "me_message_idx", - "columns": [ - { - "expression": "message_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "message_envelopes_message_id_messages_id_fk": { - "name": "message_envelopes_message_id_messages_id_fk", - "tableFrom": "message_envelopes", - "tableTo": "messages", - "columnsFrom": [ - "message_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "message_envelopes_recipient_device_id_devices_id_fk": { - "name": "message_envelopes_recipient_device_id_devices_id_fk", - "tableFrom": "message_envelopes", - "tableTo": "devices", - "columnsFrom": [ - "recipient_device_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "message_envelopes_recipient_user_id_users_id_fk": { - "name": "message_envelopes_recipient_user_id_users_id_fk", - "tableFrom": "message_envelopes", - "tableTo": "users", - "columnsFrom": [ - "recipient_user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.messages": { - "name": "messages", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "conversation_id": { - "name": "conversation_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "sender_id": { - "name": "sender_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "sender_device_id": { - "name": "sender_device_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "content_type": { - "name": "content_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'text'" - }, - "ciphertext": { - "name": "ciphertext", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "system_payload": { - "name": "system_payload", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "file_id": { - "name": "file_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "edits_message_id": { - "name": "edits_message_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "messages_conversation_created_idx": { - "name": "messages_conversation_created_idx", - "columns": [ - { - "expression": "conversation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "messages_conversation_id_conversations_id_fk": { - "name": "messages_conversation_id_conversations_id_fk", - "tableFrom": "messages", - "tableTo": "conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "messages_sender_id_users_id_fk": { - "name": "messages_sender_id_users_id_fk", - "tableFrom": "messages", - "tableTo": "users", - "columnsFrom": [ - "sender_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "messages_sender_device_id_devices_id_fk": { - "name": "messages_sender_device_id_devices_id_fk", - "tableFrom": "messages", - "tableTo": "devices", - "columnsFrom": [ - "sender_device_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "messages_file_id_files_id_fk": { - "name": "messages_file_id_files_id_fk", - "tableFrom": "messages", - "tableTo": "files", - "columnsFrom": [ - "file_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "messages_edits_message_id_messages_id_fk": { - "name": "messages_edits_message_id_messages_id_fk", - "tableFrom": "messages", - "tableTo": "messages", - "columnsFrom": [ - "edits_message_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "messages_system_payload_check": { - "name": "messages_system_payload_check", - "value": "\"messages\".\"content_type\" <> 'system' OR (\"messages\".\"ciphertext\" IS NULL AND \"messages\".\"system_payload\" IS NOT NULL)" - } - }, - "isRLSEnabled": false - }, - "public.proposal_votes": { - "name": "proposal_votes", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "treasury_proposal_id": { - "name": "treasury_proposal_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "vote": { - "name": "vote", - "type": "proposal_vote_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "signature": { - "name": "signature", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "proposal_votes_proposal_user_unique": { - "name": "proposal_votes_proposal_user_unique", - "columns": [ - { - "expression": "treasury_proposal_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "proposal_votes_treasury_proposal_id_treasury_proposals_id_fk": { - "name": "proposal_votes_treasury_proposal_id_treasury_proposals_id_fk", - "tableFrom": "proposal_votes", - "tableTo": "treasury_proposals", - "columnsFrom": [ - "treasury_proposal_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "proposal_votes_user_id_users_id_fk": { - "name": "proposal_votes_user_id_users_id_fk", - "tableFrom": "proposal_votes", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.push_subscriptions": { - "name": "push_subscriptions", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "device_id": { - "name": "device_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "endpoint": { - "name": "endpoint", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "p256dh": { - "name": "p256dh", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "auth": { - "name": "auth", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "last_used_at": { - "name": "last_used_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "disabled_at": { - "name": "disabled_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "push_subscriptions_device_id_devices_id_fk": { - "name": "push_subscriptions_device_id_devices_id_fk", - "tableFrom": "push_subscriptions", - "tableTo": "devices", - "columnsFrom": [ - "device_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "push_subscriptions_endpoint_unique": { - "name": "push_subscriptions_endpoint_unique", - "nullsNotDistinct": false, - "columns": [ - "endpoint" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.token_transfers": { - "name": "token_transfers", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "conversation_id": { - "name": "conversation_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "sender_id": { - "name": "sender_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "recipient_address": { - "name": "recipient_address", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "amount": { - "name": "amount", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token_contract_id": { - "name": "token_contract_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "tx_hash": { - "name": "tx_hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "memo": { - "name": "memo", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "token_transfers_conversation_id_conversations_id_fk": { - "name": "token_transfers_conversation_id_conversations_id_fk", - "tableFrom": "token_transfers", - "tableTo": "conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "token_transfers_sender_id_users_id_fk": { - "name": "token_transfers_sender_id_users_id_fk", - "tableFrom": "token_transfers", - "tableTo": "users", - "columnsFrom": [ - "sender_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "token_transfers_tx_hash_unique": { - "name": "token_transfers_tx_hash_unique", - "nullsNotDistinct": false, - "columns": [ - "tx_hash" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.treasury_proposals": { - "name": "treasury_proposals", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "contract_id": { - "name": "contract_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "proposal_id": { - "name": "proposal_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "conversation_id": { - "name": "conversation_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "treasury_proposal_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'active'" - }, - "approvals_count": { - "name": "approvals_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "rejections_count": { - "name": "rejections_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "recipient": { - "name": "recipient", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "amount": { - "name": "amount", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "threshold": { - "name": "threshold", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 3 - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "treasury_proposals_contract_proposal_idx": { - "name": "treasury_proposals_contract_proposal_idx", - "columns": [ - { - "expression": "contract_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "proposal_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "treasury_proposals_conversation_id_conversations_id_fk": { - "name": "treasury_proposals_conversation_id_conversations_id_fk", - "tableFrom": "treasury_proposals", - "tableTo": "conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.users": { - "name": "users", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "username": { - "name": "username", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "avatar_url": { - "name": "avatar_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "presence_visible": { - "name": "presence_visible", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "send_read_receipts": { - "name": "send_read_receipts", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "users_username_unique": { - "name": "users_username_unique", - "nullsNotDistinct": false, - "columns": [ - "username" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.wallets": { - "name": "wallets", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "address": { - "name": "address", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "is_primary": { - "name": "is_primary", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "wallets_user_id_users_id_fk": { - "name": "wallets_user_id_users_id_fk", - "tableFrom": "wallets", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "wallets_address_unique": { - "name": "wallets_address_unique", - "nullsNotDistinct": false, - "columns": [ - "address" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": { - "public.content_type": { - "name": "content_type", - "schema": "public", - "values": [ - "text", - "file", - "image", - "video", - "audio", - "system" - ] - }, - "public.conversation_type": { - "name": "conversation_type", - "schema": "public", - "values": [ - "dm", - "group" - ] - }, - "public.device_platform": { - "name": "device_platform", - "schema": "public", - "values": [ - "web", - "ios", - "android" - ] - }, - "public.file_status": { - "name": "file_status", - "schema": "public", - "values": [ - "pending", - "ready", - "deleted" - ] - }, - "public.prekey_type": { - "name": "prekey_type", - "schema": "public", - "values": [ - "signed", - "one_time" - ] - }, - "public.proposal_vote_type": { - "name": "proposal_vote_type", - "schema": "public", - "values": [ - "approve", - "reject" - ] - }, - "public.treasury_proposal_status": { - "name": "treasury_proposal_status", - "schema": "public", - "values": [ - "active", - "approved", - "rejected", - "executed", - "expired" - ] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index 0777af9..a5fa946 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -5,33 +5,8 @@ { "idx": 0, "version": "7", - "when": 1784899426825, - "tag": "0000_stale_mandarin", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1785395646991, - "tag": "0001_mls_group_state", - "when": 1785395076224, - "tag": "0001_mls_key_packages", - "when": 1785129818340, - "tag": "0001_add_system_payload_to_messages", - "breakpoints": true - }, - { - "idx": 2, - "version": "7", - "when": 1785412264325, - "tag": "0002_strengthen_system_payload_check", - "breakpoints": true - }, - { - "idx": 3, - "version": "7", - "when": 1785700000000, - "tag": "0004_envelope_protocol", + "when": 1787281148373, + "tag": "0000_lean_scrambler", "breakpoints": true } ] diff --git a/apps/backend/drizzle/rollback/0003_ciphertext_only_messages.down.sql b/apps/backend/drizzle/rollback/0003_ciphertext_only_messages.down.sql deleted file mode 100644 index 239dd42..0000000 --- a/apps/backend/drizzle/rollback/0003_ciphertext_only_messages.down.sql +++ /dev/null @@ -1,35 +0,0 @@ --- Rollback for 0003_ciphertext_only_messages.sql. --- --- drizzle-kit has no built-in "down" migration runner — this script is --- invoked manually (e.g. `psql "$DATABASE_URL" -f drizzle/rollback/0003_ciphertext_only_messages.down.sql`) --- and is NOT part of the drizzle migration journal, so it is never applied --- automatically by `db:migrate`. --- --- LIMITATIONS (see docs/message-encryption-migration.md for full detail): --- * Only rows present in `message_content_archive` get their plaintext --- restored. Any message sent after the forward migration ran was only --- ever stored as ciphertext — there is no plaintext to bring back for it. --- * The recreated GIN index is a reasonable equivalent (full-text search --- over `content`), not necessarily byte-identical to whatever indexdef --- existed pre-squash — that definition was not recoverable (see the --- forward migration's comment on why several index names are dropped --- defensively rather than one exact name). --- * This script does NOT drop the ciphertext/envelope/device-capability/ --- GC columns and tables added by this migration set. Rolling those back --- would remove the entire E2EE data model, not just this one migration's --- change — treat that as a separate, deliberate decision, not a side --- effect of undoing the content-column drop. --- * `message_content_archive` is left in place after restoring so no data --- is destroyed by running this script; drop it manually once satisfied. - -ALTER TABLE "messages" ADD COLUMN IF NOT EXISTS "content" text; ---> statement-breakpoint - -CREATE INDEX IF NOT EXISTS "messages_content_gin_idx" ON "messages" USING gin (to_tsvector('english', "content")); ---> statement-breakpoint - -UPDATE "messages" m -SET "content" = a."content" -FROM "message_content_archive" a -WHERE a."original_message_id" = m."id" - AND m."content" IS NULL; diff --git a/apps/backend/package.json b/apps/backend/package.json index b36b7a6..2875342 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -59,7 +59,7 @@ "drizzle-kit": "^0.31.10", "eslint": "^9.39.4", "ioredis-mock": "8.13.1", - "prettier": "^3.8.3", + "prettier": "3.9.1", "socket.io-client": "^4.8.3", "supertest": "^7.2.2", "ts-node": "^10.9.2", diff --git a/apps/backend/src/lib/validateMessagePayload.ts b/apps/backend/src/lib/validateMessagePayload.ts index 6140576..0803b50 100644 --- a/apps/backend/src/lib/validateMessagePayload.ts +++ b/apps/backend/src/lib/validateMessagePayload.ts @@ -37,8 +37,7 @@ export interface MessagePayload { } export type MessagePayloadValidationResult = - | { ok: true } - | { ok: false; code: 400 | 403; message: string }; + { ok: true } | { ok: false; code: 400 | 403; message: string }; /** All content types clients are allowed to send */ const ALLOWED_CONTENT_TYPES = new Set(['text', 'file', 'image', 'video', 'audio'] as const); diff --git a/apps/backend/src/services/e2eeProtocol.ts b/apps/backend/src/services/e2eeProtocol.ts index 7706456..02c2263 100644 --- a/apps/backend/src/services/e2eeProtocol.ts +++ b/apps/backend/src/services/e2eeProtocol.ts @@ -53,8 +53,7 @@ export interface ProtocolViolation { } export type EnvelopeProtocolCheck = - | { ok: true } - | { ok: false; code: 400 | 409; error: string; violations: ProtocolViolation[] }; + { ok: true } | { ok: false; code: 400 | 409; error: string; violations: ProtocolViolation[] }; /** * Validates the protocol each outgoing envelope claims against what the sender diff --git a/apps/backend/src/services/mlsGroups.ts b/apps/backend/src/services/mlsGroups.ts index 3541de7..9a00958 100644 --- a/apps/backend/src/services/mlsGroups.ts +++ b/apps/backend/src/services/mlsGroups.ts @@ -166,8 +166,7 @@ export interface CommitInput { } export type CommitResult = - | { ok: true; epoch: number } - | { ok: false; reason: 'epoch_conflict'; currentEpoch: number }; + { ok: true; epoch: number } | { ok: false; reason: 'epoch_conflict'; currentEpoch: number }; /** * Applies a commit and everything that follows from it in one transaction: diff --git a/apps/web/docs/api-rest-client.md b/apps/web/docs/api-rest-client.md index ffd3889..c11bcaf 100644 --- a/apps/web/docs/api-rest-client.md +++ b/apps/web/docs/api-rest-client.md @@ -17,9 +17,9 @@ The base URL is read from the `NEXT_PUBLIC_API_URL` environment variable at buil ### Environment variable -| Variable | Required | Default | Description | -|---|---|---|---| -| `NEXT_PUBLIC_API_URL` | No | `http://localhost:4000` | Full origin of the backend REST API, e.g. `https://api.clicked.app`. Must **not** include a trailing slash (any trailing slash is stripped automatically). | +| Variable | Required | Default | Description | +| --------------------- | -------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `NEXT_PUBLIC_API_URL` | No | `http://localhost:4000` | Full origin of the backend REST API, e.g. `https://api.clicked.app`. Must **not** include a trailing slash (any trailing slash is stripped automatically). | Set this variable in your `.env.local` file (or in your deployment environment) before building or running the app: @@ -79,11 +79,11 @@ There is **no global interceptor** — every call site is responsible for passin ### Token lifecycle -| Event | Behaviour | -|---|---| -| Sign-in | `AuthContext.signIn()` calls `POST /auth/challenge` then `POST /auth/verify`, receives a JWT, and writes it to all three `localStorage` keys. | -| Page reload | `AuthContext` reads the first non-null token from `localStorage` on mount and restores the session. | -| Sign-out | `AuthContext.signOut()` calls `removeToken()`, which deletes all three keys and clears in-memory state. | +| Event | Behaviour | +| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| Sign-in | `AuthContext.signIn()` calls `POST /auth/challenge` then `POST /auth/verify`, receives a JWT, and writes it to all three `localStorage` keys. | +| Page reload | `AuthContext` reads the first non-null token from `localStorage` on mount and restores the session. | +| Sign-out | `AuthContext.signOut()` calls `removeToken()`, which deletes all three keys and clears in-memory state. | ### 401 / expired-token handling @@ -108,30 +108,30 @@ There is no automatic token-refresh mechanism. Expired tokens remain in `localSt All paths are relative to `API_BASE_URL`. The **Auth** column indicates whether the request attaches an `Authorization: Bearer ` header. -| Endpoint | Method | Auth | Description | Calling File(s) | -|---|---|---|---|---| -| `/auth/challenge` | `POST` | No | Request a sign-in challenge for a given `walletAddress`. Returns `{ message, nonce }`. | [`src/contexts/AuthContext.tsx`](../src/contexts/AuthContext.tsx) | -| `/auth/verify` | `POST` | No | Verify a signed challenge. Returns `{ token, deviceId? }`. | [`src/contexts/AuthContext.tsx`](../src/contexts/AuthContext.tsx) | -| `/users/me` | `GET` | Yes | Fetch the current user's profile (`id`, `username`, `avatarUrl`, `wallets`). | [`src/app/app/profile/page.tsx`](../src/app/app/profile/page.tsx), [`src/app/app/conversations/[id]/page.tsx`](../src/app/app/conversations/%5Bid%5D/page.tsx) | -| `/users/me` | `PATCH` | Yes | Update the current user's `username` and `avatarUrl`. | [`src/app/app/profile/page.tsx`](../src/app/app/profile/page.tsx) | -| `/users/:userId/key-fingerprint` | `GET` | Yes | Fetch the identity key fingerprint for a given user (used for safety-number verification). | [`src/app/app/conversations/[id]/page.tsx`](../src/app/app/conversations/%5Bid%5D/page.tsx) | -| `/users/:userId/presence` | `GET` | Yes | Fetch whether a user is currently online. Returns `{ online: boolean }`. | [`src/components/conversations/ConversationListSidebar.tsx`](../src/components/conversations/ConversationListSidebar.tsx) | -| `/conversations` | `GET` | Yes | Fetch all conversations for the current user (includes members, latest message, unread count). | [`src/components/conversations/ConversationListSidebar.tsx`](../src/components/conversations/ConversationListSidebar.tsx) | -| `/conversations/:id` | `GET` | Yes | Fetch a single conversation by ID (includes type, name, members). | [`src/app/app/conversations/[id]/page.tsx`](../src/app/app/conversations/%5Bid%5D/page.tsx) | -| `/conversations/:id/messages` | `GET` | Yes | Fetch the message history for a conversation. Returns `{ messages: Message[] }`. | [`src/app/app/conversations/[id]/page.tsx`](../src/app/app/conversations/%5Bid%5D/page.tsx) | -| `/devices` | `GET` | Yes | List all devices linked to the current user's account. | [`src/app/app/devices/page.tsx`](../src/app/app/devices/page.tsx) | -| `/devices/:deviceId` | `DELETE` | Yes | Revoke a device by ID. Immediately ends its session. | [`src/app/app/devices/page.tsx`](../src/app/app/devices/page.tsx) | -| `/devices/logout-everywhere` | `POST` | Yes | Revoke every device except the current one. | [`src/app/app/devices/page.tsx`](../src/app/app/devices/page.tsx) | -| `/push/subscriptions` | `POST` | Yes | Register a Web Push subscription (endpoint + keys). Idempotent — safe to call multiple times. | [`src/hooks/usePushSubscription.ts`](../src/hooks/usePushSubscription.ts) | -| `/sync` | `GET` | Yes | Pull encrypted envelopes newer than a sequence cursor. Query params: `deviceId`, `sinceSequence`. Returns `{ envelopes, nextCursor, hasMore }`. | [`src/hooks/useInboundPipeline.ts`](../src/hooks/useInboundPipeline.ts), [`src/lib/realtime.ts`](../src/lib/realtime.ts) | -| `/crypto/prekeys` | `POST` | Yes | Upload a new signed prekey and a batch of one-time prekeys after device registration. | [`src/lib/prekeyStore.ts`](../src/lib/prekeyStore.ts) | -| `/crypto/prekeys/replenish` | `POST` | Yes | Upload additional one-time prekeys when the server's supply runs low. | [`src/lib/prekeyStore.ts`](../src/lib/prekeyStore.ts) | -| `/crypto/bundles/:recipientId/:deviceId` | `GET` | Yes | Fetch the key bundle (identity key, signed prekey, one-time prekey) needed to establish an E2E session with a recipient device. | [`src/lib/sessionStore.ts`](../src/lib/sessionStore.ts) | -| `/user-devices/:senderDeviceId/public-key` | `GET` | Yes | Fetch the identity public key for a specific sender device. Results are cached in memory for the page lifetime. | [`src/lib/crypto/deviceKeys.ts`](../src/lib/crypto/deviceKeys.ts) | -| `/treasury/proposals` | `GET` | Yes | List all treasury withdrawal proposals. | [`src/app/app/treasury/page.tsx`](../src/app/app/treasury/page.tsx) | -| `/treasury/proposals/:id/approve` | `POST` | Yes | Cast an approval vote on a proposal. Body: `{ signature }`. | [`src/components/treasury/ProposalCard.tsx`](../src/components/treasury/ProposalCard.tsx) | -| `/treasury/proposals/:id/reject` | `POST` | Yes | Cast a rejection vote on a proposal. Body: `{ signature }`. | [`src/components/treasury/ProposalCard.tsx`](../src/components/treasury/ProposalCard.tsx) | -| `/treasury/propose` | `POST` | Yes | Submit a new withdrawal proposal. Body: `{ amount, token, recipient, ttl }`. | [`src/components/treasury/ProposeWithdrawalModal.tsx`](../src/components/treasury/ProposeWithdrawalModal.tsx) | +| Endpoint | Method | Auth | Description | Calling File(s) | +| ------------------------------------------ | -------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/auth/challenge` | `POST` | No | Request a sign-in challenge for a given `walletAddress`. Returns `{ message, nonce }`. | [`src/contexts/AuthContext.tsx`](../src/contexts/AuthContext.tsx) | +| `/auth/verify` | `POST` | No | Verify a signed challenge. Returns `{ token, deviceId? }`. | [`src/contexts/AuthContext.tsx`](../src/contexts/AuthContext.tsx) | +| `/users/me` | `GET` | Yes | Fetch the current user's profile (`id`, `username`, `avatarUrl`, `wallets`). | [`src/app/app/profile/page.tsx`](../src/app/app/profile/page.tsx), [`src/app/app/conversations/[id]/page.tsx`](../src/app/app/conversations/%5Bid%5D/page.tsx) | +| `/users/me` | `PATCH` | Yes | Update the current user's `username` and `avatarUrl`. | [`src/app/app/profile/page.tsx`](../src/app/app/profile/page.tsx) | +| `/users/:userId/key-fingerprint` | `GET` | Yes | Fetch the identity key fingerprint for a given user (used for safety-number verification). | [`src/app/app/conversations/[id]/page.tsx`](../src/app/app/conversations/%5Bid%5D/page.tsx) | +| `/users/:userId/presence` | `GET` | Yes | Fetch whether a user is currently online. Returns `{ online: boolean }`. | [`src/components/conversations/ConversationListSidebar.tsx`](../src/components/conversations/ConversationListSidebar.tsx) | +| `/conversations` | `GET` | Yes | Fetch all conversations for the current user (includes members, latest message, unread count). | [`src/components/conversations/ConversationListSidebar.tsx`](../src/components/conversations/ConversationListSidebar.tsx) | +| `/conversations/:id` | `GET` | Yes | Fetch a single conversation by ID (includes type, name, members). | [`src/app/app/conversations/[id]/page.tsx`](../src/app/app/conversations/%5Bid%5D/page.tsx) | +| `/conversations/:id/messages` | `GET` | Yes | Fetch the message history for a conversation. Returns `{ messages: Message[] }`. | [`src/app/app/conversations/[id]/page.tsx`](../src/app/app/conversations/%5Bid%5D/page.tsx) | +| `/devices` | `GET` | Yes | List all devices linked to the current user's account. | [`src/app/app/devices/page.tsx`](../src/app/app/devices/page.tsx) | +| `/devices/:deviceId` | `DELETE` | Yes | Revoke a device by ID. Immediately ends its session. | [`src/app/app/devices/page.tsx`](../src/app/app/devices/page.tsx) | +| `/devices/logout-everywhere` | `POST` | Yes | Revoke every device except the current one. | [`src/app/app/devices/page.tsx`](../src/app/app/devices/page.tsx) | +| `/push/subscriptions` | `POST` | Yes | Register a Web Push subscription (endpoint + keys). Idempotent — safe to call multiple times. | [`src/hooks/usePushSubscription.ts`](../src/hooks/usePushSubscription.ts) | +| `/sync` | `GET` | Yes | Pull encrypted envelopes newer than a sequence cursor. Query params: `deviceId`, `sinceSequence`. Returns `{ envelopes, nextCursor, hasMore }`. | [`src/hooks/useInboundPipeline.ts`](../src/hooks/useInboundPipeline.ts), [`src/lib/realtime.ts`](../src/lib/realtime.ts) | +| `/crypto/prekeys` | `POST` | Yes | Upload a new signed prekey and a batch of one-time prekeys after device registration. | [`src/lib/prekeyStore.ts`](../src/lib/prekeyStore.ts) | +| `/crypto/prekeys/replenish` | `POST` | Yes | Upload additional one-time prekeys when the server's supply runs low. | [`src/lib/prekeyStore.ts`](../src/lib/prekeyStore.ts) | +| `/crypto/bundles/:recipientId/:deviceId` | `GET` | Yes | Fetch the key bundle (identity key, signed prekey, one-time prekey) needed to establish an E2E session with a recipient device. | [`src/lib/sessionStore.ts`](../src/lib/sessionStore.ts) | +| `/user-devices/:senderDeviceId/public-key` | `GET` | Yes | Fetch the identity public key for a specific sender device. Results are cached in memory for the page lifetime. | [`src/lib/crypto/deviceKeys.ts`](../src/lib/crypto/deviceKeys.ts) | +| `/treasury/proposals` | `GET` | Yes | List all treasury withdrawal proposals. | [`src/app/app/treasury/page.tsx`](../src/app/app/treasury/page.tsx) | +| `/treasury/proposals/:id/approve` | `POST` | Yes | Cast an approval vote on a proposal. Body: `{ signature }`. | [`src/components/treasury/ProposalCard.tsx`](../src/components/treasury/ProposalCard.tsx) | +| `/treasury/proposals/:id/reject` | `POST` | Yes | Cast a rejection vote on a proposal. Body: `{ signature }`. | [`src/components/treasury/ProposalCard.tsx`](../src/components/treasury/ProposalCard.tsx) | +| `/treasury/propose` | `POST` | Yes | Submit a new withdrawal proposal. Body: `{ amount, token, recipient, ttl }`. | [`src/components/treasury/ProposeWithdrawalModal.tsx`](../src/components/treasury/ProposeWithdrawalModal.tsx) | --- diff --git a/apps/web/docs/api-soroban-client.md b/apps/web/docs/api-soroban-client.md index 52dd9df..3e3d0be 100644 --- a/apps/web/docs/api-soroban-client.md +++ b/apps/web/docs/api-soroban-client.md @@ -30,11 +30,11 @@ Thin wrapper around `@stellar/freighter-api`: ## Contract functions invoked from the frontend -| Function | Called via | Triggered from | User action | -|---|---|---|---| -| `transfer` (token-transfer contract) | `transferToken` in `lib/soroban.ts` | `components/chat/MessageInput.tsx` (`handleConfirmTransfer`) | Clicking the token icon in the chat message input to open the "Send token" popover, entering an amount, then clicking **Confirm** | -| Freighter `requestAccess` (wallet connect, not a contract call) | `requestWalletAccess` in `lib/freighter.ts` | `app/app/layout.tsx` (`handleWalletAction`) | Clicking **Connect Wallet** in the app sidebar | -| Freighter `signMessage` (message signing, not a contract call) | `signWalletMessage` in `lib/freighter.ts` | `components/treasury/ProposalCard.tsx` (`castVote`) | Clicking **Approve** or **Reject** on a treasury proposal card — signs `` `${type}:${proposalId}` `` and POSTs it to the backend for verification | +| Function | Called via | Triggered from | User action | +| --------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `transfer` (token-transfer contract) | `transferToken` in `lib/soroban.ts` | `components/chat/MessageInput.tsx` (`handleConfirmTransfer`) | Clicking the token icon in the chat message input to open the "Send token" popover, entering an amount, then clicking **Confirm** | +| Freighter `requestAccess` (wallet connect, not a contract call) | `requestWalletAccess` in `lib/freighter.ts` | `app/app/layout.tsx` (`handleWalletAction`) | Clicking **Connect Wallet** in the app sidebar | +| Freighter `signMessage` (message signing, not a contract call) | `signWalletMessage` in `lib/freighter.ts` | `components/treasury/ProposalCard.tsx` (`castVote`) | Clicking **Approve** or **Reject** on a treasury proposal card — signs `` `${type}:${proposalId}` `` and POSTs it to the backend for verification | `transferToken` is currently the only function that submits an actual Soroban **contract** invocation; `requestWalletAccess`/`signWalletMessage` are Freighter wallet operations (connect / sign-message) used for wallet connection and off-chain approval signatures respectively, not contract calls. @@ -66,12 +66,12 @@ All steps are in `apps/web/src/lib/soroban.ts`: Configuration is read directly in `soroban.ts` from `NEXT_PUBLIC_*` environment variables, each with a hardcoded fallback: -| Env var | Default if unset | Used for | -|---|---|---| -| `NEXT_PUBLIC_SOROBAN_RPC_URL` | `https://soroban-testnet.stellar.org` | Soroban RPC server URL | -| `NEXT_PUBLIC_NETWORK_PASSPHRASE` | `Networks.TESTNET` (from `stellar-sdk`) | Network passphrase for building/signing the transaction | -| `NEXT_PUBLIC_TOKEN_TRANSFER_CONTRACT` | literal placeholder string `REPLACE_WITH_TOKEN_TRANSFER_CONTRACT_ID` | Contract ID passed to `new Contract(...)` | -| `NEXT_PUBLIC_NETWORK` (read separately in `components/chat/TransferCard.tsx`) | `test` | Only used to build the Stellar Explorer link, not for building/submitting the transaction | +| Env var | Default if unset | Used for | +| ----------------------------------------------------------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| `NEXT_PUBLIC_SOROBAN_RPC_URL` | `https://soroban-testnet.stellar.org` | Soroban RPC server URL | +| `NEXT_PUBLIC_NETWORK_PASSPHRASE` | `Networks.TESTNET` (from `stellar-sdk`) | Network passphrase for building/signing the transaction | +| `NEXT_PUBLIC_TOKEN_TRANSFER_CONTRACT` | literal placeholder string `REPLACE_WITH_TOKEN_TRANSFER_CONTRACT_ID` | Contract ID passed to `new Contract(...)` | +| `NEXT_PUBLIC_NETWORK` (read separately in `components/chat/TransferCard.tsx`) | `test` | Only used to build the Stellar Explorer link, not for building/submitting the transaction | None of these are currently listed in the root `.env.example` — that file only defines backend-facing equivalents without the `NEXT_PUBLIC_` prefix (`RPC_URL`, `TOKEN_TRANSFER_CONTRACT_ID`, `GROUP_TREASURY_CONTRACT_ID`, `PROPOSALS_CONTRACT_ID`), which are validated separately in `apps/backend/src/config.ts` and are not exposed to the browser. `next.config.ts` doesn't do anything special for the Soroban vars — Next.js exposes any `NEXT_PUBLIC_*` var automatically — but because they aren't documented anywhere in `apps/web`, it's easy to leave `NEXT_PUBLIC_TOKEN_TRANSFER_CONTRACT` unset and silently deploy with the placeholder contract ID. Backend and frontend contract IDs are validated independently and can point at different contracts if misconfigured. diff --git a/apps/web/docs/api-websocket-client.md b/apps/web/docs/api-websocket-client.md index a3919e4..ebd70d1 100644 --- a/apps/web/docs/api-websocket-client.md +++ b/apps/web/docs/api-websocket-client.md @@ -4,9 +4,10 @@ This document explains the architecture and usage of the WebSocket client in the ## Connection Setup and Lifecycle -Connections to the Socket.IO server are established via the `useSocket` hook or manually through `initSocket`. +Connections to the Socket.IO server are established via the `useSocket` hook or manually through `initSocket`. ### Auth Handshake & Token Passing + The connection passes the user's authentication `token` and a unique End-to-End Encryption (E2EE) `deviceId` in the connection handshake. ```typescript @@ -14,13 +15,14 @@ io(SOCKET_URL, { auth: { token, deviceId }, transports: ['websocket'], reconnection: true, -}) +}); ``` - **Token Passing**: The JWT token and device ID are extracted and injected into the `auth` object on connection. - **Reconnection Policy**: `reconnection: true` is configured out of the box, with `websocket` being the exclusive transport (polling is disabled). ### Reconnection and Resume Behavior + Upon successfully connecting (or reconnecting) via the `connect` event, the client emits a `resume` envelope containing the `lastEventId`. This `lastEventId` is tracked locally in `localStorage` (`clicked.socket.resumeCursor:*`) every time an event that supports resumption is received. This instructs the server to redeliver any missed socket events. Additionally, after resuming, a fallback sync process (`runSocketSync` via an HTTP API endpoint) fetches missed envelopes since the last known `sequenceNumber`. @@ -30,6 +32,7 @@ Additionally, after resuming, a fallback sync process (`runSocketSync` via an HT Instead of emitting raw socket events (e.g., `socket.emit('event_name', payload)`), the frontend enforces an envelope convention using the `emitSocketEnvelope` helper. When `emitSocketEnvelope(socket, type, payload)` is called, it packages the request into a standard `EventEnvelope` containing: + - `eventId`: A unique UUID (`crypto.randomUUID()`). - `type`: The actual event type (e.g., `'resume'`, `'message_delivered'`). - `timestamp`: The timestamp of the emission. @@ -42,6 +45,7 @@ socket.emit('dispatch', envelope); ``` ### Example Usage + ```typescript emitSocketEnvelope(socket, 'message_delivered', { conversationId: 'conv-123', @@ -52,35 +56,37 @@ emitSocketEnvelope(socket, 'message_delivered', { ``` ### Enveloped Events vs. Raw Events + Currently, **all** client-to-server emissions use the envelope pattern via the `'dispatch'` channel. There are no raw `socket.emit` calls for business events triggered by the frontend. Events currently wrapped in the envelope to the server: + - `'resume'` - `'message_delivered'` -*Note*: The server sends raw socket events to the client (e.g., `'new_message'`, `'message_envelope'`, `'connect'`, `'resume_complete'`). +_Note_: The server sends raw socket events to the client (e.g., `'new_message'`, `'message_envelope'`, `'connect'`, `'resume_complete'`). ## Event Listeners Map The following table documents the inbound events the frontend listens for and which component or hook consumes them: -| Socket Event | Consumer(s) | Purpose | -|--------------|-------------|---------| -| `connect` | `hooks/useSocket.ts`, `lib/socket.ts` | Triggers the `resume` process and HTTP data sync. | -| `disconnect` | `lib/socket.ts` | Logs disconnect state. | -| `error` | `lib/socket.ts` | Logs socket errors. | -| `resume_complete` | `hooks/useSocket.ts`, `lib/socket.ts` | Updates the resume cursor and triggers HTTP sync if required. | -| `ephemeral_replay` | `hooks/useSocket.ts`, `lib/socket.ts` | Allows the server to trigger a replay of an ephemeral event locally. | -| `message_envelope` | `hooks/useSocket.ts`, `lib/socket.ts`, `hooks/useInboundPipeline.ts`, `app/conversations/[id]/page.tsx` | Acknowledges receipt of message envelopes by dispatching a `message_delivered` envelope. | -| `user_online` | `ConversationListSidebar.tsx` | Updates a user's presence state to online in the sidebar. | -| `user_offline` | `ConversationListSidebar.tsx` | Updates a user's presence state to offline in the sidebar. | -| `presence_update` | `ConversationListSidebar.tsx` | Updates arbitrary presence metadata in the sidebar. | -| `new_message` | `ConversationListSidebar.tsx`, `hooks/useInboundPipeline.ts`, `hooks/useMessageHistory.ts`, `MessageThread.tsx`, `app/conversations/[id]/page.tsx`, `app/app/conversations/[id]/page.tsx` | Appends a new message to the local chat view. | -| `device_envelope` | `hooks/useInboundPipeline.ts` | Receives key exchange or device synchronization events. | -| `message_history` | `hooks/useMessageHistory.ts`, `app/conversations/[id]/page.tsx` | Populates the initial chunk of message history for a conversation. | -| `message_ack` | `app/conversations/[id]/page.tsx` | Acknowledges the server processed an outgoing message. | -| `delivery_receipt` | `app/conversations/[id]/page.tsx` | Marks a message as delivered to a participant. | -| `read_receipt` | `app/conversations/[id]/page.tsx` | Marks a message as read by a participant. | -| `typing_start` | `MessageThread.tsx`, `app/conversations/[id]/page.tsx` | Displays a typing indicator. | -| `typing_stop` | `MessageThread.tsx`, `app/conversations/[id]/page.tsx` | Hides the typing indicator. | -| `treasury_proposal_updated` | `app/app/treasury/page.tsx` | Updates treasury UI when a proposal's status changes. | +| Socket Event | Consumer(s) | Purpose | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `connect` | `hooks/useSocket.ts`, `lib/socket.ts` | Triggers the `resume` process and HTTP data sync. | +| `disconnect` | `lib/socket.ts` | Logs disconnect state. | +| `error` | `lib/socket.ts` | Logs socket errors. | +| `resume_complete` | `hooks/useSocket.ts`, `lib/socket.ts` | Updates the resume cursor and triggers HTTP sync if required. | +| `ephemeral_replay` | `hooks/useSocket.ts`, `lib/socket.ts` | Allows the server to trigger a replay of an ephemeral event locally. | +| `message_envelope` | `hooks/useSocket.ts`, `lib/socket.ts`, `hooks/useInboundPipeline.ts`, `app/conversations/[id]/page.tsx` | Acknowledges receipt of message envelopes by dispatching a `message_delivered` envelope. | +| `user_online` | `ConversationListSidebar.tsx` | Updates a user's presence state to online in the sidebar. | +| `user_offline` | `ConversationListSidebar.tsx` | Updates a user's presence state to offline in the sidebar. | +| `presence_update` | `ConversationListSidebar.tsx` | Updates arbitrary presence metadata in the sidebar. | +| `new_message` | `ConversationListSidebar.tsx`, `hooks/useInboundPipeline.ts`, `hooks/useMessageHistory.ts`, `MessageThread.tsx`, `app/conversations/[id]/page.tsx`, `app/app/conversations/[id]/page.tsx` | Appends a new message to the local chat view. | +| `device_envelope` | `hooks/useInboundPipeline.ts` | Receives key exchange or device synchronization events. | +| `message_history` | `hooks/useMessageHistory.ts`, `app/conversations/[id]/page.tsx` | Populates the initial chunk of message history for a conversation. | +| `message_ack` | `app/conversations/[id]/page.tsx` | Acknowledges the server processed an outgoing message. | +| `delivery_receipt` | `app/conversations/[id]/page.tsx` | Marks a message as delivered to a participant. | +| `read_receipt` | `app/conversations/[id]/page.tsx` | Marks a message as read by a participant. | +| `typing_start` | `MessageThread.tsx`, `app/conversations/[id]/page.tsx` | Displays a typing indicator. | +| `typing_stop` | `MessageThread.tsx`, `app/conversations/[id]/page.tsx` | Hides the typing indicator. | +| `treasury_proposal_updated` | `app/app/treasury/page.tsx` | Updates treasury UI when a proposal's status changes. | diff --git a/apps/web/docs/concepts-auth-device-lifecycle.md b/apps/web/docs/concepts-auth-device-lifecycle.md index f3cac37..27bcf01 100644 --- a/apps/web/docs/concepts-auth-device-lifecycle.md +++ b/apps/web/docs/concepts-auth-device-lifecycle.md @@ -3,9 +3,11 @@ This document explains the frontend authentication and device identity lifecycle in Clicked, specifically focusing on how `AuthContext.tsx`, `lib/jwt.ts`, and `lib/deviceIdentity.ts` work together to create a device-bound session model required by the backend. ## Overview -Clicked uses a device-bound session model where authentication is tied not just to a user's wallet address, but also to a specific cryptographic device identity generated on the client. + +Clicked uses a device-bound session model where authentication is tied not just to a user's wallet address, but also to a specific cryptographic device identity generated on the client. This is orchestrated by three main files: + 1. **`lib/deviceIdentity.ts`**: Manages the local cryptographic identity (Ed25519 keypair) and a persistent `deviceId`. 2. **`contexts/AuthContext.tsx`**: Orchestrates the wallet connection, signature challenge, and session state. 3. **`lib/jwt.ts`**: Handles the JWT payload parsing and device ID syncing for End-to-End Encryption (E2EE). @@ -16,11 +18,11 @@ This is orchestrated by three main files: When a user logs in on a new device, the following step-by-step flow occurs: -1. **Connect Wallet**: +1. **Connect Wallet**: The user initiates login via `AuthContext.tsx` (`signIn()`). It checks for an existing public key or prompts the user to connect their wallet using `useWallet()`. -2. **Establish Device Identity**: - Before requesting a challenge, `getOrCreateDeviceIdentity()` is called from `lib/deviceIdentity.ts`. +2. **Establish Device Identity**: + Before requesting a challenge, `getOrCreateDeviceIdentity()` is called from `lib/deviceIdentity.ts`. - If no identity exists, it generates a new Ed25519 keypair and a random UUID (`deviceId`), saving both in `localStorage`. - It returns the `deviceId` and the Base64-encoded `identityPublicKey`. @@ -34,11 +36,11 @@ When a user logs in on a new device, the following step-by-step flow occurs: - `signature` - `nonce` - `identityPublicKey` (from the device identity) - + The backend verifies the signature and binds the new session to the `identityPublicKey`. 5. **Receive JWT**: - Upon success, the backend returns a JWT (`token`) and potentially a synchronized `deviceId`. + Upon success, the backend returns a JWT (`token`) and potentially a synchronized `deviceId`. The token is persisted in `localStorage` across multiple keys (for compatibility/redundancy) by `AuthContext.tsx`. The JWT payload contains the `userId`, `walletAddress`, and `deviceId`. --- @@ -60,9 +62,10 @@ When a user returns with an active session: ## The Device-Bound Session Model -By tying the JWT to a specific `deviceId` and `identityPublicKey`, the application achieves a **device-bound session**. +By tying the JWT to a specific `deviceId` and `identityPublicKey`, the application achieves a **device-bound session**. + - **`lib/jwt.ts`** enforces that the frontend can read its `deviceId` from the token and sync it to the `clicked.e2eDeviceId` storage key. - The backend can reject requests if a token is used from a device that doesn't hold the corresponding private key for the `identityPublicKey` (used in End-to-End Encryption features). - This prevents token-theft attacks: stealing the JWT is insufficient if the attacker cannot also steal the local `localStorage` Ed25519 private key. -*Note: For details on how the backend validates these tokens and binds them to the device identity, refer to the Backend JWT/Auth Contract Documentation.* +_Note: For details on how the backend validates these tokens and binds them to the device identity, refer to the Backend JWT/Auth Contract Documentation._ diff --git a/apps/web/docs/concepts-e2ee-architecture.md b/apps/web/docs/concepts-e2ee-architecture.md index fca2174..9b226fa 100644 --- a/apps/web/docs/concepts-e2ee-architecture.md +++ b/apps/web/docs/concepts-e2ee-architecture.md @@ -6,14 +6,14 @@ The client-side E2EE crypto stack is split across two phases. **Phase 1** implem ## Module Map -| Module | Responsibility | Phase | Status | -|---|---|---|---| -| `lib/cryptoStore.ts` | IndexedDB-backed identity key pair storage (device ID + ECDH P-256 keypair) | 1 | PARTIAL | -| `lib/prekeyStore.ts` | Prekey generation, signing, and upload (X25519 + ECDSA P-256) | 1 | PARTIAL | -| `lib/sessionStore.ts` | Session establishment (bundle fetch, X3DH key derivation, sealed-box encryption) | 1 | DEAD | -| `lib/x3dh.ts` | X3DH initiator/responder key agreement using `@noble/curves` | 2 | DEAD | -| `lib/crypto.ts` | Sealed-box encrypt, device set resolution, `sendEncryptedMessage` pipeline | 1 | PARTIAL | -| `lib/signalClient.ts` | `@signalapp/libsignal-client` stub adapter (throws on use) | 2 | DEAD | +| Module | Responsibility | Phase | Status | +| --------------------- | -------------------------------------------------------------------------------- | ----- | ------- | +| `lib/cryptoStore.ts` | IndexedDB-backed identity key pair storage (device ID + ECDH P-256 keypair) | 1 | PARTIAL | +| `lib/prekeyStore.ts` | Prekey generation, signing, and upload (X25519 + ECDSA P-256) | 1 | PARTIAL | +| `lib/sessionStore.ts` | Session establishment (bundle fetch, X3DH key derivation, sealed-box encryption) | 1 | DEAD | +| `lib/x3dh.ts` | X3DH initiator/responder key agreement using `@noble/curves` | 2 | DEAD | +| `lib/crypto.ts` | Sealed-box encrypt, device set resolution, `sendEncryptedMessage` pipeline | 1 | PARTIAL | +| `lib/signalClient.ts` | `@signalapp/libsignal-client` stub adapter (throws on use) | 2 | DEAD | ## Crypto Stack Diagram @@ -79,6 +79,7 @@ The client-side E2EE crypto stack is split across two phases. **Phase 1** implem **Dependencies**: None (uses browser APIs: `indexedDB`, `window.crypto.subtle`). **Live/dead status**: PARTIAL. The grep found two imports within `apps/web/src`: + - `lib/prekeyStore.ts:1` → `import { cryptoStore } from './cryptoStore'` - `lib/messageCache.ts:1` → `import { cryptoStore } from './cryptoStore'` @@ -95,6 +96,7 @@ Neither `prekeyStore.ts` nor `messageCache.ts` is imported by any component, con **Dependencies**: `cryptoStore` (imports `getIdentityPrivateKey`), `apiFetch` (for uploading prekeys to the server). **Live/dead status**: PARTIAL. The grep found one import within `apps/web/src`: + - `lib/sessionStore.ts:2` → `import { prekeyStore } from './prekeyStore'` `sessionStore.ts` itself is not imported by any component, context, or hook (see below). The chain ends at a dead module. @@ -122,6 +124,7 @@ Neither `prekeyStore.ts` nor `messageCache.ts` is imported by any component, con **Dependencies**: `@noble/curves/ed25519`, `@noble/hashes/hkdf`, `@noble/hashes/sha2`, `@noble/hashes/utils`. **Live/dead status**: DEAD. The grep found one import within `apps/web/src`: + - `lib/x3dh.test.ts:13` → `import ... from './x3dh'` This is a test file only. No production component, context, or hook imports this module. @@ -137,6 +140,7 @@ This is a test file only. No production component, context, or hook imports this **Dependencies**: None (uses browser APIs and `./api` indirectly through `sendEncryptedMessage`'s fetch calls). **Live/dead status**: PARTIAL. The grep found three imports within `apps/web/src`: + - `lib/fileEncryption.ts:25` → `import { buildEnvelopes, type DeviceRecord, type MessageEnvelope } from './crypto.js'` (runtime value import) - `lib/session.ts:19` → `import { buildEnvelopes as phase1BuildEnvelopes, sealedBoxEncrypt } from './crypto.js'` (runtime value import) - `lib/signalClient.ts:28` → `import type { DeviceRecord, MessageEnvelope } from './crypto.js'` (type-only import) @@ -207,4 +211,4 @@ The following modules have no import path from any running component, context, o 6. **No `useInboundPipeline.ts` or `useMessageHistory.ts` connection**: The live inbound message decryption in `useInboundPipeline.ts` imports from `@/lib/crypto/processEnvelope` and `@/lib/crypto/types`, not from any of the six modules documented here. The production E2EE receive path is entirely separate from the stored modules. -7. **`fileEncryption.ts` is dead code at runtime**: `EncryptedThumbnail.tsx` only imports `type { FileMessagePayload }` from `fileEncryption.ts`. Since this is a type-only import, the bundler does not include `fileEncryption.ts` (and by extension `crypto.ts`) in the runtime bundle. The `sendEncryptedFile` and `downloadAndDecryptFile` functions in `fileEncryption.ts` are effectively unreachable. \ No newline at end of file +7. **`fileEncryption.ts` is dead code at runtime**: `EncryptedThumbnail.tsx` only imports `type { FileMessagePayload }` from `fileEncryption.ts`. Since this is a type-only import, the bundler does not include `fileEncryption.ts` (and by extension `crypto.ts`) in the runtime bundle. The `sendEncryptedFile` and `downloadAndDecryptFile` functions in `fileEncryption.ts` are effectively unreachable. diff --git a/apps/web/docs/concepts-file-encryption.md b/apps/web/docs/concepts-file-encryption.md index 82e34df..ad50c93 100644 --- a/apps/web/docs/concepts-file-encryption.md +++ b/apps/web/docs/concepts-file-encryption.md @@ -1,6 +1,6 @@ # File & Thumbnail Encryption Pipeline -This document explains the end-to-end (E2E) encrypted file sharing architecture in Clicked, detailing the roles of `lib/fileEncryption.ts`, `lib/thumbnail.ts`, and `EncryptedThumbnail.tsx`. +This document explains the end-to-end (E2E) encrypted file sharing architecture in Clicked, detailing the roles of `lib/fileEncryption.ts`, `lib/thumbnail.ts`, and `EncryptedThumbnail.tsx`. Our architecture guarantees that the server only ever handles ciphertext. It never has access to the plaintext files or the keys required to decrypt them. @@ -12,7 +12,7 @@ The lifecycle of an encrypted file attachment follows a strict sequence to ensur 2. **Client-Side Encryption:** Using `lib/fileEncryption.ts`, the client generates a unique AES-256-GCM symmetric key and Initialization Vector (IV). The file is encrypted in memory, outputting the ciphertext. 3. **Presigned URL Request:** The client requests a presigned upload URL from the server for the storage provider (e.g., S3/R2). **No encryption keys are sent during this request.** 4. **Ciphertext Upload:** The client uploads the encrypted file directly to the storage provider using the presigned URL. -5. **Message Transmission:** The client constructs the chat message. The AES key and IV used for the file are placed *inside* the E2E-encrypted message payload. The server routes this message to the recipient without being able to read the payload. +5. **Message Transmission:** The client constructs the chat message. The AES key and IV used for the file are placed _inside_ the E2E-encrypted message payload. The server routes this message to the recipient without being able to read the payload. 6. **Recipient Download:** When the recipient receives the message, their client requests a presigned GET URL for the ciphertext from the storage provider and downloads it. 7. **Client-Side Decryption:** The recipient's client extracts the AES key and IV from the decrypted message payload. It passes the ciphertext, key, and IV to `lib/fileEncryption.ts` to decrypt the file in memory and offer it as a downloadable Blob. @@ -20,14 +20,14 @@ The lifecycle of an encrypted file attachment follows a strict sequence to ensur Security relies on strict key isolation. Here is the exact state of the AES decryption key at every stage: -| Stage | Key Location & State | Server Visibility | -|---|---|---| -| **Generation** | In-memory on the sender's device. | None | -| **File Encryption** | In-memory on the sender's device. | None | -| **Storage Upload** | Does not exist in the upload payload. | None | +| Stage | Key Location & State | Server Visibility | +| ------------------- | -------------------------------------------------------------------------- | ------------------------------------- | +| **Generation** | In-memory on the sender's device. | None | +| **File Encryption** | In-memory on the sender's device. | None | +| **Storage Upload** | Does not exist in the upload payload. | None | | **Message Routing** | Encrypted within the E2EE message payload using the channel/recipient key. | **Zero** (Server sees opaque payload) | -| **File Download** | Retained securely in the recipient's local state. | None | -| **Decryption** | In-memory on the recipient's device. | None | +| **File Download** | Retained securely in the recipient's local state. | None | +| **Decryption** | In-memory on the recipient's device. | None | ## Thumbnail Generation & Encryption diff --git a/apps/web/docs/concepts-local-search.md b/apps/web/docs/concepts-local-search.md index df5e8b2..8a4bdb3 100644 --- a/apps/web/docs/concepts-local-search.md +++ b/apps/web/docs/concepts-local-search.md @@ -1,6 +1,6 @@ # Client-Side Local Encrypted Search -To support End-to-End Encryption (E2EE), the server only stores ciphertext. This imposes a strict security constraint: **the server cannot perform full-text search on message content**. +To support End-to-End Encryption (E2EE), the server only stores ciphertext. This imposes a strict security constraint: **the server cannot perform full-text search on message content**. To provide users with a fast, comprehensive search experience without compromising privacy, we implemented a 100% local, client-side search architecture powered by IndexedDB and Web Workers, using a BM25 ranking algorithm. @@ -19,17 +19,19 @@ Keeping the index up-to-date with a stream of incoming E2EE messages involves a To ensure the UI remains smooth (60fps) even when indexing thousands of messages or performing complex queries, work is split across the Main Thread and a Web Worker. ### Main Thread + - **`hooks/useMessageSearchIndex.ts`**: Handles the decryption of incoming messages and initiates the indexing flow. - **`lib/search/db.ts`**: Manages the IndexedDB lifecycle for persisting decrypted messages locally. - **`hooks/useLocalSearch.ts`**: Manages the React state for search inputs, debounces user typing (e.g., 180ms), and coordinates query requests to the worker. ### Web Worker (`searchWorker.ts`) + - **Inverted Index**: Maintains a `Map` of tokens to Set of document IDs. - **Tokenization**: Breaks plaintext down into searchable tokens. - **BM25 Scoring**: Performs the mathematical heavy lifting of ranking documents based on Term Frequency (TF), Document Frequency (DF), and average document length. - **Snippet Generation**: Extracts the surrounding context of the matched text to display in the UI. -**Why the boundary?** +**Why the boundary?** String manipulation, tokenization, large Set intersections, and floating-point scoring loops are CPU-bound. If run on the main thread, they would block React renders and cause jank during typing. ## 3. The BM25 Inverted-Index Approach diff --git a/apps/web/docs/concepts-message-pipeline.md b/apps/web/docs/concepts-message-pipeline.md index 50b8907..f9f697d 100644 --- a/apps/web/docs/concepts-message-pipeline.md +++ b/apps/web/docs/concepts-message-pipeline.md @@ -79,10 +79,10 @@ Reconciliation against the `message_ack`: A recipient device receives messages through exactly one of two paths, but the **processing code is the same for both**. That shared code is `useInboundPipeline` and `processInboundEnvelope`. -| Channel | When used | Source | -|---|---|---| -| Live `message_envelope` socket event | Device is connected when message is sent | `deliverMessage` in the backend | -| `/sync` HTTP backfill | Device reconnects after an offline period | `GET /sync` route | +| Channel | When used | Source | +| ------------------------------------ | ----------------------------------------- | ------------------------------- | +| Live `message_envelope` socket event | Device is connected when message is sent | `deliverMessage` in the backend | +| `/sync` HTTP backfill | Device reconnects after an offline period | `GET /sync` route | ### 2.2 Live socket delivery @@ -167,6 +167,7 @@ The `useMessageHistory` hook (used by `MessageThread`) applies its own guard: `i **Detection:** `getSessionKey(senderDeviceId)` returns `null` inside `decryptAndVerifyEnvelope`, throwing `PreLinkError`. **`processInboundEnvelope` returns:** + ```ts { status: 'unavailable', unavailableReason: 'pre-link' } ``` @@ -174,7 +175,8 @@ The `useMessageHistory` hook (used by `MessageThread`) applies its own guard: `i The `/sync` backfill also propagates this: if the server-side envelope's `unavailable: true` flag is set, `ingestMeta` in `useInboundPipeline` sets the message directly to `unavailable/pre-link` without waiting for ciphertext. **UI (`UnavailableMessagePlaceholder`):** -> 🔒 *Waiting for secure session — message from before this device was linked.* + +> 🔒 _Waiting for secure session — message from before this device was linked._ ### 3.2 Signature verification failure @@ -183,12 +185,14 @@ The `/sync` backfill also propagates this: if the server-side envelope's `unavai **Detection:** `crypto.subtle.verify` returns `false` in `verifyEnvelopeSignature`, throwing `VerificationFailedError`. **`processInboundEnvelope` returns:** + ```ts { status: 'unavailable', unavailableReason: 'verification-failed' } ``` **UI (`UnavailableMessagePlaceholder`):** -> 🔒 *Message could not be verified.* + +> 🔒 _Message could not be verified._ ### 3.3 AES-GCM decryption failure @@ -197,12 +201,14 @@ The `/sync` backfill also propagates this: if the server-side envelope's `unavai **Detection:** `crypto.subtle.decrypt` throws, caught and re-thrown as `DecryptError`. **`processInboundEnvelope` returns:** + ```ts { status: 'unavailable', unavailableReason: 'undecryptable' } ``` **UI (`UnavailableMessagePlaceholder`):** -> 🔒 *Unable to decrypt this message.* + +> 🔒 _Unable to decrypt this message._ ### 3.4 Missing `senderDeviceId` @@ -211,6 +217,7 @@ The `/sync` backfill also propagates this: if the server-side envelope's `unavai **Detection:** `processInboundEnvelope` checks `!envelope.senderDeviceId` before attempting any crypto. **`processInboundEnvelope` returns:** + ```ts { status: 'unavailable', unavailableReason: 'pre-link' } ``` @@ -233,7 +240,7 @@ The `/sync` backfill also propagates this: if the server-side envelope's `unavai - `useSocket` has `reconnection: true` with Socket.IO's default exponential back-off. No explicit "disconnected" banner is rendered by the pipeline itself; the conversation header in `apps/web/src/app/chat/page.tsx` shows `Disconnected` next to the room name when `socket?.connected` is false. - In the conversations page (`/app/conversations/[id]`), initial data is fetched over HTTP (`GET /conversations/:id/messages`) independently of the socket. If the HTTP fetch fails, the full page replaces with an `EmptyState`: - > **Conversation unavailable** — *[server error message]* + > **Conversation unavailable** — _[server error message]_ - `useInboundPipeline` sets `syncing: true` while the `/sync` loop is in flight. This flag is exposed to the parent page to optionally show a sync indicator. - Messages that arrived while offline are recovered on reconnect through the `runSync` / `runSocketSync` cycle (§2.3). The cursor stored in `localStorage` ensures the sync picks up exactly from the last known position. @@ -249,12 +256,12 @@ The `/sync` backfill also propagates this: if the server-side envelope's `unavai The server can reject a message in several ways, each returning a named error event: -| Server error | `event` field | Meaning | -|---|---|---| -| Missing `messageId` | `send_message` | Client bug | -| Not a conversation member | `send_message` | Auth/membership issue | +| Server error | `event` field | Meaning | +| --------------------------------- | --------------------- | ------------------------------------------------ | +| Missing `messageId` | `send_message` | Client bug | +| Not a conversation member | `send_message` | Auth/membership issue | | Sibling devices missing envelopes | `device_set_mismatch` | Client must re-encrypt for the listed device IDs | -| DB write failure | `send_message` | Transient; retry advised | +| DB write failure | `send_message` | Transient; retry advised | The `MessageInput` component emits `send_message` and the `ChatPage` listens on `socket.on('error', ...)`, displaying the error message in a red bar above the input. No retry logic is currently implemented client-side for `device_set_mismatch`. @@ -316,20 +323,20 @@ useSocket GET /sync useInboundPipeline ## Key source locations -| Concern | File | -|---|---| -| Socket init and resume | `src/hooks/useSocket.ts`, `src/lib/socket.ts`, `src/lib/realtime.ts` | -| Inbound pipeline hook | `src/hooks/useInboundPipeline.ts` | -| Envelope decrypt/verify | `src/lib/crypto/processEnvelope.ts`, `src/lib/crypto/decrypt.ts` | -| Crypto types and errors | `src/lib/crypto/types.ts` | -| Session key store | `src/lib/crypto/sessionStore.ts` | -| Device key cache | `src/lib/crypto/deviceKeys.ts` | -| Plaintext cache | `src/lib/crypto/plaintextCache.ts` | -| X3DH key exchange | `src/lib/x3dh.ts` | -| Message thread UI | `src/components/messaging/MessageThread.tsx` | -| Unavailable placeholder | `src/components/messaging/UnavailableMessagePlaceholder.tsx` | -| Pagination (load older) | `src/hooks/useMessageHistory.ts` | -| Backend send handler | `apps/backend/src/socket/messaging.ts` | -| Backend fan-out | `apps/backend/src/services/deliveryPipeline.ts` | -| Backend `/sync` route | `apps/backend/src/routes/sync.ts` | -| Ephemeral event replay | `apps/backend/src/services/resumeStream.ts` | +| Concern | File | +| ----------------------- | -------------------------------------------------------------------- | +| Socket init and resume | `src/hooks/useSocket.ts`, `src/lib/socket.ts`, `src/lib/realtime.ts` | +| Inbound pipeline hook | `src/hooks/useInboundPipeline.ts` | +| Envelope decrypt/verify | `src/lib/crypto/processEnvelope.ts`, `src/lib/crypto/decrypt.ts` | +| Crypto types and errors | `src/lib/crypto/types.ts` | +| Session key store | `src/lib/crypto/sessionStore.ts` | +| Device key cache | `src/lib/crypto/deviceKeys.ts` | +| Plaintext cache | `src/lib/crypto/plaintextCache.ts` | +| X3DH key exchange | `src/lib/x3dh.ts` | +| Message thread UI | `src/components/messaging/MessageThread.tsx` | +| Unavailable placeholder | `src/components/messaging/UnavailableMessagePlaceholder.tsx` | +| Pagination (load older) | `src/hooks/useMessageHistory.ts` | +| Backend send handler | `apps/backend/src/socket/messaging.ts` | +| Backend fan-out | `apps/backend/src/services/deliveryPipeline.ts` | +| Backend `/sync` route | `apps/backend/src/routes/sync.ts` | +| Ephemeral event replay | `apps/backend/src/services/resumeStream.ts` | diff --git a/apps/web/docs/concepts-wallet-treasury-ui.md b/apps/web/docs/concepts-wallet-treasury-ui.md index ae45214..081d3f1 100644 --- a/apps/web/docs/concepts-wallet-treasury-ui.md +++ b/apps/web/docs/concepts-wallet-treasury-ui.md @@ -31,12 +31,12 @@ The treasury experience lives in [src/app/app/treasury/page.tsx](../src/app/app/ ### User actions and where they go -| UI action | First step | Second step | Notes | -| --- | --- | --- | --- | -| Open treasury page | Backend REST: `GET /treasury/proposals` | None | The page fetches proposal rows from the backend and renders them in the UI. | -| Create a withdrawal proposal | Backend REST: `POST /treasury/propose` | None | The modal sends the proposal payload to the backend, which stores the proposal metadata and returns the draft row. | -| Approve a proposal | Wallet signing via Freighter | Backend REST: `POST /treasury/proposals/:id/approve` | The UI first asks Freighter to sign a message based on the proposal id, then posts the signature to the backend. | -| Reject a proposal | Wallet signing via Freighter | Backend REST: `POST /treasury/proposals/:id/reject` | Same pattern as approve: sign locally, then send the signature to the backend. | +| UI action | First step | Second step | Notes | +| ---------------------------- | --------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| Open treasury page | Backend REST: `GET /treasury/proposals` | None | The page fetches proposal rows from the backend and renders them in the UI. | +| Create a withdrawal proposal | Backend REST: `POST /treasury/propose` | None | The modal sends the proposal payload to the backend, which stores the proposal metadata and returns the draft row. | +| Approve a proposal | Wallet signing via Freighter | Backend REST: `POST /treasury/proposals/:id/approve` | The UI first asks Freighter to sign a message based on the proposal id, then posts the signature to the backend. | +| Reject a proposal | Wallet signing via Freighter | Backend REST: `POST /treasury/proposals/:id/reject` | Same pattern as approve: sign locally, then send the signature to the backend. | The current UI does not call Soroban contracts directly from these treasury actions. Instead, the web app uses the backend REST layer as the authoritative entry point for proposal creation and voting metadata. The backend is responsible for persisting proposal rows, vote records, and the user-visible state that feeds the UI. diff --git a/apps/web/docs/contracts-auth-session.md b/apps/web/docs/contracts-auth-session.md index 0949cf2..527c155 100644 --- a/apps/web/docs/contracts-auth-session.md +++ b/apps/web/docs/contracts-auth-session.md @@ -39,12 +39,12 @@ Note that two other implementations use overlapping-but-different key sets — s Source: [src/lib/jwt.ts](../src/lib/jwt.ts). The client decodes the JWT payload only — it never verifies the signature and never checks `exp`/expiry. -| Export | Purpose | Input | Output | -| --- | --- | --- | --- | -| `JwtPayload` (interface) | Shape of the decoded payload the client relies on | — | `{ userId: string; walletAddress: string; deviceId: string }` | -| `parseJwtPayload` | Decode the base64url payload segment and JSON-parse it; returns `null` if the token has no payload, if decoding throws, or if `userId`/`deviceId` are absent. No signature or expiry check. | `token: string` | `JwtPayload \| null` | -| `getE2EDeviceId` | Return the E2E device id — prefers the value stored under the `clicked.e2eDeviceId` localStorage key, otherwise falls back to `deviceId` from the decoded token. Documented as `userDevices.id` for envelope sync, which "may differ from JWT devices.id". | `token: string` | `string \| null` | -| `setE2EDeviceId` | Persist the E2E device id under the `clicked.e2eDeviceId` localStorage key (no-op when `window` is undefined, e.g. SSR). | `deviceId: string` | `void` | +| Export | Purpose | Input | Output | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | ------------------------------------------------------------- | +| `JwtPayload` (interface) | Shape of the decoded payload the client relies on | — | `{ userId: string; walletAddress: string; deviceId: string }` | +| `parseJwtPayload` | Decode the base64url payload segment and JSON-parse it; returns `null` if the token has no payload, if decoding throws, or if `userId`/`deviceId` are absent. No signature or expiry check. | `token: string` | `JwtPayload \| null` | +| `getE2EDeviceId` | Return the E2E device id — prefers the value stored under the `clicked.e2eDeviceId` localStorage key, otherwise falls back to `deviceId` from the decoded token. Documented as `userDevices.id` for envelope sync, which "may differ from JWT devices.id". | `token: string` | `string \| null` | +| `setE2EDeviceId` | Persist the E2E device id under the `clicked.e2eDeviceId` localStorage key (no-op when `window` is undefined, e.g. SSR). | `deviceId: string` | `void` | ## 4. Session Initialization Sequence @@ -54,6 +54,7 @@ Driven by `AuthProvider` in **Token present (returning session):** on mount, a `useEffect` ([src/contexts/AuthContext.tsx:53-59](../src/contexts/AuthContext.tsx#L53-L59)) runs: + 1. `readToken()` returns the first non-empty value among the three storage keys. 2. If found, `setToken(savedToken)`. 3. `setUser(parseJwtUser(savedToken))` — `parseJwtUser` calls `parseJwtClaims` @@ -69,6 +70,7 @@ stay `null` and the app is unauthenticated. There is no automatic recovery. A session is established only when `signIn()` ([src/contexts/AuthContext.tsx:61-101](../src/contexts/AuthContext.tsx#L61-L101)) is invoked: + 1. Resolve `walletAddress` from the wallet context (`publicKey ?? await connect()`). 2. `getOrCreateDeviceIdentity()` — loads or generates the Ed25519 device identity. 3. `POST /auth/challenge` with `{ walletAddress }` → `{ message, nonce }`. @@ -92,12 +94,14 @@ nothing clears or refreshes it automatically. `signOut()` ([src/contexts/AuthContext.tsx:103-107](../src/contexts/AuthContext.tsx#L103-L107)) clears exactly: + - The three token keys via `removeToken()` — `clicked.jwt`, `clicked_token`, `auth_token`. - React state: `setToken(null)` and `setUser(null)`. **Device-side crypto state is NOT cleared.** `signOut` does not touch any of the following, all of which survive logout: + - The Ed25519 device identity in `localStorage`: `clicked.e2eeDeviceId` and `clicked.deviceIdentityPublicKey` ([src/lib/deviceIdentity.ts:3-4](../src/lib/deviceIdentity.ts#L3-L4)), diff --git a/apps/web/docs/contracts-indexeddb-schemas.md b/apps/web/docs/contracts-indexeddb-schemas.md index 72d933a..5cf12b7 100644 --- a/apps/web/docs/contracts-indexeddb-schemas.md +++ b/apps/web/docs/contracts-indexeddb-schemas.md @@ -8,13 +8,13 @@ ## Overview -| # | DB Name | Source File | Version | Object Stores | Encryption at Rest | -|---|---------|------------|---------|---------------|---------------------| -| 1 | `clicked_crypto` | [`src/lib/cryptoStore.ts`](../src/lib/cryptoStore.ts) | 2 | 3 | ❌ (plaintext JWKs + structured-clone CryptoKey) | -| 2 | `clicked_prekeys` | [`src/lib/prekeyStore.ts`](../src/lib/prekeyStore.ts) | 1 | 2 | ❌ (plaintext JWKs) | -| 3 | `clicked_sessions` | [`src/lib/sessionStore.ts`](../src/lib/sessionStore.ts) | 1 | 1 | ❌ (plaintext JWK for AES-GCM shared secret) | -| 4 | `clicked_messages` | [`src/lib/messageCache.ts`](../src/lib/messageCache.ts) | 1 | 1 | ✅ (AES‑GCM, key derived from identity key via PBKDF2) | -| 5 | `clicked-search` | [`src/lib/search/db.ts`](../src/lib/search/db.ts) | 1 | 1 | ❌ (fully decrypted plaintext) | +| # | DB Name | Source File | Version | Object Stores | Encryption at Rest | +| --- | ------------------ | ------------------------------------------------------- | ------- | ------------- | ------------------------------------------------------ | +| 1 | `clicked_crypto` | [`src/lib/cryptoStore.ts`](../src/lib/cryptoStore.ts) | 2 | 3 | ❌ (plaintext JWKs + structured-clone CryptoKey) | +| 2 | `clicked_prekeys` | [`src/lib/prekeyStore.ts`](../src/lib/prekeyStore.ts) | 1 | 2 | ❌ (plaintext JWKs) | +| 3 | `clicked_sessions` | [`src/lib/sessionStore.ts`](../src/lib/sessionStore.ts) | 1 | 1 | ❌ (plaintext JWK for AES-GCM shared secret) | +| 4 | `clicked_messages` | [`src/lib/messageCache.ts`](../src/lib/messageCache.ts) | 1 | 1 | ✅ (AES‑GCM, key derived from identity key via PBKDF2) | +| 5 | `clicked-search` | [`src/lib/search/db.ts`](../src/lib/search/db.ts) | 1 | 1 | ❌ (fully decrypted plaintext) | --- @@ -26,19 +26,19 @@ ### Object Store: `keys` -| Property | Value | -|----------|-------| -| Key path | *(none — inline keys)* | -| Indexes | *(none)* | +| Property | Value | +| -------- | ---------------------- | +| Key path | _(none — inline keys)_ | +| Indexes | _(none)_ | **Stored record shape** (key: `"identity_keypair"`): ```ts interface StoredIdentityKeyPair { - id: "identity_keypair"; - publicKey: JsonWebKey; // ECDH P-256 public key in JWK format - privateKey: JsonWebKey; // ECDH P-256 private key in JWK format - createdAt: number; // epoch ms + id: 'identity_keypair'; + publicKey: JsonWebKey; // ECDH P-256 public key in JWK format + privateKey: JsonWebKey; // ECDH P-256 private key in JWK format + createdAt: number; // epoch ms } ``` @@ -46,15 +46,15 @@ interface StoredIdentityKeyPair { ### Object Store: `deviceId` -| Property | Value | -|----------|-------| -| Key path | *(none — inline keys)* | -| Indexes | *(none)* | +| Property | Value | +| -------- | ---------------------- | +| Key path | _(none — inline keys)_ | +| Indexes | _(none)_ | **Stored record shape** (key: `"id"`): ```ts -string // e.g. "device_lr8x2k_a3b9c1d" +string; // e.g. "device_lr8x2k_a3b9c1d" ``` Generated via `Math.random()` and `Date.now()` — not cryptographically random. Stored in plaintext. @@ -63,17 +63,17 @@ Generated via `Math.random()` and `Date.now()` — not cryptographically random. Added in **version 2** migration (`event.oldVersion < 2`). -| Property | Value | -|----------|-------| -| Key path | *(none — inline keys)* | -| Indexes | *(none)* | +| Property | Value | +| -------- | ---------------------- | +| Key path | _(none — inline keys)_ | +| Indexes | _(none)_ | **Stored record shape** (key: `"current"`): ```ts interface IdentityKeyPairRecord { - keyPair: CryptoKeyPair; // structured‑clone of the live CryptoKey objects - createdAt: number; // epoch ms + keyPair: CryptoKeyPair; // structured‑clone of the live CryptoKey objects + createdAt: number; // epoch ms } ``` @@ -88,20 +88,20 @@ interface IdentityKeyPairRecord { ### Object Store: `prekeys` -| Property | Value | -|----------|-------| -| Key path | `keyId` | -| Indexes | `isOneTime` on `isOneTime` (unique: `false`) | +| Property | Value | +| -------- | -------------------------------------------- | +| Key path | `keyId` | +| Indexes | `isOneTime` on `isOneTime` (unique: `false`) | **Stored record shape:** ```ts interface StoredPrekey { - keyId: string; // e.g. "prekey_1722441600000_a3b9c1d" - publicKey: JsonWebKey; // ECDH X25519 public key in JWK format - privateKeyJwk: JsonWebKey; // ECDH X25519 private key in JWK format (not CryptoKey) - createdAt: number; // epoch ms - isOneTime: 0 | 1; // 1 = one-time prekey, 0 = not (used for signed prekey in this store) + keyId: string; // e.g. "prekey_1722441600000_a3b9c1d" + publicKey: JsonWebKey; // ECDH X25519 public key in JWK format + privateKeyJwk: JsonWebKey; // ECDH X25519 private key in JWK format (not CryptoKey) + createdAt: number; // epoch ms + isOneTime: 0 | 1; // 1 = one-time prekey, 0 = not (used for signed prekey in this store) } ``` @@ -111,20 +111,20 @@ interface StoredPrekey { ### Object Store: `signedPrekey` -| Property | Value | -|----------|-------| -| Key path | *(none — inline keys)* | -| Indexes | *(none)* | +| Property | Value | +| -------- | ---------------------- | +| Key path | _(none — inline keys)_ | +| Indexes | _(none)_ | **Stored record shape** (key: `"signed"`): ```ts interface SignedPrekeyRecord { - keyId: string; // e.g. "prekey_1722441600000_a3b9c1d" - publicKey: JsonWebKey; // ECDH X25519 public key in JWK format - privateKeyJwk: JsonWebKey; // ECDH X25519 private key in JWK format - createdAt: number; // epoch ms - isOneTime: 0; // always 0 for signed prekey + keyId: string; // e.g. "prekey_1722441600000_a3b9c1d" + publicKey: JsonWebKey; // ECDH X25519 public key in JWK format + privateKeyJwk: JsonWebKey; // ECDH X25519 private key in JWK format + createdAt: number; // epoch ms + isOneTime: 0; // always 0 for signed prekey } ``` @@ -140,19 +140,19 @@ interface SignedPrekeyRecord { ### Object Store: `sessions` -| Property | Value | -|----------|-------| -| Key path | `sessionId` | -| Indexes | `deviceId` on `deviceId` (unique: `true` — one session per device) | +| Property | Value | +| -------- | ------------------------------------------------------------------ | +| Key path | `sessionId` | +| Indexes | `deviceId` on `deviceId` (unique: `true` — one session per device) | **Stored record shape:** ```ts interface CachedSession { - sessionId: string; // e.g. "session_1722441600000_a3b9c1d" - deviceId: string; // the remote device this session is with - sharedSecretJwk: JsonWebKey;// AES-GCM shared secret in JWK format - createdAt: number; // epoch ms + sessionId: string; // e.g. "session_1722441600000_a3b9c1d" + deviceId: string; // the remote device this session is with + sharedSecretJwk: JsonWebKey; // AES-GCM shared secret in JWK format + createdAt: number; // epoch ms } ``` @@ -167,35 +167,36 @@ interface CachedSession { ### Object Store: `messages` -| Property | Value | -|----------|-------| -| Key path | `id` | -| Indexes | `conversationId` on `conversationId` (unique: `false`), `timestamp` on `timestamp` (unique: `false`) | +| Property | Value | +| -------- | ---------------------------------------------------------------------------------------------------- | +| Key path | `id` | +| Indexes | `conversationId` on `conversationId` (unique: `false`), `timestamp` on `timestamp` (unique: `false`) | **Stored record shape:** ```ts interface CachedMessage { - id: string; // message UUID - conversationId: string; // conversation UUID - content: string; // ⚠️ ALWAYS EMPTY at rest — the real content is encrypted - senderId: string; // ⚠️ ALWAYS EMPTY at rest — encrypted - timestamp: number; // epoch ms — stored in plaintext - iv: string; // AES-GCM initialization vector (hex) - encryptedContent: string;// AES-GCM ciphertext (hex) containing { content, senderId } + id: string; // message UUID + conversationId: string; // conversation UUID + content: string; // ⚠️ ALWAYS EMPTY at rest — the real content is encrypted + senderId: string; // ⚠️ ALWAYS EMPTY at rest — encrypted + timestamp: number; // epoch ms — stored in plaintext + iv: string; // AES-GCM initialization vector (hex) + encryptedContent: string; // AES-GCM ciphertext (hex) containing { content, senderId } } ``` **Encryption scheme:** + 1. Derive a 256-bit AES-GCM key via PBKDF2 (SHA-256, 100k iterations) from the identity public key JWK, salted with `"clicked_cache_salt"`. 2. On write: serialize `{ content, senderId }` as JSON, encrypt with AES-GCM, store ciphertext in `encryptedContent` along with the random IV in `iv`. 3. On read: decrypt `encryptedContent` with the cached key and parse the JSON. **Encryption status:** ✅ Encrypted at rest. Message content and sender identity are stored as AES-GCM ciphertext. However: + - `id`, `conversationId`, and `timestamp` remain in plaintext (leaking conversation membership and timing metadata). - The encryption key is derived from the identity public key (which is itself stored in plaintext in `clicked_crypto`), so the encryption is only as strong as the identity key's secrecy. - > **⚠️ Subtle bug note:** The `CachedMessage` interface declares `content` and `senderId` as `string` fields, and the `addMessage` method spreads the input message (which includes `content`/`senderId`) into the stored object alongside `iv`/`encryptedContent`. This means the plaintext `content` and `senderId` are **also written to IndexedDB in the clear** alongside the ciphertext. The `getMessage` path relies on decryption, but the raw record still contains the plaintext duplicates in the DB. --- @@ -208,10 +209,10 @@ interface CachedMessage { ### Object Store: `messages` -| Property | Value | -|----------|-------| -| Key path | `id` | -| Indexes | `conversationId` on `conversationId` (unique: `false`), `createdAt` on `createdAt` (unique: `false`), `conversation_created` compound on `["conversationId", "createdAt"]` (unique: `false`) | +| Property | Value | +| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Key path | `id` | +| Indexes | `conversationId` on `conversationId` (unique: `false`), `createdAt` on `createdAt` (unique: `false`), `conversation_created` compound on `["conversationId", "createdAt"]` (unique: `false`) | **Stored record shape** (from [`types.ts`](../src/lib/search/types.ts)): @@ -220,9 +221,9 @@ interface DecryptedMessage { id: string; conversationId: string; senderId: string; - plaintext: string; // fully decrypted message body + plaintext: string; // fully decrypted message body contentType: string; - createdAt: string; // ISO 8601 + createdAt: string; // ISO 8601 sequenceNumber?: number | null; } ``` @@ -233,13 +234,13 @@ interface DecryptedMessage { ## Encryption Summary -| DB | Sensitive Data Stored | Protected? | -|----|----------------------|------------| -| `clicked_crypto` | Identity private key (JWK), CryptoKey handles | ❌ JWKs in plaintext; CryptoKey handles rely on browser key store | -| `clicked_prekeys` | Prekey private keys (JWK) | ❌ Plaintext JWKs | -| `clicked_sessions` | AES-GCM session shared secrets (JWK) | ❌ Plaintext JWKs | -| `clicked_messages` | Message content + sender ID | ✅ AES-GCM encrypted (but see plaintext-duplicate caveat above) | -| `clicked-search` | Fully decrypted message plaintext | ❌ No encryption at all | +| DB | Sensitive Data Stored | Protected? | +| ------------------ | --------------------------------------------- | ----------------------------------------------------------------- | +| `clicked_crypto` | Identity private key (JWK), CryptoKey handles | ❌ JWKs in plaintext; CryptoKey handles rely on browser key store | +| `clicked_prekeys` | Prekey private keys (JWK) | ❌ Plaintext JWKs | +| `clicked_sessions` | AES-GCM session shared secrets (JWK) | ❌ Plaintext JWKs | +| `clicked_messages` | Message content + sender ID | ✅ AES-GCM encrypted (but see plaintext-duplicate caveat above) | +| `clicked-search` | Fully decrypted message plaintext | ❌ No encryption at all | All databases rely on the browser's same-origin storage isolation for baseline protection. No database uses [WebCrypto non-extractable keys](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/generateKey#extractable) or application-level key-wrapping beyond `clicked_messages`' PBKDF2-derived AES-GCM. diff --git a/apps/web/docs/contracts-response-types.md b/apps/web/docs/contracts-response-types.md index 8cca216..29e0a82 100644 --- a/apps/web/docs/contracts-response-types.md +++ b/apps/web/docs/contracts-response-types.md @@ -4,7 +4,7 @@ This document catalogs the TypeScript types `apps/web` uses to model backend RES ## Scope note: there is no response-side schema in the backend -Before comparing types, one thing needs to be stated up front: **the backend has no Zod (or any) schema that validates or shapes outgoing responses.** The only Zod schemas in `apps/backend/src/schemas/` (`auth.schemas.ts`, `message.schemas.ts`) validate *inbound request bodies* — `ChallengeSchema`, `VerifySchema`, `DeviceSchema` for `/auth/*` and `/devices`, and `SendMessageSchema`/`EnvelopeSchema` for `POST /messages`. A handful of routes (`treasury.ts`, `uploads.ts`, `devices.ts`) also define inline Zod schemas, but again only for request validation. +Before comparing types, one thing needs to be stated up front: **the backend has no Zod (or any) schema that validates or shapes outgoing responses.** The only Zod schemas in `apps/backend/src/schemas/` (`auth.schemas.ts`, `message.schemas.ts`) validate _inbound request bodies_ — `ChallengeSchema`, `VerifySchema`, `DeviceSchema` for `/auth/*` and `/devices`, and `SendMessageSchema`/`EnvelopeSchema` for `POST /messages`. A handful of routes (`treasury.ts`, `uploads.ts`, `devices.ts`) also define inline Zod schemas, but again only for request validation. So "the backend schema a frontend type is meant to correspond to" below means: the literal shape returned by `res.json(...)` in the route handler — usually a raw Drizzle row (`typeof table.$inferSelect`), a `db.query...with:{...}` relational result, or a hand-built object literal — or, for Socket.IO, the payload object literal passed to `.emit(...)`. The one semi-shared transform is `serializeMessage()` (`apps/backend/src/lib/messages.ts:13-50`), which normalizes a message's `ciphertext` per-device (see [`api-conversations.md`](../../backend/docs/api-conversations.md) for the full explanation) — but it is applied inconsistently (noted per-entity below). @@ -15,6 +15,7 @@ Because there's no shared source of truth, the same conceptual entity (Message, ## Message ### `apps/web/src/app/conversations/[id]/page.tsx:17-31` + ```ts interface Message { id: string; @@ -32,9 +33,11 @@ interface Message { sender?: Sender; } ``` + Models: `GET /conversations/:id/messages`, and socket `message_history` / `new_message` / `message_envelope` / `message_ack` / `delivery_receipt` / `read_receipt`. ### `apps/web/src/app/app/conversations/[id]/page.tsx:32-42` + ```ts type Message = { id: string; @@ -48,9 +51,11 @@ type Message = { unavailable?: boolean; }; ``` + Models the same events as above, at a second, parallel conversation route — narrower field set (no `senderDeviceId`, `sequenceNumber`, `pending`, `delivered`, `readBy`). ### `apps/web/src/components/conversations/ConversationListSidebar.tsx:28-32` + ```ts interface Message { id: string; @@ -58,9 +63,11 @@ interface Message { createdAt: string; } ``` + Models the `messages[0]` last-message-preview sub-object embedded in each item of `GET /conversations`'s response array. ### `apps/web/src/hooks/useMessageHistory.ts:12-22` (`ChatMessage`) + ```ts export interface ChatMessage { id: string; @@ -73,53 +80,93 @@ export interface ChatMessage { sequenceNumber?: number | null; } ``` + Models the socket `message_history` ack and `new_message`. ### `apps/web/src/lib/crypto/types.ts:6-16` (`MessageEnvelopeEvent`) + ```ts export interface MessageEnvelopeEvent { - messageId: string; conversationId: string; senderId: string; - senderDeviceId: string | null; contentType: string; - sequenceNumber: number; createdAt: string; envelopeId: string; ciphertext: string; + messageId: string; + conversationId: string; + senderId: string; + senderDeviceId: string | null; + contentType: string; + sequenceNumber: number; + createdAt: string; + envelopeId: string; + ciphertext: string; } ``` + Models socket `message_envelope`, emitted from `apps/backend/src/services/deliveryPipeline.ts:78-87`. ### `apps/web/src/lib/crypto/types.ts:19-24` (`DeviceEnvelopeEvent`) + ```ts export interface DeviceEnvelopeEvent { - messageId: string; conversationId: string; ciphertext: string; sequenceNumber: number; + messageId: string; + conversationId: string; + ciphertext: string; + sequenceNumber: number; } ``` + Models socket `device_envelope`. ### `apps/web/src/lib/crypto/types.ts:27-38` (`SyncEnvelope`) + ```ts export interface SyncEnvelope { - id: string; messageId: string; conversationId: string; ciphertext: string; - sequenceNumber: number; senderId: string; senderDeviceId: string | null; - contentType: string; createdAt: string; messageCreatedAt: string; + id: string; + messageId: string; + conversationId: string; + ciphertext: string; + sequenceNumber: number; + senderId: string; + senderDeviceId: string | null; + contentType: string; + createdAt: string; + messageCreatedAt: string; } ``` + Models an entry in the `envelopes[]` array returned by `GET /sync` (`apps/backend/src/routes/sync.ts`). ### `apps/web/src/lib/realtime.ts:21-32` (`SyncedEnvelope`) + ```ts export interface SyncedEnvelope { - id: string; messageId: string; conversationId: string; senderId?: string; - senderDeviceId?: string | null; contentType?: string; ciphertext: string; - sequenceNumber: number; deliveredAt?: string | null; createdAt: string; + id: string; + messageId: string; + conversationId: string; + senderId?: string; + senderDeviceId?: string | null; + contentType?: string; + ciphertext: string; + sequenceNumber: number; + deliveredAt?: string | null; + createdAt: string; } ``` + A second, independent type modeling the same `GET /sync` envelope shape as `SyncEnvelope` above. ### `apps/web/src/hooks/useInboundPipeline.ts:20-29` (`NewMessageMeta`) + ```ts interface NewMessageMeta { - id: string; conversationId: string; senderId: string; senderDeviceId: string | null; - contentType: string; sequenceNumber: number; createdAt: string; unavailable?: boolean; + id: string; + conversationId: string; + senderId: string; + senderDeviceId: string | null; + contentType: string; + sequenceNumber: number; + createdAt: string; + unavailable?: boolean; } ``` + Models socket `new_message`. **Backend shape these are all meant to track:** the `messages` Drizzle table (`apps/backend/src/db/schema.ts:113-136`) — `{ id, conversationId, senderId, senderDeviceId, contentType, ciphertext, fileId, editsMessageId, createdAt, deletedAt }` — plus, where applicable, `serializeMessage()`'s normalization (`ciphertext: string | null`, optional `unavailable: true`, `deletedAt`/`envelopes` stripped). Discrepancies: see [DRIFT-1](#drift-1-sequencenumber-is-typed-everywhere-but-never-sent), [DRIFT-6](#drift-6-fileid-and-editsmessageid-are-sent-but-never-typed), [DRIFT-7](#drift-7-message_deleted-payload-differs-between-rest-and-socket). @@ -129,18 +176,27 @@ Models socket `new_message`. ## Conversation ### `apps/web/src/app/conversations/[id]/page.tsx:44-49` + ```ts type Conversation = { id: string; type: 'dm' | 'group'; name?: string | null; members?: Member[] }; ``` + Models `GET /conversations/:id`. ### `apps/web/src/components/conversations/ConversationListSidebar.tsx:34-42` + ```ts interface Conversation { - id: string; type: 'dm' | 'group'; name?: string | null; createdAt?: string; - members?: Member[]; messages?: Message[]; unreadCount?: number; + id: string; + type: 'dm' | 'group'; + name?: string | null; + createdAt?: string; + members?: Member[]; + messages?: Message[]; + unreadCount?: number; } ``` + Models the array returned by `GET /conversations` (see [`api-conversations.md`](../../backend/docs/api-conversations.md#get-conversations)). **Backend shape:** `conversations` table (`apps/backend/src/db/schema.ts:41-47`) plus, on the list route, the aggregated `isMuted`, `isArchived`, `messageCount`, `unreadCount` fields computed in `apps/backend/src/routes/conversations.ts:167-173`. Discrepancy: see [DRIFT-8](#drift-8-messagecount-is-sent-but-not-typed-on-the-frontend). @@ -150,27 +206,45 @@ Models the array returned by `GET /conversations` (see [`api-conversations.md`]( ## ConversationMember / Member ### `apps/web/src/app/conversations/[id]/page.tsx:17-24`, `ConversationListSidebar.tsx:19-26`, `ConversationHeader.tsx:6-13` (all three define an equivalent nested shape) + ```ts -type Member = { user?: { id?: string; username?: string | null; avatarUrl?: string | null; wallets?: Wallet[] } }; +type Member = { + user?: { id?: string; username?: string | null; avatarUrl?: string | null; wallets?: Wallet[] }; +}; ``` + Models the `members[]` array embedded in `GET /conversations` and `GET /conversations/:id` (the relational `with: { members: { with: { user: { with: { wallets } } } } }` include in `apps/backend/src/routes/conversations.ts:24-32`). -**Note:** this nested `{ user: {...} }` shape is *not* what `GET /conversations/:id/members` returns — that route flattens the member/user fields into one object and adds `primaryWalletAddress`. See [DRIFT-5](#drift-5-get-conversationsidmembers-response-shape-has-no-matching-frontend-type) — no frontend type or caller for that flattened shape currently exists in `apps/web`. +**Note:** this nested `{ user: {...} }` shape is _not_ what `GET /conversations/:id/members` returns — that route flattens the member/user fields into one object and adds `primaryWalletAddress`. See [DRIFT-5](#drift-5-get-conversationsidmembers-response-shape-has-no-matching-frontend-type) — no frontend type or caller for that flattened shape currently exists in `apps/web`. --- ## User ### `apps/web/src/app/app/profile/page.tsx:18-23` (`UserProfile`) + ```ts -type UserProfile = { id: string; username: string | null; avatarUrl: string | null; wallets: Wallet[] }; +type UserProfile = { + id: string; + username: string | null; + avatarUrl: string | null; + wallets: Wallet[]; +}; ``` + Models `GET /users/me` and `PATCH /users/me`. ### `apps/web/src/app/conversations/[id]/page.tsx:51-56` (`CurrentUser`) + ```ts -type CurrentUser = { id: string; username: string | null; avatarUrl: string | null; wallets: Wallet[] }; +type CurrentUser = { + id: string; + username: string | null; + avatarUrl: string | null; + wallets: Wallet[]; +}; ``` + A second, structurally identical type for the same endpoint, defined independently in a different file. **Backend shape:** `apps/backend/src/routes/users.ts:93-103` also sends `presenceVisible` and `createdAt` on `GET /users/me`, neither of which any frontend `User`-shaped type declares. See [DRIFT-9](#drift-9-users-me-sends-presencevisible-and-createdat-which-no-frontend-type-declares). @@ -180,25 +254,43 @@ A second, structurally identical type for the same endpoint, defined independent ## Device ### `apps/web/src/app/app/devices/page.tsx:8-17` + ```ts type Device = { - id: string; identityPublicKey: string; deviceName: string | null; - platform: 'web' | 'ios' | 'android' | null; lastSeenAt: string | null; - isRevoked: boolean; createdAt: string; current: boolean; + id: string; + identityPublicKey: string; + deviceName: string | null; + platform: 'web' | 'ios' | 'android' | null; + lastSeenAt: string | null; + isRevoked: boolean; + createdAt: string; + current: boolean; }; ``` + Models `GET /devices` and `DELETE /devices/:id`. ### `apps/web/src/lib/crypto/types.ts:48-52` (`DevicePublicKey`) + ```ts -export interface DevicePublicKey { id: string; userId: string; identityPublicKey: string; } +export interface DevicePublicKey { + id: string; + userId: string; + identityPublicKey: string; +} ``` + Models `GET /user-devices/:id/public-key`. ### `apps/web/src/lib/crypto.ts:22-27` (`DeviceRecord`) + ```ts -export interface DeviceRecord { id: string; identityPublicKey: string; } +export interface DeviceRecord { + id: string; + identityPublicKey: string; +} ``` + Models an entry in the `devices[]` array from `GET /conversations/:id/devices` (see [`api-conversations.md`](../../backend/docs/api-conversations.md#get-conversationsiddevices)). **Backend shape:** `devices` table (`apps/backend/src/db/schema.ts`). `GET /devices` (`apps/backend/src/routes/devices.ts:76-88`) actually returns `revokedAt: Date | null` and `oneTimePreKeysRemaining`, not `isRevoked`. See [DRIFT-2](#drift-2-devices-declares-isrevoked-but-the-backend-sends-revokedat). @@ -216,20 +308,30 @@ Models an entry in the `devices[]` array from `GET /conversations/:id/devices` ( ## TreasuryProposal / Proposal ### `apps/web/src/components/treasury/ProposalCard.tsx:9-23` (real, wired to the backend via `apps/web/src/app/app/treasury/page.tsx`) + ```ts export type ProposalStatus = 'active' | 'approved' | 'rejected' | 'executed' | 'expired'; export interface Proposal { - id: string; proposalId: string; status: ProposalStatus; - approvalsCount: number; rejectionsCount: number; - recipient: string | null; amount: string | null; token: string | null; - threshold: number; hasVoted: boolean; myVote: 'approve' | 'reject' | null; + id: string; + proposalId: string; + status: ProposalStatus; + approvalsCount: number; + rejectionsCount: number; + recipient: string | null; + amount: string | null; + token: string | null; + threshold: number; + hasVoted: boolean; + myVote: 'approve' | 'reject' | null; } ``` + Models `GET /treasury/proposals`, and the socket `treasury_proposal_updated` event (partially). **Backend shape:** `treasuryProposals` table + `treasuryProposalStatusEnum` (`'active' | 'approved' | 'rejected' | 'executed' | 'expired'`), matches this type's `ProposalStatus` union. However, `apps/backend/src/services/stellarListener.ts:183,189,197` reads `row.onChainId` when emitting `treasury_proposal_updated` — a column that does not exist on the `treasuryProposals` table (it has `proposalId: text`, not `onChainId`). This is a backend-side bug independent of the frontend type, but it means the socket event this `Proposal` type partially models may not fire correctly at runtime. Flagged as a follow-up, not fixed here (see [Follow-ups](#follow-ups-to-file-as-separate-issues) item 2). ### Two additional, non-canonical `Proposal`/`ProposalStatus` definitions exist and should **not** be used as the reference: + - `apps/web/src/components/ui/ProposalCard.tsx:5-15` — `ProposalStatus = 'pending' | 'approved' | 'executed' | 'rejected' | 'expired'` (uses `'pending'` where the DB enum uses `'active'`) plus an `expiryLedger: number` field the backend never sends. Only referenced by its own test file; not used by any real page. - `apps/web/src/app/app/proposals/page.tsx:5-15` — a fully hardcoded demo `Proposal` with `status: 'Active' | 'Succeeded' | 'Defeated'`, unrelated to the real enum. No network calls. See [Dead/demo code](#deaddemo-code-not-covered-by-this-doc). @@ -238,10 +340,17 @@ Models `GET /treasury/proposals`, and the socket `treasury_proposal_updated` eve ## File / Upload ### `apps/web/src/lib/fileEncryption.ts:58-68` + ```ts -export interface PresignedUploadResponse { fileId: string; uploadUrl: string; } -export interface PresignedDownloadResponse { url: string; } +export interface PresignedUploadResponse { + fileId: string; + uploadUrl: string; +} +export interface PresignedDownloadResponse { + url: string; +} ``` + `PresignedDownloadResponse` correctly matches `GET /files/:fileId` (`apps/backend/src/routes/files.ts:15,59` — `res.json({ url: presignedUrl })`). `PresignedUploadResponse` is intended to model the upload-slot request, but see [DRIFT-3](#drift-3-file-upload-request-targets-a-route-and-body-shape-that-dont-match-the-backend) — the request this type's caller sends doesn't reach the route it's shaped for. @@ -250,16 +359,23 @@ export interface PresignedDownloadResponse { url: string; } ## PushSubscription -No dedicated response type exists — `apps/web/src/hooks/usePushSubscription.ts` only types the *request* body (`{ endpoint, keys }`, from the native `PushSubscription.toJSON()`) and never types the `POST /push/subscriptions` response (`{ success: true }` on the backend, or a `204` on `DELETE`). Gap, not drift — see [Follow-ups](#follow-ups-to-file-as-separate-issues) item 1. +No dedicated response type exists — `apps/web/src/hooks/usePushSubscription.ts` only types the _request_ body (`{ endpoint, keys }`, from the native `PushSubscription.toJSON()`) and never types the `POST /push/subscriptions` response (`{ success: true }` on the backend, or a `204` on `DELETE`). Gap, not drift — see [Follow-ups](#follow-ups-to-file-as-separate-issues) item 1. --- ## Socket.IO event envelope ### `apps/web/src/lib/realtime.ts:8-13` + ```ts -export interface EventEnvelope { eventId: string; type: string; timestamp: number; payload: T; } +export interface EventEnvelope { + eventId: string; + type: string; + timestamp: number; + payload: T; +} ``` + Matches `EventEnvelopeSchema` in `apps/backend/src/lib/eventEnvelope.ts:31-36` field-for-field. **No drift** — this is the one type in this document that tracks its backend counterpart exactly. Note it only covers the outbound `dispatch` envelope wrapper (`apps/backend/src/socket/dispatcher.ts`); it is not used for the many plain `socket.emit(...)` events listed under [Message](#message) above, which bypass the envelope entirely. --- @@ -267,6 +383,7 @@ Matches `EventEnvelopeSchema` in `apps/backend/src/lib/eventEnvelope.ts:31-36` f ## Dead/demo code not covered by this doc These "types" don't model any real API response and are excluded from the comparison above rather than flagged as drift: + - `apps/web/src/app/app/page.tsx` (`/app` route) — hardcoded `useState` seed messages, no `fetch`/socket calls. - `apps/web/src/app/app/proposals/page.tsx` — hardcoded seed proposals with a `status` union that doesn't match the real enum. - `apps/web/src/components/ui/ProposalCard.tsx` — separate, unused-in-production `ProposalStatus`/`Proposal` shape. @@ -279,33 +396,43 @@ These "types" don't model any real API response and are excluded from the compar These are documented as **known issues to file separately** per the acceptance criteria for this doc — nothing below has been fixed as part of writing this document. ### DRIFT-1: `sequenceNumber` is typed everywhere but never sent + Seven frontend types (`MessageEnvelopeEvent`, `DeviceEnvelopeEvent`, `SyncEnvelope`, `SyncedEnvelope`, `NewMessageMeta`, the `app/app/conversations/[id]` `Message`, `ChatMessage`) declare a `sequenceNumber: number` field. The backend deliberately dropped a per-conversation sequence counter (see the doc comment at `apps/backend/src/db/schema.ts:107-112`), so `message_envelope`, `new_message`, and `GET /sync`'s `envelopes[]` never include this key. Frontend code defends with `msg.sequenceNumber ?? 0` (e.g. `apps/web/src/lib/crypto/processEnvelope.ts:76`'s `sortBySequenceNumber`, `apps/web/src/hooks/useInboundPipeline.ts:133`), meaning ordering silently collapses to `0` for every live/synced message. This is a real, currently-silent correctness bug in message ordering, not just a stale type. ### DRIFT-2: `Device.isRevoked` vs. backend's `revokedAt` + `apps/web/src/app/app/devices/page.tsx:8-16`'s `Device` type declares `isRevoked: boolean`, but `GET /devices` (`apps/backend/src/routes/devices.ts:76-88`) sends `revokedAt: Date | null`, never `isRevoked`. On initial page load, `device.isRevoked` is `undefined`/falsy for every device — including already-revoked ones — until the frontend sets it locally after a same-session `DELETE`. `oneTimePreKeysRemaining`, also sent by the backend, isn't declared by this type either. ### DRIFT-3: file-upload request targets a route and body shape that don't match the backend + `apps/web/src/lib/fileEncryption.ts:146` (`requestPresignedUpload`) calls `POST /files/presign-upload`, but the backend's upload-slot route is `POST /uploads` (`apps/backend/src/routes/uploads.ts:39`). Body shapes also differ: backend's `RequestSlotSchema` expects `{ conversationId, size, mimeType, sha256, isThumbnail? }` (`uploads.ts:30-36`) and responds `{ fileId, uploadUrl }`; the frontend sends `{ fileName, mimeType, sizeBytes }` — missing `conversationId`/`sha256`, and `sizeBytes` instead of `size`. This path appears non-functional as written. ### DRIFT-4: `GET /sync` cursor type and param name mismatch + Backend `apps/backend/src/routes/sync.ts` reads `deviceId`, `cursor` (an opaque string `":"`) and `limit`, and returns `nextCursor` as a string or `null`. The frontend sends `sinceSequence` (a `number`) instead of `cursor` in both `apps/web/src/lib/realtime.ts:140-142` and `apps/web/src/hooks/useInboundPipeline.ts:188-191` — a param the backend never reads, so every sync call restarts from the beginning of the retention window rather than resuming. Both call sites also type `nextCursor` as `number` and do `Math.max(cursor, data.nextCursor ?? cursor)` against what is actually a string, which would produce `NaN`. ### DRIFT-5: `GET /conversations/:id/members` response shape has no matching frontend type -The route returns a flattened `{ id, username, avatarUrl, primaryWalletAddress, joinedAt }` per member (`apps/backend/src/routes/conversations.ts:70-81`), where `id` is the *user* id. Every frontend `Member` type instead expects the nested `{ user: { id, username, avatarUrl, wallets: [...] } }` shape that `GET /conversations`/`GET /conversations/:id` return via their relational include. No `fetch` call to `.../members` was found anywhere in `apps/web/src` — the endpoint currently has no frontend consumer at all. + +The route returns a flattened `{ id, username, avatarUrl, primaryWalletAddress, joinedAt }` per member (`apps/backend/src/routes/conversations.ts:70-81`), where `id` is the _user_ id. Every frontend `Member` type instead expects the nested `{ user: { id, username, avatarUrl, wallets: [...] } }` shape that `GET /conversations`/`GET /conversations/:id` return via their relational include. No `fetch` call to `.../members` was found anywhere in `apps/web/src` — the endpoint currently has no frontend consumer at all. ### DRIFT-6: `fileId` and `editsMessageId` are sent but never typed + Raw `messages`-row emits (`new_message`, and the row underlying `message_envelope`) include `fileId` and `editsMessageId` (`apps/backend/src/db/schema.ts:128-129`). No frontend `Message`/`ChatMessage`/`NewMessageMeta` type declares either field, so file-attachment and edit-chain metadata delivered over the socket is invisible to code typed against these interfaces (present on the object at runtime, untyped and unread). ### DRIFT-7: `message_deleted` payload differs between REST and socket + REST delete (`apps/backend/src/routes/messages.ts:167-170`) emits `{ messageId, conversationId }`; the socket `delete_message` path (`apps/backend/src/socket/messaging.ts:600-601`) emits `{ messageId }` only, with no `conversationId`. No frontend listener for `message_deleted` currently exists, so this is latent rather than actively broken — but any future listener keyed on `conversationId` would silently break for the socket path. ### DRIFT-8: `messageCount` is sent but not typed on the frontend + `GET /conversations` includes `messageCount` per conversation (`apps/backend/src/routes/conversations.ts:171`), alongside `unreadCount`. `apps/web/src/components/conversations/ConversationListSidebar.tsx:34-42`'s `Conversation` type declares `unreadCount` but not `messageCount`. ### DRIFT-9: `/users/me` sends `presenceVisible` and `createdAt` which no frontend type declares + `apps/backend/src/routes/users.ts:93-103` includes both fields on `GET /users/me`; neither `UserProfile` (`apps/web/src/app/app/profile/page.tsx:18-23`) nor `CurrentUser` (`apps/web/src/app/conversations/[id]/page.tsx:51-56`) declares them. ### DRIFT-10: `send_message` content/ciphertext field aliasing is legacy cruft + The socket `send_message` handler (`apps/backend/src/socket/messaging.ts:90-106`) accepts both `content` and `ciphertext` (`effectiveCiphertext = ciphertext ?? content`), but `POST /messages`'s `SendMessageSchema` only accepts `ciphertext`. The frontend's own `sendMessage()` (`apps/web/src/app/app/conversations/[id]/page.tsx:265-271`) only ever sends `ciphertext`, yet `normaliseMessage()` in the same file (lines 70-88) still reads `msg.content ?? msg.ciphertext` defensively, suggesting a stale code path from before the field was renamed. Worth cleaning up but not itself a type-correctness bug for current traffic. --- diff --git a/apps/web/src/app/app/conversations/[id]/page.tsx b/apps/web/src/app/app/conversations/[id]/page.tsx index bd51c70..a09562b 100644 --- a/apps/web/src/app/app/conversations/[id]/page.tsx +++ b/apps/web/src/app/app/conversations/[id]/page.tsx @@ -273,7 +273,11 @@ export default function ConversationPage() { const inbound = inboundById.get(msg.id); if (inbound && inbound.status === 'decrypted' && inbound.plaintext) { let filePayload: Message['filePayload']; - if (msg.contentType === 'file' || msg.contentType === 'image' || msg.contentType === 'video') { + if ( + msg.contentType === 'file' || + msg.contentType === 'image' || + msg.contentType === 'video' + ) { try { filePayload = parseFileMessagePayload(inbound.plaintext); } catch { @@ -298,7 +302,11 @@ export default function ConversationPage() { if (!exists) { if (inbound.status === 'decrypted' && inbound.plaintext) { let filePayload: Message['filePayload']; - if (inbound.contentType === 'file' || inbound.contentType === 'image' || inbound.contentType === 'video') { + if ( + inbound.contentType === 'file' || + inbound.contentType === 'image' || + inbound.contentType === 'video' + ) { try { filePayload = parseFileMessagePayload(inbound.plaintext); } catch { @@ -327,9 +335,7 @@ export default function ConversationPage() { } } - return merged.sort( - (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), - ); + return merged.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); }, [messages, inboundMessages]); const contacts = useMemo(() => { @@ -553,7 +559,11 @@ export default function ConversationPage() { try { const messageId = crypto.randomUUID(); const devices = await fetchConversationDevices(id, token, API_BASE_URL); - const thumbnail = await generateEncryptedThumbnail({ file, authToken: token, apiBaseUrl: API_BASE_URL }); + const thumbnail = await generateEncryptedThumbnail({ + file, + authToken: token, + apiBaseUrl: API_BASE_URL, + }); const result = await sendEncryptedFile({ file, conversationId: id, @@ -567,7 +577,11 @@ export default function ConversationPage() { conversationId: id, messageId, fileId: result.fileId, - contentType: file.type.startsWith('image/') ? 'image' : file.type.startsWith('video/') ? 'video' : 'file', + contentType: file.type.startsWith('image/') + ? 'image' + : file.type.startsWith('video/') + ? 'video' + : 'file', envelopes: result.envelopes, }); if (fileInputRef.current) fileInputRef.current.value = ''; @@ -755,12 +769,7 @@ export default function ConversationPage() {
- +