diff --git a/.gitignore b/.gitignore
index 5702e0a..8f78b68 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,5 +12,12 @@ target/
.DS_Store
.turbo/
+# TypeScript build artifacts
+apps/backend/src/**/*.js
+apps/backend/src/**/*.js.map
+apps/backend/src/**/*.d.ts
+apps/backend/src/**/*.d.ts.map
+
+
ISSUES.md
IMPLEMENTATION_DOCS.md
\ No newline at end of file
diff --git a/apps/backend/drizzle.config.d.ts b/apps/backend/drizzle.config.d.ts
new file mode 100644
index 0000000..48f7c2e
--- /dev/null
+++ b/apps/backend/drizzle.config.d.ts
@@ -0,0 +1,3 @@
+declare const _default: import("drizzle-kit").Config;
+export default _default;
+//# sourceMappingURL=drizzle.config.d.ts.map
\ No newline at end of file
diff --git a/apps/backend/drizzle.config.d.ts.map b/apps/backend/drizzle.config.d.ts.map
new file mode 100644
index 0000000..c62be7f
--- /dev/null
+++ b/apps/backend/drizzle.config.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"drizzle.config.d.ts","sourceRoot":"","sources":["drizzle.config.ts"],"names":[],"mappings":";AAEA,wBAOG"}
\ No newline at end of file
diff --git a/apps/backend/drizzle.config.js b/apps/backend/drizzle.config.js
new file mode 100644
index 0000000..63ca54a
--- /dev/null
+++ b/apps/backend/drizzle.config.js
@@ -0,0 +1,10 @@
+import { defineConfig } from 'drizzle-kit';
+export default defineConfig({
+ schema: './src/db/schema.ts',
+ out: './drizzle',
+ dialect: 'postgresql',
+ dbCredentials: {
+ url: process.env['DATABASE_URL'] ?? '',
+ },
+});
+//# sourceMappingURL=drizzle.config.js.map
\ No newline at end of file
diff --git a/apps/backend/drizzle.config.js.map b/apps/backend/drizzle.config.js.map
new file mode 100644
index 0000000..c6aaa2d
--- /dev/null
+++ b/apps/backend/drizzle.config.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"drizzle.config.js","sourceRoot":"","sources":["drizzle.config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,eAAe,YAAY,CAAC;IAC1B,MAAM,EAAE,oBAAoB;IAC5B,GAAG,EAAE,WAAW;IAChB,OAAO,EAAE,YAAY;IACrB,aAAa,EAAE;QACb,GAAG,EAAE,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,IAAI,EAAE;KACvC;CACF,CAAC,CAAC"}
\ No newline at end of file
diff --git a/apps/backend/src/__tests__/conversations.cache.test.ts b/apps/backend/src/__tests__/conversations.cache.test.ts
index 7e7f679..fdc93b6 100644
--- a/apps/backend/src/__tests__/conversations.cache.test.ts
+++ b/apps/backend/src/__tests__/conversations.cache.test.ts
@@ -66,6 +66,7 @@ vi.mock('../db/schema.js', () => ({
createdAt: 'createdAt',
deletedAt: 'deletedAt',
},
+ messageEnvelopes: { recipientDeviceId: 'recipientDeviceId' },
tokenTransfers: {},
}));
vi.mock('drizzle-orm', () => {
@@ -94,7 +95,10 @@ const TEST_USER_ID = 'user-test-123';
vi.mock('../middleware/auth.js', () => ({
requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
- (req as express.Request & { auth: { userId: string } }).auth = { userId: TEST_USER_ID };
+ (req as express.Request & { auth: { userId: string; deviceId: string } }).auth = {
+ userId: TEST_USER_ID,
+ deviceId: 'device-test-123',
+ };
next();
},
}));
@@ -192,45 +196,12 @@ describe('GET /conversations — Redis caching', () => {
describe('GET /conversations/:id/search', () => {
beforeEach(() => {
vi.clearAllMocks();
- mockRedisInstance = { get: mockGet, setex: mockSetex, del: mockDel };
- });
-
- it('returns 400 when the query is empty', async () => {
- const res = await request(makeApp()).get('/conversations/conv-1/search?q= ');
-
- expect(res.status).toBe(400);
- expect(mockFindFirst).not.toHaveBeenCalled();
- expect(mockExecute).not.toHaveBeenCalled();
});
- it('returns 403 when the user is not a conversation member', async () => {
- mockFindFirst.mockResolvedValue(undefined);
-
+ it('returns 501 for E2EE environments', async () => {
const res = await request(makeApp()).get('/conversations/conv-1/search?q=hello');
- expect(res.status).toBe(403);
- expect(mockExecute).not.toHaveBeenCalled();
- });
-
- it('returns ranked highlighted matches for conversation members', async () => {
- const searchResults = [
- {
- id: 'msg-1',
- conversationId: 'conv-1',
- senderId: TEST_USER_ID,
- content: 'hello from stellar',
- snippet: 'hello from stellar',
- rank: '0.1',
- },
- ];
- mockFindFirst.mockResolvedValue({ id: 'member-1' });
- mockExecute.mockResolvedValue(searchResults);
-
- const res = await request(makeApp()).get('/conversations/conv-1/search?q=hello');
-
- expect(res.status).toBe(200);
- expect(res.body).toEqual({ results: searchResults });
- expect(mockExecute).toHaveBeenCalledTimes(1);
+ expect(res.status).toBe(501);
});
});
diff --git a/apps/backend/src/__tests__/conversations.routes.test.ts b/apps/backend/src/__tests__/conversations.routes.test.ts
index f402138..20f1202 100644
--- a/apps/backend/src/__tests__/conversations.routes.test.ts
+++ b/apps/backend/src/__tests__/conversations.routes.test.ts
@@ -58,6 +58,7 @@ vi.mock('../db/schema.js', () => ({
createdAt: 'createdAt',
deletedAt: 'deletedAt',
},
+ messageEnvelopes: { recipientDeviceId: 'recipientDeviceId' },
tokenTransfers: {},
}));
@@ -73,7 +74,10 @@ vi.mock('drizzle-orm', () => ({
vi.mock('../middleware/auth.js', () => ({
requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
- (req as express.Request & { auth: { userId: string } }).auth = { userId: 'user-1' };
+ (req as express.Request & { auth: { userId: string; deviceId: string } }).auth = {
+ userId: 'user-1',
+ deviceId: 'device-1',
+ };
next();
},
}));
@@ -138,7 +142,7 @@ describe('GET /conversations/:id', () => {
id: 'msg-1',
conversationId: 'conv-1',
senderId: 'user-1',
- content: 'hello',
+ ciphertext: 'hello',
deletedAt: null,
sender: {
id: 'user-1',
@@ -157,7 +161,7 @@ describe('GET /conversations/:id', () => {
expect(res.status).toBe(200);
expect(res.body.id).toBe('conv-1');
expect(res.body.messages).toHaveLength(1);
- expect(res.body.messages[0].content).toBe('hello');
+ expect(res.body.messages[0].ciphertext).toBe('hello');
});
});
diff --git a/apps/backend/src/__tests__/messages.routes.test.ts b/apps/backend/src/__tests__/messages.routes.test.ts
index f48ae04..aa5361b 100644
--- a/apps/backend/src/__tests__/messages.routes.test.ts
+++ b/apps/backend/src/__tests__/messages.routes.test.ts
@@ -8,6 +8,7 @@ const mockUpdate = vi.fn();
const mockEmit = vi.fn();
const mockTo = vi.fn(() => ({ emit: mockEmit }));
+const mockDelete = vi.fn();
let mockSocketServer: { to: typeof mockTo } | null = { to: mockTo };
vi.mock('../lib/socket.js', () => ({
@@ -31,6 +32,7 @@ vi.mock('../db/index.js', () => ({
conversationMembers: { findMany: mockFindMembers },
},
update: mockUpdate,
+ delete: mockDelete,
},
}));
@@ -45,6 +47,7 @@ vi.mock('../db/schema.js', () => ({
createdAt: 'createdAt',
deletedAt: 'deletedAt',
},
+ messageEnvelopes: { messageId: 'messageId' },
tokenTransfers: {},
}));
@@ -104,14 +107,16 @@ describe('DELETE /messages/:id', () => {
const setFn = vi.fn().mockReturnThis();
const whereFn = vi.fn().mockResolvedValue([{ conversationId: 'conv-1' }]);
+ const deleteWhereFn = vi.fn().mockResolvedValue([]);
mockUpdate.mockReturnValue({ set: setFn });
setFn.mockReturnValue({ where: whereFn });
+ mockDelete.mockReturnValue({ where: deleteWhereFn });
mockFindMembers.mockResolvedValue([{ userId: 'user-1' }, { userId: 'user-2' }]);
const res = await request(makeApp()).delete('/messages/msg-1');
expect(res.status).toBe(204);
- expect(setFn).toHaveBeenCalledWith({ deletedAt: expect.any(Date) });
+ expect(setFn).toHaveBeenCalledWith({ deletedAt: expect.any(Date), ciphertext: null });
expect(mockTo).toHaveBeenCalledWith('conv-1');
expect(mockEmit).toHaveBeenCalledWith('message_deleted', {
messageId: 'msg-1',
diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts
index 988f6cb..9823e8d 100644
--- a/apps/backend/src/db/schema.ts
+++ b/apps/backend/src/db/schema.ts
@@ -7,8 +7,8 @@ import {
pgEnum,
index,
integer,
+ serial,
uniqueIndex,
- bigint,
} from 'drizzle-orm/pg-core';
import { relations, sql } from 'drizzle-orm';
@@ -68,31 +68,45 @@ export const conversationMembers = pgTable('conversation_members', {
joinedAt: timestamp('joined_at').notNull().defaultNow(),
});
-export const messages = pgTable(
- 'messages',
+export const messages = pgTable('messages', {
+ id: uuid('id').primaryKey().defaultRandom(),
+ conversationId: uuid('conversation_id')
+ .notNull()
+ .references(() => conversations.id, { onDelete: 'cascade' }),
+ senderId: uuid('sender_id')
+ .notNull()
+ .references(() => users.id, { onDelete: 'cascade' }),
+ senderDeviceId: uuid('sender_device_id').references(() => userDevices.id, {
+ onDelete: 'set null',
+ }),
+ contentType: text('content_type').notNull().default('text/plain'),
+ sequenceNumber: serial('sequence_number'),
+ ciphertext: text('ciphertext'),
+ createdAt: timestamp('created_at').notNull().defaultNow(),
+ deletedAt: timestamp('deleted_at'),
+});
+
+export const messageEnvelopes = pgTable(
+ 'message_envelopes',
{
id: uuid('id').primaryKey().defaultRandom(),
- conversationId: uuid('conversation_id')
+ messageId: uuid('message_id')
.notNull()
- .references(() => conversations.id, { onDelete: 'cascade' }),
- senderId: uuid('sender_id')
- .notNull()
- .references(() => users.id, { onDelete: 'cascade' }),
- content: text('content').notNull(),
- contentType: contentTypeEnum('content_type').notNull().default('text'),
- senderDeviceId: uuid('sender_device_id')
+ .references(() => messages.id, { onDelete: 'cascade' }),
+ recipientDeviceId: uuid('recipient_device_id')
.notNull()
.references(() => userDevices.id, { onDelete: 'cascade' }),
- sequenceNumber: bigint('sequence_number', { mode: 'bigint' }).notNull(),
- expiresAt: timestamp('expires_at'),
+ recipientUserId: uuid('recipient_user_id')
+ .notNull()
+ .references(() => users.id, { onDelete: 'cascade' }),
+ ciphertext: text('ciphertext').notNull(),
+ deliveredAt: timestamp('delivered_at'),
+ readAt: timestamp('read_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
- deletedAt: timestamp('deleted_at'),
},
(table) => [
- index('messages_content_search_idx').using(
- 'gin',
- sql`to_tsvector('english', ${table.content})`,
- ),
+ index('me_recipient_device_created_idx').on(table.recipientDeviceId, table.createdAt),
+ index('me_message_idx').on(table.messageId),
],
);
@@ -276,7 +290,7 @@ export const conversationMembersRelations = relations(conversationMembers, ({ on
user: one(users, { fields: [conversationMembers.userId], references: [users.id] }),
}));
-export const messagesRelations = relations(messages, ({ one }) => ({
+export const messagesRelations = relations(messages, ({ one, many }) => ({
conversation: one(conversations, {
fields: [messages.conversationId],
references: [conversations.id],
@@ -286,6 +300,16 @@ export const messagesRelations = relations(messages, ({ one }) => ({
fields: [messages.senderDeviceId],
references: [userDevices.id],
}),
+ envelopes: many(messageEnvelopes),
+}));
+
+export const messageEnvelopesRelations = relations(messageEnvelopes, ({ one }) => ({
+ message: one(messages, { fields: [messageEnvelopes.messageId], references: [messages.id] }),
+ recipientDevice: one(userDevices, {
+ fields: [messageEnvelopes.recipientDeviceId],
+ references: [userDevices.id],
+ }),
+ recipientUser: one(users, { fields: [messageEnvelopes.recipientUserId], references: [users.id] }),
}));
export const tokenTransfersRelations = relations(tokenTransfers, ({ one }) => ({
@@ -329,6 +353,8 @@ export type NewConversation = typeof conversations.$inferInsert;
export type ConversationMember = typeof conversationMembers.$inferSelect;
export type Message = typeof messages.$inferSelect;
export type NewMessage = typeof messages.$inferInsert;
+export type MessageEnvelope = typeof messageEnvelopes.$inferSelect;
+export type NewMessageEnvelope = typeof messageEnvelopes.$inferInsert;
export type TokenTransfer = typeof tokenTransfers.$inferSelect;
export type NewTokenTransfer = typeof tokenTransfers.$inferInsert;
export type Device = typeof devices.$inferSelect;
diff --git a/apps/backend/src/lib/messages.ts b/apps/backend/src/lib/messages.ts
index a07cb4c..37601c2 100644
--- a/apps/backend/src/lib/messages.ts
+++ b/apps/backend/src/lib/messages.ts
@@ -1,15 +1,51 @@
type MessageLike = {
- content: string | null;
+ id: string;
+ senderId: string;
+ senderDeviceId?: string | null;
+ contentType: string;
+ sequenceNumber: number;
+ createdAt: Date;
+ ciphertext?: string | null;
deletedAt?: Date | null;
+ envelopes?: Array<{ ciphertext: string }>;
+ [key: string]: any;
};
export function serializeMessage(
message: T,
-): Omit & { content: string | null } {
- const { deletedAt, ...rest } = message;
+): Omit & {
+ ciphertext: string | null;
+ unavailable?: boolean;
+} {
+ const { deletedAt, envelopes, ciphertext: baseCiphertext, ...rest } = message;
+ if (deletedAt) {
+ return {
+ ...rest,
+ ciphertext: null,
+ };
+ }
+
+ // If there's an envelope, its ciphertext takes precedence.
+ if (envelopes && envelopes.length > 0) {
+ return {
+ ...rest,
+ ciphertext: envelopes[0]!.ciphertext,
+ };
+ }
+
+ // If no envelope but we have base ciphertext (e.g. system message or legacy), use it.
+ if (baseCiphertext) {
+ return {
+ ...rest,
+ ciphertext: baseCiphertext,
+ };
+ }
+
+ // Otherwise, it's unavailable.
return {
...rest,
- content: deletedAt ? null : message.content,
+ ciphertext: null,
+ unavailable: true,
};
}
diff --git a/apps/backend/src/routes/conversations.ts b/apps/backend/src/routes/conversations.ts
index f0b1252..673385b 100644
--- a/apps/backend/src/routes/conversations.ts
+++ b/apps/backend/src/routes/conversations.ts
@@ -2,7 +2,13 @@ import { Router } from 'express';
import type { IRouter } from 'express';
import { asc, and, count, desc, eq, lt, sql, ne } from 'drizzle-orm';
import { db } from '../db/index.js';
-import { conversationMembers, conversations, messages, tokenTransfers } from '../db/schema.js';
+import {
+ conversationMembers,
+ conversations,
+ messages,
+ tokenTransfers,
+ messageEnvelopes,
+} from '../db/schema.js';
import { requireAuth, type AuthRequest } from '../middleware/auth.js';
import { redis, CONV_CACHE_TTL, convCacheKey } from '../lib/redis.js';
import { invalidateConversationCaches } from '../lib/conversationCache.js';
@@ -14,9 +20,7 @@ export const conversationsRouter: IRouter = Router();
conversationsRouter.use(requireAuth);
-const SEARCH_RESULT_LIMIT = 20;
-
-const conversationRelations = {
+const getConversationRelations = (deviceId: string) => ({
members: {
with: {
user: {
@@ -28,9 +32,15 @@ const conversationRelations = {
messages: {
orderBy: desc(messages.createdAt),
limit: 1,
- with: { sender: { columns: { id: true, username: true, avatarUrl: true } } },
+ with: {
+ sender: { columns: { id: true, username: true, avatarUrl: true } },
+ envelopes: {
+ where: eq(messageEnvelopes.recipientDeviceId, deviceId),
+ limit: 1,
+ },
+ },
},
-} as const;
+});
type ConversationPayload = {
messages?: Array>;
@@ -40,7 +50,9 @@ type ConversationPayload = {
function serializeConversation(conversation: T): T {
return {
...conversation,
- messages: (conversation.messages ?? []).map((message) => serializeMessage(message)),
+ messages: (conversation.messages ?? []).map((message) =>
+ serializeMessage(message as any),
+ ) as any,
};
}
@@ -93,13 +105,13 @@ conversationsRouter.get('/', async (req: AuthRequest, res) => {
showArchived ? undefined : ne(conversationMembers.isArchived, true),
),
with: {
- conversation: conversationRelations as never,
+ conversation: getConversationRelations(req.auth!.deviceId) as never,
},
})) as unknown as Array<{
conversationId: string;
- conversation: ConversationPayload;
isMuted: boolean;
isArchived: boolean;
+ conversation: ConversationPayload;
}>;
// Single subquery for message counts — no N+1
@@ -182,7 +194,7 @@ conversationsRouter.get('/:id', async (req: AuthRequest, res) => {
const conversation = (await db.query.conversations.findFirst({
where: eq(conversations.id, conversationId),
- with: conversationRelations as never,
+ with: getConversationRelations(req.auth!.deviceId) as never,
})) as ConversationPayload | undefined;
if (!conversation) {
@@ -476,7 +488,13 @@ conversationsRouter.get('/:id/messages', async (req: AuthRequest, res) => {
: eq(messages.conversationId, conversationId),
orderBy: desc(messages.createdAt),
limit: limit + 1,
- with: { sender: { columns: { id: true, username: true, avatarUrl: true } } },
+ with: {
+ sender: { columns: { id: true, username: true, avatarUrl: true } },
+ envelopes: {
+ where: eq(messageEnvelopes.recipientDeviceId, req.auth!.deviceId),
+ limit: 1,
+ },
+ },
});
const hasMore = rows.length > limit;
@@ -491,66 +509,7 @@ conversationsRouter.get('/:id/messages', async (req: AuthRequest, res) => {
});
conversationsRouter.get('/:id/search', async (req: AuthRequest, res) => {
- const userId = req.auth!.userId;
- const conversationId = req.params['id'] as string | undefined;
- const query = typeof req.query.q === 'string' ? req.query.q.trim() : '';
-
- if (!conversationId) {
- res.status(400).json({ error: 'Conversation id is required' });
- return;
- }
-
- if (!query) {
- res.status(400).json({ error: 'Search query is required' });
- return;
- }
-
- const membership = await db.query.conversationMembers.findFirst({
- where: and(
- eq(conversationMembers.conversationId, conversationId),
- eq(conversationMembers.userId, userId),
- ),
- });
-
- if (!membership) {
- res.status(403).json({ error: 'Not a member of this conversation' });
- return;
- }
-
- const results = await db.execute<{
- id: string;
- conversationId: string;
- senderId: string;
- content: string;
- createdAt: Date;
- snippet: string;
- rank: string;
- }>(sql`
- WITH search_query AS (
- SELECT websearch_to_tsquery('english', ${query}) AS query
- )
- SELECT
- ${messages.id} AS "id",
- ${messages.conversationId} AS "conversationId",
- ${messages.senderId} AS "senderId",
- ${messages.content} AS "content",
- ${messages.createdAt} AS "createdAt",
- ts_headline(
- 'english',
- ${messages.content},
- search_query.query,
- 'StartSel=, StopSel=, MaxWords=24, MinWords=8, ShortWord=3, HighlightAll=false'
- ) AS "snippet",
- ts_rank_cd(to_tsvector('english', ${messages.content}), search_query.query) AS "rank"
- FROM ${messages}, search_query
- WHERE ${messages.conversationId} = ${conversationId}
- AND ${messages.deletedAt} IS NULL
- AND search_query.query @@ to_tsvector('english', ${messages.content})
- ORDER BY "rank" DESC, ${messages.createdAt} DESC
- LIMIT ${SEARCH_RESULT_LIMIT}
- `);
-
- res.json({ results });
+ res.status(501).json({ error: 'Search is not supported in E2EE conversations' });
});
// PATCH /conversations/:id/settings — update muted/archived state for the authenticated user
diff --git a/apps/backend/src/routes/messages.ts b/apps/backend/src/routes/messages.ts
index 0c3838b..32e1ba3 100644
--- a/apps/backend/src/routes/messages.ts
+++ b/apps/backend/src/routes/messages.ts
@@ -2,7 +2,7 @@ import { Router } from 'express';
import type { IRouter } from 'express';
import { and, eq } from 'drizzle-orm';
import { db } from '../db/index.js';
-import { conversationMembers, messages } from '../db/schema.js';
+import { conversationMembers, messages, messageEnvelopes } from '../db/schema.js';
import { requireAuth, type AuthRequest } from '../middleware/auth.js';
import { invalidateConversationCaches } from '../lib/conversationCache.js';
import { getSocketServer } from '../lib/socket.js';
@@ -36,9 +36,11 @@ messagesRouter.delete('/:id', async (req: AuthRequest, res) => {
await db
.update(messages)
- .set({ deletedAt: new Date() })
+ .set({ deletedAt: new Date(), ciphertext: null })
.where(and(eq(messages.id, messageId), eq(messages.senderId, userId)));
+ await db.delete(messageEnvelopes).where(eq(messageEnvelopes.messageId, messageId));
+
getSocketServer()?.to(message.conversationId).emit('message_deleted', {
messageId: message.id,
conversationId: message.conversationId,
diff --git a/apps/backend/src/routes/treasury.ts b/apps/backend/src/routes/treasury.ts
index 3a205c0..f11d342 100644
--- a/apps/backend/src/routes/treasury.ts
+++ b/apps/backend/src/routes/treasury.ts
@@ -1,9 +1,9 @@
-import { Router, type Router as RouterType } from 'express';
+import { Router, type IRouter } from 'express';
import { z } from 'zod';
import { requireAuth, type AuthRequest } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
-export const treasuryRouter: RouterType = Router();
+export const treasuryRouter: IRouter = Router();
treasuryRouter.use(requireAuth);
diff --git a/apps/backend/src/socket/messaging.ts b/apps/backend/src/socket/messaging.ts
index 8c92463..47b1471 100644
--- a/apps/backend/src/socket/messaging.ts
+++ b/apps/backend/src/socket/messaging.ts
@@ -1,7 +1,13 @@
import type { Server } from 'socket.io';
-import { and, eq, lt, desc, sql } from 'drizzle-orm';
+import { and, eq, lt, desc, sql, inArray } from 'drizzle-orm';
import { db } from '../db/index.js';
-import { conversations, conversationMembers, messages } from '../db/schema.js';
+import {
+ conversations,
+ conversationMembers,
+ messages,
+ messageEnvelopes,
+ userDevices,
+} from '../db/schema.js';
import type { AuthSocket } from '../middleware/socketAuth.js';
import { invalidateConversationCaches } from '../lib/conversationCache.js';
import { serializeMessage } from '../lib/messages.js';
@@ -35,42 +41,105 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void
});
// ── send_message ───────────────────────────────────────────────────────────
- // Payload: { conversationId: string; content: string }
+ // Payload: { conversationId, messageId, contentType, ciphertext, envelopes }
// Persists the message and broadcasts it to all room members.
- socket.on('send_message', async (payload: { conversationId: string; content: string }) => {
- const { conversationId, content } = payload;
+ socket.on(
+ 'send_message',
+ async (payload: {
+ conversationId: string;
+ messageId: string;
+ contentType?: string;
+ ciphertext?: string;
+ envelopes?: Array<{ recipientDeviceId: string; ciphertext: string }>;
+ }) => {
+ const { conversationId, messageId, contentType, ciphertext, envelopes } = payload;
+ const deviceId = socket.auth!.deviceId;
+
+ if (!messageId) {
+ socket.emit('error', { event: 'send_message', message: 'messageId is required' });
+ return;
+ }
- if (!content?.trim()) {
- socket.emit('error', { event: 'send_message', message: 'Content must not be empty' });
- return;
- }
+ if (!ciphertext?.trim() && (!envelopes || envelopes.length === 0)) {
+ socket.emit('error', { event: 'send_message', message: 'Message content is empty' });
+ return;
+ }
- const membership = await db.query.conversationMembers.findFirst({
- where: and(
- eq(conversationMembers.conversationId, conversationId),
- eq(conversationMembers.userId, userId),
- ),
- });
+ const membership = await db.query.conversationMembers.findFirst({
+ where: and(
+ eq(conversationMembers.conversationId, conversationId),
+ eq(conversationMembers.userId, userId),
+ ),
+ });
- if (!membership) {
- socket.emit('error', { event: 'send_message', message: 'Not a member of this conversation' });
- return;
- }
+ if (!membership) {
+ socket.emit('error', {
+ event: 'send_message',
+ message: 'Not a member of this conversation',
+ });
+ return;
+ }
- const [message] = await db
- .insert(messages)
- .values({ conversationId, senderId: userId, content: content.trim() })
- .returning();
+ // Idempotency check
+ const existing = await db.query.messages.findFirst({
+ where: eq(messages.id, messageId),
+ columns: { sequenceNumber: true },
+ });
- io.to(conversationId).volatile.emit('new_message', message);
+ if (existing) {
+ socket.emit('message_ack', { messageId, sequenceNumber: existing.sequenceNumber });
+ return;
+ }
- const members = await db.query.conversationMembers.findMany({
- where: eq(conversationMembers.conversationId, conversationId),
- columns: { userId: true },
- });
+ const [message] = await db
+ .insert(messages)
+ .values({
+ id: messageId,
+ conversationId,
+ senderId: userId,
+ senderDeviceId: deviceId,
+ contentType: contentType || 'text/plain',
+ ciphertext: ciphertext || null,
+ })
+ .returning();
- await invalidateConversationCaches(members.map((member) => member.userId));
- });
+ if (envelopes && envelopes.length > 0) {
+ const deviceIds = envelopes.map((e) => e.recipientDeviceId);
+ const devicesList = await db.query.userDevices.findMany({
+ where: inArray(userDevices.id, deviceIds),
+ columns: { id: true, userId: true },
+ });
+ const deviceToUser = new Map(devicesList.map((d) => [d.id, d.userId]));
+
+ const validEnvelopes = envelopes
+ .filter((env) => deviceToUser.has(env.recipientDeviceId))
+ .map((env) => ({
+ messageId,
+ recipientDeviceId: env.recipientDeviceId,
+ recipientUserId: deviceToUser.get(env.recipientDeviceId)!,
+ ciphertext: env.ciphertext,
+ }));
+
+ if (validEnvelopes.length > 0) {
+ await db.insert(messageEnvelopes).values(validEnvelopes);
+ }
+ }
+
+ // Emit acknowledgment to sender
+ if (message) {
+ socket.emit('message_ack', { messageId, sequenceNumber: message.sequenceNumber });
+ }
+
+ io.to(conversationId).emit('new_message', message);
+
+ const members = await db.query.conversationMembers.findMany({
+ where: eq(conversationMembers.conversationId, conversationId),
+ columns: { userId: true },
+ });
+
+ await invalidateConversationCaches(members.map((member) => member.userId));
+ },
+ );
// ── message_history ────────────────────────────────────────────────────────
// Payload: { conversationId: string; before?: string } (before = message id cursor)
@@ -116,6 +185,31 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void
});
});
+ // ── delete_message ─────────────────────────────────────────────────────────
+ // Payload: { messageId: string }
+ // Sender retraction
+ socket.on('delete_message', async (payload: { messageId: string }) => {
+ const { messageId } = payload;
+ if (!messageId) return;
+
+ const message = await db.query.messages.findFirst({
+ where: eq(messages.id, messageId),
+ });
+
+ if (!message || message.senderId !== userId) {
+ socket.emit('error', { event: 'delete_message', message: 'Message not found or not sender' });
+ return;
+ }
+
+ await db
+ .update(messages)
+ .set({ deletedAt: new Date(), ciphertext: null })
+ .where(eq(messages.id, messageId));
+ await db.delete(messageEnvelopes).where(eq(messageEnvelopes.messageId, messageId));
+
+ io.to(message.conversationId).emit('message_deleted', { messageId });
+ });
+
// ── message_read ───────────────────────────────────────────────────────────
// Payload: { conversationId: string; lastReadMessageId: string }
// Persists the caller's read position and broadcasts to the room.
@@ -317,7 +411,8 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void
.values({
conversationId,
senderId: ASSISTANT_USER_ID,
- content: data.reply,
+ contentType: 'text/plain',
+ ciphertext: data.reply,
})
.returning();
diff --git a/apps/backend/vitest.config.d.ts b/apps/backend/vitest.config.d.ts
new file mode 100644
index 0000000..2b17c25
--- /dev/null
+++ b/apps/backend/vitest.config.d.ts
@@ -0,0 +1,3 @@
+declare const _default: import("vite").UserConfig;
+export default _default;
+//# sourceMappingURL=vitest.config.d.ts.map
\ No newline at end of file
diff --git a/apps/backend/vitest.config.d.ts.map b/apps/backend/vitest.config.d.ts.map
new file mode 100644
index 0000000..062d697
--- /dev/null
+++ b/apps/backend/vitest.config.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"vitest.config.d.ts","sourceRoot":"","sources":["vitest.config.ts"],"names":[],"mappings":";AAEA,wBAKG"}
\ No newline at end of file
diff --git a/apps/backend/vitest.config.js b/apps/backend/vitest.config.js
new file mode 100644
index 0000000..771b453
--- /dev/null
+++ b/apps/backend/vitest.config.js
@@ -0,0 +1,8 @@
+import { defineConfig } from 'vitest/config';
+export default defineConfig({
+ test: {
+ environment: 'node',
+ setupFiles: ['./src/__tests__/setup.ts'],
+ },
+});
+//# sourceMappingURL=vitest.config.js.map
\ No newline at end of file
diff --git a/apps/backend/vitest.config.js.map b/apps/backend/vitest.config.js.map
new file mode 100644
index 0000000..0958da5
--- /dev/null
+++ b/apps/backend/vitest.config.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"vitest.config.js","sourceRoot":"","sources":["vitest.config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAE7C,eAAe,YAAY,CAAC;IAC1B,IAAI,EAAE;QACJ,WAAW,EAAE,MAAM;QACnB,UAAU,EAAE,CAAC,0BAA0B,CAAC;KACzC;CACF,CAAC,CAAC"}
\ No newline at end of file