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