@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.
- package/.coderabbit.yaml +4 -1
- package/.env.example +6 -0
- package/.env.test.example +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +101 -76
- package/dist/index.js.map +1 -1
- package/dist/lib/config/cors.d.ts +16 -0
- package/dist/lib/config/cors.d.ts.map +1 -0
- package/dist/lib/config/cors.js +75 -0
- package/dist/lib/config/cors.js.map +1 -0
- package/dist/lib/config/env.d.ts +14 -0
- package/dist/lib/config/env.d.ts.map +1 -1
- package/dist/lib/config/env.js +9 -2
- package/dist/lib/config/env.js.map +1 -1
- package/dist/lib/fileUpload.d.ts +10 -2
- package/dist/lib/fileUpload.d.ts.map +1 -1
- package/dist/lib/fileUpload.js +38 -6
- package/dist/lib/fileUpload.js.map +1 -1
- package/dist/lib/prisma.d.ts +14 -2
- package/dist/lib/prisma.d.ts.map +1 -1
- package/dist/lib/prisma.js +27 -8
- package/dist/lib/prisma.js.map +1 -1
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +18 -5
- package/dist/middleware/auth.js.map +1 -1
- package/dist/middleware/security.d.ts.map +1 -1
- package/dist/middleware/security.js +3 -3
- package/dist/middleware/security.js.map +1 -1
- package/dist/models/agenda.d.ts +16 -16
- package/dist/models/announcement.d.ts +59 -23
- package/dist/models/announcement.d.ts.map +1 -1
- package/dist/models/assignment.d.ts +363 -276
- package/dist/models/assignment.d.ts.map +1 -1
- package/dist/models/attendance.d.ts +63 -21
- package/dist/models/attendance.d.ts.map +1 -1
- package/dist/models/auth.d.ts +115 -26
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/auth.js +53 -30
- package/dist/models/auth.js.map +1 -1
- package/dist/models/class.d.ts +115 -65
- package/dist/models/class.d.ts.map +1 -1
- package/dist/models/class.js +16 -4
- package/dist/models/class.js.map +1 -1
- package/dist/models/comment.d.ts +52 -16
- package/dist/models/comment.d.ts.map +1 -1
- package/dist/models/conversation.d.ts +46 -16
- package/dist/models/conversation.d.ts.map +1 -1
- package/dist/models/event.d.ts +107 -53
- package/dist/models/event.d.ts.map +1 -1
- package/dist/models/file.d.ts +213 -165
- package/dist/models/file.d.ts.map +1 -1
- package/dist/models/folder.d.ts +161 -77
- package/dist/models/folder.d.ts.map +1 -1
- package/dist/models/labChat.d.ts +73 -31
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/marketing.d.ts +25 -7
- package/dist/models/marketing.d.ts.map +1 -1
- package/dist/models/message.d.ts +31 -13
- package/dist/models/message.d.ts.map +1 -1
- package/dist/models/newtonChat.d.ts +34 -10
- package/dist/models/newtonChat.d.ts.map +1 -1
- package/dist/models/notification.d.ts +29 -10
- package/dist/models/notification.d.ts.map +1 -1
- package/dist/models/notification.js +13 -9
- package/dist/models/notification.js.map +1 -1
- package/dist/models/section.d.ts +71 -23
- package/dist/models/section.d.ts.map +1 -1
- package/dist/models/user.d.ts +27 -9
- package/dist/models/user.d.ts.map +1 -1
- package/dist/models/worksheet.d.ts +606 -106
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/models/worksheet.js +168 -2
- package/dist/models/worksheet.js.map +1 -1
- package/dist/pipelines/aiLabChat.d.ts +22 -2
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +133 -95
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiLabChatContract.d.ts +25 -25
- package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
- package/dist/pipelines/aiNewtonChat.js +25 -14
- package/dist/pipelines/aiNewtonChat.js.map +1 -1
- package/dist/pipelines/gradeWorksheet.d.ts +8 -8
- package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
- package/dist/pipelines/gradeWorksheet.js +12 -5
- package/dist/pipelines/gradeWorksheet.js.map +1 -1
- package/dist/pipelines/labChatPrompt.d.ts +27 -0
- package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
- package/dist/pipelines/labChatPrompt.js +143 -69
- package/dist/pipelines/labChatPrompt.js.map +1 -1
- package/dist/routers/_app.d.ts +1579 -1257
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/agenda.d.ts +16 -16
- package/dist/routers/announcement.d.ts +19 -19
- package/dist/routers/assignment.d.ts +307 -291
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/assignment.js +3 -2
- package/dist/routers/assignment.js.map +1 -1
- package/dist/routers/attendance.d.ts +7 -7
- package/dist/routers/auth.d.ts +1 -1
- package/dist/routers/class.d.ts +79 -72
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/class.js +3 -3
- package/dist/routers/class.js.map +1 -1
- package/dist/routers/comment.d.ts +6 -6
- package/dist/routers/conversation.d.ts +11 -11
- package/dist/routers/event.d.ts +35 -35
- package/dist/routers/file.d.ts +12 -12
- package/dist/routers/folder.d.ts +54 -54
- package/dist/routers/labChat.d.ts +18 -12
- package/dist/routers/labChat.d.ts.map +1 -1
- package/dist/routers/labChat.js +5 -3
- package/dist/routers/labChat.js.map +1 -1
- package/dist/routers/marketing.d.ts +2 -2
- package/dist/routers/message.d.ts +33 -2
- package/dist/routers/message.d.ts.map +1 -1
- package/dist/routers/message.js +27 -4
- package/dist/routers/message.js.map +1 -1
- package/dist/routers/newtonChat.d.ts +7 -1
- package/dist/routers/newtonChat.d.ts.map +1 -1
- package/dist/routers/newtonChat.js +5 -4
- package/dist/routers/newtonChat.js.map +1 -1
- package/dist/routers/notifications.d.ts +13 -6
- package/dist/routers/notifications.d.ts.map +1 -1
- package/dist/routers/notifications.js +4 -4
- package/dist/routers/notifications.js.map +1 -1
- package/dist/routers/section.d.ts +7 -7
- package/dist/routers/studentProgress.d.ts +86 -0
- package/dist/routers/studentProgress.d.ts.map +1 -1
- package/dist/routers/studentProgress.js +14 -4
- package/dist/routers/studentProgress.js.map +1 -1
- package/dist/routers/user.d.ts +1 -1
- package/dist/routers/worksheet.d.ts +64 -62
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/routers/worksheet.js +18 -16
- package/dist/routers/worksheet.js.map +1 -1
- package/dist/seedDatabase.d.ts +1 -1
- package/dist/services/agenda.d.ts +16 -16
- package/dist/services/announcement.d.ts +8 -8
- package/dist/services/assignment.d.ts +299 -283
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/assignment.js +24 -5
- package/dist/services/assignment.js.map +1 -1
- package/dist/services/attendance.d.ts +7 -7
- package/dist/services/auth.d.ts +2 -2
- package/dist/services/auth.d.ts.map +1 -1
- package/dist/services/auth.js +19 -12
- package/dist/services/auth.js.map +1 -1
- package/dist/services/class.d.ts +74 -68
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +6 -3
- package/dist/services/class.js.map +1 -1
- package/dist/services/comment.d.ts +6 -6
- package/dist/services/conversation.d.ts +11 -11
- package/dist/services/event.d.ts +31 -31
- package/dist/services/file.d.ts +12 -12
- package/dist/services/file.d.ts.map +1 -1
- package/dist/services/file.js +7 -2
- package/dist/services/file.js.map +1 -1
- package/dist/services/folder.d.ts +52 -52
- package/dist/services/labChat.d.ts +18 -12
- package/dist/services/labChat.d.ts.map +1 -1
- package/dist/services/labChat.js +30 -18
- package/dist/services/labChat.js.map +1 -1
- package/dist/services/marketing.d.ts +2 -2
- package/dist/services/message.d.ts +30 -0
- package/dist/services/message.d.ts.map +1 -1
- package/dist/services/message.js +97 -21
- package/dist/services/message.js.map +1 -1
- package/dist/services/newtonChat.d.ts +6 -1
- package/dist/services/newtonChat.d.ts.map +1 -1
- package/dist/services/newtonChat.js +36 -18
- package/dist/services/newtonChat.js.map +1 -1
- package/dist/services/notification.d.ts +16 -9
- package/dist/services/notification.d.ts.map +1 -1
- package/dist/services/notification.js +24 -9
- package/dist/services/notification.js.map +1 -1
- package/dist/services/section.d.ts +6 -6
- package/dist/services/studentProgress.d.ts +75 -0
- package/dist/services/studentProgress.d.ts.map +1 -1
- package/dist/services/studentProgress.js +300 -107
- package/dist/services/studentProgress.js.map +1 -1
- package/dist/services/worksheet.d.ts +64 -64
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +138 -54
- package/dist/services/worksheet.js.map +1 -1
- package/dist/trpc.d.ts.map +1 -1
- package/dist/trpc.js +11 -17
- package/dist/trpc.js.map +1 -1
- package/dist/utils/inference.d.ts +9 -12
- package/dist/utils/inference.d.ts.map +1 -1
- package/dist/utils/inference.js +135 -95
- package/dist/utils/inference.js.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260410124000_add_submission_recommendation_state/migration.sql +14 -0
- package/prisma/migrations/20260415103500_add_session_type/migration.sql +33 -0
- package/prisma/migrations/20260415133000_add_ai_usage_logs/migration.sql +19 -0
- package/prisma/schema.prisma +41 -1
- package/scripts/test-pre-push.ts +6 -2
- package/src/index.ts +119 -79
- package/src/lib/config/cors.ts +96 -0
- package/src/lib/config/env.ts +12 -1
- package/src/lib/fileUpload.ts +55 -5
- package/src/lib/prisma.ts +25 -6
- package/src/middleware/auth.ts +19 -4
- package/src/middleware/security.ts +1 -1
- package/src/models/auth.ts +59 -29
- package/src/models/class.ts +15 -4
- package/src/models/notification.ts +13 -7
- package/src/models/worksheet.ts +202 -0
- package/src/pipelines/aiLabChat.ts +183 -104
- package/src/pipelines/aiNewtonChat.ts +24 -14
- package/src/pipelines/gradeWorksheet.ts +10 -2
- package/src/pipelines/labChatPrompt.ts +196 -68
- package/src/routers/assignment.ts +1 -0
- package/src/routers/class.ts +2 -2
- package/src/routers/labChat.ts +3 -1
- package/src/routers/message.ts +38 -1
- package/src/routers/newtonChat.ts +4 -2
- package/src/routers/notifications.ts +2 -2
- package/src/routers/studentProgress.ts +25 -1
- package/src/routers/worksheet.ts +31 -19
- package/src/services/assignment.ts +30 -2
- package/src/services/auth.ts +26 -12
- package/src/services/class.ts +4 -1
- package/src/services/file.ts +6 -0
- package/src/services/labChat.ts +42 -18
- package/src/services/message.ts +163 -18
- package/src/services/newtonChat.ts +49 -18
- package/src/services/notification.ts +27 -8
- package/src/services/studentProgress.ts +425 -121
- package/src/services/worksheet.ts +191 -51
- package/src/trpc.ts +12 -18
- package/src/utils/inference.ts +205 -112
- package/tests/globalSetup.ts +13 -4
- package/tests/lib/cors.test.ts +103 -0
- package/tests/pipelines/aiLabChat.test.ts +64 -84
- package/tests/routers/auth.test.ts +120 -17
- package/tests/routers/class.test.ts +7 -1
- package/tests/routers/message.test.ts +236 -9
- package/tests/routers/newtonChat.test.ts +81 -0
- package/tests/routers/notification.test.ts +12 -0
- package/tests/routers/studentProgress.test.ts +2 -31
- package/tests/routers/worksheet.test.ts +194 -0
- package/tests/setup.ts +22 -5
- package/tests/utils/aiLabChatPrompt.test.ts +114 -6
- package/tests/utils/inference.test.ts +218 -0
- package/tests/utils/studentProgress.test.ts +361 -0
- package/vitest.unit.config.ts +1 -0
package/package.json
CHANGED
|
@@ -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");
|
package/prisma/schema.prisma
CHANGED
|
@@ -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
|
+
}
|
package/scripts/test-pre-push.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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,
|
|
67
|
-
optionsSuccessStatus: 204,
|
|
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
|
|
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
|
-
|
|
248
|
-
|
|
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
|
|
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:
|
|
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 &&
|
|
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
|
-
|
|
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
|
+
};
|
package/src/lib/config/env.ts
CHANGED
|
@@ -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>;
|