1import { httpRouter } from 'convex/server';
2import { httpAction } from './_generated/server';
3import { internal } from './_generated/api';
4import type { Id } from './_generated/dataModel';
5
6const http = httpRouter();
7
8// Constant-time string comparison to prevent timing attacks
9function constantTimeCompare(a: string, b: string): boolean {
10 if (a.length !== b.length) {
11 // Still do work to avoid leaking length via timing
12 let result = a.length ^ b.length;
13 for (let i = 0; i < a.length; i++) {
14 result |= a.charCodeAt(i) ^ a.charCodeAt(i);
15 }
16 return result === 0; // always false when lengths differ
17 }
18 let result = 0;
19 for (let i = 0; i < a.length; i++) {
20 result |= a.charCodeAt(i) ^ b.charCodeAt(i);
21 }
22 return result === 0;
23}
24
25// Validate the secret header for server-to-server calls
26function validateSecret(request: Request): boolean {
27 const secret = request.headers.get('X-Convex-Secret');
28 const expectedSecret = process.env.CONVEX_API_SECRET;
29
30 if (!expectedSecret) {
31 console.error('CONVEX_API_SECRET environment variable not set');
32 return false;
33 }
34
35 if (!secret) {
36 return false;
37 }
38
39 return constantTimeCompare(secret, expectedSecret);
40}
41
42// Generic error response
43function unauthorizedResponse() {
44 return new Response(JSON.stringify({ error: 'Unauthorized' }), {
45 status: 401,
46 headers: { 'Content-Type': 'application/json' }
47 });
48}
49
50function errorResponse(message: string, status = 400) {
51 return new Response(JSON.stringify({ error: message }), {
52 status,
53 headers: { 'Content-Type': 'application/json' }
54 });
55}
56
57// Helper function to safely parse JSON body
58async function parseJsonBody<T>(request: Request): Promise<T | null> {
59 try {
60 return (await request.json()) as T;
61 } catch {
62 return null;
63 }
64}
65
66// ============ USERS (server-side only) ============
67
68// Get user by nickname - for login
69http.route({
70 path: '/api/users/getByNickname',
71 method: 'POST',
72 handler: httpAction(async (ctx, request) => {
73 if (!validateSecret(request)) return unauthorizedResponse();
74
75 const body = await parseJsonBody<{ nickname: string }>(request);
76 if (!body) return errorResponse('Invalid JSON', 400);
77
78 const user = await ctx.runQuery(internal.usersInternal.getByNickname, {
79 nickname: body.nickname
80 });
81
82 return new Response(JSON.stringify(user), {
83 headers: { 'Content-Type': 'application/json' }
84 });
85 })
86});
87
88// Create user - for registration
89http.route({
90 path: '/api/users/create',
91 method: 'POST',
92 handler: httpAction(async (ctx, request) => {
93 if (!validateSecret(request)) return unauthorizedResponse();
94
95 const body = await parseJsonBody<{ nickname: string; password: string }>(request);
96 if (!body) return errorResponse('Invalid JSON', 400);
97
98 try {
99 const userId = await ctx.runMutation(internal.usersInternal.create, {
100 nickname: body.nickname,
101 password: body.password
102 });
103 return new Response(JSON.stringify({ userId }), {
104 headers: { 'Content-Type': 'application/json' }
105 });
106 } catch (e) {
107 return errorResponse(e instanceof Error ? e.message : 'Failed to create user');
108 }
109 })
110});
111
112// Get user by ID
113http.route({
114 path: '/api/users/getById',
115 method: 'POST',
116 handler: httpAction(async (ctx, request) => {
117 if (!validateSecret(request)) return unauthorizedResponse();
118
119 const body = await parseJsonBody<{ id: string }>(request);
120 if (!body) return errorResponse('Invalid JSON', 400);
121
122 const user = await ctx.runQuery(internal.usersInternal.getById, { id: body.id as Id<'users'> });
123
124 return new Response(JSON.stringify(user), {
125 headers: { 'Content-Type': 'application/json' }
126 });
127 })
128});
129
130// List all users
131http.route({
132 path: '/api/users/list',
133 method: 'POST',
134 handler: httpAction(async (ctx, request) => {
135 if (!validateSecret(request)) return unauthorizedResponse();
136
137 const users = await ctx.runQuery(internal.usersInternal.list);
138
139 return new Response(JSON.stringify(users), {
140 headers: { 'Content-Type': 'application/json' }
141 });
142 })
143});
144
145// Update user status
146http.route({
147 path: '/api/users/updateStatus',
148 method: 'POST',
149 handler: httpAction(async (ctx, request) => {
150 if (!validateSecret(request)) return unauthorizedResponse();
151
152 const body = await parseJsonBody<{ userId: string; status: string }>(request);
153 if (!body) return errorResponse('Invalid JSON', 400);
154
155 try {
156 const user = await ctx.runMutation(internal.usersInternal.updateStatus, {
157 id: body.userId as Id<'users'>,
158 status: body.status
159 });
160 return new Response(JSON.stringify(user), {
161 headers: { 'Content-Type': 'application/json' }
162 });
163 } catch (e) {
164 return errorResponse(e instanceof Error ? e.message : 'Failed to update status');
165 }
166 })
167});
168
169// Set all users offline (server startup)
170http.route({
171 path: '/api/users/setAllOffline',
172 method: 'POST',
173 handler: httpAction(async (ctx, request) => {
174 if (!validateSecret(request)) return unauthorizedResponse();
175
176 const result = await ctx.runMutation(internal.usersInternal.setAllOffline);
177
178 return new Response(JSON.stringify(result), {
179 headers: { 'Content-Type': 'application/json' }
180 });
181 })
182});
183
184// Update last read mention timestamp
185http.route({
186 path: '/api/users/updateLastReadMentionTimestamp',
187 method: 'POST',
188 handler: httpAction(async (ctx, request) => {
189 if (!validateSecret(request)) return unauthorizedResponse();
190
191 const body = await parseJsonBody<{ userId: string; timestamp: number }>(request);
192 if (!body) return errorResponse('Invalid JSON', 400);
193
194 try {
195 const result = await ctx.runMutation(internal.usersInternal.updateLastReadMentionTimestamp, {
196 id: body.userId as Id<'users'>,
197 timestamp: body.timestamp
198 });
199 return new Response(JSON.stringify(result), {
200 headers: { 'Content-Type': 'application/json' }
201 });
202 } catch (e) {
203 return errorResponse(e instanceof Error ? e.message : 'Failed to update timestamp');
204 }
205 })
206});
207
208// ============ SESSIONS (server-side only) ============
209
210// Create session - for login
211http.route({
212 path: '/api/sessions/create',
213 method: 'POST',
214 handler: httpAction(async (ctx, request) => {
215 if (!validateSecret(request)) return unauthorizedResponse();
216
217 const body = await parseJsonBody<{ tokenHash: string; userId: string; expiresAt: number }>(
218 request
219 );
220 if (!body) return errorResponse('Invalid JSON', 400);
221
222 try {
223 const result = await ctx.runMutation(internal.sessionsInternal.create, {
224 tokenHash: body.tokenHash,
225 userId: body.userId as Id<'users'>,
226 expiresAt: body.expiresAt
227 });
228 return new Response(JSON.stringify(result), {
229 headers: { 'Content-Type': 'application/json' }
230 });
231 } catch (e) {
232 return errorResponse(e instanceof Error ? e.message : 'Failed to create session');
233 }
234 })
235});
236
237// Validate session
238http.route({
239 path: '/api/sessions/validate',
240 method: 'POST',
241 handler: httpAction(async (ctx, request) => {
242 if (!validateSecret(request)) return unauthorizedResponse();
243
244 const body = await parseJsonBody<{ tokenHash: string }>(request);
245 if (!body) return errorResponse('Invalid JSON', 400);
246
247 const result = await ctx.runQuery(internal.sessionsInternal.validateAndGetUser, {
248 tokenHash: body.tokenHash
249 });
250
251 return new Response(JSON.stringify(result), {
252 headers: { 'Content-Type': 'application/json' }
253 });
254 })
255});
256
257// Remove session - for logout
258http.route({
259 path: '/api/sessions/remove',
260 method: 'POST',
261 handler: httpAction(async (ctx, request) => {
262 if (!validateSecret(request)) return unauthorizedResponse();
263
264 const body = await parseJsonBody<{ tokenHash: string }>(request);
265 if (!body) return errorResponse('Invalid JSON', 400);
266
267 await ctx.runMutation(internal.sessionsInternal.remove, { tokenHash: body.tokenHash });
268
269 return new Response(JSON.stringify({ success: true }), {
270 headers: { 'Content-Type': 'application/json' }
271 });
272 })
273});
274
275// ============ CHAT ROOMS (server-side only) ============
276
277// Get or create default room
278http.route({
279 path: '/api/chatRooms/getOrCreateDefault',
280 method: 'POST',
281 handler: httpAction(async (ctx, request) => {
282 if (!validateSecret(request)) return unauthorizedResponse();
283
284 const room = await ctx.runMutation(internal.chatRoomsInternal.getOrCreateDefault);
285
286 return new Response(JSON.stringify(room), {
287 headers: { 'Content-Type': 'application/json' }
288 });
289 })
290});
291
292// Get room by ID
293http.route({
294 path: '/api/chatRooms/getById',
295 method: 'POST',
296 handler: httpAction(async (ctx, request) => {
297 if (!validateSecret(request)) return unauthorizedResponse();
298
299 const body = await parseJsonBody<{ id: string }>(request);
300 if (!body) return errorResponse('Invalid JSON', 400);
301
302 const room = await ctx.runQuery(internal.chatRoomsInternal.getById, {
303 id: body.id as Id<'chatRooms'>
304 });
305
306 return new Response(JSON.stringify(room), {
307 headers: { 'Content-Type': 'application/json' }
308 });
309 })
310});
311
312// ============ MESSAGES (server-side only) ============
313
314// Get messages by room - for initial load
315http.route({
316 path: '/api/messages/getByRoom',
317 method: 'POST',
318 handler: httpAction(async (ctx, request) => {
319 if (!validateSecret(request)) return unauthorizedResponse();
320
321 const body = await parseJsonBody<{ roomId: string; limit?: number; beforeTimestamp?: number }>(
322 request
323 );
324 if (!body) return errorResponse('Invalid JSON', 400);
325
326 const result = await ctx.runQuery(internal.messagesInternal.getByRoom, {
327 roomId: body.roomId as Id<'chatRooms'>,
328 limit: body.limit,
329 beforeTimestamp: body.beforeTimestamp
330 });
331
332 return new Response(JSON.stringify(result), {
333 headers: { 'Content-Type': 'application/json' }
334 });
335 })
336});
337
338// Send message
339http.route({
340 path: '/api/messages/send',
341 method: 'POST',
342 handler: httpAction(async (ctx, request) => {
343 if (!validateSecret(request)) return unauthorizedResponse();
344
345 const body = await parseJsonBody<{
346 chatRoomId: string;
347 senderId: string;
348 content: string;
349 type: string;
350 styleData?: string;
351 hasFormatting?: boolean;
352 timestamp?: number;
353 }>(request);
354 if (!body) return errorResponse('Invalid JSON', 400);
355
356 try {
357 const message = await ctx.runMutation(internal.messagesInternal.send, {
358 chatRoomId: body.chatRoomId as Id<'chatRooms'>,
359 senderId: body.senderId as Id<'users'>,
360 content: body.content,
361 type: body.type,
362 styleData: body.styleData,
363 hasFormatting: body.hasFormatting,
364 timestamp: body.timestamp
365 });
366 return new Response(JSON.stringify(message), {
367 headers: { 'Content-Type': 'application/json' }
368 });
369 } catch (e) {
370 return errorResponse(e instanceof Error ? e.message : 'Failed to send message');
371 }
372 })
373});
374
375// ============ TEXT PREFERENCES (server-side only) ============
376
377// Get user text preferences
378http.route({
379 path: '/api/textPreferences/get',
380 method: 'POST',
381 handler: httpAction(async (ctx, request) => {
382 if (!validateSecret(request)) return unauthorizedResponse();
383
384 const body = await parseJsonBody<{ userId: string }>(request);
385 if (!body) return errorResponse('Invalid JSON', 400);
386
387 const preferences = await ctx.runQuery(internal.textPreferencesInternal.getByUserId, {
388 userId: body.userId as Id<'users'>
389 });
390
391 return new Response(JSON.stringify(preferences), {
392 headers: { 'Content-Type': 'application/json' }
393 });
394 })
395});
396
397// Save user text preferences
398http.route({
399 path: '/api/textPreferences/save',
400 method: 'POST',
401 handler: httpAction(async (ctx, request) => {
402 if (!validateSecret(request)) return unauthorizedResponse();
403
404 const body = await parseJsonBody<{
405 userId: string;
406 defaultStyle: {
407 fontFamily: string;
408 fontSize: number;
409 color?: string;
410 gradient?: string[];
411 bold: boolean;
412 italic: boolean;
413 underline: boolean;
414 strikethrough: boolean;
415 };
416 allowFormatting: boolean;
417 maxMessageLength: number;
418 }>(request);
419 if (!body) return errorResponse('Invalid JSON', 400);
420
421 try {
422 const result = await ctx.runMutation(internal.textPreferencesInternal.upsert, {
423 userId: body.userId as Id<'users'>,
424 defaultStyle: body.defaultStyle,
425 allowFormatting: body.allowFormatting,
426 maxMessageLength: body.maxMessageLength
427 });
428 return new Response(JSON.stringify({ success: true, id: result }), {
429 headers: { 'Content-Type': 'application/json' }
430 });
431 } catch (e) {
432 return errorResponse(e instanceof Error ? e.message : 'Failed to save preferences');
433 }
434 })
435});
436
437export default http;