@studious-lms/server 1.4.2 → 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/dist/index.d.ts.map +1 -1
- package/dist/index.js +67 -28
- package/dist/index.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/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +18 -5
- package/dist/middleware/auth.js.map +1 -1
- package/dist/models/auth.d.ts +19 -14
- 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 +3 -1
- 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/notification.d.ts +11 -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/worksheet.d.ts +378 -7
- 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.map +1 -1
- package/dist/pipelines/aiLabChat.js +11 -3
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiLabChatContract.d.ts +3 -3
- 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.map +1 -1
- package/dist/pipelines/gradeWorksheet.js +10 -3
- package/dist/pipelines/gradeWorksheet.js.map +1 -1
- package/dist/routers/_app.d.ts +124 -18
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/class.d.ts +2 -1
- 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/labChat.d.ts +6 -0
- 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/message.d.ts +31 -0
- 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 +6 -0
- 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 +9 -2
- 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/worksheet.d.ts +8 -6
- 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/services/auth.d.ts +1 -1
- 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 +1 -1
- 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/file.d.ts.map +1 -1
- package/dist/services/file.js +7 -2
- package/dist/services/file.js.map +1 -1
- package/dist/services/labChat.d.ts +6 -0
- 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/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 +12 -5
- 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/studentProgress.d.ts.map +1 -1
- package/dist/services/studentProgress.js +6 -3
- package/dist/services/studentProgress.js.map +1 -1
- package/dist/services/worksheet.d.ts +15 -15
- 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 -1
- package/dist/utils/inference.d.ts.map +1 -1
- package/dist/utils/inference.js +138 -50
- package/dist/utils/inference.js.map +1 -1
- package/package.json +1 -1
- 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 +27 -1
- package/scripts/test-pre-push.ts +6 -2
- package/src/index.ts +80 -28
- package/src/lib/fileUpload.ts +55 -5
- package/src/middleware/auth.ts +19 -4
- 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 +9 -1
- package/src/pipelines/aiNewtonChat.ts +24 -14
- package/src/pipelines/gradeWorksheet.ts +8 -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/worksheet.ts +31 -19
- 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 +4 -1
- package/src/services/worksheet.ts +191 -51
- package/src/trpc.ts +12 -18
- package/src/utils/inference.ts +209 -55
- package/tests/globalSetup.ts +13 -4
- 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/worksheet.test.ts +194 -0
- package/tests/setup.ts +22 -5
- package/tests/utils/inference.test.ts +218 -0
package/src/utils/inference.ts
CHANGED
|
@@ -7,10 +7,9 @@ import { prisma } from '../lib/prisma.js';
|
|
|
7
7
|
import { chatChannel, pusher } from '../lib/pusher.js';
|
|
8
8
|
import { ensureAIUserExists, getAIUserId } from './aiUser.js';
|
|
9
9
|
import { env } from '../lib/config/env.js';
|
|
10
|
+
import { Prisma } from '@prisma/client';
|
|
10
11
|
import { ZodSchema } from 'zod';
|
|
11
|
-
import { zodTextFormat } from
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
import { zodTextFormat } from 'openai/helpers/zod';
|
|
14
13
|
|
|
15
14
|
export const inferenceClient = new OpenAI({
|
|
16
15
|
apiKey: env.INFERENCE_API_KEY,
|
|
@@ -19,6 +18,38 @@ export const inferenceClient = new OpenAI({
|
|
|
19
18
|
|
|
20
19
|
export const openAIClient = new OpenAI();
|
|
21
20
|
|
|
21
|
+
const DEFAULT_INFERENCE_MODEL = 'gpt-5-nano';
|
|
22
|
+
|
|
23
|
+
type InferenceMessage = {
|
|
24
|
+
role: 'user' | 'assistant' | 'system';
|
|
25
|
+
content: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export interface InferenceOptions {
|
|
29
|
+
reason?: string;
|
|
30
|
+
metadata?: Prisma.InputJsonValue;
|
|
31
|
+
model?: string;
|
|
32
|
+
temperature?: number;
|
|
33
|
+
maxTokens?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface InferenceUsage {
|
|
37
|
+
promptTokens?: number;
|
|
38
|
+
completionTokens?: number;
|
|
39
|
+
totalTokens?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface InferenceAuditLog {
|
|
43
|
+
model: string;
|
|
44
|
+
operation: 'chat.completions' | 'responses.parse';
|
|
45
|
+
success: boolean;
|
|
46
|
+
estimatedInputTokens: number;
|
|
47
|
+
reason: string;
|
|
48
|
+
metadata?: Prisma.InputJsonValue;
|
|
49
|
+
usage?: InferenceUsage;
|
|
50
|
+
error?: unknown;
|
|
51
|
+
}
|
|
52
|
+
|
|
22
53
|
// Types for lab chat context
|
|
23
54
|
export interface LabChatContext {
|
|
24
55
|
subject: string;
|
|
@@ -39,6 +70,116 @@ export interface InferenceResponse {
|
|
|
39
70
|
finishReason: string;
|
|
40
71
|
}
|
|
41
72
|
|
|
73
|
+
function normalizeInferenceInput(
|
|
74
|
+
content: string | OpenAI.Chat.Completions.ChatCompletionMessageParam[],
|
|
75
|
+
): InferenceMessage[] {
|
|
76
|
+
if (typeof content === 'string') {
|
|
77
|
+
return [{ role: 'user', content }];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return content.map((message) => ({
|
|
81
|
+
role: message.role as InferenceMessage['role'],
|
|
82
|
+
content: typeof message.content === 'string' ? message.content : JSON.stringify(message.content),
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getInferenceReason(options?: InferenceOptions) {
|
|
87
|
+
return options?.reason?.trim() || 'unspecified';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function serializeInferenceError(error: unknown) {
|
|
91
|
+
if (error instanceof Error) {
|
|
92
|
+
return error.message;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (typeof error === 'string') {
|
|
96
|
+
return error;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
return JSON.stringify(error);
|
|
101
|
+
} catch {
|
|
102
|
+
return 'Unknown inference error';
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function logInferenceUsage({
|
|
107
|
+
model,
|
|
108
|
+
operation,
|
|
109
|
+
success,
|
|
110
|
+
estimatedInputTokens,
|
|
111
|
+
reason,
|
|
112
|
+
metadata,
|
|
113
|
+
usage,
|
|
114
|
+
error,
|
|
115
|
+
}: InferenceAuditLog) {
|
|
116
|
+
const context = {
|
|
117
|
+
reason,
|
|
118
|
+
model,
|
|
119
|
+
operation,
|
|
120
|
+
success,
|
|
121
|
+
estimatedInputTokens,
|
|
122
|
+
promptTokens: usage?.promptTokens,
|
|
123
|
+
completionTokens: usage?.completionTokens,
|
|
124
|
+
totalTokens: usage?.totalTokens,
|
|
125
|
+
metadata,
|
|
126
|
+
...(error ? { error: serializeInferenceError(error) } : {}),
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
await prisma.aiUsageLog.create({
|
|
131
|
+
data: {
|
|
132
|
+
reason,
|
|
133
|
+
model,
|
|
134
|
+
operation,
|
|
135
|
+
success,
|
|
136
|
+
estimatedInputTokens,
|
|
137
|
+
promptTokens: usage?.promptTokens,
|
|
138
|
+
completionTokens: usage?.completionTokens,
|
|
139
|
+
totalTokens: usage?.totalTokens,
|
|
140
|
+
...(metadata !== undefined ? { metadata } : {}),
|
|
141
|
+
...(error ? { error: serializeInferenceError(error) } : {}),
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
} catch (loggingError) {
|
|
145
|
+
logger.error('Failed to persist AI usage log', {
|
|
146
|
+
...context,
|
|
147
|
+
loggingError: serializeInferenceError(loggingError),
|
|
148
|
+
});
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
logger.info('AI usage logged', context);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function getChatCompletionUsage(completion: {
|
|
156
|
+
usage?: {
|
|
157
|
+
prompt_tokens?: number;
|
|
158
|
+
completion_tokens?: number;
|
|
159
|
+
total_tokens?: number;
|
|
160
|
+
};
|
|
161
|
+
}): InferenceUsage {
|
|
162
|
+
return {
|
|
163
|
+
promptTokens: completion.usage?.prompt_tokens,
|
|
164
|
+
completionTokens: completion.usage?.completion_tokens,
|
|
165
|
+
totalTokens: completion.usage?.total_tokens,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function getResponseUsage(completion: {
|
|
170
|
+
usage?: {
|
|
171
|
+
input_tokens?: number;
|
|
172
|
+
output_tokens?: number;
|
|
173
|
+
total_tokens?: number;
|
|
174
|
+
};
|
|
175
|
+
}): InferenceUsage {
|
|
176
|
+
return {
|
|
177
|
+
promptTokens: completion.usage?.input_tokens,
|
|
178
|
+
completionTokens: completion.usage?.output_tokens,
|
|
179
|
+
totalTokens: completion.usage?.total_tokens,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
42
183
|
/**
|
|
43
184
|
* Centralized function to send AI messages to conversations
|
|
44
185
|
* Handles database storage and Pusher broadcasting
|
|
@@ -65,10 +206,8 @@ export async function sendAIMessage(
|
|
|
65
206
|
createdAt: Date;
|
|
66
207
|
meta?: Record<string, any>;
|
|
67
208
|
}> {
|
|
68
|
-
// Ensure AI user exists
|
|
69
209
|
await ensureAIUserExists();
|
|
70
210
|
|
|
71
|
-
// Create message in database
|
|
72
211
|
const aiMessage = await prisma.message.create({
|
|
73
212
|
data: {
|
|
74
213
|
content,
|
|
@@ -94,17 +233,15 @@ export async function sendAIMessage(
|
|
|
94
233
|
contentLength: content.length,
|
|
95
234
|
});
|
|
96
235
|
|
|
97
|
-
// Prepare sender info
|
|
98
236
|
const senderInfo = {
|
|
99
237
|
id: getAIUserId(),
|
|
100
238
|
username: 'Newton_AI',
|
|
101
239
|
profile: {
|
|
102
|
-
displayName:
|
|
240
|
+
displayName: 'Newton AI',
|
|
103
241
|
profilePicture: options.customSender?.profilePicture || null,
|
|
104
242
|
},
|
|
105
243
|
};
|
|
106
244
|
|
|
107
|
-
// Broadcast via Pusher
|
|
108
245
|
try {
|
|
109
246
|
await pusher.trigger(chatChannel(conversationId), 'new-message', {
|
|
110
247
|
id: aiMessage.id,
|
|
@@ -115,7 +252,7 @@ export async function sendAIMessage(
|
|
|
115
252
|
sender: senderInfo,
|
|
116
253
|
mentionedUserIds: [],
|
|
117
254
|
meta: aiMessage.meta,
|
|
118
|
-
attachments: aiMessage.attachments.map(attachment => ({
|
|
255
|
+
attachments: aiMessage.attachments.map((attachment) => ({
|
|
119
256
|
id: attachment.id,
|
|
120
257
|
attachmentId: attachment.id,
|
|
121
258
|
name: attachment.name,
|
|
@@ -140,67 +277,85 @@ export async function sendAIMessage(
|
|
|
140
277
|
|
|
141
278
|
export async function inference<T>(
|
|
142
279
|
content: string | OpenAI.Chat.Completions.ChatCompletionMessageParam[],
|
|
143
|
-
format?: ZodSchema
|
|
280
|
+
format?: ZodSchema,
|
|
281
|
+
options?: InferenceOptions,
|
|
144
282
|
): Promise<T> {
|
|
145
|
-
|
|
283
|
+
const input = normalizeInferenceInput(content);
|
|
284
|
+
const reason = getInferenceReason(options);
|
|
285
|
+
const model = options?.model ?? DEFAULT_INFERENCE_MODEL;
|
|
286
|
+
const estimatedInputTokens = input.reduce(
|
|
287
|
+
(total, message) => total + estimateTokenCount(message.content),
|
|
288
|
+
0,
|
|
289
|
+
);
|
|
290
|
+
const operation = format ? 'responses.parse' : 'chat.completions';
|
|
146
291
|
|
|
292
|
+
try {
|
|
147
293
|
if (!format) {
|
|
148
294
|
const completion = await openAIClient.chat.completions.create({
|
|
149
|
-
model
|
|
150
|
-
messages:
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
295
|
+
model,
|
|
296
|
+
messages: input,
|
|
297
|
+
...(options?.temperature !== undefined
|
|
298
|
+
? { temperature: options.temperature }
|
|
299
|
+
: {}),
|
|
300
|
+
...(options?.maxTokens !== undefined
|
|
301
|
+
? { max_tokens: options.maxTokens }
|
|
302
|
+
: {}),
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
await logInferenceUsage({
|
|
306
|
+
model,
|
|
307
|
+
operation,
|
|
308
|
+
success: true,
|
|
309
|
+
estimatedInputTokens,
|
|
310
|
+
reason,
|
|
311
|
+
metadata: options?.metadata,
|
|
312
|
+
usage: getChatCompletionUsage(completion),
|
|
156
313
|
});
|
|
157
314
|
|
|
158
315
|
return completion.choices[0]?.message?.content as T;
|
|
159
316
|
}
|
|
160
317
|
|
|
161
318
|
const completion = await openAIClient.responses.parse({
|
|
162
|
-
model
|
|
163
|
-
input
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
},
|
|
168
|
-
] : content as Array<{ role: 'user' | 'assistant' | 'system'; content: string }>,
|
|
169
|
-
...(format ? { text: {
|
|
170
|
-
format: zodTextFormat(format, "newton_response_format"),
|
|
171
|
-
},
|
|
172
|
-
} : {}),
|
|
319
|
+
model,
|
|
320
|
+
input,
|
|
321
|
+
text: {
|
|
322
|
+
format: zodTextFormat(format, 'newton_response_format'),
|
|
323
|
+
},
|
|
173
324
|
});
|
|
174
325
|
|
|
175
|
-
console.log({
|
|
176
|
-
model: 'gpt-5-nano',
|
|
177
|
-
input: typeof content === 'string' ? [
|
|
178
|
-
{
|
|
179
|
-
role: 'user',
|
|
180
|
-
content: content,
|
|
181
|
-
},
|
|
182
|
-
] : content as Array<{ role: 'user' | 'assistant' | 'system'; content: string }>,
|
|
183
|
-
...(format ? { text: {
|
|
184
|
-
format: zodTextFormat(format, "newton_response_format"),
|
|
185
|
-
},
|
|
186
|
-
} : {}),
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
|
|
190
326
|
if (!completion) {
|
|
191
327
|
throw new Error('No response generated from inference API');
|
|
192
328
|
}
|
|
193
329
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
330
|
+
await logInferenceUsage({
|
|
331
|
+
model,
|
|
332
|
+
operation,
|
|
333
|
+
success: true,
|
|
334
|
+
estimatedInputTokens,
|
|
335
|
+
reason,
|
|
336
|
+
metadata: options?.metadata,
|
|
337
|
+
usage: getResponseUsage(completion),
|
|
338
|
+
});
|
|
200
339
|
|
|
201
|
-
return completion.output_parsed;
|
|
340
|
+
return completion.output_parsed as T;
|
|
202
341
|
} catch (error) {
|
|
203
|
-
|
|
342
|
+
await logInferenceUsage({
|
|
343
|
+
model,
|
|
344
|
+
operation,
|
|
345
|
+
success: false,
|
|
346
|
+
estimatedInputTokens,
|
|
347
|
+
reason,
|
|
348
|
+
metadata: options?.metadata,
|
|
349
|
+
error,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
logger.error('Failed to generate inference response', {
|
|
353
|
+
error,
|
|
354
|
+
reason,
|
|
355
|
+
model,
|
|
356
|
+
operation,
|
|
357
|
+
metadata: options?.metadata,
|
|
358
|
+
});
|
|
204
359
|
throw error;
|
|
205
360
|
}
|
|
206
361
|
}
|
|
@@ -217,7 +372,7 @@ export async function getAvailableModels(): Promise<string[]> {
|
|
|
217
372
|
.sort();
|
|
218
373
|
} catch (error) {
|
|
219
374
|
logger.error('Failed to fetch inference models', { error });
|
|
220
|
-
return ['command-r-plus', 'command-r', 'command-light'];
|
|
375
|
+
return ['command-r-plus', 'command-r', 'command-light'];
|
|
221
376
|
}
|
|
222
377
|
}
|
|
223
378
|
|
|
@@ -225,6 +380,5 @@ export async function getAvailableModels(): Promise<string[]> {
|
|
|
225
380
|
* Estimate token count for a message (rough approximation)
|
|
226
381
|
*/
|
|
227
382
|
export function estimateTokenCount(text: string): number {
|
|
228
|
-
// Rough approximation: 1 token ≈ 4 characters for English text
|
|
229
383
|
return Math.ceil(text.length / 4);
|
|
230
|
-
}
|
|
384
|
+
}
|
package/tests/globalSetup.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { appRouter } from '../src/routers/_app';
|
|
|
9
9
|
import { createTRPCContext } from '../src/trpc';
|
|
10
10
|
import { clearDatabase } from '../src/seedDatabase';
|
|
11
11
|
import { execSync } from 'child_process';
|
|
12
|
+
import { SessionType } from '@prisma/client';
|
|
12
13
|
|
|
13
14
|
async function runSetup() {
|
|
14
15
|
const ctx = await createTRPCContext({
|
|
@@ -40,9 +41,18 @@ async function runSetup() {
|
|
|
40
41
|
confirmPassword: 'password_is_1234',
|
|
41
42
|
});
|
|
42
43
|
|
|
43
|
-
const session1 = await prisma.session.findFirst({
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
const session1 = await prisma.session.findFirst({
|
|
45
|
+
where: { userId: user1.user.id, type: SessionType.EMAIL_VERIFICATION },
|
|
46
|
+
orderBy: { createdAt: 'desc' },
|
|
47
|
+
});
|
|
48
|
+
const session2 = await prisma.session.findFirst({
|
|
49
|
+
where: { userId: user2.user.id, type: SessionType.EMAIL_VERIFICATION },
|
|
50
|
+
orderBy: { createdAt: 'desc' },
|
|
51
|
+
});
|
|
52
|
+
const session3 = await prisma.session.findFirst({
|
|
53
|
+
where: { userId: user3.user.id, type: SessionType.EMAIL_VERIFICATION },
|
|
54
|
+
orderBy: { createdAt: 'desc' },
|
|
55
|
+
});
|
|
46
56
|
|
|
47
57
|
if (!session1 || !session2 || !session3) {
|
|
48
58
|
throw new Error('Failed to create sessions for test users');
|
|
@@ -59,4 +69,3 @@ export default async function setup(project: TestProject) {
|
|
|
59
69
|
project.onTestsRerun(runSetup);
|
|
60
70
|
await runSetup();
|
|
61
71
|
}
|
|
62
|
-
|
|
@@ -1,12 +1,58 @@
|
|
|
1
1
|
// @note: some sloppy impl
|
|
2
2
|
|
|
3
|
+
import { randomUUID } from 'crypto';
|
|
4
|
+
import { SessionType } from '@prisma/client';
|
|
3
5
|
import { test, expect, describe } from 'vitest';
|
|
4
6
|
import { appRouter } from '../../src/routers/_app';
|
|
5
7
|
import { createTRPCContext } from '../../src/trpc';
|
|
6
8
|
import { prisma } from '../../src/lib/prisma';
|
|
7
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
caller,
|
|
11
|
+
login1,
|
|
12
|
+
login2,
|
|
13
|
+
login3,
|
|
14
|
+
session1,
|
|
15
|
+
session2,
|
|
16
|
+
session3,
|
|
17
|
+
user1Caller,
|
|
18
|
+
user2Caller,
|
|
19
|
+
user3Caller,
|
|
20
|
+
verification1,
|
|
21
|
+
verification2,
|
|
22
|
+
verification3,
|
|
23
|
+
} from '../setup';
|
|
8
24
|
import { expectUnauth } from '../helpers';
|
|
9
25
|
|
|
26
|
+
type TokenHeaderSource = 'x-user' | 'authorization';
|
|
27
|
+
|
|
28
|
+
const createCallerForToken = async (
|
|
29
|
+
token: string,
|
|
30
|
+
source: TokenHeaderSource = 'x-user'
|
|
31
|
+
) => {
|
|
32
|
+
const headers =
|
|
33
|
+
source === 'x-user'
|
|
34
|
+
? { 'x-user': token }
|
|
35
|
+
: { authorization: `Bearer ${token}` };
|
|
36
|
+
|
|
37
|
+
const ctx = await createTRPCContext({
|
|
38
|
+
req: {
|
|
39
|
+
headers,
|
|
40
|
+
} as any,
|
|
41
|
+
res: {} as any,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return appRouter.createCaller(ctx);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const expectTokenRejected = async (token: string) => {
|
|
48
|
+
for (const source of ['x-user', 'authorization'] as const) {
|
|
49
|
+
const protectedCaller = await createCallerForToken(token, source);
|
|
50
|
+
await expect(protectedCaller.auth.check()).rejects.toThrow(
|
|
51
|
+
'Invalid or expired session'
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
10
56
|
describe('Auth Router', () => {
|
|
11
57
|
test('check returns user when authenticated', async () => {
|
|
12
58
|
const result = await user1Caller.auth.check();
|
|
@@ -15,8 +61,7 @@ describe('Auth Router', () => {
|
|
|
15
61
|
expect(result.user.username).toBe('testuser1');
|
|
16
62
|
});
|
|
17
63
|
|
|
18
|
-
test('check fails when unauthenticated', () =>
|
|
19
|
-
expectUnauth((c) => c.auth.check()));
|
|
64
|
+
test('check fails when unauthenticated', () => expectUnauth((c) => c.auth.check()));
|
|
20
65
|
|
|
21
66
|
test('resendVerificationEmail succeeds for existing user', async () => {
|
|
22
67
|
await caller.auth.register({
|
|
@@ -39,16 +84,77 @@ describe('Auth Router', () => {
|
|
|
39
84
|
expect(result.success).toBe(true);
|
|
40
85
|
});
|
|
41
86
|
|
|
87
|
+
test('expired auth sessions are rejected', async () => {
|
|
88
|
+
const expiredSession = await prisma.session.create({
|
|
89
|
+
data: {
|
|
90
|
+
id: randomUUID(),
|
|
91
|
+
type: SessionType.AUTH,
|
|
92
|
+
userId: login1.user.id,
|
|
93
|
+
expiresAt: new Date(Date.now() - 60_000),
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await expectTokenRejected(expiredSession.id);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('password reset tokens cannot authenticate API calls', async () => {
|
|
101
|
+
const resetToken = await prisma.session.create({
|
|
102
|
+
data: {
|
|
103
|
+
id: randomUUID(),
|
|
104
|
+
type: SessionType.PASSWORD_RESET,
|
|
105
|
+
userId: login1.user.id,
|
|
106
|
+
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await expectTokenRejected(resetToken.id);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('email verification tokens cannot authenticate API calls', async () => {
|
|
114
|
+
const verificationToken = await prisma.session.create({
|
|
115
|
+
data: {
|
|
116
|
+
id: randomUUID(),
|
|
117
|
+
type: SessionType.EMAIL_VERIFICATION,
|
|
118
|
+
userId: login1.user.id,
|
|
119
|
+
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await expectTokenRejected(verificationToken.id);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('class invite tokens cannot authenticate API calls', async () => {
|
|
127
|
+
const inviteClass = await prisma.class.create({
|
|
128
|
+
data: {
|
|
129
|
+
id: randomUUID(),
|
|
130
|
+
name: 'Auth Invite Guard',
|
|
131
|
+
subject: 'Security',
|
|
132
|
+
section: 'A',
|
|
133
|
+
teachers: { connect: { id: login1.user.id } },
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const inviteToken = await prisma.session.create({
|
|
138
|
+
data: {
|
|
139
|
+
id: randomUUID(),
|
|
140
|
+
type: SessionType.CLASS_INVITE,
|
|
141
|
+
classId: inviteClass.id,
|
|
142
|
+
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await expectTokenRejected(inviteToken.id);
|
|
147
|
+
});
|
|
148
|
+
|
|
42
149
|
test('resetPassword works with valid token', async () => {
|
|
43
150
|
await caller.auth.requestPasswordReset({ email: 'test@studious.sh' });
|
|
44
|
-
// Reset tokens expire within 2hr; login sessions expire in 30 days
|
|
45
151
|
const twoHoursFromNow = new Date(Date.now() + 2 * 60 * 60 * 1000);
|
|
46
152
|
const resetSession = await prisma.session.findFirst({
|
|
47
153
|
where: {
|
|
48
154
|
userId: login1.user.id,
|
|
49
|
-
|
|
155
|
+
type: SessionType.PASSWORD_RESET,
|
|
50
156
|
expiresAt: { lte: twoHoursFromNow },
|
|
51
|
-
createdAt: { gte: new Date(Date.now() - 60_000) },
|
|
157
|
+
createdAt: { gte: new Date(Date.now() - 60_000) },
|
|
52
158
|
},
|
|
53
159
|
orderBy: { createdAt: 'desc' },
|
|
54
160
|
});
|
|
@@ -71,7 +177,7 @@ describe('Auth Router', () => {
|
|
|
71
177
|
const restoreSession = await prisma.session.findFirst({
|
|
72
178
|
where: {
|
|
73
179
|
userId: login1.user.id,
|
|
74
|
-
|
|
180
|
+
type: SessionType.PASSWORD_RESET,
|
|
75
181
|
expiresAt: { lte: twoHoursFromNow },
|
|
76
182
|
createdAt: { gte: new Date(Date.now() - 60_000) },
|
|
77
183
|
},
|
|
@@ -95,6 +201,7 @@ describe('Auth Router', () => {
|
|
|
95
201
|
test('email verification works', async () => {
|
|
96
202
|
expect(verification1).toBeDefined();
|
|
97
203
|
expect(verification2).toBeDefined();
|
|
204
|
+
expect(verification3).toBeDefined();
|
|
98
205
|
});
|
|
99
206
|
|
|
100
207
|
test('login returns valid tokens', async () => {
|
|
@@ -141,9 +248,7 @@ describe('Auth Router', () => {
|
|
|
141
248
|
});
|
|
142
249
|
|
|
143
250
|
test('verify fails with invalid token', async () => {
|
|
144
|
-
await expect(
|
|
145
|
-
caller.auth.verify({ token: 'invalid-token-12345' })
|
|
146
|
-
).rejects.toThrow();
|
|
251
|
+
await expect(caller.auth.verify({ token: 'invalid-token-12345' })).rejects.toThrow();
|
|
147
252
|
});
|
|
148
253
|
|
|
149
254
|
test('resetPassword fails with invalid token', async () => {
|
|
@@ -161,11 +266,9 @@ describe('Auth Router', () => {
|
|
|
161
266
|
expect(logout).toBeDefined();
|
|
162
267
|
expect(logout.success).toBe(true);
|
|
163
268
|
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
const router = appRouter.createCaller(ctx);
|
|
169
|
-
await expect(router.user.getProfile()).rejects.toThrow('Invalid or expired session');
|
|
269
|
+
for (const source of ['x-user', 'authorization'] as const) {
|
|
270
|
+
const router = await createCallerForToken(login3.token, source);
|
|
271
|
+
await expect(router.user.getProfile()).rejects.toThrow('Invalid or expired session');
|
|
272
|
+
}
|
|
170
273
|
});
|
|
171
|
-
});
|
|
274
|
+
});
|
|
@@ -96,6 +96,12 @@ describe('Class Router', () => {
|
|
|
96
96
|
expect(classData.class.name).toBe('Test Class 1');
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
+
test('should forbid non-members from getting class data', async () => {
|
|
100
|
+
await expect(
|
|
101
|
+
user3Caller.class.get({ classId: testClass.id })
|
|
102
|
+
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
|
103
|
+
});
|
|
104
|
+
|
|
99
105
|
test('should fail to get non-existent class', () =>
|
|
100
106
|
expectRejects(() => user1Caller.class.get({ classId: 'non-existent-id' })));
|
|
101
107
|
|
|
@@ -349,4 +355,4 @@ describe('Class Router', () => {
|
|
|
349
355
|
).rejects.toThrow();
|
|
350
356
|
});
|
|
351
357
|
});
|
|
352
|
-
});
|
|
358
|
+
});
|