@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.
Files changed (145) hide show
  1. package/.coderabbit.yaml +4 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +67 -28
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/fileUpload.d.ts +10 -2
  6. package/dist/lib/fileUpload.d.ts.map +1 -1
  7. package/dist/lib/fileUpload.js +38 -6
  8. package/dist/lib/fileUpload.js.map +1 -1
  9. package/dist/middleware/auth.d.ts.map +1 -1
  10. package/dist/middleware/auth.js +18 -5
  11. package/dist/middleware/auth.js.map +1 -1
  12. package/dist/models/auth.d.ts +19 -14
  13. package/dist/models/auth.d.ts.map +1 -1
  14. package/dist/models/auth.js +53 -30
  15. package/dist/models/auth.js.map +1 -1
  16. package/dist/models/class.d.ts +3 -1
  17. package/dist/models/class.d.ts.map +1 -1
  18. package/dist/models/class.js +16 -4
  19. package/dist/models/class.js.map +1 -1
  20. package/dist/models/notification.d.ts +11 -10
  21. package/dist/models/notification.d.ts.map +1 -1
  22. package/dist/models/notification.js +13 -9
  23. package/dist/models/notification.js.map +1 -1
  24. package/dist/models/worksheet.d.ts +378 -7
  25. package/dist/models/worksheet.d.ts.map +1 -1
  26. package/dist/models/worksheet.js +168 -2
  27. package/dist/models/worksheet.js.map +1 -1
  28. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  29. package/dist/pipelines/aiLabChat.js +11 -3
  30. package/dist/pipelines/aiLabChat.js.map +1 -1
  31. package/dist/pipelines/aiLabChatContract.d.ts +3 -3
  32. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  33. package/dist/pipelines/aiNewtonChat.js +25 -14
  34. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  35. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  36. package/dist/pipelines/gradeWorksheet.js +10 -3
  37. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  38. package/dist/routers/_app.d.ts +124 -18
  39. package/dist/routers/_app.d.ts.map +1 -1
  40. package/dist/routers/class.d.ts +2 -1
  41. package/dist/routers/class.d.ts.map +1 -1
  42. package/dist/routers/class.js +3 -3
  43. package/dist/routers/class.js.map +1 -1
  44. package/dist/routers/labChat.d.ts +6 -0
  45. package/dist/routers/labChat.d.ts.map +1 -1
  46. package/dist/routers/labChat.js +5 -3
  47. package/dist/routers/labChat.js.map +1 -1
  48. package/dist/routers/message.d.ts +31 -0
  49. package/dist/routers/message.d.ts.map +1 -1
  50. package/dist/routers/message.js +27 -4
  51. package/dist/routers/message.js.map +1 -1
  52. package/dist/routers/newtonChat.d.ts +6 -0
  53. package/dist/routers/newtonChat.d.ts.map +1 -1
  54. package/dist/routers/newtonChat.js +5 -4
  55. package/dist/routers/newtonChat.js.map +1 -1
  56. package/dist/routers/notifications.d.ts +9 -2
  57. package/dist/routers/notifications.d.ts.map +1 -1
  58. package/dist/routers/notifications.js +4 -4
  59. package/dist/routers/notifications.js.map +1 -1
  60. package/dist/routers/worksheet.d.ts +8 -6
  61. package/dist/routers/worksheet.d.ts.map +1 -1
  62. package/dist/routers/worksheet.js +18 -16
  63. package/dist/routers/worksheet.js.map +1 -1
  64. package/dist/services/auth.d.ts +1 -1
  65. package/dist/services/auth.d.ts.map +1 -1
  66. package/dist/services/auth.js +19 -12
  67. package/dist/services/auth.js.map +1 -1
  68. package/dist/services/class.d.ts +1 -1
  69. package/dist/services/class.d.ts.map +1 -1
  70. package/dist/services/class.js +6 -3
  71. package/dist/services/class.js.map +1 -1
  72. package/dist/services/file.d.ts.map +1 -1
  73. package/dist/services/file.js +7 -2
  74. package/dist/services/file.js.map +1 -1
  75. package/dist/services/labChat.d.ts +6 -0
  76. package/dist/services/labChat.d.ts.map +1 -1
  77. package/dist/services/labChat.js +30 -18
  78. package/dist/services/labChat.js.map +1 -1
  79. package/dist/services/message.d.ts +30 -0
  80. package/dist/services/message.d.ts.map +1 -1
  81. package/dist/services/message.js +97 -21
  82. package/dist/services/message.js.map +1 -1
  83. package/dist/services/newtonChat.d.ts +6 -1
  84. package/dist/services/newtonChat.d.ts.map +1 -1
  85. package/dist/services/newtonChat.js +36 -18
  86. package/dist/services/newtonChat.js.map +1 -1
  87. package/dist/services/notification.d.ts +12 -5
  88. package/dist/services/notification.d.ts.map +1 -1
  89. package/dist/services/notification.js +24 -9
  90. package/dist/services/notification.js.map +1 -1
  91. package/dist/services/studentProgress.d.ts.map +1 -1
  92. package/dist/services/studentProgress.js +6 -3
  93. package/dist/services/studentProgress.js.map +1 -1
  94. package/dist/services/worksheet.d.ts +15 -15
  95. package/dist/services/worksheet.d.ts.map +1 -1
  96. package/dist/services/worksheet.js +138 -54
  97. package/dist/services/worksheet.js.map +1 -1
  98. package/dist/trpc.d.ts.map +1 -1
  99. package/dist/trpc.js +11 -17
  100. package/dist/trpc.js.map +1 -1
  101. package/dist/utils/inference.d.ts +9 -1
  102. package/dist/utils/inference.d.ts.map +1 -1
  103. package/dist/utils/inference.js +138 -50
  104. package/dist/utils/inference.js.map +1 -1
  105. package/package.json +1 -1
  106. package/prisma/migrations/20260415103500_add_session_type/migration.sql +33 -0
  107. package/prisma/migrations/20260415133000_add_ai_usage_logs/migration.sql +19 -0
  108. package/prisma/schema.prisma +27 -1
  109. package/scripts/test-pre-push.ts +6 -2
  110. package/src/index.ts +80 -28
  111. package/src/lib/fileUpload.ts +55 -5
  112. package/src/middleware/auth.ts +19 -4
  113. package/src/models/auth.ts +59 -29
  114. package/src/models/class.ts +15 -4
  115. package/src/models/notification.ts +13 -7
  116. package/src/models/worksheet.ts +202 -0
  117. package/src/pipelines/aiLabChat.ts +9 -1
  118. package/src/pipelines/aiNewtonChat.ts +24 -14
  119. package/src/pipelines/gradeWorksheet.ts +8 -0
  120. package/src/routers/class.ts +2 -2
  121. package/src/routers/labChat.ts +3 -1
  122. package/src/routers/message.ts +38 -1
  123. package/src/routers/newtonChat.ts +4 -2
  124. package/src/routers/notifications.ts +2 -2
  125. package/src/routers/worksheet.ts +31 -19
  126. package/src/services/auth.ts +26 -12
  127. package/src/services/class.ts +4 -1
  128. package/src/services/file.ts +6 -0
  129. package/src/services/labChat.ts +42 -18
  130. package/src/services/message.ts +163 -18
  131. package/src/services/newtonChat.ts +49 -18
  132. package/src/services/notification.ts +27 -8
  133. package/src/services/studentProgress.ts +4 -1
  134. package/src/services/worksheet.ts +191 -51
  135. package/src/trpc.ts +12 -18
  136. package/src/utils/inference.ts +209 -55
  137. package/tests/globalSetup.ts +13 -4
  138. package/tests/routers/auth.test.ts +120 -17
  139. package/tests/routers/class.test.ts +7 -1
  140. package/tests/routers/message.test.ts +236 -9
  141. package/tests/routers/newtonChat.test.ts +81 -0
  142. package/tests/routers/notification.test.ts +12 -0
  143. package/tests/routers/worksheet.test.ts +194 -0
  144. package/tests/setup.ts +22 -5
  145. package/tests/utils/inference.test.ts +218 -0
@@ -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 "openai/helpers/zod";
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: "Newton AI",
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
- try {
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: 'gpt-5-nano',
150
- messages: typeof content === 'string' ? [
151
- {
152
- role: 'user',
153
- content: content,
154
- },
155
- ] : content as Array<{ role: 'user' | 'assistant' | 'system'; content: string }>,
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: 'gpt-5-nano',
163
- input: typeof content === 'string' ? [
164
- {
165
- role: 'user',
166
- content: content,
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
- // if (format) {
195
- // if (typeof completion.output === 'string') {
196
- // return JSON.parse(completion.output);
197
- // }
198
- // return JSON.parse(completion.output);
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
- logger.error('Failed to generate inference response', { error });
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']; // Fallback Cohere models
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
+ }
@@ -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({ where: { userId: user1.user.id } });
44
- const session2 = await prisma.session.findFirst({ where: { userId: user2.user.id } });
45
- const session3 = await prisma.session.findFirst({ where: { userId: user3.user.id } });
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 { caller, login1, login2, login3, session1, session2, session3, user1Caller, user2Caller, user3Caller, verification1, verification2, verification3 } from '../setup';
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
- classId: null,
155
+ type: SessionType.PASSWORD_RESET,
50
156
  expiresAt: { lte: twoHoursFromNow },
51
- createdAt: { gte: new Date(Date.now() - 60_000) }, // created in last minute
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
- classId: null,
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 ctx = await createTRPCContext({
165
- req: { headers: { authorization: `Bearer ${login3.token}`, 'x-user': login3.token } } as any,
166
- res: {} as any,
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
+ });