@studious-lms/server 1.4.1 → 1.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/.coderabbit.yaml +4 -1
  2. package/.env.example +6 -0
  3. package/.env.test.example +2 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +101 -76
  6. package/dist/index.js.map +1 -1
  7. package/dist/lib/config/cors.d.ts +16 -0
  8. package/dist/lib/config/cors.d.ts.map +1 -0
  9. package/dist/lib/config/cors.js +75 -0
  10. package/dist/lib/config/cors.js.map +1 -0
  11. package/dist/lib/config/env.d.ts +14 -0
  12. package/dist/lib/config/env.d.ts.map +1 -1
  13. package/dist/lib/config/env.js +9 -2
  14. package/dist/lib/config/env.js.map +1 -1
  15. package/dist/lib/fileUpload.d.ts +10 -2
  16. package/dist/lib/fileUpload.d.ts.map +1 -1
  17. package/dist/lib/fileUpload.js +38 -6
  18. package/dist/lib/fileUpload.js.map +1 -1
  19. package/dist/lib/prisma.d.ts +14 -2
  20. package/dist/lib/prisma.d.ts.map +1 -1
  21. package/dist/lib/prisma.js +27 -8
  22. package/dist/lib/prisma.js.map +1 -1
  23. package/dist/middleware/auth.d.ts.map +1 -1
  24. package/dist/middleware/auth.js +18 -5
  25. package/dist/middleware/auth.js.map +1 -1
  26. package/dist/middleware/security.d.ts.map +1 -1
  27. package/dist/middleware/security.js +3 -3
  28. package/dist/middleware/security.js.map +1 -1
  29. package/dist/models/agenda.d.ts +16 -16
  30. package/dist/models/announcement.d.ts +59 -23
  31. package/dist/models/announcement.d.ts.map +1 -1
  32. package/dist/models/assignment.d.ts +363 -276
  33. package/dist/models/assignment.d.ts.map +1 -1
  34. package/dist/models/attendance.d.ts +63 -21
  35. package/dist/models/attendance.d.ts.map +1 -1
  36. package/dist/models/auth.d.ts +115 -26
  37. package/dist/models/auth.d.ts.map +1 -1
  38. package/dist/models/auth.js +53 -30
  39. package/dist/models/auth.js.map +1 -1
  40. package/dist/models/class.d.ts +115 -65
  41. package/dist/models/class.d.ts.map +1 -1
  42. package/dist/models/class.js +16 -4
  43. package/dist/models/class.js.map +1 -1
  44. package/dist/models/comment.d.ts +52 -16
  45. package/dist/models/comment.d.ts.map +1 -1
  46. package/dist/models/conversation.d.ts +46 -16
  47. package/dist/models/conversation.d.ts.map +1 -1
  48. package/dist/models/event.d.ts +107 -53
  49. package/dist/models/event.d.ts.map +1 -1
  50. package/dist/models/file.d.ts +213 -165
  51. package/dist/models/file.d.ts.map +1 -1
  52. package/dist/models/folder.d.ts +161 -77
  53. package/dist/models/folder.d.ts.map +1 -1
  54. package/dist/models/labChat.d.ts +73 -31
  55. package/dist/models/labChat.d.ts.map +1 -1
  56. package/dist/models/marketing.d.ts +25 -7
  57. package/dist/models/marketing.d.ts.map +1 -1
  58. package/dist/models/message.d.ts +31 -13
  59. package/dist/models/message.d.ts.map +1 -1
  60. package/dist/models/newtonChat.d.ts +34 -10
  61. package/dist/models/newtonChat.d.ts.map +1 -1
  62. package/dist/models/notification.d.ts +29 -10
  63. package/dist/models/notification.d.ts.map +1 -1
  64. package/dist/models/notification.js +13 -9
  65. package/dist/models/notification.js.map +1 -1
  66. package/dist/models/section.d.ts +71 -23
  67. package/dist/models/section.d.ts.map +1 -1
  68. package/dist/models/user.d.ts +27 -9
  69. package/dist/models/user.d.ts.map +1 -1
  70. package/dist/models/worksheet.d.ts +606 -106
  71. package/dist/models/worksheet.d.ts.map +1 -1
  72. package/dist/models/worksheet.js +168 -2
  73. package/dist/models/worksheet.js.map +1 -1
  74. package/dist/pipelines/aiLabChat.d.ts +22 -2
  75. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  76. package/dist/pipelines/aiLabChat.js +133 -95
  77. package/dist/pipelines/aiLabChat.js.map +1 -1
  78. package/dist/pipelines/aiLabChatContract.d.ts +25 -25
  79. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  80. package/dist/pipelines/aiNewtonChat.js +25 -14
  81. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  82. package/dist/pipelines/gradeWorksheet.d.ts +8 -8
  83. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  84. package/dist/pipelines/gradeWorksheet.js +12 -5
  85. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  86. package/dist/pipelines/labChatPrompt.d.ts +27 -0
  87. package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
  88. package/dist/pipelines/labChatPrompt.js +143 -69
  89. package/dist/pipelines/labChatPrompt.js.map +1 -1
  90. package/dist/routers/_app.d.ts +1579 -1257
  91. package/dist/routers/_app.d.ts.map +1 -1
  92. package/dist/routers/agenda.d.ts +16 -16
  93. package/dist/routers/announcement.d.ts +19 -19
  94. package/dist/routers/assignment.d.ts +307 -291
  95. package/dist/routers/assignment.d.ts.map +1 -1
  96. package/dist/routers/assignment.js +3 -2
  97. package/dist/routers/assignment.js.map +1 -1
  98. package/dist/routers/attendance.d.ts +7 -7
  99. package/dist/routers/auth.d.ts +1 -1
  100. package/dist/routers/class.d.ts +79 -72
  101. package/dist/routers/class.d.ts.map +1 -1
  102. package/dist/routers/class.js +3 -3
  103. package/dist/routers/class.js.map +1 -1
  104. package/dist/routers/comment.d.ts +6 -6
  105. package/dist/routers/conversation.d.ts +11 -11
  106. package/dist/routers/event.d.ts +35 -35
  107. package/dist/routers/file.d.ts +12 -12
  108. package/dist/routers/folder.d.ts +54 -54
  109. package/dist/routers/labChat.d.ts +18 -12
  110. package/dist/routers/labChat.d.ts.map +1 -1
  111. package/dist/routers/labChat.js +5 -3
  112. package/dist/routers/labChat.js.map +1 -1
  113. package/dist/routers/marketing.d.ts +2 -2
  114. package/dist/routers/message.d.ts +33 -2
  115. package/dist/routers/message.d.ts.map +1 -1
  116. package/dist/routers/message.js +27 -4
  117. package/dist/routers/message.js.map +1 -1
  118. package/dist/routers/newtonChat.d.ts +7 -1
  119. package/dist/routers/newtonChat.d.ts.map +1 -1
  120. package/dist/routers/newtonChat.js +5 -4
  121. package/dist/routers/newtonChat.js.map +1 -1
  122. package/dist/routers/notifications.d.ts +13 -6
  123. package/dist/routers/notifications.d.ts.map +1 -1
  124. package/dist/routers/notifications.js +4 -4
  125. package/dist/routers/notifications.js.map +1 -1
  126. package/dist/routers/section.d.ts +7 -7
  127. package/dist/routers/studentProgress.d.ts +86 -0
  128. package/dist/routers/studentProgress.d.ts.map +1 -1
  129. package/dist/routers/studentProgress.js +14 -4
  130. package/dist/routers/studentProgress.js.map +1 -1
  131. package/dist/routers/user.d.ts +1 -1
  132. package/dist/routers/worksheet.d.ts +64 -62
  133. package/dist/routers/worksheet.d.ts.map +1 -1
  134. package/dist/routers/worksheet.js +18 -16
  135. package/dist/routers/worksheet.js.map +1 -1
  136. package/dist/seedDatabase.d.ts +1 -1
  137. package/dist/services/agenda.d.ts +16 -16
  138. package/dist/services/announcement.d.ts +8 -8
  139. package/dist/services/assignment.d.ts +299 -283
  140. package/dist/services/assignment.d.ts.map +1 -1
  141. package/dist/services/assignment.js +24 -5
  142. package/dist/services/assignment.js.map +1 -1
  143. package/dist/services/attendance.d.ts +7 -7
  144. package/dist/services/auth.d.ts +2 -2
  145. package/dist/services/auth.d.ts.map +1 -1
  146. package/dist/services/auth.js +19 -12
  147. package/dist/services/auth.js.map +1 -1
  148. package/dist/services/class.d.ts +74 -68
  149. package/dist/services/class.d.ts.map +1 -1
  150. package/dist/services/class.js +6 -3
  151. package/dist/services/class.js.map +1 -1
  152. package/dist/services/comment.d.ts +6 -6
  153. package/dist/services/conversation.d.ts +11 -11
  154. package/dist/services/event.d.ts +31 -31
  155. package/dist/services/file.d.ts +12 -12
  156. package/dist/services/file.d.ts.map +1 -1
  157. package/dist/services/file.js +7 -2
  158. package/dist/services/file.js.map +1 -1
  159. package/dist/services/folder.d.ts +52 -52
  160. package/dist/services/labChat.d.ts +18 -12
  161. package/dist/services/labChat.d.ts.map +1 -1
  162. package/dist/services/labChat.js +30 -18
  163. package/dist/services/labChat.js.map +1 -1
  164. package/dist/services/marketing.d.ts +2 -2
  165. package/dist/services/message.d.ts +30 -0
  166. package/dist/services/message.d.ts.map +1 -1
  167. package/dist/services/message.js +97 -21
  168. package/dist/services/message.js.map +1 -1
  169. package/dist/services/newtonChat.d.ts +6 -1
  170. package/dist/services/newtonChat.d.ts.map +1 -1
  171. package/dist/services/newtonChat.js +36 -18
  172. package/dist/services/newtonChat.js.map +1 -1
  173. package/dist/services/notification.d.ts +16 -9
  174. package/dist/services/notification.d.ts.map +1 -1
  175. package/dist/services/notification.js +24 -9
  176. package/dist/services/notification.js.map +1 -1
  177. package/dist/services/section.d.ts +6 -6
  178. package/dist/services/studentProgress.d.ts +75 -0
  179. package/dist/services/studentProgress.d.ts.map +1 -1
  180. package/dist/services/studentProgress.js +300 -107
  181. package/dist/services/studentProgress.js.map +1 -1
  182. package/dist/services/worksheet.d.ts +64 -64
  183. package/dist/services/worksheet.d.ts.map +1 -1
  184. package/dist/services/worksheet.js +138 -54
  185. package/dist/services/worksheet.js.map +1 -1
  186. package/dist/trpc.d.ts.map +1 -1
  187. package/dist/trpc.js +11 -17
  188. package/dist/trpc.js.map +1 -1
  189. package/dist/utils/inference.d.ts +9 -12
  190. package/dist/utils/inference.d.ts.map +1 -1
  191. package/dist/utils/inference.js +135 -95
  192. package/dist/utils/inference.js.map +1 -1
  193. package/package.json +1 -1
  194. package/prisma/migrations/20260410124000_add_submission_recommendation_state/migration.sql +14 -0
  195. package/prisma/migrations/20260415103500_add_session_type/migration.sql +33 -0
  196. package/prisma/migrations/20260415133000_add_ai_usage_logs/migration.sql +19 -0
  197. package/prisma/schema.prisma +41 -1
  198. package/scripts/test-pre-push.ts +6 -2
  199. package/src/index.ts +119 -79
  200. package/src/lib/config/cors.ts +96 -0
  201. package/src/lib/config/env.ts +12 -1
  202. package/src/lib/fileUpload.ts +55 -5
  203. package/src/lib/prisma.ts +25 -6
  204. package/src/middleware/auth.ts +19 -4
  205. package/src/middleware/security.ts +1 -1
  206. package/src/models/auth.ts +59 -29
  207. package/src/models/class.ts +15 -4
  208. package/src/models/notification.ts +13 -7
  209. package/src/models/worksheet.ts +202 -0
  210. package/src/pipelines/aiLabChat.ts +183 -104
  211. package/src/pipelines/aiNewtonChat.ts +24 -14
  212. package/src/pipelines/gradeWorksheet.ts +10 -2
  213. package/src/pipelines/labChatPrompt.ts +196 -68
  214. package/src/routers/assignment.ts +1 -0
  215. package/src/routers/class.ts +2 -2
  216. package/src/routers/labChat.ts +3 -1
  217. package/src/routers/message.ts +38 -1
  218. package/src/routers/newtonChat.ts +4 -2
  219. package/src/routers/notifications.ts +2 -2
  220. package/src/routers/studentProgress.ts +25 -1
  221. package/src/routers/worksheet.ts +31 -19
  222. package/src/services/assignment.ts +30 -2
  223. package/src/services/auth.ts +26 -12
  224. package/src/services/class.ts +4 -1
  225. package/src/services/file.ts +6 -0
  226. package/src/services/labChat.ts +42 -18
  227. package/src/services/message.ts +163 -18
  228. package/src/services/newtonChat.ts +49 -18
  229. package/src/services/notification.ts +27 -8
  230. package/src/services/studentProgress.ts +425 -121
  231. package/src/services/worksheet.ts +191 -51
  232. package/src/trpc.ts +12 -18
  233. package/src/utils/inference.ts +205 -112
  234. package/tests/globalSetup.ts +13 -4
  235. package/tests/lib/cors.test.ts +103 -0
  236. package/tests/pipelines/aiLabChat.test.ts +64 -84
  237. package/tests/routers/auth.test.ts +120 -17
  238. package/tests/routers/class.test.ts +7 -1
  239. package/tests/routers/message.test.ts +236 -9
  240. package/tests/routers/newtonChat.test.ts +81 -0
  241. package/tests/routers/notification.test.ts +12 -0
  242. package/tests/routers/studentProgress.test.ts +2 -31
  243. package/tests/routers/worksheet.test.ts +194 -0
  244. package/tests/setup.ts +22 -5
  245. package/tests/utils/aiLabChatPrompt.test.ts +114 -6
  246. package/tests/utils/inference.test.ts +218 -0
  247. package/tests/utils/studentProgress.test.ts +361 -0
  248. package/vitest.unit.config.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@studious-lms/server",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "Backend server for Studious application",
5
5
  "main": "dist/exportType.js",
6
6
  "types": "dist/exportType.d.ts",
@@ -0,0 +1,14 @@
1
+ -- CreateEnum
2
+ CREATE TYPE "SubmissionRecommendationState" AS ENUM ('NONE', 'OPEN', 'ASSIGNED', 'DISMISSED');
3
+
4
+ -- AlterTable
5
+ ALTER TABLE "Submission"
6
+ ADD COLUMN "recommendationState" "SubmissionRecommendationState" NOT NULL DEFAULT 'NONE',
7
+ ADD COLUMN "recommendationUpdatedAt" TIMESTAMP(3),
8
+ ADD COLUMN "targetedAssignmentId" TEXT;
9
+
10
+ -- CreateIndex
11
+ CREATE INDEX "Submission_studentId_recommendationState_idx" ON "Submission"("studentId", "recommendationState");
12
+
13
+ -- AddForeignKey
14
+ ALTER TABLE "Submission" ADD CONSTRAINT "Submission_targetedAssignmentId_fkey" FOREIGN KEY ("targetedAssignmentId") REFERENCES "Assignment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,33 @@
1
+ -- CreateEnum
2
+ CREATE TYPE "SessionType" AS ENUM ('AUTH', 'EMAIL_VERIFICATION', 'PASSWORD_RESET', 'CLASS_INVITE');
3
+
4
+ -- AlterTable
5
+ ALTER TABLE "Session"
6
+ ADD COLUMN "type" "SessionType" NOT NULL DEFAULT 'AUTH';
7
+
8
+ -- Backfill existing non-auth session records when they can be inferred safely.
9
+ UPDATE "Session"
10
+ SET "type" = 'CLASS_INVITE'
11
+ WHERE "classId" IS NOT NULL;
12
+
13
+ -- This Session.type backfill uses the expiresAt <= NOW() + INTERVAL '2 hours'
14
+ -- heuristic to identify password reset tokens. It may misclassify rare short-lived
15
+ -- AUTH sessions, but this one-time migration safely fails those sessions until users re-login.
16
+ UPDATE "Session"
17
+ SET "type" = 'PASSWORD_RESET'
18
+ WHERE "userId" IS NOT NULL
19
+ AND "classId" IS NULL
20
+ AND "expiresAt" IS NOT NULL
21
+ AND "expiresAt" <= NOW() + INTERVAL '2 hours';
22
+
23
+ UPDATE "Session"
24
+ SET "type" = 'EMAIL_VERIFICATION'
25
+ WHERE "userId" IS NOT NULL
26
+ AND "classId" IS NULL
27
+ AND "type" = 'AUTH'
28
+ AND EXISTS (
29
+ SELECT 1
30
+ FROM "User"
31
+ WHERE "User"."id" = "Session"."userId"
32
+ AND "User"."verified" = false
33
+ );
@@ -0,0 +1,19 @@
1
+ CREATE TABLE "AiUsageLog" (
2
+ "id" TEXT NOT NULL,
3
+ "reason" TEXT NOT NULL,
4
+ "provider" TEXT NOT NULL DEFAULT 'openai',
5
+ "model" TEXT NOT NULL,
6
+ "operation" TEXT NOT NULL,
7
+ "success" BOOLEAN NOT NULL DEFAULT true,
8
+ "promptTokens" INTEGER,
9
+ "completionTokens" INTEGER,
10
+ "totalTokens" INTEGER,
11
+ "estimatedInputTokens" INTEGER,
12
+ "metadata" JSONB DEFAULT '{}'::jsonb,
13
+ "error" TEXT,
14
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
15
+
16
+ CONSTRAINT "AiUsageLog_pkey" PRIMARY KEY ("id")
17
+ );
18
+
19
+ CREATE INDEX "AiUsageLog_reason_createdAt_idx" ON "AiUsageLog"("reason", "createdAt");
@@ -74,6 +74,13 @@ enum ReactionType {
74
74
  HAPPY
75
75
  }
76
76
 
77
+ enum SubmissionRecommendationState {
78
+ NONE
79
+ OPEN
80
+ ASSIGNED
81
+ DISMISSED
82
+ }
83
+
77
84
  model School {
78
85
  id String @id @default(uuid())
79
86
  name String
@@ -292,6 +299,7 @@ model Assignment {
292
299
  gradingBoundary GradingBoundary? @relation(fields: [gradingBoundaryId], references: [id], onDelete: Cascade)
293
300
  gradingBoundaryId String?
294
301
  worksheets Worksheet[] @relation("AssignmentWorksheets")
302
+ targetedBySubmissions Submission[] @relation("SubmissionTargetedAssignment")
295
303
  }
296
304
 
297
305
 
@@ -361,6 +369,10 @@ model Submission {
361
369
  worksheetResponses StudentWorksheetResponse[]
362
370
  extendedResponse String?
363
371
  gradeReceived Int?
372
+ recommendationState SubmissionRecommendationState @default(NONE)
373
+ recommendationUpdatedAt DateTime?
374
+ targetedAssignmentId String?
375
+ targetedAssignment Assignment? @relation("SubmissionTargetedAssignment", fields: [targetedAssignmentId], references: [id], onDelete: SetNull)
364
376
 
365
377
  rubricState String?
366
378
  teacherComments String?
@@ -369,6 +381,8 @@ model Submission {
369
381
  submitted Boolean? @default(false)
370
382
  returned Boolean? @default(false)
371
383
  newtonTutors NewtonChat[]
384
+
385
+ @@index([studentId, recommendationState])
372
386
  }
373
387
 
374
388
  model NewtonChat {
@@ -392,10 +406,18 @@ model Section {
392
406
  order Int?
393
407
  }
394
408
 
409
+ enum SessionType {
410
+ AUTH
411
+ EMAIL_VERIFICATION
412
+ PASSWORD_RESET
413
+ CLASS_INVITE
414
+ }
415
+
395
416
  model Session {
396
417
  id String @id @default(uuid())
397
418
  createdAt DateTime? @default(now())
398
419
  expiresAt DateTime?
420
+ type SessionType @default(AUTH)
399
421
  userId String?
400
422
  user User? @relation(fields: [userId], references: [id], onDelete: NoAction)
401
423
  classId String?
@@ -514,6 +536,24 @@ model Message {
514
536
  meta Json? @default("{}")
515
537
  }
516
538
 
539
+ model AiUsageLog {
540
+ id String @id @default(uuid())
541
+ reason String
542
+ provider String @default("openai")
543
+ model String
544
+ operation String
545
+ success Boolean @default(true)
546
+ promptTokens Int?
547
+ completionTokens Int?
548
+ totalTokens Int?
549
+ estimatedInputTokens Int?
550
+ metadata Json? @default("{}")
551
+ error String?
552
+ createdAt DateTime @default(now())
553
+
554
+ @@index([reason, createdAt])
555
+ }
556
+
517
557
  model Mention {
518
558
  id String @id @default(uuid())
519
559
  messageId String
@@ -621,4 +661,4 @@ model EarlyAccessRequest {
621
661
  email String
622
662
  createdAt DateTime @default(now())
623
663
  institutionSize String
624
- }
664
+ }
@@ -7,8 +7,12 @@ dotenv.config({ path: resolve(process.cwd(), '.env.test') });
7
7
 
8
8
  logger.info("Syncing Prisma schema to test database...");
9
9
 
10
- execSync("npx prisma db push --force-reset --skip-generate", {
10
+ if (process.env.DATABASE_URL?.includes("supabase")) {
11
+ logger.error("Supabase is not supported for test database, please don't clear the production database");
12
+ process.exit(1);
13
+ }
14
+ else execSync("npx prisma db push --force-reset --skip-generate", {
11
15
  stdio: 'inherit',
12
16
  });
13
17
 
14
- logger.info("Test database is ready!");
18
+ logger.info("Test database run has completed");
package/src/index.ts CHANGED
@@ -7,12 +7,15 @@ import dotenv from 'dotenv';
7
7
  import { createExpressMiddleware } from '@trpc/server/adapters/express';
8
8
  import { appRouter } from './routers/_app.js';
9
9
  import { createTRPCContext, createCallerFactory } from './trpc.js';
10
+ import { findUserBySessionToken } from './models/auth.js';
10
11
  import { logger } from './utils/logger.js';
11
12
  import { setupSocketHandlers } from './socket/handlers.js';
12
13
  import { bucket } from './lib/googleCloudStorage.js';
14
+ import { validateDirectUploadIntent } from './lib/fileUpload.js';
13
15
  import { prisma } from './lib/prisma.js';
14
16
  import { pusher } from './lib/pusher.js';
15
17
  import { connectRedis, disconnectRedis } from './lib/redis.js';
18
+ import { createCorsOriginMatcher } from './lib/config/cors.js';
16
19
 
17
20
  import { authLimiter, generalLimiter, helmetConfig, uploadLimiter } from './middleware/security.js';
18
21
 
@@ -27,6 +30,8 @@ import { openAIClient } from './utils/inference.js';
27
30
 
28
31
  const app = express();
29
32
 
33
+ app.set('trust proxy', 1);
34
+
30
35
  app.use(helmetConfig);
31
36
  app.use(compression());
32
37
  app.use(express.json());
@@ -38,34 +43,24 @@ app.use((req, res, next) => {
38
43
  next();
39
44
  });
40
45
 
41
- const allowedOrigins = env.NODE_ENV === 'production'
42
- ? [
43
- 'https://www.studious.sh',
44
- 'https://studious.sh',
45
- 'https://dev.studious.sh',
46
- 'https://www.dev.studious.sh',
47
- env.NEXT_PUBLIC_APP_URL,
48
- 'http://localhost:3000',
49
-
50
- ].filter(Boolean)
51
- : [
52
- 'http://localhost:3000',
53
- 'http://localhost:3001',
54
- 'http://127.0.0.1:3000',
55
- 'http://127.0.0.1:3001',
56
-
57
- env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
58
- ];
46
+ const { allowedOrigins, allowedOriginPatterns, isAllowedOrigin } = createCorsOriginMatcher(env);
59
47
 
60
48
  // CORS middleware
61
49
  app.use(cors({
62
- origin: allowedOrigins,
50
+ origin: (origin, callback) => {
51
+ if (!origin || isAllowedOrigin(origin)) {
52
+ callback(null, true);
53
+ return;
54
+ }
55
+
56
+ logger.warn('CORS origin rejected', { origin, allowedOrigins });
57
+ callback(null, false);
58
+ },
63
59
  credentials: true,
64
60
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
65
61
  allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'x-user'],
66
- preflightContinue: false, // Important: stop further handling of OPTIONS
67
- optionsSuccessStatus: 204, // Recommended for modern browsers
68
-
62
+ preflightContinue: false,
63
+ optionsSuccessStatus: 204,
69
64
  }));
70
65
 
71
66
  app.use(generalLimiter);
@@ -122,6 +117,17 @@ app.use((req, res, next) => {
122
117
  // Create HTTP server
123
118
  const httpServer = createServer(app);
124
119
 
120
+ app.get('/', async (req, res) => {
121
+ res.status(200).json({
122
+ status: 'OK',
123
+ timestamp: new Date().toISOString(),
124
+ uptime: process.uptime(),
125
+ version: process.env.npm_package_version,
126
+ environment: process.env.NODE_ENV,
127
+ appUrl: process.env.NEXT_PUBLIC_APP_URL,
128
+ });
129
+ });
130
+
125
131
  app.get('/health', async (req, res) => {
126
132
 
127
133
  try {
@@ -163,10 +169,7 @@ app.post('/api/pusher/auth', async (req, res) => {
163
169
  return res.status(401).json({ error: 'Authentication required' });
164
170
  }
165
171
 
166
- const user = await prisma.user.findFirst({
167
- where: { sessions: { some: { id: token } } },
168
- select: { id: true, username: true },
169
- });
172
+ const user = await findUserBySessionToken(token);
170
173
 
171
174
  if (!user) {
172
175
  return res.status(401).json({ error: 'Invalid or expired session' });
@@ -243,15 +246,9 @@ app.post('/api/pusher/auth', async (req, res) => {
243
246
  // Setup Socket.IO
244
247
  const io = new Server(httpServer, {
245
248
  cors: {
246
- origin: [
247
- 'http://localhost:3000', // Frontend development server
248
- 'http://localhost:3001', // Server port
249
- 'http://127.0.0.1:3000', // Alternative localhost
250
- 'http://127.0.0.1:3001', // Alternative localhost
251
- 'https://www.studious.sh', // Production frontend
252
- 'https://studious.sh', // Production frontend (without www)
253
- env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
254
- ],
249
+ origin: (origin, callback) => {
250
+ callback(null, !origin || isAllowedOrigin(origin));
251
+ },
255
252
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
256
253
  credentials: true,
257
254
  allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'Access-Control-Allow-Origin', 'x-user']
@@ -287,19 +284,7 @@ app.get('/api/files/:fileId', async (req, res) => {
287
284
  const token = typeof userHeader === 'string' ? userHeader : userHeader[0];
288
285
 
289
286
  // Find user by session token
290
- const user = await prisma.user.findFirst({
291
- where: {
292
- sessions: {
293
- some: {
294
- id: token
295
- }
296
- }
297
- },
298
- select: {
299
- id: true,
300
- username: true,
301
- }
302
- });
287
+ const user = await findUserBySessionToken(token);
303
288
 
304
289
  if (!user) {
305
290
  return res.status(401).json({ error: 'Invalid or expired session' });
@@ -476,55 +461,92 @@ app.get('/api/files/:fileId', async (req, res) => {
476
461
  app.use('/trpc/auth.login', authLimiter);
477
462
  app.use('/trpc/auth.register', authLimiter);
478
463
 
464
+ async function getAuthenticatedRequestUser(req: Request) {
465
+ const userHeader = req.headers['x-user'];
466
+ const tokenFromUserHeader = typeof userHeader === 'string' ? userHeader : userHeader?.[0];
467
+ const authorizationHeader = req.headers.authorization;
468
+ const tokenFromAuthorization =
469
+ typeof authorizationHeader === 'string' && authorizationHeader.startsWith('Bearer ')
470
+ ? authorizationHeader.slice(7)
471
+ : null;
472
+
473
+ let token = tokenFromUserHeader ?? tokenFromAuthorization;
474
+
475
+ if (!token && req.headers.cookie) {
476
+ const cookieName = env.PUSHER_AUTH_COOKIE_NAME || 'token';
477
+ const match = req.headers.cookie.match(new RegExp(cookieName + '=([^;]+)'));
478
+ token = match?.[1]?.trim() ?? null;
479
+ }
480
+
481
+ if (!token) {
482
+ return null;
483
+ }
484
+
485
+ return findUserBySessionToken(token);
486
+ }
487
+
488
+ function getUploadSessionId(req: Request) {
489
+ const queryValue = req.query.uploadSessionId;
490
+ if (typeof queryValue === 'string' && queryValue.trim()) {
491
+ return queryValue;
492
+ }
493
+
494
+ const headerValue = req.headers['x-upload-session-id'];
495
+ return typeof headerValue === 'string' ? headerValue : headerValue?.[0];
496
+ }
497
+
479
498
  // File upload endpoint for secure file uploads (supports both POST and PUT)
480
499
  app.post('/api/upload/:filePath', uploadLimiter, async (req, res) => {
481
- handleFileUpload(req, res);
500
+ await handleFileUpload(req, res);
482
501
  });
483
502
 
484
503
  app.put('/api/upload/:filePath', uploadLimiter, async (req, res) => {
485
- handleFileUpload(req, res);
504
+ await handleFileUpload(req, res);
486
505
  });
487
506
 
488
- function handleFileUpload(req: any, res: any) {
507
+ async function handleFileUpload(req: Request, res: Response) {
489
508
  try {
490
509
  const filePath = decodeURIComponent(req.params.filePath);
491
510
 
492
511
  // Set CORS headers for upload endpoint
493
512
  const origin = req.headers.origin;
494
- const allowedOrigins = [
495
- 'http://localhost:3000',
496
- 'http://localhost:3001',
497
- 'http://127.0.0.1:3000',
498
- 'http://127.0.0.1:3001',
499
- 'https://www.studious.sh', // Production frontend
500
- 'https://studious.sh', // Production frontend (without www)
501
- env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
502
- ];
503
513
 
504
- if (origin && allowedOrigins.includes(origin)) {
514
+ if (origin && !isAllowedOrigin(origin)) {
515
+ return res.status(403).json({ error: 'Origin not allowed' });
516
+ }
517
+
518
+ if (origin) {
505
519
  res.header('Access-Control-Allow-Origin', origin);
506
- } else {
507
- res.header('Access-Control-Allow-Origin', 'http://localhost:3000');
508
520
  }
509
521
 
510
522
  res.header('Access-Control-Allow-Credentials', 'true');
523
+
524
+ const user = await getAuthenticatedRequestUser(req);
525
+ if (!user) {
526
+ return res.status(401).json({ error: 'Authentication required' });
527
+ }
528
+
529
+ const uploadSessionId = getUploadSessionId(req);
530
+ if (!uploadSessionId) {
531
+ return res.status(401).json({ error: 'Upload session is required' });
532
+ }
533
+
534
+ await validateDirectUploadIntent({
535
+ filePath,
536
+ uploadSessionId,
537
+ userId: user.id,
538
+ });
511
539
 
512
- // Get content type from headers
513
540
  const contentType = req.headers['content-type'] || 'application/octet-stream';
514
-
515
- // Create a new file in the bucket
516
541
  const file = bucket.file(filePath);
517
-
518
- // Create a write stream to Google Cloud Storage
519
542
  const writeStream = file.createWriteStream({
520
543
  metadata: {
521
544
  contentType,
522
545
  },
523
546
  });
524
547
 
525
- // Handle stream events
526
548
  writeStream.on('error', (error) => {
527
- logger.error('Error uploading file:', {error});
549
+ logger.error('Error uploading file:', {error, filePath, userId: user.id});
528
550
  if (!res.headersSent) {
529
551
  res.status(500).json({ error: 'Error uploading file' });
530
552
  }
@@ -538,11 +560,21 @@ function handleFileUpload(req: any, res: any) {
538
560
  });
539
561
  });
540
562
 
541
- // Pipe the request body to the write stream
542
563
  req.pipe(writeStream);
543
564
 
544
565
  } catch (error) {
545
566
  logger.error('Error handling file upload:', {error});
567
+ const code = typeof error === 'object' && error && 'code' in error ? error.code : null;
568
+ const message = error instanceof Error ? error.message : 'Internal server error';
569
+
570
+ if (code === 'FORBIDDEN') {
571
+ return res.status(403).json({ error: message });
572
+ }
573
+
574
+ if (code === 'BAD_REQUEST') {
575
+ return res.status(400).json({ error: message });
576
+ }
577
+
546
578
  res.status(500).json({ error: 'Internal server error' });
547
579
  }
548
580
  }
@@ -550,6 +582,19 @@ function handleFileUpload(req: any, res: any) {
550
582
  // Create caller
551
583
  const createCaller = createCallerFactory(appRouter);
552
584
 
585
+ // Fallback OPTIONS handler — prevents tRPC from rejecting preflight requests
586
+ // when the cors middleware passes through without ending the response.
587
+ app.options('/trpc/*', (req, res) => {
588
+ const origin = req.headers.origin;
589
+ if (origin && isAllowedOrigin(origin)) {
590
+ res.setHeader('Access-Control-Allow-Origin', origin);
591
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
592
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
593
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, x-user');
594
+ }
595
+ res.sendStatus(204);
596
+ });
597
+
553
598
  // Setup tRPC middleware
554
599
  app.use(
555
600
  '/trpc',
@@ -594,13 +639,8 @@ logger.info('Configurations', {
594
639
 
595
640
  // Log CORS configuration
596
641
  logger.info('CORS Configuration', {
597
- allowedOrigins: [
598
- 'http://localhost:3000',
599
- 'http://localhost:3001',
600
- 'http://127.0.0.1:3000',
601
- 'http://127.0.0.1:3001',
602
- env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
603
- ]
642
+ allowedOrigins,
643
+ allowedOriginPatterns: allowedOriginPatterns.map((pattern) => pattern.source),
604
644
  });
605
645
 
606
646
  const gracefulShutdown = (signal: string) => {
@@ -632,4 +672,4 @@ const gracefulShutdown = (signal: string) => {
632
672
  };
633
673
 
634
674
  process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
635
- process.on('SIGINT', () => gracefulShutdown('SIGINT'));
675
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
@@ -0,0 +1,96 @@
1
+ type CorsEnv = {
2
+ NODE_ENV?: 'development' | 'production' | 'test';
3
+ NEXT_PUBLIC_APP_URL?: string;
4
+ CORS_ALLOWED_ORIGINS?: string;
5
+ CORS_ALLOWED_ORIGIN_PATTERNS?: string;
6
+ };
7
+
8
+ const productionOrigins = [
9
+ 'https://www.studious.sh',
10
+ 'https://studious.sh',
11
+ 'https://dev.studious.sh',
12
+ 'https://www.dev.studious.sh',
13
+ ];
14
+
15
+ const nonProductionOrigins = [
16
+ 'http://localhost:3000',
17
+ 'http://localhost:3001',
18
+ 'http://127.0.0.1:3000',
19
+ 'http://127.0.0.1:3001',
20
+ ];
21
+ const MAX_PATTERN_LENGTH = 200;
22
+ // This heuristic only targets common nested-quantifier cases like `(a+)+`.
23
+ // It does not catch every risky construct (for example `.*.*` or deep alternation nesting),
24
+ // so `parseCorsOriginPatterns` should still be limited to operator-controlled, anchored patterns.
25
+ const SUSPICIOUS_QUANTIFIER_PATTERN = /(\([^)]*[+*{][^)]*\)|\[[^\]]+[+*{][^\]]*\])[+*{]/;
26
+
27
+ const parseList = (value?: string) =>
28
+ value
29
+ ?.split(',')
30
+ .map((item) => item.trim())
31
+ .filter(Boolean) ?? [];
32
+
33
+ const isDefined = <T>(value: T | undefined | null): value is T => Boolean(value);
34
+
35
+ const toCorsOrigin = (value: string) => {
36
+ try {
37
+ return new URL(value).origin;
38
+ } catch {
39
+ return value.replace(/\/+$/, '');
40
+ }
41
+ };
42
+
43
+ export const parseCorsOrigins = (origins?: string) =>
44
+ parseList(origins).map(toCorsOrigin);
45
+
46
+ const validateCorsOriginPattern = (pattern: string) => {
47
+ if (pattern.length > MAX_PATTERN_LENGTH) {
48
+ throw new Error(`CORS pattern exceeds maximum length of ${MAX_PATTERN_LENGTH}`);
49
+ }
50
+
51
+ // Prefer short, anchored patterns and avoid other complex regex features even if they pass this check.
52
+ if (SUSPICIOUS_QUANTIFIER_PATTERN.test(pattern)) {
53
+ throw new Error("CORS pattern contains nested or ambiguous quantifiers");
54
+ }
55
+
56
+ return pattern;
57
+ };
58
+
59
+ // Patterns are operator-controlled and matched against bounded URL origins, so ReDoS risk is low.
60
+ // Syntax validation does not guarantee safety; prefer anchored patterns and avoid nested quantifiers.
61
+ export const parseCorsOriginPatterns = (patterns?: string) =>
62
+ parseList(patterns).map((pattern) => new RegExp(validateCorsOriginPattern(pattern)));
63
+
64
+ export const isValidCorsOriginPatternList = (patterns?: string) => {
65
+ try {
66
+ parseCorsOriginPatterns(patterns);
67
+ return true;
68
+ } catch {
69
+ return false;
70
+ }
71
+ };
72
+
73
+ export const createCorsOriginMatcher = (env: CorsEnv) => {
74
+ const allowedOrigins = Array.from(new Set([
75
+ ...(env.NODE_ENV === 'production' ? productionOrigins : nonProductionOrigins),
76
+ env.NEXT_PUBLIC_APP_URL ? toCorsOrigin(env.NEXT_PUBLIC_APP_URL) : undefined,
77
+ ...parseCorsOrigins(env.CORS_ALLOWED_ORIGINS),
78
+ ].filter(isDefined)));
79
+
80
+ const allowedOriginPatterns = parseCorsOriginPatterns(env.CORS_ALLOWED_ORIGIN_PATTERNS);
81
+
82
+ const isAllowedOrigin = (origin?: string) => {
83
+ const corsOrigin = origin ? toCorsOrigin(origin) : undefined;
84
+
85
+ return Boolean(corsOrigin && (
86
+ allowedOrigins.includes(corsOrigin) ||
87
+ allowedOriginPatterns.some((pattern) => pattern.test(corsOrigin))
88
+ ));
89
+ };
90
+
91
+ return {
92
+ allowedOrigins,
93
+ allowedOriginPatterns,
94
+ isAllowedOrigin,
95
+ };
96
+ };
@@ -2,6 +2,7 @@ import { z } from 'zod';
2
2
  import dotenv from 'dotenv';
3
3
  import { resolve } from 'path';
4
4
  import { logger } from '../../utils/logger.js';
5
+ import { isValidCorsOriginPatternList } from './cors.js';
5
6
 
6
7
  // Determine which env file to load based on NODE_ENV
7
8
  const nodeEnv = process.env.NODE_ENV || 'development';
@@ -23,6 +24,12 @@ dotenv.config({ path: envPath, override: true }); // Override with env-specific
23
24
  const isTest = nodeEnv === 'test';
24
25
  const isProduction = nodeEnv === 'production';
25
26
 
27
+ const corsAllowedOriginsSchema = z.string().optional();
28
+ const corsAllowedOriginPatternsSchema = z.string().optional().refine(
29
+ isValidCorsOriginPatternList,
30
+ { message: 'CORS_ALLOWED_ORIGIN_PATTERNS must contain valid regex patterns' }
31
+ );
32
+
26
33
  // Base schema with required vars for all environments
27
34
  const baseSchema = z.object({
28
35
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
@@ -33,6 +40,8 @@ const baseSchema = z.object({
33
40
  // Production/development schema with all required vars
34
41
  const fullSchema = baseSchema.extend({
35
42
  NEXT_PUBLIC_APP_URL: z.string().url().default('http://localhost:3000'),
43
+ CORS_ALLOWED_ORIGINS: corsAllowedOriginsSchema,
44
+ CORS_ALLOWED_ORIGIN_PATTERNS: corsAllowedOriginPatternsSchema,
36
45
  BACKEND_URL: z.string().url().default('http://localhost:3001'),
37
46
  SENTRY_DSN: z.string().url().optional(),
38
47
  EMAIL_HOST: z.string().min(1, 'EMAIL_HOST is required'),
@@ -59,6 +68,8 @@ const fullSchema = baseSchema.extend({
59
68
  // Test schema - only require what's needed for tests
60
69
  const testSchema = baseSchema.extend({
61
70
  NEXT_PUBLIC_APP_URL: z.string().url().optional().default('http://localhost:3000'),
71
+ CORS_ALLOWED_ORIGINS: corsAllowedOriginsSchema,
72
+ CORS_ALLOWED_ORIGIN_PATTERNS: corsAllowedOriginPatternsSchema,
62
73
  BACKEND_URL: z.string().url().optional().default('http://localhost:3001'),
63
74
  SENTRY_DSN: z.string().url().optional(),
64
75
  EMAIL_HOST: z.string().optional().default('smtp.test.com'),
@@ -129,4 +140,4 @@ function validateEnv() {
129
140
  export const env = validateEnv();
130
141
 
131
142
  // Type-safe environment access
132
- export type Env = z.infer<typeof envSchema>;
143
+ export type Env = z.infer<typeof envSchema>;