@goscribe/server 1.1.7 → 1.3.0

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 (56) hide show
  1. package/.env.example +43 -0
  2. package/check-difficulty.cjs +14 -0
  3. package/check-questions.cjs +14 -0
  4. package/db-summary.cjs +22 -0
  5. package/dist/routers/auth.js +1 -1
  6. package/mcq-test.cjs +36 -0
  7. package/package.json +10 -2
  8. package/prisma/migrations/20260413143206_init/migration.sql +873 -0
  9. package/prisma/schema.prisma +485 -292
  10. package/src/context.ts +4 -1
  11. package/src/lib/activity_human_description.test.ts +28 -0
  12. package/src/lib/activity_human_description.ts +239 -0
  13. package/src/lib/activity_log_service.test.ts +37 -0
  14. package/src/lib/activity_log_service.ts +353 -0
  15. package/src/lib/ai-session.ts +194 -112
  16. package/src/lib/constants.ts +14 -0
  17. package/src/lib/email.ts +230 -0
  18. package/src/lib/env.ts +23 -6
  19. package/src/lib/inference.ts +3 -3
  20. package/src/lib/logger.ts +26 -9
  21. package/src/lib/notification-service.test.ts +106 -0
  22. package/src/lib/notification-service.ts +677 -0
  23. package/src/lib/prisma.ts +6 -1
  24. package/src/lib/pusher.ts +90 -6
  25. package/src/lib/retry.ts +61 -0
  26. package/src/lib/storage.ts +2 -2
  27. package/src/lib/stripe.ts +39 -0
  28. package/src/lib/subscription_service.ts +722 -0
  29. package/src/lib/usage_service.ts +74 -0
  30. package/src/lib/worksheet-generation.test.ts +31 -0
  31. package/src/lib/worksheet-generation.ts +139 -0
  32. package/src/lib/workspace-access.ts +13 -0
  33. package/src/routers/_app.ts +11 -0
  34. package/src/routers/admin.ts +710 -0
  35. package/src/routers/annotations.ts +227 -0
  36. package/src/routers/auth.ts +432 -33
  37. package/src/routers/copilot.ts +719 -0
  38. package/src/routers/flashcards.ts +207 -80
  39. package/src/routers/members.ts +280 -80
  40. package/src/routers/notifications.ts +142 -0
  41. package/src/routers/payment.ts +448 -0
  42. package/src/routers/podcast.ts +133 -108
  43. package/src/routers/studyguide.ts +80 -74
  44. package/src/routers/worksheets.ts +300 -80
  45. package/src/routers/workspace.ts +538 -328
  46. package/src/scripts/purge-deleted-users.ts +167 -0
  47. package/src/server.ts +140 -12
  48. package/src/services/flashcard-progress.service.ts +52 -43
  49. package/src/trpc.ts +184 -5
  50. package/test-generate.js +30 -0
  51. package/test-ratio.cjs +9 -0
  52. package/zod-test.cjs +22 -0
  53. package/prisma/migrations/20250826124819_add_worksheet_difficulty_and_estimated_time/migration.sql +0 -213
  54. package/prisma/migrations/20250826133236_add_worksheet_question_progress/migration.sql +0 -31
  55. package/prisma/seed.mjs +0 -135
  56. package/src/routers/meetingsummary.ts +0 -416
@@ -1,18 +1,47 @@
1
1
  import { z } from 'zod';
2
2
  import { router, publicProcedure, authedProcedure } from '../trpc.js';
3
+ import PusherService from '../lib/pusher.js';
4
+ import { logger } from '../lib/logger.js';
3
5
  import bcrypt from 'bcryptjs';
4
6
  import { serialize } from 'cookie';
5
7
  import crypto from 'node:crypto';
6
8
  import { TRPCError } from '@trpc/server';
7
- import { supabaseClient } from 'src/lib/storage.js';
9
+ import { supabaseClient } from '../lib/storage.js';
10
+ import {
11
+ sendVerificationEmail,
12
+ sendAccountDeletionScheduledEmail,
13
+ sendAccountRestoredEmail,
14
+ sendPasswordResetEmail,
15
+ } from '../lib/email.js';
16
+ import { createStripeCustomer } from '../lib/stripe.js';
17
+ import {
18
+ notifyAdminsAccountDeletionScheduled,
19
+ notifyAdminsOnSignup,
20
+ } from '../lib/notification-service.js';
8
21
 
9
22
  // Helper to create custom auth token
23
+ const passwordFieldSchema = z
24
+ .string()
25
+ .min(8, 'Password must be at least 8 characters')
26
+ .regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
27
+ .regex(/[0-9]/, 'Password must contain at least one number')
28
+ .regex(/[^a-zA-Z0-9]/, 'Password must contain at least one special character');
29
+
30
+ function hashPasswordResetToken(rawToken: string): string {
31
+ return crypto.createHash('sha256').update(rawToken, 'utf8').digest('hex');
32
+ }
33
+
34
+ /** Use until `npx prisma generate` runs after adding PasswordResetToken to the schema. */
35
+ function passwordResetDb(ctx: { db: any }) {
36
+ return ctx.db.passwordResetToken;
37
+ }
38
+
10
39
  function createCustomAuthToken(userId: string): string {
11
40
  const secret = process.env.AUTH_SECRET;
12
41
  if (!secret) {
13
- throw new Error("AUTH_SECRET is not set");
42
+ throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: "AUTH_SECRET is not set" });
14
43
  }
15
-
44
+
16
45
  const base64UserId = Buffer.from(userId, 'utf8').toString('base64url');
17
46
  const hmac = crypto.createHmac('sha256', secret);
18
47
  hmac.update(base64UserId);
@@ -25,7 +54,7 @@ export const auth = router({
25
54
  .input(z.object({
26
55
  name: z.string().min(1),
27
56
  }))
28
- .mutation(async ({ctx, input}) => {
57
+ .mutation(async ({ ctx, input }) => {
29
58
  const { name } = input;
30
59
 
31
60
  await ctx.db.user.update({
@@ -42,19 +71,66 @@ export const auth = router({
42
71
  message: 'Profile updated successfully',
43
72
  };
44
73
  }),
45
- uploadProfilePicture: publicProcedure
46
- .mutation(async ({ctx, input}) => {
47
- const objectKey = `profile_picture_${ctx.session.user.id}`;
74
+ changePassword: authedProcedure
75
+ .input(z.object({
76
+ currentPassword: z.string().min(1, "Current password is required"),
77
+ newPassword: passwordFieldSchema,
78
+ }))
79
+ .mutation(async ({ ctx, input }) => {
80
+ const user = await ctx.db.user.findUnique({
81
+ where: { id: ctx.session.user.id },
82
+ select: { id: true, passwordHash: true },
83
+ });
84
+
85
+ if (!user) {
86
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' });
87
+ }
88
+
89
+ if (!user.passwordHash) {
90
+ throw new TRPCError({
91
+ code: 'BAD_REQUEST',
92
+ message: 'Password change is unavailable for this account.',
93
+ });
94
+ }
95
+
96
+ const validCurrentPassword = await bcrypt.compare(input.currentPassword, user.passwordHash);
97
+ if (!validCurrentPassword) {
98
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Current password is incorrect' });
99
+ }
100
+
101
+ const isSamePassword = await bcrypt.compare(input.newPassword, user.passwordHash);
102
+ if (isSamePassword) {
103
+ throw new TRPCError({
104
+ code: 'BAD_REQUEST',
105
+ message: 'New password must be different from current password',
106
+ });
107
+ }
108
+
109
+ const newHash = await bcrypt.hash(input.newPassword, 10);
110
+ await ctx.db.user.update({
111
+ where: { id: user.id },
112
+ data: { passwordHash: newHash },
113
+ });
114
+
115
+ return { success: true, message: 'Password changed successfully' };
116
+ }),
117
+ uploadProfilePicture: authedProcedure
118
+ .mutation(async ({ ctx }) => {
119
+ const userId = ctx.session.user.id;
120
+ logger.info(`Generating upload URL for user ${userId}`, 'AUTH');
121
+ const objectKey = `profile_picture_${userId}`;
48
122
  const { data: signedUrlData, error: signedUrlError } = await supabaseClient.storage
49
123
  .from('media')
50
- .createSignedUploadUrl(objectKey, { upsert: true }); // 5 minutes
124
+ .createSignedUploadUrl(objectKey, { upsert: true });
125
+
51
126
  if (signedUrlError) {
127
+ logger.error(`Failed to generate upload URL: ${signedUrlError.message}`, 'AUTH');
52
128
  throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: `Failed to generate upload URL: ${signedUrlError.message}` });
53
129
  }
54
130
 
55
- await ctx.db.fileAsset.create({
131
+ const fileAsset = await ctx.db.fileAsset.create({
56
132
  data: {
57
- userId: ctx.session.user.id,
133
+ userId: userId,
58
134
  name: 'Profile Picture',
59
135
  mimeType: 'image/jpeg',
60
136
  size: 0,
@@ -63,39 +139,162 @@ export const auth = router({
63
139
  },
64
140
  });
65
141
 
142
+ await ctx.db.user.update({
143
+ where: { id: userId },
144
+ data: {
145
+ fileAssetId: fileAsset.id,
146
+ },
147
+ });
148
+
149
+ logger.info(`Profile picture asset created and linked for user ${userId}`, 'AUTH');
66
150
  return {
67
151
  success: true,
68
152
  message: 'Profile picture uploaded successfully',
69
153
  signedUrl: signedUrlData.signedUrl,
70
154
  };
71
155
  }),
156
+ confirmProfileUpdate: authedProcedure
157
+ .mutation(async ({ ctx }) => {
158
+ logger.info(`Confirming profile update for user ${ctx.session.user.id}`, 'AUTH');
159
+ await PusherService.emitProfileUpdate(ctx.session.user.id);
160
+ return { success: true };
161
+ }),
72
162
  signup: publicProcedure
73
163
  .input(z.object({
74
164
  name: z.string().min(1),
75
165
  email: z.string().email(),
76
- password: z.string().min(6),
166
+ password: passwordFieldSchema,
77
167
  }))
78
168
  .mutation(async ({ ctx, input }) => {
79
169
  const existing = await ctx.db.user.findUnique({
80
170
  where: { email: input.email },
81
171
  });
82
172
  if (existing) {
83
- throw new Error("Email already registered");
173
+ throw new TRPCError({ code: 'CONFLICT', message: "Email already registered" });
84
174
  }
85
175
 
86
176
  const hash = await bcrypt.hash(input.password, 10);
87
177
 
178
+ // Get default "User" role
179
+ const userRole = await ctx.db.role.findUnique({
180
+ where: { name: 'User' },
181
+ });
182
+
88
183
  const user = await ctx.db.user.create({
89
184
  data: {
90
185
  name: input.name,
91
186
  email: input.email,
92
187
  passwordHash: hash,
93
- emailVerified: new Date(), // skip verification for demo
188
+ roleId: userRole?.id,
189
+ // emailVerified is null -- user must verify
190
+ },
191
+ });
192
+
193
+ await notifyAdminsOnSignup(ctx.db, {
194
+ id: user.id,
195
+ name: user.name,
196
+ email: user.email,
197
+ });
198
+
199
+ // Create verification token (24h expiry)
200
+ const token = crypto.randomUUID();
201
+ await ctx.db.verificationToken.create({
202
+ data: {
203
+ identifier: input.email,
204
+ token,
205
+ expires: new Date(Date.now() + 24 * 60 * 60 * 1000),
94
206
  },
95
207
  });
96
208
 
209
+ // Send verification email (non-blocking)
210
+ sendVerificationEmail(input.email, token, input.name).catch(() => { });
211
+
212
+ // Create Stripe Customer (non-blocking for registration, but we want it)
213
+ createStripeCustomer(input.email, input.name).then(async (stripeCustomerId) => {
214
+ if (stripeCustomerId) {
215
+ await ctx.db.user.update({
216
+ where: { id: user.id },
217
+ data: { stripe_customer_id: stripeCustomerId }
218
+ }).catch((err: any) => logger.error(`Failed to update user with stripe_customer_id: ${err.message}`, 'AUTH'));
219
+ }
220
+ });
221
+
97
222
  return { id: user.id, email: user.email, name: user.name };
98
223
  }),
224
+
225
+ // Verify email with token from the email link
226
+ verifyEmail: publicProcedure
227
+ .input(z.object({
228
+ token: z.string(),
229
+ }))
230
+ .mutation(async ({ ctx, input }) => {
231
+ const record = await ctx.db.verificationToken.findUnique({
232
+ where: { token: input.token },
233
+ });
234
+
235
+ if (!record) {
236
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Invalid or expired verification link' });
237
+ }
238
+
239
+ if (record.expires < new Date()) {
240
+ // Clean up expired token
241
+ await ctx.db.verificationToken.deleteMany({ where: { token: input.token } });
242
+ throw new TRPCError({ code: 'BAD_REQUEST', message: 'Verification link has expired. Please request a new one.' });
243
+ }
244
+
245
+ // Mark user as verified
246
+ await ctx.db.user.update({
247
+ where: { email: record.identifier },
248
+ data: { emailVerified: new Date() },
249
+ });
250
+
251
+ // Delete used token
252
+ await ctx.db.verificationToken.deleteMany({ where: { token: input.token } });
253
+
254
+ return { success: true, message: 'Email verified successfully' };
255
+ }),
256
+
257
+ // Resend verification email (for logged-in users who haven't verified)
258
+ resendVerification: publicProcedure
259
+ .mutation(async ({ ctx }) => {
260
+ if (!ctx.session?.user?.id) {
261
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Not logged in' });
262
+ }
263
+
264
+ const user = await ctx.db.user.findUnique({
265
+ where: { id: ctx.session.user.id },
266
+ });
267
+
268
+ if (!user || !user.email) {
269
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' });
270
+ }
271
+
272
+ if (user.emailVerified) {
273
+ return { success: true, message: 'Email is already verified' };
274
+ }
275
+
276
+ // Delete any existing tokens for this email
277
+ await ctx.db.verificationToken.deleteMany({
278
+ where: { identifier: user.email },
279
+ });
280
+
281
+ // Create new token
282
+ const token = crypto.randomUUID();
283
+ await ctx.db.verificationToken.create({
284
+ data: {
285
+ identifier: user.email,
286
+ token,
287
+ expires: new Date(Date.now() + 24 * 60 * 60 * 1000),
288
+ },
289
+ });
290
+
291
+ const sent = await sendVerificationEmail(user.email, token, user.name);
292
+ if (!sent) {
293
+ throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to send email. Please try again.' });
294
+ }
295
+
296
+ return { success: true, message: 'Verification email sent' };
297
+ }),
99
298
  login: publicProcedure
100
299
  .input(z.object({
101
300
  email: z.string().email(),
@@ -106,12 +305,16 @@ export const auth = router({
106
305
  where: { email: input.email },
107
306
  });
108
307
  if (!user) {
109
- throw new Error("Invalid credentials");
308
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: "Invalid credentials" });
309
+ }
310
+
311
+ if (user.deletedAt) {
312
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: "Account scheduled for deletion. Please check your email for a restore link." });
110
313
  }
111
314
 
112
315
  const valid = await bcrypt.compare(input.password, user.passwordHash!);
113
316
  if (!valid) {
114
- throw new Error("Invalid credentials");
317
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: "Invalid credentials" });
115
318
  }
116
319
 
117
320
  // Create custom auth token
@@ -127,49 +330,245 @@ export const auth = router({
127
330
  domain: isProduction ? "server-w8mz.onrender.com" : undefined,
128
331
  maxAge: 60 * 60 * 24 * 30, // 30 days
129
332
  });
130
-
333
+
131
334
  ctx.res.setHeader("Set-Cookie", cookieValue);
132
335
 
133
336
 
134
- return {
135
- id: user.id,
136
- email: user.email,
137
- name: user.name,
337
+ return {
338
+ id: user.id,
339
+ email: user.email,
340
+ name: user.name,
138
341
  token: authToken
139
342
  };
140
343
  }),
344
+
345
+ /**
346
+ * Request a password reset email. Always returns the same message (no email enumeration).
347
+ */
348
+ requestPasswordReset: publicProcedure
349
+ .input(z.object({ email: z.string().email() }))
350
+ .mutation(async ({ ctx, input }) => {
351
+ const email = input.email.trim().toLowerCase();
352
+ const generic = {
353
+ success: true as const,
354
+ message:
355
+ 'If an account exists for this email, we sent password reset instructions.',
356
+ };
357
+
358
+ const user = await ctx.db.user.findUnique({
359
+ where: { email },
360
+ select: {
361
+ id: true,
362
+ email: true,
363
+ name: true,
364
+ passwordHash: true,
365
+ deletedAt: true,
366
+ },
367
+ });
368
+
369
+ if (!user?.passwordHash || user.deletedAt || !user.email) {
370
+ return generic;
371
+ }
372
+
373
+ await passwordResetDb(ctx).deleteMany({
374
+ where: { userId: user.id, usedAt: null },
375
+ });
376
+
377
+ const rawToken = crypto.randomBytes(32).toString('hex');
378
+ const tokenHash = hashPasswordResetToken(rawToken);
379
+ const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
380
+
381
+ await passwordResetDb(ctx).create({
382
+ data: {
383
+ userId: user.id,
384
+ tokenHash,
385
+ expiresAt,
386
+ },
387
+ });
388
+
389
+ sendPasswordResetEmail(user.email, rawToken, user.name).catch(() => {});
390
+
391
+ return generic;
392
+ }),
393
+
394
+ /**
395
+ * Complete password reset using the token from the email link.
396
+ */
397
+ resetPassword: publicProcedure
398
+ .input(
399
+ z.object({
400
+ token: z.string().min(1),
401
+ newPassword: passwordFieldSchema,
402
+ })
403
+ )
404
+ .mutation(async ({ ctx, input }) => {
405
+ const tokenHash = hashPasswordResetToken(input.token);
406
+
407
+ const record = await passwordResetDb(ctx).findUnique({
408
+ where: { tokenHash },
409
+ });
410
+
411
+ if (!record || record.usedAt || record.expiresAt < new Date()) {
412
+ throw new TRPCError({
413
+ code: 'BAD_REQUEST',
414
+ message: 'Invalid or expired reset link. Please request a new one.',
415
+ });
416
+ }
417
+
418
+ const newHash = await bcrypt.hash(input.newPassword, 10);
419
+
420
+ await ctx.db.user.update({
421
+ where: { id: record.userId },
422
+ data: { passwordHash: newHash },
423
+ });
424
+
425
+ await passwordResetDb(ctx).update({
426
+ where: { id: record.id },
427
+ data: { usedAt: new Date() },
428
+ });
429
+
430
+ await passwordResetDb(ctx).deleteMany({
431
+ where: { userId: record.userId, id: { not: record.id } },
432
+ });
433
+
434
+ return { success: true, message: 'Password updated. You can sign in now.' };
435
+ }),
436
+
141
437
  getSession: publicProcedure.query(async ({ ctx }) => {
142
438
  // Just return the current session from context
143
439
  if (!ctx.session) {
144
- throw new Error("No session found");
440
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: "No session found" });
145
441
  }
146
442
 
443
+ const userId = (ctx.session as any).user.id;
147
444
  const user = await ctx.db.user.findUnique({
148
- where: { id: (ctx.session as any).user.id },
445
+ where: { id: userId },
446
+ include: {
447
+ profilePicture: true,
448
+ role: true,
449
+ }
149
450
  });
150
451
 
151
452
  if (!user) {
152
- throw new Error("User not found");
453
+ throw new TRPCError({ code: 'NOT_FOUND', message: "User not found" });
153
454
  }
154
455
 
155
- return {
156
- user: {
157
- id: user.id,
158
- email: user.email,
159
- name: user.name,
160
- }
456
+ const profilePictureUrl = user.profilePicture?.objectKey
457
+ ? `/profile-picture/${user.profilePicture.objectKey}?t=${new Date(user.updatedAt).getTime()}`
458
+ : null;
459
+
460
+ logger.info(`Session fetched for user ${userId}, profilePicture: ${profilePictureUrl}`, 'AUTH');
461
+
462
+ return {
463
+ user: {
464
+ id: user.id,
465
+ email: user.email,
466
+ name: user.name,
467
+ emailVerified: !!user.emailVerified,
468
+ profilePicture: profilePictureUrl,
469
+ role: user.role,
470
+ }
161
471
  };
162
472
  }),
473
+ requestAccountDeletion: authedProcedure
474
+ .mutation(async ({ ctx }) => {
475
+ const user = await ctx.db.user.findUnique({
476
+ where: { id: ctx.session.user.id },
477
+ });
478
+
479
+ if (!user) {
480
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' });
481
+ }
482
+
483
+ // Mark user as deleted
484
+ await ctx.db.user.update({
485
+ where: { id: user.id },
486
+ data: { deletedAt: new Date() },
487
+ });
488
+
489
+ await notifyAdminsAccountDeletionScheduled(ctx.db, {
490
+ id: user.id,
491
+ name: user.name,
492
+ email: user.email,
493
+ }).catch(() => {});
494
+
495
+ // Clear existing restore tokens
496
+ await ctx.db.verificationToken.deleteMany({
497
+ where: { identifier: `restore-${user.email}` },
498
+ });
499
+
500
+ // Create restore token (30 days expiry)
501
+ const token = crypto.randomUUID();
502
+ await ctx.db.verificationToken.create({
503
+ data: {
504
+ identifier: `restore-${user.email}`,
505
+ token,
506
+ expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
507
+ },
508
+ });
509
+
510
+ // Send email
511
+ if (user.email) {
512
+ sendAccountDeletionScheduledEmail(user.email, token).catch(() => { });
513
+ }
514
+
515
+ // Log out user by clearing cookie
516
+ ctx.res.setHeader("Set-Cookie", serialize("auth_token", "", {
517
+ httpOnly: true,
518
+ secure: process.env.NODE_ENV === "production",
519
+ sameSite: "lax",
520
+ path: "/",
521
+ maxAge: 0,
522
+ }));
523
+
524
+ return { success: true, message: 'Account scheduled for deletion' };
525
+ }),
526
+
527
+ restoreAccount: publicProcedure
528
+ .input(z.object({
529
+ token: z.string(),
530
+ }))
531
+ .mutation(async ({ ctx, input }) => {
532
+ const record = await ctx.db.verificationToken.findUnique({
533
+ where: { token: input.token },
534
+ });
535
+
536
+ if (!record || !record.identifier.startsWith('restore-')) {
537
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Invalid or expired restore link' });
538
+ }
539
+
540
+ if (record.expires < new Date()) {
541
+ await ctx.db.verificationToken.deleteMany({ where: { token: input.token } });
542
+ throw new TRPCError({ code: 'BAD_REQUEST', message: 'Restore link has expired.' });
543
+ }
544
+
545
+ const email = record.identifier.replace('restore-', '');
546
+
547
+ // Mark user as restored
548
+ await ctx.db.user.update({
549
+ where: { email },
550
+ data: { deletedAt: null },
551
+ });
552
+
553
+ // Delete used token
554
+ await ctx.db.verificationToken.deleteMany({ where: { token: input.token } });
555
+
556
+ // Send confirmation email
557
+ sendAccountRestoredEmail(email).catch(() => { });
558
+
559
+ return { success: true, message: 'Account restored successfully' };
560
+ }),
561
+
163
562
  logout: publicProcedure.mutation(async ({ ctx }) => {
164
563
  const token = ctx.cookies["auth_token"];
165
564
 
166
565
  if (!token) {
167
- throw new Error("No token found");
566
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: "No token found" });
168
567
  }
169
568
 
170
- await ctx.db.session.delete({
171
- where: { id: token },
172
- });
569
+ // We don't need to delete from db.session because we use a stateless
570
+ // custom HMAC auth system (auth_token cookie).
571
+
173
572
 
174
573
  ctx.res.setHeader("Set-Cookie", serialize("auth_token", "", {
175
574
  httpOnly: true,