馃 distributed transcription service
thistle.dunkirk.sh
1import db from "./db/schema";
2import {
3 authenticateUser,
4 cleanupExpiredSessions,
5 consumeEmailChangeToken,
6 consumePasswordResetToken,
7 createEmailChangeToken,
8 createEmailVerificationToken,
9 createPasswordResetToken,
10 createSession,
11 createUser,
12 deleteAllUserSessions,
13 deleteSession,
14 deleteSessionById,
15 deleteTranscription,
16 deleteUser,
17 getAllTranscriptions,
18 getAllUsersWithStats,
19 getSession,
20 getSessionFromRequest,
21 getSessionsForUser,
22 getUserByEmail,
23 getUserBySession,
24 getUserSessionsForUser,
25 getVerificationCodeSentAt,
26 isEmailVerified,
27 type UserRole,
28 updateUserAvatar,
29 updateUserEmail,
30 updateUserEmailAddress,
31 updateUserName,
32 updateUserPassword,
33 updateUserRole,
34 verifyEmailChangeToken,
35 verifyEmailCode,
36 verifyEmailToken,
37 verifyPasswordResetToken,
38} from "./lib/auth";
39import {
40 addToWaitlist,
41 createClass,
42 createMeetingTime,
43 deleteClass,
44 deleteMeetingTime,
45 deleteWaitlistEntry,
46 enrollUserInClass,
47 getAllWaitlistEntries,
48 getClassById,
49 getClassesForUser,
50 getClassMembers,
51 getMeetingTimesForClass,
52 getTranscriptionsForClass,
53 isUserEnrolledInClass,
54 joinClass,
55 removeUserFromClass,
56 searchClassesByCourseCode,
57 toggleClassArchive,
58 updateMeetingTime,
59} from "./lib/classes";
60import { sendEmail } from "./lib/email";
61import {
62 emailChangeTemplate,
63 passwordResetTemplate,
64 verifyEmailTemplate,
65} from "./lib/email-templates";
66import { AuthErrors, handleError, ValidationErrors } from "./lib/errors";
67import {
68 hasActiveSubscription,
69 requireAdmin,
70 requireAuth,
71 requireSubscription,
72} from "./lib/middleware";
73import {
74 createAuthenticationOptions,
75 createRegistrationOptions,
76 deletePasskey,
77 getPasskeysForUser,
78 updatePasskeyName,
79 verifyAndAuthenticatePasskey,
80 verifyAndCreatePasskey,
81} from "./lib/passkey";
82import { clearRateLimit, enforceRateLimit } from "./lib/rate-limit";
83import { getTranscriptVTT } from "./lib/transcript-storage";
84import {
85 MAX_FILE_SIZE,
86 TranscriptionEventEmitter,
87 type TranscriptionUpdate,
88 WhisperServiceManager,
89} from "./lib/transcription";
90import {
91 validateClassId,
92 validateCourseCode,
93 validateCourseName,
94 validateEmail,
95 validateName,
96 validatePasswordHash,
97 validateSemester,
98 validateYear,
99} from "./lib/validation";
100import adminHTML from "./pages/admin.html";
101import checkoutHTML from "./pages/checkout.html";
102import classHTML from "./pages/class.html";
103import classesHTML from "./pages/classes.html";
104import indexHTML from "./pages/index.html";
105import resetPasswordHTML from "./pages/reset-password.html";
106import settingsHTML from "./pages/settings.html";
107import transcribeHTML from "./pages/transcribe.html";
108
109// Validate required environment variables at startup
110function validateEnvVars() {
111 const required = [
112 "POLAR_ORGANIZATION_ID",
113 "POLAR_PRODUCT_ID",
114 "POLAR_SUCCESS_URL",
115 "POLAR_WEBHOOK_SECRET",
116 "MAILCHANNELS_API_KEY",
117 "DKIM_PRIVATE_KEY",
118 "LLM_API_KEY",
119 "LLM_API_BASE_URL",
120 "LLM_MODEL",
121 ];
122
123 const missing = required.filter((key) => !process.env[key]);
124
125 if (missing.length > 0) {
126 console.error(
127 `[Startup] Missing required environment variables: ${missing.join(", ")}`,
128 );
129 console.error("[Startup] Please check your .env file");
130 process.exit(1);
131 }
132
133 // Validate ORIGIN is set for production
134 if (!process.env.ORIGIN) {
135 console.warn(
136 "[Startup] ORIGIN not set, defaulting to http://localhost:3000",
137 );
138 console.warn("[Startup] Set ORIGIN in production for correct email links");
139 }
140
141 console.log("[Startup] Environment variable validation passed");
142}
143
144validateEnvVars();
145
146// Environment variables
147const WHISPER_SERVICE_URL =
148 process.env.WHISPER_SERVICE_URL || "http://localhost:8000";
149
150// Create uploads and transcripts directories if they don't exist
151await Bun.write("./uploads/.gitkeep", "");
152await Bun.write("./transcripts/.gitkeep", "");
153
154// Initialize transcription system
155console.log(
156 `[Transcription] Connecting to Murmur at ${WHISPER_SERVICE_URL}...`,
157);
158const transcriptionEvents = new TranscriptionEventEmitter();
159const whisperService = new WhisperServiceManager(
160 WHISPER_SERVICE_URL,
161 db,
162 transcriptionEvents,
163);
164
165// Clean up expired sessions every 15 minutes
166const sessionCleanupInterval = setInterval(
167 cleanupExpiredSessions,
168 15 * 60 * 1000,
169);
170
171// Helper function to sync user subscriptions from Polar
172async function syncUserSubscriptionsFromPolar(
173 userId: number,
174 email: string,
175): Promise<void> {
176 // Skip Polar sync in test mode
177 if (process.env.NODE_ENV === "test" || process.env.SKIP_POLAR_SYNC === "true") {
178 return;
179 }
180
181 try {
182 const { polar } = await import("./lib/polar");
183
184 // Search for customer by email (validated at startup)
185 const customers = await polar.customers.list({
186 organizationId: process.env.POLAR_ORGANIZATION_ID as string,
187 query: email,
188 });
189
190 if (!customers.result.items || customers.result.items.length === 0) {
191 console.log(`[Sync] No Polar customer found for ${email}`);
192 return;
193 }
194
195 const customer = customers.result.items[0];
196
197 // Get all subscriptions for this customer
198 const subscriptions = await polar.subscriptions.list({
199 customerId: customer.id,
200 });
201
202 if (
203 !subscriptions.result.items ||
204 subscriptions.result.items.length === 0
205 ) {
206 console.log(`[Sync] No subscriptions found for customer ${customer.id}`);
207 return;
208 }
209
210 // Filter to only active/trialing/past_due subscriptions (not canceled/expired)
211 const currentSubscriptions = subscriptions.result.items.filter(
212 (sub) =>
213 sub.status === "active" ||
214 sub.status === "trialing" ||
215 sub.status === "past_due",
216 );
217
218 if (currentSubscriptions.length === 0) {
219 console.log(
220 `[Sync] No current subscriptions found for customer ${customer.id}`,
221 );
222 return;
223 }
224
225 // Update each current subscription in the database
226 for (const subscription of currentSubscriptions) {
227 db.run(
228 `INSERT INTO subscriptions (id, user_id, customer_id, status, current_period_start, current_period_end, cancel_at_period_end, canceled_at, updated_at)
229 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
230 ON CONFLICT(id) DO UPDATE SET
231 user_id = excluded.user_id,
232 status = excluded.status,
233 current_period_start = excluded.current_period_start,
234 current_period_end = excluded.current_period_end,
235 cancel_at_period_end = excluded.cancel_at_period_end,
236 canceled_at = excluded.canceled_at,
237 updated_at = excluded.updated_at`,
238 [
239 subscription.id,
240 userId,
241 subscription.customerId,
242 subscription.status,
243 subscription.currentPeriodStart
244 ? Math.floor(
245 new Date(subscription.currentPeriodStart).getTime() / 1000,
246 )
247 : null,
248 subscription.currentPeriodEnd
249 ? Math.floor(
250 new Date(subscription.currentPeriodEnd).getTime() / 1000,
251 )
252 : null,
253 subscription.cancelAtPeriodEnd ? 1 : 0,
254 subscription.canceledAt
255 ? Math.floor(new Date(subscription.canceledAt).getTime() / 1000)
256 : null,
257 Math.floor(Date.now() / 1000),
258 ],
259 );
260 }
261
262 console.log(
263 `[Sync] Linked ${currentSubscriptions.length} current subscription(s) to user ${userId} (${email})`,
264 );
265 } catch (error) {
266 console.error(
267 `[Sync] Failed to sync subscriptions for ${email}:`,
268 error instanceof Error ? error.message : "Unknown error",
269 );
270 // Don't throw - registration should succeed even if sync fails
271 }
272}
273
274// Sync with Whisper DB on startup
275try {
276 await whisperService.syncWithWhisper();
277 console.log("[Transcription] Successfully connected to Murmur");
278} catch (error) {
279 console.warn(
280 "[Transcription] Murmur unavailable at startup:",
281 error instanceof Error ? error.message : "Unknown error",
282 );
283}
284
285// Periodic sync every 5 minutes as backup (SSE handles real-time updates)
286const syncInterval = setInterval(
287 async () => {
288 try {
289 await whisperService.syncWithWhisper();
290 } catch (error) {
291 console.warn(
292 "[Sync] Failed to sync with Murmur:",
293 error instanceof Error ? error.message : "Unknown error",
294 );
295 }
296 },
297 5 * 60 * 1000,
298);
299
300// Clean up stale files hourly
301const fileCleanupInterval = setInterval(
302 () => whisperService.cleanupStaleFiles(),
303 60 * 60 * 1000, // 1 hour
304);
305
306const server = Bun.serve({
307 port: process.env.NODE_ENV === "test"
308 ? 3001
309 : (process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000),
310 idleTimeout: 120, // 120 seconds for SSE connections
311 routes: {
312 "/": indexHTML,
313 "/admin": adminHTML,
314 "/checkout": checkoutHTML,
315 "/settings": settingsHTML,
316 "/reset-password": resetPasswordHTML,
317 "/transcribe": transcribeHTML,
318 "/classes": classesHTML,
319 "/classes/*": classHTML,
320 "/apple-touch-icon.png": Bun.file("./public/favicon/apple-touch-icon.png"),
321 "/favicon-32x32.png": Bun.file("./public/favicon/favicon-32x32.png"),
322 "/favicon-16x16.png": Bun.file("./public/favicon/favicon-16x16.png"),
323 "/site.webmanifest": Bun.file("./public/favicon/site.webmanifest"),
324 "/favicon.ico": Bun.file("./public/favicon/favicon.ico"),
325 "/api/auth/register": {
326 POST: async (req) => {
327 try {
328 // Rate limiting
329 const rateLimitError = enforceRateLimit(req, "register", {
330 ip: { max: 5, windowSeconds: 30 * 60 },
331 });
332 if (rateLimitError) return rateLimitError;
333
334 const body = await req.json();
335 const { email, password, name } = body;
336 if (!email || !password) {
337 return Response.json(
338 { error: "Email and password required" },
339 { status: 400 },
340 );
341 }
342 // Validate password format (client-side hashed PBKDF2)
343 const passwordValidation = validatePasswordHash(password);
344 if (!passwordValidation.valid) {
345 return Response.json(
346 { error: passwordValidation.error },
347 { status: 400 },
348 );
349 }
350 const user = await createUser(email, password, name);
351
352 // Send verification email - MUST succeed for registration to complete
353 const { code, token, sentAt } = createEmailVerificationToken(user.id);
354
355 try {
356 await sendEmail({
357 to: user.email,
358 subject: "Verify your email - Thistle",
359 html: verifyEmailTemplate({
360 name: user.name,
361 code,
362 token,
363 }),
364 });
365 } catch (err) {
366 console.error("[Email] Failed to send verification email:", err);
367 // Rollback user creation - direct DB delete since user was just created
368 db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [
369 user.id,
370 ]);
371 db.run("DELETE FROM sessions WHERE user_id = ?", [user.id]);
372 db.run("DELETE FROM users WHERE id = ?", [user.id]);
373 return Response.json(
374 {
375 error:
376 "Failed to send verification email. Please try again later.",
377 },
378 { status: 500 },
379 );
380 }
381
382 // Attempt to sync existing Polar subscriptions (after email succeeds)
383 syncUserSubscriptionsFromPolar(user.id, user.email).catch(() => {
384 // Silent fail - don't block registration
385 });
386
387 // Clear rate limits on successful registration
388 const ipAddress =
389 req.headers.get("x-forwarded-for") ??
390 req.headers.get("x-real-ip") ??
391 "unknown";
392 clearRateLimit("register", email, ipAddress);
393
394 // Return success but indicate email verification is needed
395 // Don't create session yet - they need to verify first
396 return Response.json(
397 {
398 user: { id: user.id, email: user.email },
399 email_verification_required: true,
400 verification_code_sent_at: sentAt,
401 },
402 { status: 200 },
403 );
404 } catch (err: unknown) {
405 const error = err as { message?: string };
406 if (error.message?.includes("UNIQUE constraint failed")) {
407 return Response.json(
408 { error: "Email already registered" },
409 { status: 400 },
410 );
411 }
412 console.error("[Auth] Registration error:", err);
413 return Response.json(
414 { error: "Registration failed" },
415 { status: 500 },
416 );
417 }
418 },
419 },
420 "/api/auth/login": {
421 POST: async (req) => {
422 try {
423 const body = await req.json();
424 const { email, password } = body;
425 if (!email || !password) {
426 return Response.json(
427 { error: "Email and password required" },
428 { status: 400 },
429 );
430 }
431
432 // Rate limiting: Per IP and per account
433 const rateLimitError = enforceRateLimit(req, "login", {
434 ip: { max: 10, windowSeconds: 5 * 60 },
435 account: { max: 5, windowSeconds: 5 * 60, email },
436 });
437 if (rateLimitError) return rateLimitError;
438
439 // Validate password format (client-side hashed PBKDF2)
440 const passwordValidation = validatePasswordHash(password);
441 if (!passwordValidation.valid) {
442 return Response.json(
443 { error: passwordValidation.error },
444 { status: 400 },
445 );
446 }
447 const user = await authenticateUser(email, password);
448 if (!user) {
449 return Response.json(
450 { error: "Invalid email or password" },
451 { status: 401 },
452 );
453 }
454
455 // Clear rate limits on successful authentication
456 const ipAddress =
457 req.headers.get("x-forwarded-for") ??
458 req.headers.get("x-real-ip") ??
459 "unknown";
460 clearRateLimit("login", email, ipAddress);
461
462 // Check if email is verified
463 if (!isEmailVerified(user.id)) {
464 let codeSentAt = getVerificationCodeSentAt(user.id);
465
466 // If no verification code exists, auto-send one
467 if (!codeSentAt) {
468 const { code, token, sentAt } = createEmailVerificationToken(
469 user.id,
470 );
471 codeSentAt = sentAt;
472
473 try {
474 await sendEmail({
475 to: user.email,
476 subject: "Verify your email - Thistle",
477 html: verifyEmailTemplate({
478 name: user.name,
479 code,
480 token,
481 }),
482 });
483 } catch (err) {
484 console.error(
485 "[Email] Failed to send verification email on login:",
486 err,
487 );
488 // Don't fail login - just return null timestamp so client can try resend
489 codeSentAt = null;
490 }
491 }
492
493 return Response.json(
494 {
495 user: { id: user.id, email: user.email },
496 email_verification_required: true,
497 verification_code_sent_at: codeSentAt,
498 },
499 { status: 200 },
500 );
501 }
502
503 const userAgent = req.headers.get("user-agent") ?? "unknown";
504 const sessionId = createSession(user.id, ipAddress, userAgent);
505 return Response.json(
506 { user: { id: user.id, email: user.email } },
507 {
508 headers: {
509 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,
510 },
511 },
512 );
513 } catch (error) {
514 console.error("[Auth] Login error:", error);
515 return Response.json({ error: "Login failed" }, { status: 500 });
516 }
517 },
518 },
519 "/api/auth/verify-email": {
520 GET: async (req) => {
521 try {
522 const url = new URL(req.url);
523 const token = url.searchParams.get("token");
524
525 if (!token) {
526 return Response.redirect("/", 302);
527 }
528
529 const result = verifyEmailToken(token);
530
531 if (!result) {
532 return Response.redirect("/", 302);
533 }
534
535 // Create session for the verified user
536 const ipAddress =
537 req.headers.get("x-forwarded-for") ??
538 req.headers.get("x-real-ip") ??
539 "unknown";
540 const userAgent = req.headers.get("user-agent") ?? "unknown";
541 const sessionId = createSession(result.userId, ipAddress, userAgent);
542
543 // Redirect to classes with session cookie
544 return new Response(null, {
545 status: 302,
546 headers: {
547 Location: "/classes",
548 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,
549 },
550 });
551 } catch (error) {
552 console.error("[Email] Verification error:", error);
553 return Response.redirect("/", 302);
554 }
555 },
556 POST: async (req) => {
557 try {
558 const body = await req.json();
559 const { email, code } = body;
560
561 if (!email || !code) {
562 return Response.json(
563 { error: "Email and verification code required" },
564 { status: 400 },
565 );
566 }
567
568 // Get user by email
569 const user = getUserByEmail(email);
570 if (!user) {
571 return Response.json({ error: "User not found" }, { status: 404 });
572 }
573
574 // Check if already verified
575 if (isEmailVerified(user.id)) {
576 return Response.json(
577 { error: "Email already verified" },
578 { status: 400 },
579 );
580 }
581
582 const success = verifyEmailCode(user.id, code);
583
584 if (!success) {
585 return Response.json(
586 { error: "Invalid or expired verification code" },
587 { status: 400 },
588 );
589 }
590
591 // Create session after successful verification
592 const ipAddress =
593 req.headers.get("x-forwarded-for") ??
594 req.headers.get("x-real-ip") ??
595 "unknown";
596 const userAgent = req.headers.get("user-agent") ?? "unknown";
597 const sessionId = createSession(user.id, ipAddress, userAgent);
598
599 return Response.json(
600 {
601 success: true,
602 message: "Email verified successfully",
603 email_verified: true,
604 user: { id: user.id, email: user.email },
605 },
606 {
607 headers: {
608 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,
609 },
610 },
611 );
612 } catch (error) {
613 return handleError(error);
614 }
615 },
616 },
617 "/api/auth/resend-verification": {
618 POST: async (req) => {
619 try {
620 const user = requireAuth(req);
621
622 // Rate limiting
623 const rateLimitError = enforceRateLimit(req, "resend-verification", {
624 account: { max: 3, windowSeconds: 60 * 60, email: user.email },
625 });
626 if (rateLimitError) return rateLimitError;
627
628 // Check if already verified
629 if (isEmailVerified(user.id)) {
630 return Response.json(
631 { error: "Email already verified" },
632 { status: 400 },
633 );
634 }
635
636 // Generate new code and send email
637 const { code, token } = createEmailVerificationToken(user.id);
638
639 await sendEmail({
640 to: user.email,
641 subject: "Verify your email - Thistle",
642 html: verifyEmailTemplate({
643 name: user.name,
644 code,
645 token,
646 }),
647 });
648
649 return Response.json({
650 success: true,
651 message: "Verification email sent",
652 });
653 } catch (error) {
654 return handleError(error);
655 }
656 },
657 },
658 "/api/auth/resend-verification-code": {
659 POST: async (req) => {
660 try {
661 const body = await req.json();
662 const { email } = body;
663
664 if (!email) {
665 return Response.json({ error: "Email required" }, { status: 400 });
666 }
667
668 // Rate limiting by email
669 const rateLimitError = enforceRateLimit(
670 req,
671 "resend-verification-code",
672 {
673 account: { max: 3, windowSeconds: 5 * 60, email },
674 },
675 );
676 if (rateLimitError) return rateLimitError;
677
678 // Get user by email
679 const user = getUserByEmail(email);
680 if (!user) {
681 // Don't reveal if user exists
682 return Response.json({
683 success: true,
684 message:
685 "If an account exists with that email, a verification code has been sent",
686 });
687 }
688
689 // Check if already verified
690 if (isEmailVerified(user.id)) {
691 return Response.json(
692 { error: "Email already verified" },
693 { status: 400 },
694 );
695 }
696
697 // Generate new code and send email
698 const { code, token, sentAt } = createEmailVerificationToken(user.id);
699
700 await sendEmail({
701 to: user.email,
702 subject: "Verify your email - Thistle",
703 html: verifyEmailTemplate({
704 name: user.name,
705 code,
706 token,
707 }),
708 });
709
710 return Response.json({
711 success: true,
712 message: "Verification code sent",
713 verification_code_sent_at: sentAt,
714 });
715 } catch (error) {
716 return handleError(error);
717 }
718 },
719 },
720 "/api/auth/forgot-password": {
721 POST: async (req) => {
722 try {
723 // Rate limiting
724 const rateLimitError = enforceRateLimit(req, "forgot-password", {
725 ip: { max: 5, windowSeconds: 60 * 60 },
726 });
727 if (rateLimitError) return rateLimitError;
728
729 const body = await req.json();
730 const { email } = body;
731
732 if (!email) {
733 return Response.json({ error: "Email required" }, { status: 400 });
734 }
735
736 // Always return success to prevent email enumeration
737 const user = getUserByEmail(email);
738 if (user) {
739 const origin = process.env.ORIGIN || "http://localhost:3000";
740 const resetToken = createPasswordResetToken(user.id);
741 const resetLink = `${origin}/reset-password?token=${resetToken}`;
742
743 await sendEmail({
744 to: user.email,
745 subject: "Reset your password - Thistle",
746 html: passwordResetTemplate({
747 name: user.name,
748 resetLink,
749 }),
750 }).catch((err) => {
751 console.error("[Email] Failed to send password reset:", err);
752 });
753 }
754
755 return Response.json({
756 success: true,
757 message:
758 "If an account exists with that email, a password reset link has been sent",
759 });
760 } catch (error) {
761 console.error("[Email] Forgot password error:", error);
762 return Response.json(
763 { error: "Failed to process request" },
764 { status: 500 },
765 );
766 }
767 },
768 },
769 "/api/auth/reset-password": {
770 GET: async (req) => {
771 try {
772 const url = new URL(req.url);
773 const token = url.searchParams.get("token");
774
775 if (!token) {
776 return Response.json({ error: "Token required" }, { status: 400 });
777 }
778
779 const userId = verifyPasswordResetToken(token);
780 if (!userId) {
781 return Response.json(
782 { error: "Invalid or expired reset token" },
783 { status: 400 },
784 );
785 }
786
787 // Get user's email for client-side password hashing
788 const user = db
789 .query<{ email: string }, [number]>(
790 "SELECT email FROM users WHERE id = ?",
791 )
792 .get(userId);
793
794 if (!user) {
795 return Response.json({ error: "User not found" }, { status: 404 });
796 }
797
798 return Response.json({ email: user.email });
799 } catch (error) {
800 console.error("[Email] Get reset token info error:", error);
801 return Response.json(
802 { error: "Failed to verify token" },
803 { status: 500 },
804 );
805 }
806 },
807 POST: async (req) => {
808 try {
809 const body = await req.json();
810 const { token, password } = body;
811
812 if (!token || !password) {
813 return Response.json(
814 { error: "Token and password required" },
815 { status: 400 },
816 );
817 }
818
819 // Validate password format (client-side hashed PBKDF2)
820 const passwordValidation = validatePasswordHash(password);
821 if (!passwordValidation.valid) {
822 return Response.json(
823 { error: passwordValidation.error },
824 { status: 400 },
825 );
826 }
827
828 const userId = verifyPasswordResetToken(token);
829 if (!userId) {
830 return Response.json(
831 { error: "Invalid or expired reset token" },
832 { status: 400 },
833 );
834 }
835
836 // Update password and consume token
837 await updateUserPassword(userId, password);
838 consumePasswordResetToken(token);
839
840 return Response.json({
841 success: true,
842 message: "Password reset successfully",
843 });
844 } catch (error) {
845 console.error("[Email] Reset password error:", error);
846 return Response.json(
847 { error: "Failed to reset password" },
848 { status: 500 },
849 );
850 }
851 },
852 },
853 "/api/auth/logout": {
854 POST: async (req) => {
855 const sessionId = getSessionFromRequest(req);
856 if (sessionId) {
857 deleteSession(sessionId);
858 }
859 return Response.json(
860 { success: true },
861 {
862 headers: {
863 "Set-Cookie":
864 "session=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax",
865 },
866 },
867 );
868 },
869 },
870 "/api/auth/me": {
871 GET: (req) => {
872 try {
873 const user = requireAuth(req);
874
875 // Check subscription status
876 const subscription = db
877 .query<{ status: string }, [number]>(
878 "SELECT status FROM subscriptions WHERE user_id = ? AND status IN ('active', 'trialing', 'past_due') ORDER BY created_at DESC LIMIT 1",
879 )
880 .get(user.id);
881
882 // Get notification preferences
883 const prefs = db
884 .query<{ email_notifications_enabled: number }, [number]>(
885 "SELECT email_notifications_enabled FROM users WHERE id = ?",
886 )
887 .get(user.id);
888
889 return Response.json({
890 email: user.email,
891 name: user.name,
892 avatar: user.avatar,
893 created_at: user.created_at,
894 role: user.role,
895 has_subscription: !!subscription,
896 email_verified: isEmailVerified(user.id),
897 email_notifications_enabled: prefs?.email_notifications_enabled === 1,
898 });
899 } catch (err) {
900 return handleError(err);
901 }
902 },
903 },
904 "/api/passkeys/register/options": {
905 POST: async (req) => {
906 try {
907 const user = requireAuth(req);
908
909 const rateLimitError = enforceRateLimit(
910 req,
911 "passkey-register-options",
912 {
913 ip: { max: 10, windowSeconds: 5 * 60 },
914 },
915 );
916 if (rateLimitError) return rateLimitError;
917
918 const options = await createRegistrationOptions(user);
919 return Response.json(options);
920 } catch (err) {
921 return handleError(err);
922 }
923 },
924 },
925 "/api/passkeys/register/verify": {
926 POST: async (req) => {
927 try {
928 const _user = requireAuth(req);
929
930 const rateLimitError = enforceRateLimit(
931 req,
932 "passkey-register-verify",
933 {
934 ip: { max: 10, windowSeconds: 5 * 60 },
935 },
936 );
937 if (rateLimitError) return rateLimitError;
938
939 const body = await req.json();
940 const { response: credentialResponse, challenge, name } = body;
941
942 const passkey = await verifyAndCreatePasskey(
943 credentialResponse,
944 challenge,
945 name,
946 );
947
948 return Response.json({
949 success: true,
950 passkey: {
951 id: passkey.id,
952 name: passkey.name,
953 created_at: passkey.created_at,
954 },
955 });
956 } catch (err) {
957 return handleError(err);
958 }
959 },
960 },
961 "/api/passkeys/authenticate/options": {
962 POST: async (req) => {
963 try {
964 const rateLimitError = enforceRateLimit(req, "passkey-auth-options", {
965 ip: { max: 10, windowSeconds: 5 * 60 },
966 });
967 if (rateLimitError) return rateLimitError;
968
969 const body = await req.json();
970 const { email } = body;
971
972 const options = await createAuthenticationOptions(email);
973 return Response.json(options);
974 } catch (err) {
975 return handleError(err);
976 }
977 },
978 },
979 "/api/passkeys/authenticate/verify": {
980 POST: async (req) => {
981 try {
982 const rateLimitError = enforceRateLimit(req, "passkey-auth-verify", {
983 ip: { max: 10, windowSeconds: 5 * 60 },
984 });
985 if (rateLimitError) return rateLimitError;
986
987 const body = await req.json();
988 const { response: credentialResponse, challenge } = body;
989
990 const result = await verifyAndAuthenticatePasskey(
991 credentialResponse,
992 challenge,
993 );
994
995 if ("error" in result) {
996 return new Response(JSON.stringify({ error: result.error }), {
997 status: 401,
998 });
999 }
1000
1001 const { user } = result;
1002
1003 // Create session
1004 const ipAddress =
1005 req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
1006 req.headers.get("x-real-ip") ||
1007 "unknown";
1008 const userAgent = req.headers.get("user-agent") || "unknown";
1009 const sessionId = createSession(user.id, ipAddress, userAgent);
1010
1011 return Response.json(
1012 {
1013 email: user.email,
1014 name: user.name,
1015 avatar: user.avatar,
1016 created_at: user.created_at,
1017 role: user.role,
1018 },
1019 {
1020 headers: {
1021 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,
1022 },
1023 },
1024 );
1025 } catch (err) {
1026 return handleError(err);
1027 }
1028 },
1029 },
1030 "/api/passkeys": {
1031 GET: async (req) => {
1032 try {
1033 const user = requireAuth(req);
1034 const passkeys = getPasskeysForUser(user.id);
1035 return Response.json({
1036 passkeys: passkeys.map((p) => ({
1037 id: p.id,
1038 name: p.name,
1039 created_at: p.created_at,
1040 last_used_at: p.last_used_at,
1041 })),
1042 });
1043 } catch (err) {
1044 return handleError(err);
1045 }
1046 },
1047 },
1048 "/api/passkeys/:id": {
1049 PUT: async (req) => {
1050 try {
1051 const user = requireAuth(req);
1052
1053 const rateLimitError = enforceRateLimit(req, "passkey-update", {
1054 ip: { max: 10, windowSeconds: 60 * 60 },
1055 });
1056 if (rateLimitError) return rateLimitError;
1057
1058 const body = await req.json();
1059 const { name } = body;
1060 const passkeyId = req.params.id;
1061
1062 if (!name) {
1063 return Response.json({ error: "Name required" }, { status: 400 });
1064 }
1065
1066 updatePasskeyName(passkeyId, user.id, name);
1067 return Response.json({ success: true });
1068 } catch (err) {
1069 return handleError(err);
1070 }
1071 },
1072 DELETE: async (req) => {
1073 try {
1074 const user = requireAuth(req);
1075
1076 const rateLimitError = enforceRateLimit(req, "passkey-delete", {
1077 ip: { max: 10, windowSeconds: 60 * 60 },
1078 });
1079 if (rateLimitError) return rateLimitError;
1080
1081 const passkeyId = req.params.id;
1082 deletePasskey(passkeyId, user.id);
1083 return Response.json({ success: true });
1084 } catch (err) {
1085 return handleError(err);
1086 }
1087 },
1088 },
1089 "/api/sessions": {
1090 GET: (req) => {
1091 try {
1092 const sessionId = getSessionFromRequest(req);
1093 if (!sessionId) {
1094 return Response.json({ error: "Not authenticated" }, { status: 401 });
1095 }
1096 const user = getUserBySession(sessionId);
1097 if (!user) {
1098 return Response.json({ error: "Invalid session" }, { status: 401 });
1099 }
1100 const sessions = getUserSessionsForUser(user.id);
1101 return Response.json({
1102 sessions: sessions.map((s) => ({
1103 id: s.id,
1104 ip_address: s.ip_address,
1105 user_agent: s.user_agent,
1106 created_at: s.created_at,
1107 expires_at: s.expires_at,
1108 is_current: s.id === sessionId,
1109 })),
1110 });
1111 } catch (err) {
1112 return handleError(err);
1113 }
1114 },
1115 DELETE: async (req) => {
1116 try {
1117 const currentSessionId = getSessionFromRequest(req);
1118 if (!currentSessionId) {
1119 return Response.json({ error: "Not authenticated" }, { status: 401 });
1120 }
1121 const user = getUserBySession(currentSessionId);
1122 if (!user) {
1123 return Response.json({ error: "Invalid session" }, { status: 401 });
1124 }
1125
1126 const rateLimitError = enforceRateLimit(req, "delete-session", {
1127 ip: { max: 20, windowSeconds: 60 * 60 },
1128 });
1129 if (rateLimitError) return rateLimitError;
1130
1131 const body = await req.json();
1132 const targetSessionId = body.sessionId;
1133 if (!targetSessionId) {
1134 return Response.json(
1135 { error: "Session ID required" },
1136 { status: 400 },
1137 );
1138 }
1139 // Prevent deleting current session
1140 if (targetSessionId === currentSessionId) {
1141 return Response.json(
1142 { error: "Cannot kill current session. Use logout instead." },
1143 { status: 400 },
1144 );
1145 }
1146 // Verify the session belongs to the user
1147 const targetSession = getSession(targetSessionId);
1148 if (!targetSession || targetSession.user_id !== user.id) {
1149 return Response.json({ error: "Session not found" }, { status: 404 });
1150 }
1151 deleteSession(targetSessionId);
1152 return Response.json({ success: true });
1153 } catch (err) {
1154 return handleError(err);
1155 }
1156 },
1157 },
1158 "/api/user": {
1159 DELETE: async (req) => {
1160 try {
1161 const user = requireAuth(req);
1162
1163 // Rate limiting
1164 const rateLimitError = enforceRateLimit(req, "delete-user", {
1165 ip: { max: 3, windowSeconds: 60 * 60 },
1166 });
1167 if (rateLimitError) return rateLimitError;
1168
1169 await deleteUser(user.id);
1170 return Response.json(
1171 { success: true },
1172 {
1173 headers: {
1174 "Set-Cookie":
1175 "session=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax",
1176 },
1177 },
1178 );
1179 } catch (err) {
1180 return handleError(err);
1181 }
1182 },
1183 },
1184 "/api/user/email": {
1185 PUT: async (req) => {
1186 try {
1187 const user = requireAuth(req);
1188
1189 // Rate limiting
1190 const rateLimitError = enforceRateLimit(req, "update-email", {
1191 ip: { max: 5, windowSeconds: 60 * 60 },
1192 });
1193 if (rateLimitError) return rateLimitError;
1194
1195 const body = await req.json();
1196 const { email } = body;
1197 if (!email) {
1198 return Response.json({ error: "Email required" }, { status: 400 });
1199 }
1200
1201 // Check if email is already in use
1202 const existingUser = getUserByEmail(email);
1203 if (existingUser) {
1204 return Response.json(
1205 { error: "Email already in use" },
1206 { status: 400 },
1207 );
1208 }
1209
1210 try {
1211 // Create email change token
1212 const token = createEmailChangeToken(user.id, email);
1213
1214 // Send verification email to the CURRENT address
1215 const origin = process.env.ORIGIN || "http://localhost:3000";
1216 const verifyUrl = `${origin}/api/user/email/verify?token=${token}`;
1217
1218 await sendEmail({
1219 to: user.email,
1220 subject: "Verify your email change",
1221 html: emailChangeTemplate({
1222 name: user.name,
1223 currentEmail: user.email,
1224 newEmail: email,
1225 verifyLink: verifyUrl,
1226 }),
1227 });
1228
1229 return Response.json({
1230 success: true,
1231 message: `Verification email sent to ${user.email}`,
1232 pendingEmail: email,
1233 });
1234 } catch (error) {
1235 console.error(
1236 "[Email] Failed to send email change verification:",
1237 error,
1238 );
1239 return Response.json(
1240 { error: "Failed to send verification email" },
1241 { status: 500 },
1242 );
1243 }
1244 } catch (err) {
1245 return handleError(err);
1246 }
1247 },
1248 },
1249 "/api/user/email/verify": {
1250 GET: async (req) => {
1251 try {
1252 const url = new URL(req.url);
1253 const token = url.searchParams.get("token");
1254
1255 if (!token) {
1256 return Response.redirect(
1257 "/settings?tab=account&error=invalid-token",
1258 302,
1259 );
1260 }
1261
1262 const result = verifyEmailChangeToken(token);
1263
1264 if (!result) {
1265 return Response.redirect(
1266 "/settings?tab=account&error=expired-token",
1267 302,
1268 );
1269 }
1270
1271 // Update the user's email
1272 updateUserEmail(result.userId, result.newEmail);
1273
1274 // Consume the token
1275 consumeEmailChangeToken(token);
1276
1277 // Redirect to settings with success message
1278 return Response.redirect(
1279 "/settings?tab=account&success=email-changed",
1280 302,
1281 );
1282 } catch (error) {
1283 console.error("[Email] Email change verification error:", error);
1284 return Response.redirect(
1285 "/settings?tab=account&error=verification-failed",
1286 302,
1287 );
1288 }
1289 },
1290 },
1291 "/api/user/password": {
1292 PUT: async (req) => {
1293 try {
1294 const user = requireAuth(req);
1295
1296 // Rate limiting
1297 const rateLimitError = enforceRateLimit(req, "update-password", {
1298 ip: { max: 5, windowSeconds: 60 * 60 },
1299 });
1300 if (rateLimitError) return rateLimitError;
1301
1302 const body = await req.json();
1303 const { password } = body;
1304 if (!password) {
1305 return Response.json({ error: "Password required" }, { status: 400 });
1306 }
1307 // Validate password format (client-side hashed PBKDF2)
1308 const passwordValidation = validatePasswordHash(password);
1309 if (!passwordValidation.valid) {
1310 return Response.json(
1311 { error: passwordValidation.error },
1312 { status: 400 },
1313 );
1314 }
1315 try {
1316 await updateUserPassword(user.id, password);
1317 return Response.json({ success: true });
1318 } catch {
1319 return Response.json(
1320 { error: "Failed to update password" },
1321 { status: 500 },
1322 );
1323 }
1324 } catch (err) {
1325 return handleError(err);
1326 }
1327 },
1328 },
1329 "/api/user/name": {
1330 PUT: async (req) => {
1331 try {
1332 const user = requireAuth(req);
1333
1334 const rateLimitError = enforceRateLimit(req, "update-name", {
1335 ip: { max: 10, windowSeconds: 5 * 60 },
1336 });
1337 if (rateLimitError) return rateLimitError;
1338
1339 const body = await req.json();
1340 const { name } = body;
1341 if (!name) {
1342 return Response.json({ error: "Name required" }, { status: 400 });
1343 }
1344 try {
1345 updateUserName(user.id, name);
1346 return Response.json({ success: true });
1347 } catch {
1348 return Response.json(
1349 { error: "Failed to update name" },
1350 { status: 500 },
1351 );
1352 }
1353 } catch (err) {
1354 return handleError(err);
1355 }
1356 },
1357 },
1358 "/api/user/avatar": {
1359 PUT: async (req) => {
1360 try {
1361 const user = requireAuth(req);
1362
1363 const rateLimitError = enforceRateLimit(req, "update-avatar", {
1364 ip: { max: 10, windowSeconds: 5 * 60 },
1365 });
1366 if (rateLimitError) return rateLimitError;
1367
1368 const body = await req.json();
1369 const { avatar } = body;
1370 if (!avatar) {
1371 return Response.json({ error: "Avatar required" }, { status: 400 });
1372 }
1373 try {
1374 updateUserAvatar(user.id, avatar);
1375 return Response.json({ success: true });
1376 } catch {
1377 return Response.json(
1378 { error: "Failed to update avatar" },
1379 { status: 500 },
1380 );
1381 }
1382 } catch (err) {
1383 return handleError(err);
1384 }
1385 },
1386 },
1387 "/api/user/notifications": {
1388 PUT: async (req) => {
1389 try {
1390 const user = requireAuth(req);
1391
1392 const rateLimitError = enforceRateLimit(req, "update-notifications", {
1393 ip: { max: 10, windowSeconds: 5 * 60 },
1394 });
1395 if (rateLimitError) return rateLimitError;
1396
1397 const body = await req.json();
1398 const { email_notifications_enabled } = body;
1399 if (typeof email_notifications_enabled !== "boolean") {
1400 return Response.json(
1401 { error: "email_notifications_enabled must be a boolean" },
1402 { status: 400 },
1403 );
1404 }
1405 try {
1406 db.run(
1407 "UPDATE users SET email_notifications_enabled = ? WHERE id = ?",
1408 [email_notifications_enabled ? 1 : 0, user.id],
1409 );
1410 return Response.json({ success: true });
1411 } catch {
1412 return Response.json(
1413 { error: "Failed to update notification settings" },
1414 { status: 500 },
1415 );
1416 }
1417 } catch (err) {
1418 return handleError(err);
1419 }
1420 },
1421 },
1422 "/api/billing/checkout": {
1423 POST: async (req) => {
1424 try {
1425 const user = requireAuth(req);
1426
1427 const { polar } = await import("./lib/polar");
1428
1429 // Validated at startup
1430 const productId = process.env.POLAR_PRODUCT_ID as string;
1431 const successUrl =
1432 process.env.POLAR_SUCCESS_URL || "http://localhost:3000";
1433
1434 const checkout = await polar.checkouts.create({
1435 products: [productId],
1436 successUrl,
1437 customerEmail: user.email,
1438 customerName: user.name ?? undefined,
1439 metadata: {
1440 userId: user.id.toString(),
1441 },
1442 });
1443
1444 return Response.json({ url: checkout.url });
1445 } catch (err) {
1446 return handleError(err);
1447 }
1448 },
1449 },
1450 "/api/billing/subscription": {
1451 GET: async (req) => {
1452 try {
1453 const user = requireAuth(req);
1454
1455 // Get subscription from database
1456 const subscription = db
1457 .query<
1458 {
1459 id: string;
1460 status: string;
1461 current_period_start: number | null;
1462 current_period_end: number | null;
1463 cancel_at_period_end: number;
1464 canceled_at: number | null;
1465 },
1466 [number]
1467 >(
1468 "SELECT id, status, current_period_start, current_period_end, cancel_at_period_end, canceled_at FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1",
1469 )
1470 .get(user.id);
1471
1472 if (!subscription) {
1473 return Response.json({ subscription: null });
1474 }
1475
1476 return Response.json({ subscription });
1477 } catch (err) {
1478 return handleError(err);
1479 }
1480 },
1481 },
1482 "/api/billing/portal": {
1483 POST: async (req) => {
1484 try {
1485 const user = requireAuth(req);
1486
1487 const { polar } = await import("./lib/polar");
1488
1489 // Get subscription to find customer ID
1490 const subscription = db
1491 .query<
1492 {
1493 customer_id: string;
1494 },
1495 [number]
1496 >(
1497 "SELECT customer_id FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1",
1498 )
1499 .get(user.id);
1500
1501 if (!subscription || !subscription.customer_id) {
1502 return Response.json(
1503 { error: "No subscription found" },
1504 { status: 404 },
1505 );
1506 }
1507
1508 // Create customer portal session
1509 const session = await polar.customerSessions.create({
1510 customerId: subscription.customer_id,
1511 });
1512
1513 return Response.json({ url: session.customerPortalUrl });
1514 } catch (err) {
1515 return handleError(err);
1516 }
1517 },
1518 },
1519 "/api/webhooks/polar": {
1520 POST: async (req) => {
1521 const { validateEvent } = await import("@polar-sh/sdk/webhooks");
1522
1523 // Get raw body as string
1524 const rawBody = await req.text();
1525 const headers = Object.fromEntries(req.headers.entries());
1526
1527 // Validate webhook signature (validated at startup)
1528 const webhookSecret = process.env.POLAR_WEBHOOK_SECRET as string;
1529 let event: ReturnType<typeof validateEvent>;
1530 try {
1531 event = validateEvent(rawBody, headers, webhookSecret);
1532 } catch (error) {
1533 // Validation failed - log but return generic response
1534 console.error("[Webhook] Signature validation failed:", error);
1535 return Response.json({ error: "Invalid webhook" }, { status: 400 });
1536 }
1537
1538 console.log(`[Webhook] Received event: ${event.type}`);
1539
1540 // Handle different event types
1541 try {
1542 switch (event.type) {
1543 case "subscription.updated": {
1544 const { id, status, customerId, metadata } = event.data;
1545 const userId = metadata?.userId
1546 ? Number.parseInt(metadata.userId as string, 10)
1547 : null;
1548
1549 if (!userId) {
1550 console.warn("[Webhook] No userId in subscription metadata");
1551 break;
1552 }
1553
1554 // Upsert subscription
1555 db.run(
1556 `INSERT INTO subscriptions (id, user_id, customer_id, status, current_period_start, current_period_end, cancel_at_period_end, canceled_at, updated_at)
1557 VALUES (?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'))
1558 ON CONFLICT(id) DO UPDATE SET
1559 status = excluded.status,
1560 current_period_start = excluded.current_period_start,
1561 current_period_end = excluded.current_period_end,
1562 cancel_at_period_end = excluded.cancel_at_period_end,
1563 canceled_at = excluded.canceled_at,
1564 updated_at = strftime('%s', 'now')`,
1565 [
1566 id,
1567 userId,
1568 customerId,
1569 status,
1570 event.data.currentPeriodStart
1571 ? Math.floor(
1572 new Date(event.data.currentPeriodStart).getTime() /
1573 1000,
1574 )
1575 : null,
1576 event.data.currentPeriodEnd
1577 ? Math.floor(
1578 new Date(event.data.currentPeriodEnd).getTime() / 1000,
1579 )
1580 : null,
1581 event.data.cancelAtPeriodEnd ? 1 : 0,
1582 event.data.canceledAt
1583 ? Math.floor(
1584 new Date(event.data.canceledAt).getTime() / 1000,
1585 )
1586 : null,
1587 ],
1588 );
1589
1590 console.log(
1591 `[Webhook] Updated subscription ${id} for user ${userId}`,
1592 );
1593 break;
1594 }
1595
1596 default:
1597 console.log(`[Webhook] Unhandled event type: ${event.type}`);
1598 }
1599
1600 return Response.json({ received: true });
1601 } catch (error) {
1602 // Processing failed - log with detail but return generic response
1603 console.error("[Webhook] Event processing failed:", error);
1604 return Response.json({ error: "Invalid webhook" }, { status: 400 });
1605 }
1606 },
1607 },
1608 "/api/transcriptions/:id/stream": {
1609 GET: async (req) => {
1610 try {
1611 const user = requireAuth(req);
1612 const transcriptionId = req.params.id;
1613 // Verify ownership
1614 const transcription = db
1615 .query<
1616 {
1617 id: string;
1618 user_id: number;
1619 class_id: string | null;
1620 status: string;
1621 },
1622 [string]
1623 >(
1624 "SELECT id, user_id, class_id, status FROM transcriptions WHERE id = ?",
1625 )
1626 .get(transcriptionId);
1627
1628 if (!transcription) {
1629 return Response.json(
1630 { error: "Transcription not found" },
1631 { status: 404 },
1632 );
1633 }
1634
1635 // Check access permissions
1636 const isOwner = transcription.user_id === user.id;
1637 const isAdmin = user.role === "admin";
1638 let isClassMember = false;
1639
1640 // If transcription belongs to a class, check enrollment
1641 if (transcription.class_id) {
1642 isClassMember = isUserEnrolledInClass(
1643 user.id,
1644 transcription.class_id,
1645 );
1646 }
1647
1648 // Allow access if: owner, admin, or enrolled in the class
1649 if (!isOwner && !isAdmin && !isClassMember) {
1650 return Response.json(
1651 { error: "Transcription not found" },
1652 { status: 404 },
1653 );
1654 }
1655
1656 // Require subscription only if accessing own transcription (not class)
1657 if (
1658 isOwner &&
1659 !transcription.class_id &&
1660 !isAdmin &&
1661 !hasActiveSubscription(user.id)
1662 ) {
1663 throw AuthErrors.subscriptionRequired();
1664 }
1665 // Event-driven SSE stream with reconnection support
1666 const stream = new ReadableStream({
1667 async start(controller) {
1668 // Track this stream for graceful shutdown
1669 activeSSEStreams.add(controller);
1670
1671 const encoder = new TextEncoder();
1672 let isClosed = false;
1673 let lastEventId = Math.floor(Date.now() / 1000);
1674
1675 const sendEvent = (data: Partial<TranscriptionUpdate>) => {
1676 if (isClosed) return;
1677 try {
1678 // Send event ID for reconnection support
1679 lastEventId = Math.floor(Date.now() / 1000);
1680 controller.enqueue(
1681 encoder.encode(
1682 `id: ${lastEventId}\nevent: update\ndata: ${JSON.stringify(data)}\n\n`,
1683 ),
1684 );
1685 } catch {
1686 // Controller already closed (client disconnected)
1687 isClosed = true;
1688 }
1689 };
1690
1691 const sendHeartbeat = () => {
1692 if (isClosed) return;
1693 try {
1694 controller.enqueue(encoder.encode(": heartbeat\n\n"));
1695 } catch {
1696 isClosed = true;
1697 }
1698 };
1699 // Send initial state from DB and file
1700 const current = db
1701 .query<
1702 {
1703 status: string;
1704 progress: number;
1705 },
1706 [string]
1707 >("SELECT status, progress FROM transcriptions WHERE id = ?")
1708 .get(transcriptionId);
1709 if (current) {
1710 sendEvent({
1711 status: current.status as TranscriptionUpdate["status"],
1712 progress: current.progress,
1713 });
1714 }
1715 // If already complete, close immediately
1716 if (
1717 current?.status === "completed" ||
1718 current?.status === "failed"
1719 ) {
1720 isClosed = true;
1721 activeSSEStreams.delete(controller);
1722 controller.close();
1723 return;
1724 }
1725 // Send heartbeats every 2.5 seconds to keep connection alive
1726 const heartbeatInterval = setInterval(sendHeartbeat, 2500);
1727
1728 // Subscribe to EventEmitter for live updates
1729 const updateHandler = (data: TranscriptionUpdate) => {
1730 if (isClosed) return;
1731
1732 // Only send changed fields to save bandwidth
1733 const payload: Partial<TranscriptionUpdate> = {
1734 status: data.status,
1735 progress: data.progress,
1736 };
1737
1738 if (data.transcript !== undefined) {
1739 payload.transcript = data.transcript;
1740 }
1741 if (data.error_message !== undefined) {
1742 payload.error_message = data.error_message;
1743 }
1744
1745 sendEvent(payload);
1746
1747 // Close stream when done
1748 if (data.status === "completed" || data.status === "failed") {
1749 isClosed = true;
1750 clearInterval(heartbeatInterval);
1751 transcriptionEvents.off(transcriptionId, updateHandler);
1752 activeSSEStreams.delete(controller);
1753 controller.close();
1754 }
1755 };
1756 transcriptionEvents.on(transcriptionId, updateHandler);
1757 // Cleanup on client disconnect
1758 return () => {
1759 isClosed = true;
1760 clearInterval(heartbeatInterval);
1761 transcriptionEvents.off(transcriptionId, updateHandler);
1762 activeSSEStreams.delete(controller);
1763 };
1764 },
1765 });
1766 return new Response(stream, {
1767 headers: {
1768 "Content-Type": "text/event-stream",
1769 "Cache-Control": "no-cache",
1770 Connection: "keep-alive",
1771 },
1772 });
1773 } catch (error) {
1774 return handleError(error);
1775 }
1776 },
1777 },
1778 "/api/health": {
1779 GET: async () => {
1780 const health = {
1781 status: "healthy",
1782 timestamp: new Date().toISOString(),
1783 services: {
1784 database: false,
1785 whisper: false,
1786 storage: false,
1787 },
1788 details: {} as Record<string, unknown>,
1789 };
1790
1791 // Check database
1792 try {
1793 db.query("SELECT 1").get();
1794 health.services.database = true;
1795 } catch (error) {
1796 health.status = "unhealthy";
1797 health.details.databaseError =
1798 error instanceof Error ? error.message : String(error);
1799 }
1800
1801 // Check Whisper service
1802 try {
1803 const whisperHealthy = await whisperService.checkHealth();
1804 health.services.whisper = whisperHealthy;
1805 if (!whisperHealthy) {
1806 health.status = "degraded";
1807 health.details.whisperNote = "Whisper service unavailable";
1808 }
1809 } catch (error) {
1810 health.status = "degraded";
1811 health.details.whisperError =
1812 error instanceof Error ? error.message : String(error);
1813 }
1814
1815 // Check storage (uploads and transcripts directories)
1816 try {
1817 const fs = await import("node:fs/promises");
1818 const uploadsExists = await fs
1819 .access("./uploads")
1820 .then(() => true)
1821 .catch(() => false);
1822 const transcriptsExists = await fs
1823 .access("./transcripts")
1824 .then(() => true)
1825 .catch(() => false);
1826 health.services.storage = uploadsExists && transcriptsExists;
1827 if (!health.services.storage) {
1828 health.status = "unhealthy";
1829 health.details.storageNote = `Missing directories: ${[
1830 !uploadsExists && "uploads",
1831 !transcriptsExists && "transcripts",
1832 ]
1833 .filter(Boolean)
1834 .join(", ")}`;
1835 }
1836 } catch (error) {
1837 health.status = "unhealthy";
1838 health.details.storageError =
1839 error instanceof Error ? error.message : String(error);
1840 }
1841
1842 const statusCode = health.status === "healthy" ? 200 : 503;
1843 return Response.json(health, { status: statusCode });
1844 },
1845 },
1846 "/api/transcriptions/:id": {
1847 GET: async (req) => {
1848 try {
1849 const user = requireAuth(req);
1850 const transcriptionId = req.params.id;
1851
1852 // Verify ownership or admin
1853 const transcription = db
1854 .query<
1855 {
1856 id: string;
1857 user_id: number;
1858 class_id: string | null;
1859 filename: string;
1860 original_filename: string;
1861 status: string;
1862 progress: number;
1863 created_at: number;
1864 },
1865 [string]
1866 >(
1867 "SELECT id, user_id, class_id, filename, original_filename, status, progress, created_at FROM transcriptions WHERE id = ?",
1868 )
1869 .get(transcriptionId);
1870
1871 if (!transcription) {
1872 return Response.json(
1873 { error: "Transcription not found" },
1874 { status: 404 },
1875 );
1876 }
1877
1878 // Check access permissions
1879 const isOwner = transcription.user_id === user.id;
1880 const isAdmin = user.role === "admin";
1881 let isClassMember = false;
1882
1883 // If transcription belongs to a class, check enrollment
1884 if (transcription.class_id) {
1885 isClassMember = isUserEnrolledInClass(
1886 user.id,
1887 transcription.class_id,
1888 );
1889 }
1890
1891 // Allow access if: owner, admin, or enrolled in the class
1892 if (!isOwner && !isAdmin && !isClassMember) {
1893 return Response.json(
1894 { error: "Transcription not found" },
1895 { status: 404 },
1896 );
1897 }
1898
1899 // Require subscription only if accessing own transcription (not class)
1900 if (
1901 isOwner &&
1902 !transcription.class_id &&
1903 !isAdmin &&
1904 !hasActiveSubscription(user.id)
1905 ) {
1906 throw AuthErrors.subscriptionRequired();
1907 }
1908
1909 if (transcription.status !== "completed") {
1910 return Response.json(
1911 { error: "Transcription not completed yet" },
1912 { status: 400 },
1913 );
1914 }
1915
1916 // Get format from query parameter
1917 const url = new URL(req.url);
1918 const format = url.searchParams.get("format");
1919
1920 // Return WebVTT format if requested
1921 if (format === "vtt") {
1922 const vttContent = await getTranscriptVTT(transcriptionId);
1923
1924 if (!vttContent) {
1925 return Response.json(
1926 { error: "VTT transcript not available" },
1927 { status: 404 },
1928 );
1929 }
1930
1931 return new Response(vttContent, {
1932 headers: {
1933 "Content-Type": "text/vtt",
1934 "Content-Disposition": `attachment; filename="${transcription.original_filename}.vtt"`,
1935 },
1936 });
1937 }
1938
1939 // return info on transcript
1940 const transcript = {
1941 id: transcription.id,
1942 filename: transcription.original_filename,
1943 status: transcription.status,
1944 progress: transcription.progress,
1945 created_at: transcription.created_at,
1946 };
1947 return new Response(JSON.stringify(transcript), {
1948 headers: {
1949 "Content-Type": "application/json",
1950 },
1951 });
1952 } catch (error) {
1953 return handleError(error);
1954 }
1955 },
1956 },
1957 "/api/transcriptions/:id/audio": {
1958 GET: async (req) => {
1959 try {
1960 const user = requireAuth(req);
1961 const transcriptionId = req.params.id;
1962
1963 // Verify ownership or admin
1964 const transcription = db
1965 .query<
1966 {
1967 id: string;
1968 user_id: number;
1969 class_id: string | null;
1970 filename: string;
1971 status: string;
1972 },
1973 [string]
1974 >(
1975 "SELECT id, user_id, class_id, filename, status FROM transcriptions WHERE id = ?",
1976 )
1977 .get(transcriptionId);
1978
1979 if (!transcription) {
1980 return Response.json(
1981 { error: "Transcription not found" },
1982 { status: 404 },
1983 );
1984 }
1985
1986 // Check access permissions
1987 const isOwner = transcription.user_id === user.id;
1988 const isAdmin = user.role === "admin";
1989 let isClassMember = false;
1990
1991 // If transcription belongs to a class, check enrollment
1992 if (transcription.class_id) {
1993 isClassMember = isUserEnrolledInClass(
1994 user.id,
1995 transcription.class_id,
1996 );
1997 }
1998
1999 // Allow access if: owner, admin, or enrolled in the class
2000 if (!isOwner && !isAdmin && !isClassMember) {
2001 return Response.json(
2002 { error: "Transcription not found" },
2003 { status: 404 },
2004 );
2005 }
2006
2007 // Require subscription only if accessing own transcription (not class)
2008 if (
2009 isOwner &&
2010 !transcription.class_id &&
2011 !isAdmin &&
2012 !hasActiveSubscription(user.id)
2013 ) {
2014 throw AuthErrors.subscriptionRequired();
2015 }
2016
2017 // For pending recordings, audio file exists even though transcription isn't complete
2018 // Allow audio access for pending and completed statuses
2019 if (
2020 transcription.status !== "completed" &&
2021 transcription.status !== "pending"
2022 ) {
2023 return Response.json(
2024 { error: "Audio not available yet" },
2025 { status: 400 },
2026 );
2027 }
2028
2029 // Serve the audio file with range request support
2030 const filePath = `./uploads/${transcription.filename}`;
2031 const file = Bun.file(filePath);
2032
2033 if (!(await file.exists())) {
2034 return Response.json(
2035 { error: "Audio file not found" },
2036 { status: 404 },
2037 );
2038 }
2039
2040 const fileSize = file.size;
2041 const range = req.headers.get("range");
2042
2043 // Handle range requests for seeking
2044 if (range) {
2045 const parts = range.replace(/bytes=/, "").split("-");
2046 const start = Number.parseInt(parts[0] || "0", 10);
2047 const end = parts[1] ? Number.parseInt(parts[1], 10) : fileSize - 1;
2048 const chunkSize = end - start + 1;
2049
2050 const fileSlice = file.slice(start, end + 1);
2051
2052 return new Response(fileSlice, {
2053 status: 206,
2054 headers: {
2055 "Content-Range": `bytes ${start}-${end}/${fileSize}`,
2056 "Accept-Ranges": "bytes",
2057 "Content-Length": chunkSize.toString(),
2058 "Content-Type": file.type || "audio/mpeg",
2059 },
2060 });
2061 }
2062
2063 // No range request, send entire file
2064 return new Response(file, {
2065 headers: {
2066 "Content-Type": file.type || "audio/mpeg",
2067 "Accept-Ranges": "bytes",
2068 "Content-Length": fileSize.toString(),
2069 },
2070 });
2071 } catch (error) {
2072 return handleError(error);
2073 }
2074 },
2075 },
2076 "/api/transcriptions": {
2077 GET: async (req) => {
2078 try {
2079 const user = requireSubscription(req);
2080 const url = new URL(req.url);
2081
2082 // Parse pagination params
2083 const limit = Math.min(
2084 Number.parseInt(url.searchParams.get("limit") || "50", 10),
2085 100,
2086 );
2087 const cursorParam = url.searchParams.get("cursor");
2088
2089 let transcriptions: Array<{
2090 id: string;
2091 filename: string;
2092 original_filename: string;
2093 class_id: string | null;
2094 status: string;
2095 progress: number;
2096 created_at: number;
2097 }>;
2098
2099 if (cursorParam) {
2100 // Decode cursor
2101 const { decodeCursor } = await import("./lib/cursor");
2102 const parts = decodeCursor(cursorParam);
2103
2104 if (parts.length !== 2) {
2105 return Response.json(
2106 { error: "Invalid cursor format" },
2107 { status: 400 },
2108 );
2109 }
2110
2111 const cursorTime = Number.parseInt(parts[0] || "", 10);
2112 const id = parts[1] || "";
2113
2114 if (Number.isNaN(cursorTime) || !id) {
2115 return Response.json(
2116 { error: "Invalid cursor format" },
2117 { status: 400 },
2118 );
2119 }
2120
2121 transcriptions = db
2122 .query<
2123 {
2124 id: string;
2125 filename: string;
2126 original_filename: string;
2127 class_id: string | null;
2128 status: string;
2129 progress: number;
2130 created_at: number;
2131 },
2132 [number, number, string, number]
2133 >(
2134 `SELECT id, filename, original_filename, class_id, status, progress, created_at
2135 FROM transcriptions
2136 WHERE user_id = ? AND (created_at < ? OR (created_at = ? AND id < ?))
2137 ORDER BY created_at DESC, id DESC
2138 LIMIT ?`,
2139 )
2140 .all(user.id, cursorTime, cursorTime, id, limit + 1);
2141 } else {
2142 transcriptions = db
2143 .query<
2144 {
2145 id: string;
2146 filename: string;
2147 original_filename: string;
2148 class_id: string | null;
2149 status: string;
2150 progress: number;
2151 created_at: number;
2152 },
2153 [number, number]
2154 >(
2155 `SELECT id, filename, original_filename, class_id, status, progress, created_at
2156 FROM transcriptions
2157 WHERE user_id = ?
2158 ORDER BY created_at DESC, id DESC
2159 LIMIT ?`,
2160 )
2161 .all(user.id, limit + 1);
2162 }
2163
2164 // Check if there are more results
2165 const hasMore = transcriptions.length > limit;
2166 if (hasMore) {
2167 transcriptions.pop(); // Remove extra item
2168 }
2169
2170 // Build next cursor
2171 let nextCursor: string | null = null;
2172 if (hasMore && transcriptions.length > 0) {
2173 const { encodeCursor } = await import("./lib/cursor");
2174 const last = transcriptions[transcriptions.length - 1];
2175 if (last) {
2176 nextCursor = encodeCursor([last.created_at.toString(), last.id]);
2177 }
2178 }
2179
2180 // Load transcripts from files for completed jobs
2181 const jobs = await Promise.all(
2182 transcriptions.map(async (t) => {
2183 return {
2184 id: t.id,
2185 filename: t.original_filename,
2186 class_id: t.class_id,
2187 status: t.status,
2188 progress: t.progress,
2189 created_at: t.created_at,
2190 };
2191 }),
2192 );
2193
2194 return Response.json({
2195 jobs,
2196 pagination: {
2197 limit,
2198 hasMore,
2199 nextCursor,
2200 },
2201 });
2202 } catch (error) {
2203 return handleError(error);
2204 }
2205 },
2206 POST: async (req) => {
2207 try {
2208 const user = requireSubscription(req);
2209
2210 const rateLimitError = enforceRateLimit(req, "upload-transcription", {
2211 ip: { max: 20, windowSeconds: 60 * 60 },
2212 });
2213 if (rateLimitError) return rateLimitError;
2214
2215 const formData = await req.formData();
2216 const file = formData.get("audio") as File;
2217 const classId = formData.get("class_id") as string | null;
2218 const meetingTimeId = formData.get("meeting_time_id") as
2219 | string
2220 | null;
2221
2222 if (!file) throw ValidationErrors.missingField("audio");
2223
2224 // If class_id provided, verify user is enrolled (or admin)
2225 if (classId) {
2226 const enrolled = isUserEnrolledInClass(user.id, classId);
2227 if (!enrolled && user.role !== "admin") {
2228 return Response.json(
2229 { error: "Not enrolled in this class" },
2230 { status: 403 },
2231 );
2232 }
2233
2234 // Verify class exists
2235 const classInfo = getClassById(classId);
2236 if (!classInfo) {
2237 return Response.json(
2238 { error: "Class not found" },
2239 { status: 404 },
2240 );
2241 }
2242
2243 // Check if class is archived
2244 if (classInfo.archived) {
2245 return Response.json(
2246 { error: "Cannot upload to archived class" },
2247 { status: 400 },
2248 );
2249 }
2250 }
2251
2252 // Validate file type
2253 const fileExtension = file.name.split(".").pop()?.toLowerCase();
2254 const allowedExtensions = [
2255 "mp3",
2256 "wav",
2257 "m4a",
2258 "aac",
2259 "ogg",
2260 "webm",
2261 "flac",
2262 "mp4",
2263 ];
2264 const isAudioType =
2265 file.type.startsWith("audio/") || file.type === "video/mp4";
2266 const isAudioExtension =
2267 fileExtension && allowedExtensions.includes(fileExtension);
2268
2269 if (!isAudioType && !isAudioExtension) {
2270 throw ValidationErrors.unsupportedFileType(
2271 "MP3, WAV, M4A, AAC, OGG, WebM, FLAC",
2272 );
2273 }
2274
2275 if (file.size > MAX_FILE_SIZE) {
2276 throw ValidationErrors.fileTooLarge("100MB");
2277 }
2278
2279 // Generate unique filename
2280 const transcriptionId = crypto.randomUUID();
2281 const filename = `${transcriptionId}.${fileExtension}`;
2282
2283 // Save file to disk
2284 const uploadDir = "./uploads";
2285 await Bun.write(`${uploadDir}/${filename}`, file);
2286
2287 // Create database record
2288 db.run(
2289 "INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?, ?, ?)",
2290 [
2291 transcriptionId,
2292 user.id,
2293 classId,
2294 meetingTimeId,
2295 filename,
2296 file.name,
2297 "pending",
2298 ],
2299 );
2300
2301 // Don't auto-start transcription - admin will select recordings
2302 // whisperService.startTranscription(transcriptionId, filename);
2303
2304 return Response.json({
2305 id: transcriptionId,
2306 message: "Upload successful",
2307 });
2308 } catch (error) {
2309 return handleError(error);
2310 }
2311 },
2312 },
2313 "/api/admin/transcriptions": {
2314 GET: async (req) => {
2315 try {
2316 requireAdmin(req);
2317 const url = new URL(req.url);
2318
2319 const limit = Math.min(
2320 Number.parseInt(url.searchParams.get("limit") || "50", 10),
2321 100,
2322 );
2323 const cursor = url.searchParams.get("cursor") || undefined;
2324
2325 const result = getAllTranscriptions(limit, cursor);
2326 return Response.json(result.data); // Return just the array for now, can add pagination UI later
2327 } catch (error) {
2328 return handleError(error);
2329 }
2330 },
2331 },
2332 "/api/admin/users": {
2333 GET: async (req) => {
2334 try {
2335 requireAdmin(req);
2336 const url = new URL(req.url);
2337
2338 const limit = Math.min(
2339 Number.parseInt(url.searchParams.get("limit") || "50", 10),
2340 100,
2341 );
2342 const cursor = url.searchParams.get("cursor") || undefined;
2343
2344 const result = getAllUsersWithStats(limit, cursor);
2345 return Response.json(result.data); // Return just the array for now, can add pagination UI later
2346 } catch (error) {
2347 return handleError(error);
2348 }
2349 },
2350 },
2351 "/api/admin/classes": {
2352 GET: async (req) => {
2353 try {
2354 requireAdmin(req);
2355 const classes = getClassesForUser(0, true); // Admin sees all classes
2356 return Response.json({ classes });
2357 } catch (error) {
2358 return handleError(error);
2359 }
2360 },
2361 },
2362 "/api/admin/waitlist": {
2363 GET: async (req) => {
2364 try {
2365 requireAdmin(req);
2366 const waitlist = getAllWaitlistEntries();
2367 return Response.json({ waitlist });
2368 } catch (error) {
2369 return handleError(error);
2370 }
2371 },
2372 },
2373 "/api/admin/waitlist/:id": {
2374 DELETE: async (req) => {
2375 try {
2376 requireAdmin(req);
2377 const id = req.params.id;
2378 deleteWaitlistEntry(id);
2379 return Response.json({ success: true });
2380 } catch (error) {
2381 return handleError(error);
2382 }
2383 },
2384 },
2385 "/api/admin/transcriptions/:id": {
2386 DELETE: async (req) => {
2387 try {
2388 requireAdmin(req);
2389 const transcriptionId = req.params.id;
2390 deleteTranscription(transcriptionId);
2391 return Response.json({ success: true });
2392 } catch (error) {
2393 return handleError(error);
2394 }
2395 },
2396 },
2397 "/api/admin/users/:id": {
2398 DELETE: async (req) => {
2399 try {
2400 requireAdmin(req);
2401 const userId = Number.parseInt(req.params.id, 10);
2402 if (Number.isNaN(userId)) {
2403 return Response.json({ error: "Invalid user ID" }, { status: 400 });
2404 }
2405 await deleteUser(userId);
2406 return Response.json({ success: true });
2407 } catch (error) {
2408 return handleError(error);
2409 }
2410 },
2411 },
2412 "/api/admin/users/:id/role": {
2413 PUT: async (req) => {
2414 try {
2415 requireAdmin(req);
2416 const userId = Number.parseInt(req.params.id, 10);
2417 if (Number.isNaN(userId)) {
2418 return Response.json({ error: "Invalid user ID" }, { status: 400 });
2419 }
2420
2421 const body = await req.json();
2422 const { role } = body as { role: UserRole };
2423
2424 if (!role || (role !== "user" && role !== "admin")) {
2425 return Response.json(
2426 { error: "Invalid role. Must be 'user' or 'admin'" },
2427 { status: 400 },
2428 );
2429 }
2430
2431 updateUserRole(userId, role);
2432 return Response.json({ success: true });
2433 } catch (error) {
2434 return handleError(error);
2435 }
2436 },
2437 },
2438 "/api/admin/users/:id/subscription": {
2439 DELETE: async (req) => {
2440 try {
2441 requireAdmin(req);
2442 const userId = Number.parseInt(req.params.id, 10);
2443 if (Number.isNaN(userId)) {
2444 return Response.json({ error: "Invalid user ID" }, { status: 400 });
2445 }
2446
2447 const body = await req.json();
2448 const { subscriptionId } = body as { subscriptionId: string };
2449
2450 if (!subscriptionId) {
2451 return Response.json(
2452 { error: "Subscription ID required" },
2453 { status: 400 },
2454 );
2455 }
2456
2457 try {
2458 const { polar } = await import("./lib/polar");
2459 await polar.subscriptions.revoke({ id: subscriptionId });
2460 return Response.json({
2461 success: true,
2462 message: "Subscription revoked successfully",
2463 });
2464 } catch (error) {
2465 console.error(
2466 `[Admin] Failed to revoke subscription ${subscriptionId}:`,
2467 error,
2468 );
2469 return Response.json(
2470 {
2471 error:
2472 error instanceof Error
2473 ? error.message
2474 : "Failed to revoke subscription",
2475 },
2476 { status: 500 },
2477 );
2478 }
2479 } catch (error) {
2480 return handleError(error);
2481 }
2482 },
2483 PUT: async (req) => {
2484 try {
2485 requireAdmin(req);
2486 const userId = Number.parseInt(req.params.id, 10);
2487 if (Number.isNaN(userId)) {
2488 return Response.json({ error: "Invalid user ID" }, { status: 400 });
2489 }
2490
2491 // Get user email
2492 const user = db
2493 .query<{ email: string }, [number]>(
2494 "SELECT email FROM users WHERE id = ?",
2495 )
2496 .get(userId);
2497
2498 if (!user) {
2499 return Response.json({ error: "User not found" }, { status: 404 });
2500 }
2501
2502 try {
2503 await syncUserSubscriptionsFromPolar(userId, user.email);
2504 return Response.json({
2505 success: true,
2506 message: "Subscription synced successfully",
2507 });
2508 } catch (error) {
2509 console.error(
2510 `[Admin] Failed to sync subscription for user ${userId}:`,
2511 error,
2512 );
2513 return Response.json(
2514 {
2515 error:
2516 error instanceof Error
2517 ? error.message
2518 : "Failed to sync subscription",
2519 },
2520 { status: 500 },
2521 );
2522 }
2523 } catch (error) {
2524 return handleError(error);
2525 }
2526 },
2527 },
2528 "/api/admin/users/:id/details": {
2529 GET: async (req) => {
2530 try {
2531 requireAdmin(req);
2532 const userId = Number.parseInt(req.params.id, 10);
2533 if (Number.isNaN(userId)) {
2534 return Response.json({ error: "Invalid user ID" }, { status: 400 });
2535 }
2536
2537 const user = db
2538 .query<
2539 {
2540 id: number;
2541 email: string;
2542 name: string | null;
2543 avatar: string;
2544 created_at: number;
2545 role: UserRole;
2546 password_hash: string | null;
2547 last_login: number | null;
2548 },
2549 [number]
2550 >(
2551 "SELECT id, email, name, avatar, created_at, role, password_hash, last_login FROM users WHERE id = ?",
2552 )
2553 .get(userId);
2554
2555 if (!user) {
2556 return Response.json({ error: "User not found" }, { status: 404 });
2557 }
2558
2559 const passkeys = getPasskeysForUser(userId);
2560 const sessions = getSessionsForUser(userId);
2561
2562 // Get transcription count
2563 const transcriptionCount =
2564 db
2565 .query<{ count: number }, [number]>(
2566 "SELECT COUNT(*) as count FROM transcriptions WHERE user_id = ?",
2567 )
2568 .get(userId)?.count ?? 0;
2569
2570 return Response.json({
2571 id: user.id,
2572 email: user.email,
2573 name: user.name,
2574 avatar: user.avatar,
2575 created_at: user.created_at,
2576 role: user.role,
2577 last_login: user.last_login,
2578 hasPassword: !!user.password_hash,
2579 transcriptionCount,
2580 passkeys: passkeys.map((pk) => ({
2581 id: pk.id,
2582 name: pk.name,
2583 created_at: pk.created_at,
2584 last_used_at: pk.last_used_at,
2585 })),
2586 sessions: sessions.map((s) => ({
2587 id: s.id,
2588 ip_address: s.ip_address,
2589 user_agent: s.user_agent,
2590 created_at: s.created_at,
2591 expires_at: s.expires_at,
2592 })),
2593 });
2594 } catch (error) {
2595 return handleError(error);
2596 }
2597 },
2598 },
2599 "/api/admin/users/:id/password-reset": {
2600 POST: async (req) => {
2601 try {
2602 requireAdmin(req);
2603 const userId = Number.parseInt(req.params.id, 10);
2604 if (Number.isNaN(userId)) {
2605 return Response.json({ error: "Invalid user ID" }, { status: 400 });
2606 }
2607
2608 // Get user details
2609 const user = db
2610 .query<
2611 { id: number; email: string; name: string | null },
2612 [number]
2613 >("SELECT id, email, name FROM users WHERE id = ?")
2614 .get(userId);
2615
2616 if (!user) {
2617 return Response.json({ error: "User not found" }, { status: 404 });
2618 }
2619
2620 // Create password reset token
2621 const origin = process.env.ORIGIN || "http://localhost:3000";
2622 const resetToken = createPasswordResetToken(user.id);
2623 const resetLink = `${origin}/reset-password?token=${resetToken}`;
2624
2625 // Send password reset email
2626 await sendEmail({
2627 to: user.email,
2628 subject: "Reset your password - Thistle",
2629 html: passwordResetTemplate({
2630 name: user.name,
2631 resetLink,
2632 }),
2633 });
2634
2635 return Response.json({
2636 success: true,
2637 message: "Password reset email sent",
2638 });
2639 } catch (error) {
2640 console.error("[Admin] Password reset error:", error);
2641 return handleError(error);
2642 }
2643 },
2644 },
2645 "/api/admin/users/:id/passkeys/:passkeyId": {
2646 DELETE: async (req) => {
2647 try {
2648 requireAdmin(req);
2649 const userId = Number.parseInt(req.params.id, 10);
2650 if (Number.isNaN(userId)) {
2651 return Response.json({ error: "Invalid user ID" }, { status: 400 });
2652 }
2653
2654 const { passkeyId } = req.params;
2655 deletePasskey(passkeyId, userId);
2656 return Response.json({ success: true });
2657 } catch (error) {
2658 return handleError(error);
2659 }
2660 },
2661 },
2662 "/api/admin/users/:id/name": {
2663 PUT: async (req) => {
2664 try {
2665 requireAdmin(req);
2666 const userId = Number.parseInt(req.params.id, 10);
2667 if (Number.isNaN(userId)) {
2668 return Response.json({ error: "Invalid user ID" }, { status: 400 });
2669 }
2670
2671 const body = await req.json();
2672 const { name } = body as { name: string };
2673
2674 const nameValidation = validateName(name);
2675 if (!nameValidation.valid) {
2676 return Response.json(
2677 { error: nameValidation.error },
2678 { status: 400 },
2679 );
2680 }
2681
2682 updateUserName(userId, name.trim());
2683 return Response.json({ success: true });
2684 } catch (error) {
2685 return handleError(error);
2686 }
2687 },
2688 },
2689 "/api/admin/users/:id/email": {
2690 PUT: async (req) => {
2691 try {
2692 requireAdmin(req);
2693 const userId = Number.parseInt(req.params.id, 10);
2694 if (Number.isNaN(userId)) {
2695 return Response.json({ error: "Invalid user ID" }, { status: 400 });
2696 }
2697
2698 const body = await req.json();
2699 const { email, skipVerification } = body as {
2700 email: string;
2701 skipVerification?: boolean;
2702 };
2703
2704 const emailValidation = validateEmail(email);
2705 if (!emailValidation.valid) {
2706 return Response.json(
2707 { error: emailValidation.error },
2708 { status: 400 },
2709 );
2710 }
2711
2712 // Check if email already exists
2713 const existing = db
2714 .query<{ id: number }, [string, number]>(
2715 "SELECT id FROM users WHERE email = ? AND id != ?",
2716 )
2717 .get(email, userId);
2718
2719 if (existing) {
2720 return Response.json(
2721 { error: "Email already in use" },
2722 { status: 400 },
2723 );
2724 }
2725
2726 if (skipVerification) {
2727 // Admin override: change email immediately without verification
2728 updateUserEmailAddress(userId, email);
2729 return Response.json({
2730 success: true,
2731 message: "Email updated immediately (verification skipped)",
2732 });
2733 }
2734
2735 // Get user's current email
2736 const user = db
2737 .query<{ email: string; name: string | null }, [number]>(
2738 "SELECT email, name FROM users WHERE id = ?",
2739 )
2740 .get(userId);
2741
2742 if (!user) {
2743 return Response.json({ error: "User not found" }, { status: 404 });
2744 }
2745
2746 // Send verification email to user's current email
2747 try {
2748 const token = createEmailChangeToken(userId, email);
2749 const origin = process.env.ORIGIN || "http://localhost:3000";
2750 const verifyUrl = `${origin}/api/user/email/verify?token=${token}`;
2751
2752 await sendEmail({
2753 to: user.email,
2754 subject: "Verify your email change",
2755 html: emailChangeTemplate({
2756 name: user.name,
2757 currentEmail: user.email,
2758 newEmail: email,
2759 verifyLink: verifyUrl,
2760 }),
2761 });
2762
2763 return Response.json({
2764 success: true,
2765 message: `Verification email sent to ${user.email}`,
2766 pendingEmail: email,
2767 });
2768 } catch (emailError) {
2769 console.error(
2770 "[Admin] Failed to send email change verification:",
2771 emailError,
2772 );
2773 return Response.json(
2774 { error: "Failed to send verification email" },
2775 { status: 500 },
2776 );
2777 }
2778 } catch (error) {
2779 return handleError(error);
2780 }
2781 },
2782 },
2783 "/api/admin/users/:id/sessions": {
2784 GET: async (req) => {
2785 try {
2786 requireAdmin(req);
2787 const userId = Number.parseInt(req.params.id, 10);
2788 if (Number.isNaN(userId)) {
2789 return Response.json({ error: "Invalid user ID" }, { status: 400 });
2790 }
2791
2792 const sessions = getSessionsForUser(userId);
2793 return Response.json(sessions);
2794 } catch (error) {
2795 return handleError(error);
2796 }
2797 },
2798 DELETE: async (req) => {
2799 try {
2800 requireAdmin(req);
2801 const userId = Number.parseInt(req.params.id, 10);
2802 if (Number.isNaN(userId)) {
2803 return Response.json({ error: "Invalid user ID" }, { status: 400 });
2804 }
2805
2806 deleteAllUserSessions(userId);
2807 return Response.json({ success: true });
2808 } catch (error) {
2809 return handleError(error);
2810 }
2811 },
2812 },
2813 "/api/admin/users/:id/sessions/:sessionId": {
2814 DELETE: async (req) => {
2815 try {
2816 requireAdmin(req);
2817 const userId = Number.parseInt(req.params.id, 10);
2818 if (Number.isNaN(userId)) {
2819 return Response.json({ error: "Invalid user ID" }, { status: 400 });
2820 }
2821
2822 const { sessionId } = req.params;
2823 const success = deleteSessionById(sessionId, userId);
2824
2825 if (!success) {
2826 return Response.json(
2827 { error: "Session not found" },
2828 { status: 404 },
2829 );
2830 }
2831
2832 return Response.json({ success: true });
2833 } catch (error) {
2834 return handleError(error);
2835 }
2836 },
2837 },
2838 "/api/admin/transcriptions/:id/details": {
2839 GET: async (req) => {
2840 try {
2841 requireAdmin(req);
2842 const transcriptionId = req.params.id;
2843
2844 const transcription = db
2845 .query<
2846 {
2847 id: string;
2848 original_filename: string;
2849 status: string;
2850 created_at: number;
2851 updated_at: number;
2852 error_message: string | null;
2853 user_id: number;
2854 },
2855 [string]
2856 >(
2857 "SELECT id, original_filename, status, created_at, updated_at, error_message, user_id FROM transcriptions WHERE id = ?",
2858 )
2859 .get(transcriptionId);
2860
2861 if (!transcription) {
2862 return Response.json(
2863 { error: "Transcription not found" },
2864 { status: 404 },
2865 );
2866 }
2867
2868 const user = db
2869 .query<{ email: string; name: string | null }, [number]>(
2870 "SELECT email, name FROM users WHERE id = ?",
2871 )
2872 .get(transcription.user_id);
2873
2874 return Response.json({
2875 id: transcription.id,
2876 original_filename: transcription.original_filename,
2877 status: transcription.status,
2878 created_at: transcription.created_at,
2879 completed_at: transcription.updated_at,
2880 error_message: transcription.error_message,
2881 user_id: transcription.user_id,
2882 user_email: user?.email || "Unknown",
2883 user_name: user?.name || null,
2884 });
2885 } catch (error) {
2886 return handleError(error);
2887 }
2888 },
2889 },
2890 "/api/classes": {
2891 GET: async (req) => {
2892 try {
2893 const user = requireAuth(req);
2894 const url = new URL(req.url);
2895
2896 const limit = Math.min(
2897 Number.parseInt(url.searchParams.get("limit") || "50", 10),
2898 100,
2899 );
2900 const cursor = url.searchParams.get("cursor") || undefined;
2901
2902 const result = getClassesForUser(
2903 user.id,
2904 user.role === "admin",
2905 limit,
2906 cursor,
2907 );
2908
2909 // For admin, return flat array. For users, group by semester/year
2910 if (user.role === "admin") {
2911 return Response.json(result.data);
2912 }
2913
2914 // Group by semester/year for regular users
2915 const grouped: Record<
2916 string,
2917 Array<{
2918 id: string;
2919 course_code: string;
2920 name: string;
2921 professor: string;
2922 semester: string;
2923 year: number;
2924 archived: boolean;
2925 }>
2926 > = {};
2927
2928 for (const cls of result.data) {
2929 const key = `${cls.semester} ${cls.year}`;
2930 if (!grouped[key]) {
2931 grouped[key] = [];
2932 }
2933 grouped[key]?.push({
2934 id: cls.id,
2935 course_code: cls.course_code,
2936 name: cls.name,
2937 professor: cls.professor,
2938 semester: cls.semester,
2939 year: cls.year,
2940 archived: cls.archived,
2941 });
2942 }
2943
2944 return Response.json({
2945 classes: grouped,
2946 pagination: result.pagination,
2947 });
2948 } catch (error) {
2949 return handleError(error);
2950 }
2951 },
2952 POST: async (req) => {
2953 try {
2954 requireAdmin(req);
2955 const body = await req.json();
2956 const {
2957 course_code,
2958 name,
2959 professor,
2960 semester,
2961 year,
2962 meeting_times,
2963 } = body;
2964
2965 // Validate all required fields
2966 const courseCodeValidation = validateCourseCode(course_code);
2967 if (!courseCodeValidation.valid) {
2968 return Response.json(
2969 { error: courseCodeValidation.error },
2970 { status: 400 },
2971 );
2972 }
2973
2974 const nameValidation = validateCourseName(name);
2975 if (!nameValidation.valid) {
2976 return Response.json(
2977 { error: nameValidation.error },
2978 { status: 400 },
2979 );
2980 }
2981
2982 const professorValidation = validateName(professor, "Professor name");
2983 if (!professorValidation.valid) {
2984 return Response.json(
2985 { error: professorValidation.error },
2986 { status: 400 },
2987 );
2988 }
2989
2990 const semesterValidation = validateSemester(semester);
2991 if (!semesterValidation.valid) {
2992 return Response.json(
2993 { error: semesterValidation.error },
2994 { status: 400 },
2995 );
2996 }
2997
2998 const yearValidation = validateYear(year);
2999 if (!yearValidation.valid) {
3000 return Response.json(
3001 { error: yearValidation.error },
3002 { status: 400 },
3003 );
3004 }
3005
3006 const newClass = createClass({
3007 course_code,
3008 name,
3009 professor,
3010 semester,
3011 year,
3012 meeting_times,
3013 });
3014
3015 return Response.json(newClass);
3016 } catch (error) {
3017 return handleError(error);
3018 }
3019 },
3020 },
3021 "/api/classes/search": {
3022 GET: async (req) => {
3023 try {
3024 const user = requireAuth(req);
3025 const url = new URL(req.url);
3026 const query = url.searchParams.get("q");
3027
3028 if (!query) {
3029 return Response.json({ classes: [] });
3030 }
3031
3032 const classes = searchClassesByCourseCode(query);
3033
3034 // Get user's enrolled classes to mark them
3035 const enrolledClassIds = db
3036 .query<{ class_id: string }, [number]>(
3037 "SELECT class_id FROM class_members WHERE user_id = ?",
3038 )
3039 .all(user.id)
3040 .map((row) => row.class_id);
3041
3042 // Add is_enrolled flag to each class
3043 const classesWithEnrollment = classes.map((cls) => ({
3044 ...cls,
3045 is_enrolled: enrolledClassIds.includes(cls.id),
3046 }));
3047
3048 return Response.json({ classes: classesWithEnrollment });
3049 } catch (error) {
3050 return handleError(error);
3051 }
3052 },
3053 },
3054 "/api/classes/join": {
3055 POST: async (req) => {
3056 try {
3057 const user = requireAuth(req);
3058 const body = await req.json();
3059 const classId = body.class_id;
3060
3061 const classIdValidation = validateClassId(classId);
3062 if (!classIdValidation.valid) {
3063 return Response.json(
3064 { error: classIdValidation.error },
3065 { status: 400 },
3066 );
3067 }
3068
3069 const result = joinClass(classId, user.id);
3070
3071 if (!result.success) {
3072 return Response.json({ error: result.error }, { status: 400 });
3073 }
3074
3075 return Response.json({ success: true });
3076 } catch (error) {
3077 return handleError(error);
3078 }
3079 },
3080 },
3081 "/api/classes/waitlist": {
3082 POST: async (req) => {
3083 try {
3084 const user = requireAuth(req);
3085 const body = await req.json();
3086
3087 const {
3088 courseCode,
3089 courseName,
3090 professor,
3091 semester,
3092 year,
3093 additionalInfo,
3094 meetingTimes,
3095 } = body;
3096
3097 // Validate all required fields
3098 const courseCodeValidation = validateCourseCode(courseCode);
3099 if (!courseCodeValidation.valid) {
3100 return Response.json(
3101 { error: courseCodeValidation.error },
3102 { status: 400 },
3103 );
3104 }
3105
3106 const nameValidation = validateCourseName(courseName);
3107 if (!nameValidation.valid) {
3108 return Response.json(
3109 { error: nameValidation.error },
3110 { status: 400 },
3111 );
3112 }
3113
3114 const professorValidation = validateName(professor, "Professor name");
3115 if (!professorValidation.valid) {
3116 return Response.json(
3117 { error: professorValidation.error },
3118 { status: 400 },
3119 );
3120 }
3121
3122 const semesterValidation = validateSemester(semester);
3123 if (!semesterValidation.valid) {
3124 return Response.json(
3125 { error: semesterValidation.error },
3126 { status: 400 },
3127 );
3128 }
3129
3130 const yearValidation = validateYear(
3131 typeof year === "string" ? Number.parseInt(year, 10) : year,
3132 );
3133 if (!yearValidation.valid) {
3134 return Response.json(
3135 { error: yearValidation.error },
3136 { status: 400 },
3137 );
3138 }
3139
3140 const id = addToWaitlist(
3141 user.id,
3142 courseCode,
3143 courseName,
3144 professor,
3145 semester,
3146 Number.parseInt(year, 10),
3147 additionalInfo || null,
3148 meetingTimes || null,
3149 );
3150
3151 return Response.json({ success: true, id });
3152 } catch (error) {
3153 return handleError(error);
3154 }
3155 },
3156 },
3157 "/api/classes/:id": {
3158 GET: async (req) => {
3159 try {
3160 const user = requireAuth(req);
3161 const classId = req.params.id;
3162
3163 const classInfo = getClassById(classId);
3164 if (!classInfo) {
3165 return Response.json({ error: "Class not found" }, { status: 404 });
3166 }
3167
3168 // Check enrollment or admin
3169 const isEnrolled = isUserEnrolledInClass(user.id, classId);
3170 if (!isEnrolled && user.role !== "admin") {
3171 return Response.json(
3172 { error: "Not enrolled in this class" },
3173 { status: 403 },
3174 );
3175 }
3176
3177 const meetingTimes = getMeetingTimesForClass(classId);
3178 const transcriptions = getTranscriptionsForClass(classId);
3179
3180 return Response.json({
3181 class: classInfo,
3182 meetingTimes,
3183 transcriptions,
3184 });
3185 } catch (error) {
3186 return handleError(error);
3187 }
3188 },
3189 DELETE: async (req) => {
3190 try {
3191 requireAdmin(req);
3192 const classId = req.params.id;
3193
3194 deleteClass(classId);
3195 return Response.json({ success: true });
3196 } catch (error) {
3197 return handleError(error);
3198 }
3199 },
3200 },
3201 "/api/classes/:id/archive": {
3202 PUT: async (req) => {
3203 try {
3204 requireAdmin(req);
3205 const classId = req.params.id;
3206 const body = await req.json();
3207 const { archived } = body;
3208
3209 if (typeof archived !== "boolean") {
3210 return Response.json(
3211 { error: "archived must be a boolean" },
3212 { status: 400 },
3213 );
3214 }
3215
3216 toggleClassArchive(classId, archived);
3217 return Response.json({ success: true });
3218 } catch (error) {
3219 return handleError(error);
3220 }
3221 },
3222 },
3223 "/api/classes/:id/members": {
3224 GET: async (req) => {
3225 try {
3226 requireAdmin(req);
3227 const classId = req.params.id;
3228
3229 const members = getClassMembers(classId);
3230 return Response.json({ members });
3231 } catch (error) {
3232 return handleError(error);
3233 }
3234 },
3235 POST: async (req) => {
3236 try {
3237 requireAdmin(req);
3238 const classId = req.params.id;
3239 const body = await req.json();
3240 const { email } = body;
3241
3242 if (!email) {
3243 return Response.json({ error: "Email required" }, { status: 400 });
3244 }
3245
3246 const user = getUserByEmail(email);
3247 if (!user) {
3248 return Response.json({ error: "User not found" }, { status: 404 });
3249 }
3250
3251 enrollUserInClass(user.id, classId);
3252 return Response.json({ success: true });
3253 } catch (error) {
3254 return handleError(error);
3255 }
3256 },
3257 },
3258 "/api/classes/:id/members/:userId": {
3259 DELETE: async (req) => {
3260 try {
3261 requireAdmin(req);
3262 const classId = req.params.id;
3263 const userId = Number.parseInt(req.params.userId, 10);
3264
3265 if (Number.isNaN(userId)) {
3266 return Response.json({ error: "Invalid user ID" }, { status: 400 });
3267 }
3268
3269 removeUserFromClass(userId, classId);
3270 return Response.json({ success: true });
3271 } catch (error) {
3272 return handleError(error);
3273 }
3274 },
3275 },
3276 "/api/classes/:id/meetings": {
3277 GET: async (req) => {
3278 try {
3279 const user = requireAuth(req);
3280 const classId = req.params.id;
3281
3282 // Check enrollment or admin
3283 const isEnrolled = isUserEnrolledInClass(user.id, classId);
3284 if (!isEnrolled && user.role !== "admin") {
3285 return Response.json(
3286 { error: "Not enrolled in this class" },
3287 { status: 403 },
3288 );
3289 }
3290
3291 const meetingTimes = getMeetingTimesForClass(classId);
3292 return Response.json({ meetings: meetingTimes });
3293 } catch (error) {
3294 return handleError(error);
3295 }
3296 },
3297 POST: async (req) => {
3298 try {
3299 requireAdmin(req);
3300 const classId = req.params.id;
3301 const body = await req.json();
3302 const { label } = body;
3303
3304 if (!label) {
3305 return Response.json({ error: "Label required" }, { status: 400 });
3306 }
3307
3308 const meetingTime = createMeetingTime(classId, label);
3309 return Response.json(meetingTime);
3310 } catch (error) {
3311 return handleError(error);
3312 }
3313 },
3314 },
3315 "/api/meetings/:id": {
3316 PUT: async (req) => {
3317 try {
3318 requireAdmin(req);
3319 const meetingId = req.params.id;
3320 const body = await req.json();
3321 const { label } = body;
3322
3323 if (!label) {
3324 return Response.json({ error: "Label required" }, { status: 400 });
3325 }
3326
3327 updateMeetingTime(meetingId, label);
3328 return Response.json({ success: true });
3329 } catch (error) {
3330 return handleError(error);
3331 }
3332 },
3333 DELETE: async (req) => {
3334 try {
3335 requireAdmin(req);
3336 const meetingId = req.params.id;
3337
3338 deleteMeetingTime(meetingId);
3339 return Response.json({ success: true });
3340 } catch (error) {
3341 return handleError(error);
3342 }
3343 },
3344 },
3345 "/api/transcripts/:id/select": {
3346 PUT: async (req) => {
3347 try {
3348 requireAdmin(req);
3349 const transcriptId = req.params.id;
3350
3351 // Check if transcription exists and get its current status
3352 const transcription = db
3353 .query<{ filename: string; status: string }, [string]>(
3354 "SELECT filename, status FROM transcriptions WHERE id = ?",
3355 )
3356 .get(transcriptId);
3357
3358 if (!transcription) {
3359 return Response.json(
3360 { error: "Transcription not found" },
3361 { status: 404 },
3362 );
3363 }
3364
3365 // Validate that status is appropriate for selection (e.g., 'uploading' or 'pending')
3366 const validStatuses = ["uploading", "pending", "failed"];
3367 if (!validStatuses.includes(transcription.status)) {
3368 return Response.json(
3369 {
3370 error: `Cannot select transcription with status: ${transcription.status}`,
3371 },
3372 { status: 400 },
3373 );
3374 }
3375
3376 // Update status to 'selected' and start transcription
3377 db.run("UPDATE transcriptions SET status = ? WHERE id = ?", [
3378 "selected",
3379 transcriptId,
3380 ]);
3381
3382 whisperService.startTranscription(
3383 transcriptId,
3384 transcription.filename,
3385 );
3386
3387 return Response.json({ success: true });
3388 } catch (error) {
3389 return handleError(error);
3390 }
3391 },
3392 },
3393 },
3394 development: {
3395 hmr: true,
3396 console: true,
3397 },
3398});
3399console.log(`馃 Thistle running at http://localhost:${server.port}`);
3400
3401// Track active SSE streams for graceful shutdown
3402const activeSSEStreams = new Set<ReadableStreamDefaultController>();
3403
3404// Graceful shutdown handler
3405let isShuttingDown = false;
3406
3407async function shutdown(signal: string) {
3408 if (isShuttingDown) return;
3409 isShuttingDown = true;
3410
3411 console.log(`\n${signal} received, starting graceful shutdown...`);
3412
3413 // 1. Stop accepting new requests
3414 console.log("[Shutdown] Closing server...");
3415 server.stop();
3416
3417 // 2. Close all active SSE streams (safe to kill - sync will handle reconnection)
3418 console.log(
3419 `[Shutdown] Closing ${activeSSEStreams.size} active SSE streams...`,
3420 );
3421 for (const controller of activeSSEStreams) {
3422 try {
3423 controller.close();
3424 } catch {
3425 // Already closed
3426 }
3427 }
3428 activeSSEStreams.clear();
3429
3430 // 3. Stop transcription service (closes streams to Murmur)
3431 whisperService.stop();
3432
3433 // 4. Stop cleanup intervals
3434 console.log("[Shutdown] Stopping cleanup intervals...");
3435 clearInterval(sessionCleanupInterval);
3436 clearInterval(syncInterval);
3437 clearInterval(fileCleanupInterval);
3438
3439 // 5. Close database connections
3440 console.log("[Shutdown] Closing database...");
3441 db.close();
3442
3443 console.log("[Shutdown] Complete");
3444 process.exit(0);
3445}
3446
3447// Register shutdown handlers
3448process.on("SIGTERM", () => shutdown("SIGTERM"));
3449process.on("SIGINT", () => shutdown("SIGINT"));